diff --git a/build-system/Make/BazelLocation.py b/build-system/Make/BazelLocation.py index 4d33191a948..e246d5ba472 100644 --- a/build-system/Make/BazelLocation.py +++ b/build-system/Make/BazelLocation.py @@ -1,10 +1,41 @@ import os import stat import sys +from urllib.parse import urlparse, urlunparse +import tempfile +import hashlib +import shutil -from BuildEnvironment import is_apple_silicon, resolve_executable, call_executable, BuildEnvironmentVersions +from BuildEnvironment import is_apple_silicon, resolve_executable, call_executable, run_executable_with_status, BuildEnvironmentVersions -def locate_bazel(base_path): +def transform_cache_host_into_http(grpc_url): + parsed_url = urlparse(grpc_url) + + new_scheme = "http" + new_port = 8080 + + transformed_url = urlunparse(( + new_scheme, + f"{parsed_url.hostname}:{new_port}", + parsed_url.path, + parsed_url.params, + parsed_url.query, + parsed_url.fragment + )) + + return transformed_url + + +def calculate_sha256(file_path): + sha256_hash = hashlib.sha256() + with open(file_path, "rb") as file: + # Read the file in chunks to avoid using too much memory + for byte_block in iter(lambda: file.read(4096), b""): + sha256_hash.update(byte_block) + return sha256_hash.hexdigest() + + +def locate_bazel(base_path, cache_host): build_input_dir = '{}/build-input'.format(base_path) if not os.path.isdir(build_input_dir): os.mkdir(build_input_dir) @@ -17,6 +48,33 @@ def locate_bazel(base_path): bazel_name = 'bazel-{version}-{arch}'.format(version=versions.bazel_version, arch=arch) bazel_path = '{}/build-input/{}'.format(base_path, bazel_name) + if not os.path.isfile(bazel_path): + if cache_host is not None and versions.bazel_version_sha256 is not None: + http_cache_host = transform_cache_host_into_http(cache_host) + + with tempfile.NamedTemporaryFile(delete=True) as temp_output_file: + call_executable([ + 'curl', + '-L', + '{cache_host}/cache/cas/{hash}'.format( + cache_host=http_cache_host, + hash=versions.bazel_version_sha256 + ), + '--output', + temp_output_file.name + ], check_result=False) + test_sha256 = calculate_sha256(temp_output_file.name) + if test_sha256 == versions.bazel_version_sha256: + shutil.copyfile(temp_output_file.name, bazel_path) + + + if os.path.isfile(bazel_path) and versions.bazel_version_sha256 is not None: + test_sha256 = calculate_sha256(bazel_path) + if test_sha256 != versions.bazel_version_sha256: + print(f"Bazel at {bazel_path} does not match SHA256 {versions.bazel_version_sha256}, removing") + os.remove(bazel_path) + + if not os.path.isfile(bazel_path): call_executable([ 'curl', @@ -29,6 +87,27 @@ def locate_bazel(base_path): bazel_path ]) + if os.path.isfile(bazel_path) and versions.bazel_version_sha256 is not None: + test_sha256 = calculate_sha256(bazel_path) + if test_sha256 != versions.bazel_version_sha256: + print(f"Bazel at {bazel_path} does not match SHA256 {versions.bazel_version_sha256}, removing") + os.remove(bazel_path) + + if cache_host is not None and versions.bazel_version_sha256 is not None: + http_cache_host = transform_cache_host_into_http(cache_host) + print(f"Uploading bazel@{versions.bazel_version_sha256} to bazel-remote") + call_executable([ + 'curl', + '-X', + 'PUT', + '-T', + bazel_path, + '{cache_host}/cache/cas/{hash}'.format( + cache_host=http_cache_host, + hash=versions.bazel_version_sha256 + ) + ], check_result=False) + if not os.access(bazel_path, os.X_OK): st = os.stat(bazel_path) os.chmod(bazel_path, st.st_mode | stat.S_IEXEC) diff --git a/build-system/Make/BuildConfiguration.py b/build-system/Make/BuildConfiguration.py index 1052bb5ef1f..835ecff11c1 100644 --- a/build-system/Make/BuildConfiguration.py +++ b/build-system/Make/BuildConfiguration.py @@ -62,7 +62,7 @@ def write_to_variables_file(self, bazel_path, use_xcode_managed_codesigning, aps def build_configuration_from_json(path): if not os.path.exists(path): - print('Could not load build configuration from {}'.format(path)) + print('Could not load build configuration from non-existing path {}'.format(path)) sys.exit(1) with open(path) as file: configuration_dict = json.load(file) diff --git a/build-system/Make/BuildEnvironment.py b/build-system/Make/BuildEnvironment.py index 8d31133fe14..3f7311aa739 100644 --- a/build-system/Make/BuildEnvironment.py +++ b/build-system/Make/BuildEnvironment.py @@ -65,6 +65,28 @@ def run_executable_with_output(path, arguments, decode=True, input=None, stderr_ return output_data +def run_executable_with_status(arguments, use_clean_environment=True): + executable_path = resolve_executable(arguments[0]) + if executable_path is None: + raise Exception(f'Could not resolve {arguments[0]} to a valid executable file') + + if use_clean_environment: + resolved_env = get_clean_env() + else: + resolved_env = os.environ + + resolved_arguments = [executable_path] + arguments[1:] + + result = subprocess.run( + resolved_arguments, + env=resolved_env, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE + ) + + return result.returncode + + def call_executable(arguments, use_clean_environment=True, check_result=True): executable_path = resolve_executable(arguments[0]) if executable_path is None: @@ -135,7 +157,9 @@ def __init__( if configuration_dict['bazel'] is None: raise Exception('Missing bazel version in {}'.format(configuration_path)) else: - self.bazel_version = configuration_dict['bazel'] + bazel_version, bazel_version_sha256 = configuration_dict['bazel'].split(':') + self.bazel_version = bazel_version + self.bazel_version_sha256 = bazel_version_sha256 if configuration_dict['xcode'] is None: raise Exception('Missing xcode version in {}'.format(configuration_path)) else: diff --git a/build-system/Make/Make.py b/build-system/Make/Make.py index cb67b72a1b4..00cb956868b 100644 --- a/build-system/Make/Make.py +++ b/build-system/Make/Make.py @@ -985,7 +985,7 @@ def add_project_and_build_common_arguments(current_parser: argparse.ArgumentPars bazel_path = None if args.bazel is None: - bazel_path = locate_bazel(base_path=os.getcwd()) + bazel_path = locate_bazel(base_path=os.getcwd(), cache_host=args.cacheHost) else: bazel_path = args.bazel diff --git a/build-system/Make/RemoteBuild.py b/build-system/Make/RemoteBuild.py index 89ed9b0d13c..2b38318c6b3 100644 --- a/build-system/Make/RemoteBuild.py +++ b/build-system/Make/RemoteBuild.py @@ -161,7 +161,7 @@ def handle_ssh_credentials(credentials): sys.exit(1) DarwinContainers.run_remote_ssh(credentials=credentials, command='') - sys.exit(0) + #sys.exit(0) def handle_stopped(): pass diff --git a/build-system/fake-codesigning/BroadcastUpload.mobileprovision b/build-system/fake-codesigning/BroadcastUpload.mobileprovision new file mode 100644 index 00000000000..76d68f79320 Binary files /dev/null and b/build-system/fake-codesigning/BroadcastUpload.mobileprovision differ diff --git a/build-system/fake-codesigning/profiles/BroadcastUpload.mobileprovision b/build-system/fake-codesigning/profiles/BroadcastUpload.mobileprovision deleted file mode 100644 index 76f8200f508..00000000000 Binary files a/build-system/fake-codesigning/profiles/BroadcastUpload.mobileprovision and /dev/null differ diff --git a/build-system/fake-codesigning/profiles/Intents.mobileprovision b/build-system/fake-codesigning/profiles/Intents.mobileprovision index 8787665b413..2c3dbc97ffc 100644 Binary files a/build-system/fake-codesigning/profiles/Intents.mobileprovision and b/build-system/fake-codesigning/profiles/Intents.mobileprovision differ diff --git a/build-system/fake-codesigning/profiles/NotificationContent.mobileprovision b/build-system/fake-codesigning/profiles/NotificationContent.mobileprovision index 915083850a8..4a550cb1c70 100644 Binary files a/build-system/fake-codesigning/profiles/NotificationContent.mobileprovision and b/build-system/fake-codesigning/profiles/NotificationContent.mobileprovision differ diff --git a/build-system/fake-codesigning/profiles/NotificationService.mobileprovision b/build-system/fake-codesigning/profiles/NotificationService.mobileprovision index 022bdb091f8..aff76a794cf 100644 Binary files a/build-system/fake-codesigning/profiles/NotificationService.mobileprovision and b/build-system/fake-codesigning/profiles/NotificationService.mobileprovision differ diff --git a/build-system/fake-codesigning/profiles/Share.mobileprovision b/build-system/fake-codesigning/profiles/Share.mobileprovision index d5c133b40ce..5f45bc81bef 100644 Binary files a/build-system/fake-codesigning/profiles/Share.mobileprovision and b/build-system/fake-codesigning/profiles/Share.mobileprovision differ diff --git a/build-system/fake-codesigning/profiles/Telegram.mobileprovision b/build-system/fake-codesigning/profiles/Telegram.mobileprovision index 496ad79a686..18336d85be1 100644 Binary files a/build-system/fake-codesigning/profiles/Telegram.mobileprovision and b/build-system/fake-codesigning/profiles/Telegram.mobileprovision differ diff --git a/build-system/fake-codesigning/profiles/WatchApp.mobileprovision b/build-system/fake-codesigning/profiles/WatchApp.mobileprovision index 0eb5a56cb38..7996879fbef 100644 Binary files a/build-system/fake-codesigning/profiles/WatchApp.mobileprovision and b/build-system/fake-codesigning/profiles/WatchApp.mobileprovision differ diff --git a/build-system/fake-codesigning/profiles/WatchExtension.mobileprovision b/build-system/fake-codesigning/profiles/WatchExtension.mobileprovision index 85b1a95f8d7..3bf56c09fb7 100644 Binary files a/build-system/fake-codesigning/profiles/WatchExtension.mobileprovision and b/build-system/fake-codesigning/profiles/WatchExtension.mobileprovision differ diff --git a/build-system/fake-codesigning/profiles/Widget.mobileprovision b/build-system/fake-codesigning/profiles/Widget.mobileprovision index f6fe6677e2a..dfdc9cd48a7 100644 Binary files a/build-system/fake-codesigning/profiles/Widget.mobileprovision and b/build-system/fake-codesigning/profiles/Widget.mobileprovision differ diff --git a/submodules/DebugSettingsUI/Sources/DebugController.swift b/submodules/DebugSettingsUI/Sources/DebugController.swift index d6b9f206a12..255acb20498 100644 --- a/submodules/DebugSettingsUI/Sources/DebugController.swift +++ b/submodules/DebugSettingsUI/Sources/DebugController.swift @@ -106,6 +106,7 @@ private enum DebugControllerEntry: ItemListNodeEntry { case experimentalCallMute(Bool) case liveStreamV2(Bool) case dynamicStreaming(Bool) + case enableLocalTranslation(Bool) case preferredVideoCodec(Int, String, String?, Bool) case disableVideoAspectScaling(Bool) case enableNetworkFramework(Bool) @@ -130,7 +131,7 @@ private enum DebugControllerEntry: ItemListNodeEntry { return DebugControllerSection.web.rawValue case .keepChatNavigationStack, .skipReadHistory, .dustEffect, .crashOnSlowQueries, .crashOnMemoryPressure: return DebugControllerSection.experiments.rawValue - case .clearTips, .resetNotifications, .crash, .fillLocalSavedMessageCache, .resetDatabase, .resetDatabaseAndCache, .resetHoles, .resetTagHoles, .reindexUnread, .resetCacheIndex, .reindexCache, .resetBiometricsData, .optimizeDatabase, .photoPreview, .knockoutWallpaper, .storiesExperiment, .storiesJpegExperiment, .playlistPlayback, .enableQuickReactionSwitch, .experimentalCompatibility, .enableDebugDataDisplay, .rippleEffect, .browserExperiment, .localTranscription, .enableReactionOverrides, .restorePurchases, .disableReloginTokens, .disableCallV2, .experimentalCallMute, .liveStreamV2, .dynamicStreaming: + case .clearTips, .resetNotifications, .crash, .fillLocalSavedMessageCache, .resetDatabase, .resetDatabaseAndCache, .resetHoles, .resetTagHoles, .reindexUnread, .resetCacheIndex, .reindexCache, .resetBiometricsData, .optimizeDatabase, .photoPreview, .knockoutWallpaper, .storiesExperiment, .storiesJpegExperiment, .playlistPlayback, .enableQuickReactionSwitch, .experimentalCompatibility, .enableDebugDataDisplay, .rippleEffect, .browserExperiment, .localTranscription, .enableReactionOverrides, .restorePurchases, .disableReloginTokens, .disableCallV2, .experimentalCallMute, .liveStreamV2, .dynamicStreaming, .enableLocalTranslation: return DebugControllerSection.experiments.rawValue case .logTranslationRecognition, .resetTranslationStates: return DebugControllerSection.translation.rawValue @@ -251,8 +252,10 @@ private enum DebugControllerEntry: ItemListNodeEntry { return 53 case .dynamicStreaming: return 54 + case .enableLocalTranslation: + return 55 case let .preferredVideoCodec(index, _, _, _): - return 55 + index + return 56 + index case .disableVideoAspectScaling: return 100 case .enableNetworkFramework: @@ -1361,6 +1364,16 @@ private enum DebugControllerEntry: ItemListNodeEntry { }) }).start() }) + case let .enableLocalTranslation(value): + return ItemListSwitchItem(presentationData: presentationData, title: "Local Translation", value: value, sectionId: self.section, style: .blocks, updated: { value in + let _ = arguments.sharedContext.accountManager.transaction ({ transaction in + transaction.updateSharedData(ApplicationSpecificSharedDataKeys.experimentalUISettings, { settings in + var settings = settings?.get(ExperimentalUISettings.self) ?? ExperimentalUISettings.defaultSettings + settings.enableLocalTranslation = value + return PreferencesEntry(settings) + }) + }).start() + }) case let .preferredVideoCodec(_, title, value, isSelected): return ItemListCheckboxItem(presentationData: presentationData, title: title, style: .right, checked: isSelected, zeroSeparatorInsets: false, sectionId: self.section, action: { let _ = arguments.sharedContext.accountManager.transaction ({ transaction in @@ -1519,6 +1532,7 @@ private func debugControllerEntries(sharedContext: SharedAccountContext, present entries.append(.experimentalCallMute(experimentalSettings.experimentalCallMute)) entries.append(.liveStreamV2(experimentalSettings.liveStreamV2)) entries.append(.dynamicStreaming(experimentalSettings.dynamicStreaming)) + entries.append(.enableLocalTranslation(experimentalSettings.enableLocalTranslation)) } /*let codecs: [(String, String?)] = [ diff --git a/submodules/GalleryUI/Sources/GalleryController.swift b/submodules/GalleryUI/Sources/GalleryController.swift index 3e2b6ce31d5..e2adc98ad3e 100644 --- a/submodules/GalleryUI/Sources/GalleryController.swift +++ b/submodules/GalleryUI/Sources/GalleryController.swift @@ -246,12 +246,14 @@ public func galleryItemForEntry( } else { if true || (file.mimeType == "video/mpeg4" || file.mimeType == "video/mov" || file.mimeType == "video/mp4") { var isHLS = false - if NativeVideoContent.isHLSVideo(file: file) { - isHLS = true - - if let data = context.currentAppConfiguration.with({ $0 }).data, let disableHLS = data["video_ignore_alt_documents"] as? Double { - if Int(disableHLS) != 0 { - isHLS = false + if #available(iOS 13.0, *) { + if NativeVideoContent.isHLSVideo(file: file) { + isHLS = true + + if let data = context.currentAppConfiguration.with({ $0 }).data, let disableHLS = data["video_ignore_alt_documents"] as? Double { + if Int(disableHLS) != 0 { + isHLS = false + } } } } diff --git a/submodules/GalleryUI/Sources/Items/UniversalVideoGalleryItem.swift b/submodules/GalleryUI/Sources/Items/UniversalVideoGalleryItem.swift index 2fdb9d1f9c8..bde79dbcd1c 100644 --- a/submodules/GalleryUI/Sources/Items/UniversalVideoGalleryItem.swift +++ b/submodules/GalleryUI/Sources/Items/UniversalVideoGalleryItem.swift @@ -2993,9 +2993,11 @@ final class UniversalVideoGalleryItemNode: ZoomableContentGalleryItemNode { self.activePictureInPictureController = nil self.activePictureInPictureNavigationController = nil + let previousPresentationArguments = activePictureInPictureController.presentationArguments activePictureInPictureController.presentationArguments = nil activePictureInPictureNavigationController.currentWindow?.present(activePictureInPictureController, on: .root, blockInteraction: false, completion: { }) + activePictureInPictureController.presentationArguments = previousPresentationArguments activePictureInPictureController.view.alpha = 1.0 activePictureInPictureController.view.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.35, completion: { _ in diff --git a/submodules/MediaPlayer/Sources/ChunkMediaPlayer.swift b/submodules/MediaPlayer/Sources/ChunkMediaPlayer.swift index 007c6eb2472..4a5e32c1a69 100644 --- a/submodules/MediaPlayer/Sources/ChunkMediaPlayer.swift +++ b/submodules/MediaPlayer/Sources/ChunkMediaPlayer.swift @@ -119,13 +119,15 @@ public final class ChunkMediaPlayerPart { public let startTime: Double public let endTime: Double public let file: TempBoxFile + public let clippedStartTime: Double? public var id: Id { return Id(rawValue: self.file.path) } - public init(startTime: Double, endTime: Double, file: TempBoxFile) { + public init(startTime: Double, clippedStartTime: Double? = nil, endTime: Double, file: TempBoxFile) { self.startTime = startTime + self.clippedStartTime = clippedStartTime self.endTime = endTime self.file = file } @@ -620,58 +622,54 @@ private final class ChunkMediaPlayerContext { var validParts: [ChunkMediaPlayerPart] = [] + var minStartTime: Double = 0.0 for i in 0 ..< self.partsState.parts.count { let part = self.partsState.parts[i] + + let partStartTime = max(minStartTime, part.startTime) + let partEndTime = max(partStartTime, part.endTime) + if partStartTime >= partEndTime { + continue + } + var partMatches = false - if timestamp >= part.startTime - 0.5 && timestamp < part.endTime + 0.5 { + if timestamp >= partStartTime - 0.5 && timestamp < partEndTime + 0.5 { partMatches = true } if partMatches { - validParts.append(part) + validParts.append(ChunkMediaPlayerPart( + startTime: part.startTime, + clippedStartTime: partStartTime == part.startTime ? nil : partStartTime, + endTime: part.endTime, + file: part.file + )) + minStartTime = max(minStartTime, partEndTime) } } + if let lastValidPart = validParts.last { for i in 0 ..< self.partsState.parts.count { let part = self.partsState.parts[i] - if lastValidPart !== part && part.startTime > lastValidPart.startTime && part.startTime <= lastValidPart.endTime + 0.5 { - validParts.append(part) - break - } - } - } - - /*for i in 0 ..< self.partsState.parts.count { - let part = self.partsState.parts[i] - var partMatches = false - if timestamp >= part.startTime - 0.001 && timestamp < part.endTime - 0.001 { - partMatches = true - } else if part.startTime < 0.2 && timestamp < part.endTime - 0.001 { - partMatches = true - } - - if !partMatches, i != self.partsState.parts.count - 1, part.startTime >= 0.001, timestamp >= part.startTime { - let nextPart = self.partsState.parts[i + 1] - if timestamp < nextPart.endTime - 0.001 { - if part.endTime >= nextPart.startTime - 0.1 { - partMatches = true - } - } - } - - if partMatches { - validParts.append(part) - inner: for lookaheadPart in self.partsState.parts { - if lookaheadPart.startTime >= part.endTime - 0.001 && lookaheadPart.startTime - 0.1 < part.endTime { - validParts.append(lookaheadPart) - break inner - } + let partStartTime = max(minStartTime, part.startTime) + let partEndTime = max(partStartTime, part.endTime) + if partStartTime >= partEndTime { + continue } - break + if lastValidPart !== part && partStartTime > (lastValidPart.clippedStartTime ?? lastValidPart.startTime) && partStartTime <= lastValidPart.endTime + 0.5 { + validParts.append(ChunkMediaPlayerPart( + startTime: part.startTime, + clippedStartTime: partStartTime == part.startTime ? nil : partStartTime, + endTime: part.endTime, + file: part.file + )) + minStartTime = max(minStartTime, partEndTime) + break + } } - }*/ + } if validParts.isEmpty, let initialSeekTimestamp = self.initialSeekTimestamp { for part in self.partsState.parts { @@ -701,6 +699,8 @@ private final class ChunkMediaPlayerContext { self.initialSeekTimestamp = nil } + //print("validParts: \(validParts.map { "\($0.startTime) ... \($0.endTime)" })") + self.loadedState.partStates.removeAll(where: { partState in if !validParts.contains(where: { $0.id == partState.part.id }) { return true @@ -742,7 +742,13 @@ private final class ChunkMediaPlayerContext { for i in 0 ..< self.loadedState.partStates.count { let partState = self.loadedState.partStates[i] if partState.mediaBuffersDisposable == nil { - partState.mediaBuffersDisposable = (partState.frameSource.seek(timestamp: i == 0 ? timestamp : 0.0) + let partSeekOffset: Double + if let clippedStartTime = partState.part.clippedStartTime { + partSeekOffset = clippedStartTime - partState.part.startTime + } else { + partSeekOffset = 0.0 + } + partState.mediaBuffersDisposable = (partState.frameSource.seek(timestamp: i == 0 ? timestamp : partSeekOffset) |> deliverOn(self.queue)).startStrict(next: { [weak self, weak partState] result in guard let self, let partState else { return @@ -921,13 +927,14 @@ private final class ChunkMediaPlayerContext { for partState in self.loadedState.partStates { if let audioTrackFrameBuffer = partState.mediaBuffers?.audioBuffer { + //print("Poll audio: part \(partState.part.startTime) frames: \(audioTrackFrameBuffer.frames.map(\.pts.seconds))") let frame = audioTrackFrameBuffer.takeFrame() switch frame { case .finished: continue default: /*if case let .frame(frame) = frame { - print("audio: \(frame.position.seconds) \(frame.position.value) next: (\(frame.position.value + frame.duration.value))") + print("audio: \(frame.position.seconds) \(frame.position.value) part \(partState.part.startTime) next: (\(frame.position.value + frame.duration.value))") }*/ return frame } diff --git a/submodules/TelegramBaseController/Sources/TelegramBaseController.swift b/submodules/TelegramBaseController/Sources/TelegramBaseController.swift index 80ae771ecf4..2da6f4581ef 100644 --- a/submodules/TelegramBaseController/Sources/TelegramBaseController.swift +++ b/submodules/TelegramBaseController/Sources/TelegramBaseController.swift @@ -461,6 +461,40 @@ open class TelegramBaseController: ViewController, KeyShortcutResponder { invite: nil, activeCall: EngineGroupCallDescription(id: groupCallPanelData.info.id, accessHash: groupCallPanelData.info.accessHash, title: groupCallPanelData.info.title, scheduleTimestamp: groupCallPanelData.info.scheduleTimestamp, subscribedToScheduled: groupCallPanelData.info.subscribedToScheduled, isStream: groupCallPanelData.info.isStream) ) + }, notifyScheduledTapAction: { [weak self] in + guard let self, let groupCallPanelData = self.groupCallPanelData else { + return + } + if groupCallPanelData.info.scheduleTimestamp != nil && !groupCallPanelData.info.subscribedToScheduled { + let _ = self.context.engine.calls.toggleScheduledGroupCallSubscription(peerId: groupCallPanelData.peerId, callId: groupCallPanelData.info.id, accessHash: groupCallPanelData.info.accessHash, subscribe: true).startStandalone() + + //TODO:localize + let controller = UndoOverlayController( + presentationData: presentationData, + content: .universal( + animation: "anim_profileunmute", + scale: 0.075, + colors: [ + "Middle.Group 1.Fill 1": UIColor.white, + "Top.Group 1.Fill 1": UIColor.white, + "Bottom.Group 1.Fill 1": UIColor.white, + "EXAMPLE.Group 1.Fill 1": UIColor.white, + "Line.Group 1.Stroke 1": UIColor.white + ], + title: nil, + text: "You will be notified when the liver stream starts.", + customUndoText: nil, + timeout: nil + ), + elevatedLayout: false, + animateInAsReplacement: false, + action: { _ in + return true + } + ) + self.audioRateTooltipController = controller + self.present(controller, in: .current) + } }) if let accessoryPanelContainer = self.accessoryPanelContainer { accessoryPanelContainer.addSubnode(groupCallAccessoryPanel) diff --git a/submodules/TelegramCallsUI/Sources/GroupCallNavigationAccessoryPanel.swift b/submodules/TelegramCallsUI/Sources/GroupCallNavigationAccessoryPanel.swift index 326d8dd9046..025bd94e6e9 100644 --- a/submodules/TelegramCallsUI/Sources/GroupCallNavigationAccessoryPanel.swift +++ b/submodules/TelegramCallsUI/Sources/GroupCallNavigationAccessoryPanel.swift @@ -113,6 +113,7 @@ public final class GroupCallNavigationAccessoryPanel: ASDisplayNode { private var dateTimeFormat: PresentationDateTimeFormat private let tapAction: () -> Void + private let notifyScheduledTapAction: () -> Void private let contentNode: ASDisplayNode @@ -163,13 +164,14 @@ public final class GroupCallNavigationAccessoryPanel: ASDisplayNode { private var currentData: GroupCallPanelData? private var validLayout: (CGSize, CGFloat, CGFloat, Bool)? - public init(context: AccountContext, presentationData: PresentationData, tapAction: @escaping () -> Void) { + public init(context: AccountContext, presentationData: PresentationData, tapAction: @escaping () -> Void, notifyScheduledTapAction: @escaping () -> Void) { self.context = context self.theme = presentationData.theme self.strings = presentationData.strings self.dateTimeFormat = presentationData.dateTimeFormat self.tapAction = tapAction + self.notifyScheduledTapAction = notifyScheduledTapAction self.contentNode = ASDisplayNode() @@ -234,7 +236,7 @@ public final class GroupCallNavigationAccessoryPanel: ASDisplayNode { self.joinButton.addSubnode(self.joinButtonBackgroundNode) self.joinButton.addSubnode(self.joinButtonTitleNode) self.contentNode.addSubnode(self.joinButton) - self.joinButton.addTarget(self, action: #selector(self.tapped), forControlEvents: [.touchUpInside]) + self.joinButton.addTarget(self, action: #selector(self.joinTapped), forControlEvents: [.touchUpInside]) self.micButton.addSubnode(self.micButtonBackgroundNode) self.micButton.addSubnode(self.micButtonForegroundNode) @@ -267,6 +269,14 @@ public final class GroupCallNavigationAccessoryPanel: ASDisplayNode { self.tapAction() } + @objc private func joinTapped() { + if let info = self.currentData?.info, let _ = info.scheduleTimestamp, !info.subscribedToScheduled { + self.notifyScheduledTapAction() + } else { + self.tapAction() + } + } + @objc private func micTapped() { guard let call = self.currentData?.groupCall else { return @@ -645,7 +655,7 @@ public final class GroupCallNavigationAccessoryPanel: ASDisplayNode { var text = self.currentText var isScheduled = false var isLate = false - if let scheduleTime = self.currentData?.info.scheduleTimestamp { + if let info = self.currentData?.info, let scheduleTime = info.scheduleTimestamp { isScheduled = true if let voiceChatTitle = self.currentData?.info.title { title = voiceChatTitle @@ -655,15 +665,20 @@ public final class GroupCallNavigationAccessoryPanel: ASDisplayNode { text = humanReadableStringForTimestamp(strings: self.strings, dateTimeFormat: self.dateTimeFormat, timestamp: scheduleTime, alwaysShowTime: true, format: HumanReadableStringFormat(dateFormatString: { self.strings.Conversation_ScheduledVoiceChatStartsOnShort($0) }, tomorrowFormatString: { self.strings.Conversation_ScheduledVoiceChatStartsTomorrowShort($0) }, todayFormatString: { self.strings.Conversation_ScheduledVoiceChatStartsTodayShort($0) })).string } - let currentTime = Int32(CFAbsoluteTimeGetCurrent() + kCFAbsoluteTimeIntervalSince1970) - let elapsedTime = scheduleTime - currentTime - if elapsedTime >= 86400 { - joinText = scheduledTimeIntervalString(strings: strings, value: elapsedTime) - } else if elapsedTime < 0 { - joinText = "-\(textForTimeout(value: abs(elapsedTime)))" - isLate = true + if info.subscribedToScheduled { + let currentTime = Int32(CFAbsoluteTimeGetCurrent() + kCFAbsoluteTimeIntervalSince1970) + let elapsedTime = scheduleTime - currentTime + if elapsedTime >= 86400 { + joinText = scheduledTimeIntervalString(strings: strings, value: elapsedTime).uppercased() + } else if elapsedTime < 0 { + joinText = "-\(textForTimeout(value: abs(elapsedTime)))".uppercased() + isLate = true + } else { + joinText = textForTimeout(value: elapsedTime).uppercased() + } } else { - joinText = textForTimeout(value: elapsedTime) + //TODO:localize + joinText = "Notify Me" } if self.updateTimer == nil { @@ -691,7 +706,7 @@ public final class GroupCallNavigationAccessoryPanel: ASDisplayNode { self.updateJoinButton() } - self.joinButtonTitleNode.attributedText = NSAttributedString(string: joinText.uppercased(), font: Font.with(size: 15.0, design: .round, weight: .semibold, traits: [.monospacedNumbers]), textColor: isScheduled ? .white : self.theme.chat.inputPanel.actionControlForegroundColor) + self.joinButtonTitleNode.attributedText = NSAttributedString(string: joinText, font: Font.with(size: 15.0, design: .round, weight: .semibold, traits: [.monospacedNumbers]), textColor: isScheduled ? .white : self.theme.chat.inputPanel.actionControlForegroundColor) let joinButtonTitleSize = self.joinButtonTitleNode.updateLayout(CGSize(width: 150.0, height: .greatestFiniteMagnitude)) let joinButtonSize = CGSize(width: joinButtonTitleSize.width + 20.0, height: 28.0) diff --git a/submodules/TelegramCore/Sources/State/ManagedSecretChatOutgoingOperations.swift b/submodules/TelegramCore/Sources/State/ManagedSecretChatOutgoingOperations.swift index 27c1a0c58b7..93d15dc964a 100644 --- a/submodules/TelegramCore/Sources/State/ManagedSecretChatOutgoingOperations.swift +++ b/submodules/TelegramCore/Sources/State/ManagedSecretChatOutgoingOperations.swift @@ -944,7 +944,9 @@ private func boxedDecryptedMessage(transaction: Transaction, message: Message, g if let attribute = attribute as? ReplyMessageAttribute { if let message = message.associatedMessages[attribute.messageId] { replyGlobalId = message.globallyUniqueId - flags |= (1 << 3) + if replyGlobalId != nil { + flags |= (1 << 3) + } break } } diff --git a/submodules/TelegramCore/Sources/TelegramEngine/Calls/GroupCalls.swift b/submodules/TelegramCore/Sources/TelegramEngine/Calls/GroupCalls.swift index fa18ad9478b..28355cbc287 100644 --- a/submodules/TelegramCore/Sources/TelegramEngine/Calls/GroupCalls.swift +++ b/submodules/TelegramCore/Sources/TelegramEngine/Calls/GroupCalls.swift @@ -244,40 +244,54 @@ public enum ToggleScheduledGroupCallSubscriptionError { } func _internal_toggleScheduledGroupCallSubscription(account: Account, peerId: PeerId, callId: Int64, accessHash: Int64, subscribe: Bool) -> Signal { - return account.network.request(Api.functions.phone.toggleGroupCallStartSubscription(call: .inputGroupCall(id: callId, accessHash: accessHash), subscribed: subscribe ? .boolTrue : .boolFalse)) - |> mapError { error -> ToggleScheduledGroupCallSubscriptionError in - return .generic - } - |> mapToSignal { result -> Signal in - var parsedCall: GroupCallInfo? - loop: for update in result.allUpdates { - switch update { - case let .updateGroupCall(_, call): - parsedCall = GroupCallInfo(call) - break loop - default: - break + return account.postbox.transaction { transaction -> Void in + transaction.updatePeerCachedData(peerIds: Set([peerId]), update: { _, cachedData -> CachedPeerData? in + if let cachedData = cachedData as? CachedChannelData, let activeCall = cachedData.activeCall { + return cachedData.withUpdatedActiveCall(CachedChannelData.ActiveCall(id: activeCall.id, accessHash: activeCall.accessHash, title: activeCall.title, scheduleTimestamp: activeCall.scheduleTimestamp, subscribedToScheduled: true, isStream: activeCall.isStream)) + } else if let cachedData = cachedData as? CachedGroupData, let activeCall = cachedData.activeCall { + return cachedData.withUpdatedActiveCall(CachedChannelData.ActiveCall(id: activeCall.id, accessHash: activeCall.accessHash, title: activeCall.title, scheduleTimestamp: activeCall.scheduleTimestamp, subscribedToScheduled: true, isStream: activeCall.isStream)) + } else { + return cachedData } + }) + } + |> castError(ToggleScheduledGroupCallSubscriptionError.self) + |> mapToSignal { _ -> Signal in + return account.network.request(Api.functions.phone.toggleGroupCallStartSubscription(call: .inputGroupCall(id: callId, accessHash: accessHash), subscribed: subscribe ? .boolTrue : .boolFalse)) + |> mapError { error -> ToggleScheduledGroupCallSubscriptionError in + return .generic } - - guard let callInfo = parsedCall else { - return .fail(.generic) - } - - return account.postbox.transaction { transaction in - transaction.updatePeerCachedData(peerIds: Set([peerId]), update: { _, cachedData -> CachedPeerData? in - if let cachedData = cachedData as? CachedChannelData { - return cachedData.withUpdatedActiveCall(CachedChannelData.ActiveCall(id: callInfo.id, accessHash: callInfo.accessHash, title: callInfo.title, scheduleTimestamp: callInfo.scheduleTimestamp, subscribedToScheduled: callInfo.subscribedToScheduled, isStream: callInfo.isStream)) - } else if let cachedData = cachedData as? CachedGroupData { - return cachedData.withUpdatedActiveCall(CachedChannelData.ActiveCall(id: callInfo.id, accessHash: callInfo.accessHash, title: callInfo.title, scheduleTimestamp: callInfo.scheduleTimestamp, subscribedToScheduled: callInfo.subscribedToScheduled, isStream: callInfo.isStream)) - } else { - return cachedData + |> mapToSignal { result -> Signal in + var parsedCall: GroupCallInfo? + loop: for update in result.allUpdates { + switch update { + case let .updateGroupCall(_, call): + parsedCall = GroupCallInfo(call) + break loop + default: + break } - }) + } - account.stateManager.addUpdates(result) + guard let callInfo = parsedCall else { + return .fail(.generic) + } + + return account.postbox.transaction { transaction in + transaction.updatePeerCachedData(peerIds: Set([peerId]), update: { _, cachedData -> CachedPeerData? in + if let cachedData = cachedData as? CachedChannelData { + return cachedData.withUpdatedActiveCall(CachedChannelData.ActiveCall(id: callInfo.id, accessHash: callInfo.accessHash, title: callInfo.title, scheduleTimestamp: callInfo.scheduleTimestamp, subscribedToScheduled: callInfo.subscribedToScheduled, isStream: callInfo.isStream)) + } else if let cachedData = cachedData as? CachedGroupData { + return cachedData.withUpdatedActiveCall(CachedChannelData.ActiveCall(id: callInfo.id, accessHash: callInfo.accessHash, title: callInfo.title, scheduleTimestamp: callInfo.scheduleTimestamp, subscribedToScheduled: callInfo.subscribedToScheduled, isStream: callInfo.isStream)) + } else { + return cachedData + } + }) + + account.stateManager.addUpdates(result) + } + |> castError(ToggleScheduledGroupCallSubscriptionError.self) } - |> castError(ToggleScheduledGroupCallSubscriptionError.self) } } diff --git a/submodules/TelegramCore/Sources/TelegramEngine/Messages/TelegramEngineMessages.swift b/submodules/TelegramCore/Sources/TelegramEngine/Messages/TelegramEngineMessages.swift index 202922f2abc..d6170a6de96 100644 --- a/submodules/TelegramCore/Sources/TelegramEngine/Messages/TelegramEngineMessages.swift +++ b/submodules/TelegramCore/Sources/TelegramEngine/Messages/TelegramEngineMessages.swift @@ -536,8 +536,8 @@ public extension TelegramEngine { return _internal_translate_texts(network: self.account.network, texts: texts, toLang: toLang) } - public func translateMessages(messageIds: [EngineMessage.Id], toLang: String) -> Signal { - return _internal_translateMessages(account: self.account, messageIds: messageIds, toLang: toLang) + public func translateMessages(messageIds: [EngineMessage.Id], fromLang: String?, toLang: String, enableLocalIfPossible: Bool) -> Signal { + return _internal_translateMessages(account: self.account, messageIds: messageIds, fromLang: fromLang, toLang: toLang, enableLocalIfPossible: enableLocalIfPossible) } public func togglePeerMessagesTranslationHidden(peerId: EnginePeer.Id, hidden: Bool) -> Signal { diff --git a/submodules/TelegramCore/Sources/TelegramEngine/Messages/Translate.swift b/submodules/TelegramCore/Sources/TelegramEngine/Messages/Translate.swift index 6ddcd439867..73ce8e26499 100644 --- a/submodules/TelegramCore/Sources/TelegramEngine/Messages/Translate.swift +++ b/submodules/TelegramCore/Sources/TelegramEngine/Messages/Translate.swift @@ -84,16 +84,22 @@ func _internal_translate_texts(network: Network, texts: [(String, [MessageTextEn } } -func _internal_translateMessages(account: Account, messageIds: [EngineMessage.Id], toLang: String) -> Signal { +func _internal_translateMessages(account: Account, messageIds: [EngineMessage.Id], fromLang: String?, toLang: String, enableLocalIfPossible: Bool) -> Signal { var signals: [Signal] = [] for (peerId, messageIds) in messagesIdsGroupedByPeerId(messageIds) { - signals.append(_internal_translateMessagesByPeerId(account: account, peerId: peerId, messageIds: messageIds, toLang: toLang)) + signals.append(_internal_translateMessagesByPeerId(account: account, peerId: peerId, messageIds: messageIds, fromLang: fromLang, toLang: toLang, enableLocalIfPossible: enableLocalIfPossible)) } return combineLatest(signals) |> ignoreValues } -private func _internal_translateMessagesByPeerId(account: Account, peerId: EnginePeer.Id, messageIds: [EngineMessage.Id], toLang: String) -> Signal { +public protocol ExperimentalInternalTranslationService: AnyObject { + func translate(texts: [AnyHashable: String], fromLang: String, toLang: String) -> Signal<[AnyHashable: String]?, NoError> +} + +public var engineExperimentalInternalTranslationService: ExperimentalInternalTranslationService? + +private func _internal_translateMessagesByPeerId(account: Account, peerId: EnginePeer.Id, messageIds: [EngineMessage.Id], fromLang: String?, toLang: String, enableLocalIfPossible: Bool) -> Signal { return account.postbox.transaction { transaction -> (Api.InputPeer?, [Message]) in return (transaction.getPeer(peerId).flatMap(apiInputPeer), messageIds.compactMap({ transaction.getMessage($0) })) } @@ -132,21 +138,58 @@ private func _internal_translateMessagesByPeerId(account: Account, peerId: Engin if id.isEmpty { msgs = .single(nil) } else { - msgs = account.network.request(Api.functions.messages.translateText(flags: flags, peer: inputPeer, id: id, text: nil, toLang: toLang)) - |> map(Optional.init) - |> mapError { error -> TranslationError in - if error.errorDescription.hasPrefix("FLOOD_WAIT") { - return .limitExceeded - } else if error.errorDescription == "MSG_ID_INVALID" { - return .invalidMessageId - } else if error.errorDescription == "INPUT_TEXT_EMPTY" { - return .textIsEmpty - } else if error.errorDescription == "INPUT_TEXT_TOO_LONG" { - return .textTooLong - } else if error.errorDescription == "TO_LANG_INVALID" { - return .invalidLanguage - } else { - return .generic + if enableLocalIfPossible, let engineExperimentalInternalTranslationService, let fromLang { + msgs = account.postbox.transaction { transaction -> [MessageId: String] in + var texts: [MessageId: String] = [:] + for messageId in messageIds { + if let message = transaction.getMessage(messageId) { + texts[message.id] = message.text + } + } + return texts + } + |> castError(TranslationError.self) + |> mapToSignal { messageTexts -> Signal in + var mappedTexts: [AnyHashable: String] = [:] + for (id, text) in messageTexts { + mappedTexts[AnyHashable(id)] = text + } + return engineExperimentalInternalTranslationService.translate(texts: mappedTexts, fromLang: fromLang, toLang: toLang) + |> castError(TranslationError.self) + |> mapToSignal { resultTexts -> Signal in + guard let resultTexts else { + return .fail(.generic) + } + var result: [Api.TextWithEntities] = [] + for messageId in messageIds { + if let text = resultTexts[AnyHashable(messageId)] { + result.append(.textWithEntities(text: text, entities: [])) + } else if let text = messageTexts[messageId] { + result.append(.textWithEntities(text: text, entities: [])) + } else { + result.append(.textWithEntities(text: "", entities: [])) + } + } + return .single(.translateResult(result: result)) + } + } + } else { + msgs = account.network.request(Api.functions.messages.translateText(flags: flags, peer: inputPeer, id: id, text: nil, toLang: toLang)) + |> map(Optional.init) + |> mapError { error -> TranslationError in + if error.errorDescription.hasPrefix("FLOOD_WAIT") { + return .limitExceeded + } else if error.errorDescription == "MSG_ID_INVALID" { + return .invalidMessageId + } else if error.errorDescription == "INPUT_TEXT_EMPTY" { + return .textIsEmpty + } else if error.errorDescription == "INPUT_TEXT_TOO_LONG" { + return .textTooLong + } else if error.errorDescription == "TO_LANG_INVALID" { + return .invalidLanguage + } else { + return .generic + } } } } diff --git a/submodules/TelegramUI/Sources/AppDelegate.swift b/submodules/TelegramUI/Sources/AppDelegate.swift index 4121cf7a011..2a62eefb53e 100644 --- a/submodules/TelegramUI/Sources/AppDelegate.swift +++ b/submodules/TelegramUI/Sources/AppDelegate.swift @@ -42,6 +42,7 @@ import MediaEditor import TelegramUIDeclareEncodables import ContextMenuScreen import MetalEngine +import TranslateUI #if canImport(AppCenter) import AppCenter @@ -362,6 +363,12 @@ private func extractAccountManagerState(records: AccountRecordsView - private var toLang: String? + private var translationLang: (fromLang: String?, toLang: String)? private var allowDustEffect: Bool = true private var dustEffectLayer: DustEffectLayer? @@ -838,13 +838,13 @@ public final class ChatHistoryListNodeImpl: ListView, ChatHistoryNode, ChatHisto context?.account.viewTracker.refreshStoriesForMessageIds(messageIds: Set(messageIds.map(\.messageId))) } self.translationProcessingManager.process = { [weak self, weak context] messageIds in - if let context = context, let toLang = self?.toLang { - let _ = translateMessageIds(context: context, messageIds: Array(messageIds.map(\.messageId)), toLang: toLang).startStandalone() + if let context = context, let translationLang = self?.translationLang { + let _ = translateMessageIds(context: context, messageIds: Array(messageIds.map(\.messageId)), fromLang: translationLang.fromLang, toLang: translationLang.toLang).startStandalone() } } self.factCheckProcessingManager.process = { [weak self, weak context] messageIds in - if let context = context, let toLang = self?.toLang { - let _ = translateMessageIds(context: context, messageIds: Array(messageIds.map(\.messageId)), toLang: toLang).startStandalone() + if let context = context, let translationLang = self?.translationLang { + let _ = translateMessageIds(context: context, messageIds: Array(messageIds.map(\.messageId)), fromLang: translationLang.fromLang, toLang: translationLang.toLang).startStandalone() } } @@ -1848,17 +1848,17 @@ public final class ChatHistoryListNodeImpl: ListView, ChatHistoryNode, ChatHisto providedByGroupBoost: audioTranscriptionProvidedByBoost ) - var translateToLanguage: String? + var translateToLanguage: (fromLang: String, toLang: String)? if let translationState, isPremium && translationState.isEnabled { var languageCode = translationState.toLang ?? chatPresentationData.strings.baseLanguageCode let rawSuffix = "-raw" if languageCode.hasSuffix(rawSuffix) { languageCode = String(languageCode.dropLast(rawSuffix.count)) } - translateToLanguage = normalizeTranslationLanguage(languageCode) + translateToLanguage = (normalizeTranslationLanguage(translationState.fromLang), normalizeTranslationLanguage(languageCode)) } - let associatedData = extractAssociatedData(chatLocation: chatLocation, view: view, automaticDownloadNetworkType: networkType, preferredStoryHighQuality: preferredStoryHighQuality, animatedEmojiStickers: animatedEmojiStickers, additionalAnimatedEmojiStickers: additionalAnimatedEmojiStickers, subject: subject, currentlyPlayingMessageId: currentlyPlayingMessageIdAndType?.0, isCopyProtectionEnabled: isCopyProtectionEnabled, availableReactions: availableReactions, availableMessageEffects: availableMessageEffects, savedMessageTags: savedMessageTags, defaultReaction: defaultReaction, isPremium: isPremium, alwaysDisplayTranscribeButton: alwaysDisplayTranscribeButton, accountPeer: accountPeer, topicAuthorId: topicAuthorId, hasBots: chatHasBots, translateToLanguage: translateToLanguage, maxReadStoryId: maxReadStoryId, recommendedChannels: recommendedChannels, audioTranscriptionTrial: audioTranscriptionTrial, chatThemes: chatThemes, deviceContactsNumbers: deviceContactsNumbers, isInline: !rotated, showSensitiveContent: contentSettings.ignoreContentRestrictionReasons.contains("sensitive")) + let associatedData = extractAssociatedData(chatLocation: chatLocation, view: view, automaticDownloadNetworkType: networkType, preferredStoryHighQuality: preferredStoryHighQuality, animatedEmojiStickers: animatedEmojiStickers, additionalAnimatedEmojiStickers: additionalAnimatedEmojiStickers, subject: subject, currentlyPlayingMessageId: currentlyPlayingMessageIdAndType?.0, isCopyProtectionEnabled: isCopyProtectionEnabled, availableReactions: availableReactions, availableMessageEffects: availableMessageEffects, savedMessageTags: savedMessageTags, defaultReaction: defaultReaction, isPremium: isPremium, alwaysDisplayTranscribeButton: alwaysDisplayTranscribeButton, accountPeer: accountPeer, topicAuthorId: topicAuthorId, hasBots: chatHasBots, translateToLanguage: translateToLanguage?.toLang, maxReadStoryId: maxReadStoryId, recommendedChannels: recommendedChannels, audioTranscriptionTrial: audioTranscriptionTrial, chatThemes: chatThemes, deviceContactsNumbers: deviceContactsNumbers, isInline: !rotated, showSensitiveContent: contentSettings.ignoreContentRestrictionReasons.contains("sensitive")) var includeEmbeddedSavedChatInfo = false if case let .replyThread(message) = chatLocation, message.peerId == context.account.peerId, !rotated { @@ -1958,7 +1958,11 @@ public final class ChatHistoryListNodeImpl: ListView, ChatHistoryNode, ChatHisto var scrollAnimationCurve: ListViewAnimationCurve? = nil if let strongSelf = self, case .default = source { - strongSelf.toLang = translateToLanguage + if let translateToLanguage { + strongSelf.translationLang = (fromLang: translateToLanguage.fromLang, toLang: translateToLanguage.toLang) + } else { + strongSelf.translationLang = nil + } if strongSelf.appliedScrollToMessageId == nil, let scrollToMessageId = scrollToMessageId { updatedScrollPosition = .index(subject: MessageHistoryScrollToSubject(index: .message(scrollToMessageId), quote: nil), position: .center(.top), directionHint: .Up, animated: true, highlight: false, displayLink: true, setupReply: false) scrollAnimationCurve = .Spring(duration: 0.4) diff --git a/submodules/TelegramUI/Sources/ChatPinnedMessageTitlePanelNode.swift b/submodules/TelegramUI/Sources/ChatPinnedMessageTitlePanelNode.swift index d69089f0a3a..9878fcc4998 100644 --- a/submodules/TelegramUI/Sources/ChatPinnedMessageTitlePanelNode.swift +++ b/submodules/TelegramUI/Sources/ChatPinnedMessageTitlePanelNode.swift @@ -72,7 +72,7 @@ final class ChatPinnedMessageTitlePanelNode: ChatTitleAccessoryPanelNode { private var currentLayout: (CGFloat, CGFloat, CGFloat)? private var currentMessage: ChatPinnedMessage? private var previousMediaReference: AnyMediaReference? - private var currentTranslateToLanguage: String? + private var currentTranslateToLanguage: (fromLang: String, toLang: String)? private let translationDisposable = MetaDisposable() private var isReplyThread: Bool = false @@ -496,21 +496,21 @@ final class ChatPinnedMessageTitlePanelNode: ChatTitleAccessoryPanelNode { self.clippingContainer.frame = CGRect(origin: CGPoint(), size: CGSize(width: width, height: panelHeight)) self.contentContainer.frame = CGRect(origin: CGPoint(), size: CGSize(width: width, height: panelHeight)) - var translateToLanguage: String? + var translateToLanguage: (fromLang: String, toLang: String)? if let translationState = interfaceState.translationState, translationState.isEnabled { - translateToLanguage = normalizeTranslationLanguage(translationState.toLang) + translateToLanguage = (normalizeTranslationLanguage(translationState.fromLang), normalizeTranslationLanguage(translationState.toLang)) } var currentTranslateToLanguageUpdated = false - if self.currentTranslateToLanguage != translateToLanguage { + if self.currentTranslateToLanguage?.fromLang != translateToLanguage?.fromLang || self.currentTranslateToLanguage?.toLang != translateToLanguage?.toLang { self.currentTranslateToLanguage = translateToLanguage currentTranslateToLanguageUpdated = true } if currentTranslateToLanguageUpdated || messageUpdated, let message = interfaceState.pinnedMessage?.message { - if let translation = message.attributes.first(where: { $0 is TranslationMessageAttribute }) as? TranslationMessageAttribute, translation.toLang == translateToLanguage { - } else if let translateToLanguage { - self.translationDisposable.set(translateMessageIds(context: self.context, messageIds: [message.id], toLang: translateToLanguage).startStrict()) + if let translation = message.attributes.first(where: { $0 is TranslationMessageAttribute }) as? TranslationMessageAttribute, translation.toLang == translateToLanguage?.toLang { + } else if let translateToLanguage { + self.translationDisposable.set(translateMessageIds(context: self.context, messageIds: [message.id], fromLang: translateToLanguage.fromLang, toLang: translateToLanguage.toLang).startStrict()) } } @@ -522,7 +522,7 @@ final class ChatPinnedMessageTitlePanelNode: ChatTitleAccessoryPanelNode { if let currentMessage = self.currentMessage, let currentLayout = self.currentLayout { self.dustNode?.update(revealed: false, animated: false) - self.enqueueTransition(width: currentLayout.0, panelHeight: panelHeight, leftInset: currentLayout.1, rightInset: currentLayout.2, transition: .immediate, animation: messageUpdatedAnimation, pinnedMessage: currentMessage, theme: interfaceState.theme, strings: interfaceState.strings, nameDisplayOrder: interfaceState.nameDisplayOrder, dateTimeFormat: interfaceState.dateTimeFormat, accountPeerId: self.context.account.peerId, firstTime: previousMessageWasNil, isReplyThread: isReplyThread, translateToLanguage: translateToLanguage) + self.enqueueTransition(width: currentLayout.0, panelHeight: panelHeight, leftInset: currentLayout.1, rightInset: currentLayout.2, transition: .immediate, animation: messageUpdatedAnimation, pinnedMessage: currentMessage, theme: interfaceState.theme, strings: interfaceState.strings, nameDisplayOrder: interfaceState.nameDisplayOrder, dateTimeFormat: interfaceState.dateTimeFormat, accountPeerId: self.context.account.peerId, firstTime: previousMessageWasNil, isReplyThread: isReplyThread, translateToLanguage: translateToLanguage?.toLang) } } diff --git a/submodules/TelegramUIPreferences/Sources/ExperimentalUISettings.swift b/submodules/TelegramUIPreferences/Sources/ExperimentalUISettings.swift index 011ccc11325..3e52a63cdba 100644 --- a/submodules/TelegramUIPreferences/Sources/ExperimentalUISettings.swift +++ b/submodules/TelegramUIPreferences/Sources/ExperimentalUISettings.swift @@ -60,6 +60,7 @@ public struct ExperimentalUISettings: Codable, Equatable { public var disableReloginTokens: Bool public var liveStreamV2: Bool public var dynamicStreaming: Bool + public var enableLocalTranslation: Bool public static var defaultSettings: ExperimentalUISettings { return ExperimentalUISettings( @@ -97,7 +98,8 @@ public struct ExperimentalUISettings: Codable, Equatable { allowWebViewInspection: false, disableReloginTokens: false, liveStreamV2: false, - dynamicStreaming: false + dynamicStreaming: false, + enableLocalTranslation: false ) } @@ -136,7 +138,8 @@ public struct ExperimentalUISettings: Codable, Equatable { allowWebViewInspection: Bool, disableReloginTokens: Bool, liveStreamV2: Bool, - dynamicStreaming: Bool + dynamicStreaming: Bool, + enableLocalTranslation: Bool ) { self.keepChatNavigationStack = keepChatNavigationStack self.skipReadHistory = skipReadHistory @@ -173,6 +176,7 @@ public struct ExperimentalUISettings: Codable, Equatable { self.disableReloginTokens = disableReloginTokens self.liveStreamV2 = liveStreamV2 self.dynamicStreaming = dynamicStreaming + self.enableLocalTranslation = enableLocalTranslation } public init(from decoder: Decoder) throws { @@ -213,6 +217,7 @@ public struct ExperimentalUISettings: Codable, Equatable { self.disableReloginTokens = try container.decodeIfPresent(Bool.self, forKey: "disableReloginTokens") ?? false self.liveStreamV2 = try container.decodeIfPresent(Bool.self, forKey: "liveStreamV2") ?? false self.dynamicStreaming = try container.decodeIfPresent(Bool.self, forKey: "dynamicStreaming") ?? false + self.enableLocalTranslation = try container.decodeIfPresent(Bool.self, forKey: "enableLocalTranslation") ?? false } public func encode(to encoder: Encoder) throws { @@ -253,6 +258,7 @@ public struct ExperimentalUISettings: Codable, Equatable { try container.encode(self.disableReloginTokens, forKey: "disableReloginTokens") try container.encode(self.liveStreamV2, forKey: "liveStreamV2") try container.encode(self.dynamicStreaming, forKey: "dynamicStreaming") + try container.encode(self.enableLocalTranslation, forKey: "enableLocalTranslation") } } diff --git a/submodules/TranslateUI/Sources/ChatTranslation.swift b/submodules/TranslateUI/Sources/ChatTranslation.swift index 35f2fdfd623..f6bdf92ec2f 100644 --- a/submodules/TranslateUI/Sources/ChatTranslation.swift +++ b/submodules/TranslateUI/Sources/ChatTranslation.swift @@ -117,7 +117,7 @@ public func updateChatTranslationStateInteractively(engine: TelegramEngine, peer @available(iOS 12.0, *) private let languageRecognizer = NLLanguageRecognizer() -public func translateMessageIds(context: AccountContext, messageIds: [EngineMessage.Id], toLang: String) -> Signal { +public func translateMessageIds(context: AccountContext, messageIds: [EngineMessage.Id], fromLang: String?, toLang: String) -> Signal { return context.account.postbox.transaction { transaction -> Signal in var messageIdsToTranslate: [EngineMessage.Id] = [] var messageIdsSet = Set() @@ -159,7 +159,7 @@ public func translateMessageIds(context: AccountContext, messageIds: [EngineMess } } } - return context.engine.messages.translateMessages(messageIds: messageIdsToTranslate, toLang: toLang) + return context.engine.messages.translateMessages(messageIds: messageIdsToTranslate, fromLang: fromLang, toLang: toLang, enableLocalIfPossible: context.sharedContext.immediateExperimentalUISettings.enableLocalTranslation) |> `catch` { _ -> Signal in return .complete() } diff --git a/submodules/TranslateUI/Sources/Translate.swift b/submodules/TranslateUI/Sources/Translate.swift index 64e664327b3..4d48f66e10e 100644 --- a/submodules/TranslateUI/Sources/Translate.swift +++ b/submodules/TranslateUI/Sources/Translate.swift @@ -5,6 +5,9 @@ import SwiftSignalKit import AccountContext import NaturalLanguage import TelegramCore +import SwiftUI +import Translation +import Combine // Incuding at least one Objective-C class in a swift file ensures that it doesn't get stripped by the linker private final class LinkHelperClass: NSObject { @@ -213,3 +216,190 @@ public func systemLanguageCodes() -> [String] { } return languages } + +@available(iOS 13.0, *) +class ExternalTranslationTrigger: ObservableObject { + @Published var shouldInvalidate: Int = 0 +} + +@available(iOS 18.0, *) +private struct TranslationViewImpl: View { + @State private var configuration: TranslationSession.Configuration? + @ObservedObject var externalCondition: ExternalTranslationTrigger + private let taskContainer: Atomic + + init(externalCondition: ExternalTranslationTrigger, taskContainer: Atomic) { + self.externalCondition = externalCondition + self.taskContainer = taskContainer + } + + var body: some View { + Text("ABC") + .onChange(of: self.externalCondition.shouldInvalidate) { _ in + let firstTaskLanguagePair = self.taskContainer.with { taskContainer -> (String, String)? in + if let firstTask = taskContainer.tasks.first { + return (firstTask.fromLang, firstTask.toLang) + } else { + return nil + } + } + + if let firstTaskLanguagePair { + if let configuration = self.configuration, configuration.source?.languageCode?.identifier == firstTaskLanguagePair.0, configuration.target?.languageCode?.identifier == firstTaskLanguagePair.1 { + self.configuration?.invalidate() + } else { + self.configuration = .init( + source: Locale.Language(identifier: firstTaskLanguagePair.0), + target: Locale.Language(identifier: firstTaskLanguagePair.1) + ) + } + } + } + .translationTask(self.configuration, action: { session in + var task: ExperimentalInternalTranslationServiceImpl.TranslationTask? + task = self.taskContainer.with { taskContainer -> ExperimentalInternalTranslationServiceImpl.TranslationTask? in + if !taskContainer.tasks.isEmpty { + return taskContainer.tasks.removeFirst() + } else { + return nil + } + } + + guard let task else { + return + } + + do { + var nextClientIdentifier: Int = 0 + var clientIdentifierMap: [String: AnyHashable] = [:] + let translationRequests = task.texts.map { key, value in + let id = nextClientIdentifier + nextClientIdentifier += 1 + clientIdentifierMap["\(id)"] = key + return TranslationSession.Request(sourceText: value, clientIdentifier: "\(id)") + } + + let responses = try await session.translations(from: translationRequests) + var resultMap: [AnyHashable: String] = [:] + for response in responses { + if let clientIdentifier = response.clientIdentifier, let originalKey = clientIdentifierMap[clientIdentifier] { + resultMap[originalKey] = "\(response.targetText)" + } + } + + task.completion(resultMap) + } catch let e { + print("Translation error: \(e)") + task.completion(nil) + } + + let firstTaskLanguagePair = self.taskContainer.with { taskContainer -> (String, String)? in + if let firstTask = taskContainer.tasks.first { + return (firstTask.fromLang, firstTask.toLang) + } else { + return nil + } + } + + if let firstTaskLanguagePair { + if let configuration = self.configuration, configuration.source?.languageCode?.identifier == firstTaskLanguagePair.0, configuration.target?.languageCode?.identifier == firstTaskLanguagePair.1 { + self.configuration?.invalidate() + } else { + self.configuration = .init( + source: Locale.Language(identifier: firstTaskLanguagePair.0), + target: Locale.Language(identifier: firstTaskLanguagePair.1) + ) + } + } + }) + } +} + +@available(iOS 18.0, *) +public final class ExperimentalInternalTranslationServiceImpl: ExperimentalInternalTranslationService { + fileprivate final class TranslationTask { + let id: Int + let texts: [AnyHashable: String] + let fromLang: String + let toLang: String + let completion: ([AnyHashable: String]?) -> Void + + init(id: Int, texts: [AnyHashable: String], fromLang: String, toLang: String, completion: @escaping ([AnyHashable: String]?) -> Void) { + self.id = id + self.texts = texts + self.fromLang = fromLang + self.toLang = toLang + self.completion = completion + } + } + + fileprivate final class TranslationTaskContainer { + var tasks: [TranslationTask] = [] + + init() { + } + } + + private final class Impl { + private let hostingController: UIViewController + + private let taskContainer = Atomic(value: TranslationTaskContainer()) + private let taskTrigger = ExternalTranslationTrigger() + + private var nextId: Int = 0 + + init(view: UIView) { + self.hostingController = UIHostingController(rootView: TranslationViewImpl( + externalCondition: self.taskTrigger, + taskContainer: self.taskContainer + )) + + view.addSubview(self.hostingController.view) + } + + func translate(texts: [AnyHashable: String], fromLang: String, toLang: String, onResult: @escaping ([AnyHashable: String]?) -> Void) -> Disposable { + let id = self.nextId + self.nextId += 1 + self.taskContainer.with { taskContainer in + taskContainer.tasks.append(TranslationTask( + id: id, + texts: texts, + fromLang: fromLang, + toLang: toLang, + completion: { result in + onResult(result) + } + )) + } + self.taskTrigger.shouldInvalidate += 1 + + return ActionDisposable { [weak self] in + Queue.mainQueue().async { + guard let self else { + return + } + self.taskContainer.with { taskContainer in + taskContainer.tasks.removeAll(where: { $0.id == id }) + } + } + } + } + } + + private let impl: QueueLocalObject + + public init(view: UIView) { + self.impl = QueueLocalObject(queue: .mainQueue(), generate: { + return Impl(view: view) + }) + } + + public func translate(texts: [AnyHashable: String], fromLang: String, toLang: String) -> Signal<[AnyHashable: String]?, NoError> { + return self.impl.signalWith { impl, subscriber in + return impl.translate(texts: texts, fromLang: fromLang, toLang: toLang, onResult: { result in + subscriber.putNext(result) + subscriber.putCompletion() + }) + } + } +} diff --git a/versions.json b/versions.json index 1a573248ddf..2dc865d46f1 100644 --- a/versions.json +++ b/versions.json @@ -1,6 +1,6 @@ { - "app": "11.3", + "app": "11.3.1", "xcode": "16.0", - "bazel": "7.3.1", + "bazel": "7.3.1:981f82a470bad1349322b6f51c9c6ffa0aa291dab1014fac411543c12e661dff", "macos": "15.0" }