diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 4a2997b380..a9d2756c95 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -19,6 +19,8 @@ internal: except: - tags script: + - export PATH=/opt/homebrew/opt/ruby/bin:$PATH + - export PATH=`gem environment gemdir`/bin:$PATH - python3 -u build-system/Make/Make.py remote-build --darwinContainers="$DARWIN_CONTAINERS" --darwinContainersHost="$DARWIN_CONTAINERS_HOST" --cacheHost="$TELEGRAM_BAZEL_CACHE_HOST" --configurationPath="$TELEGRAM_PRIVATE_DATA_PATH/build-configurations/enterprise-configuration.json" --gitCodesigningRepository="$TELEGRAM_GIT_CODESIGNING_REPOSITORY" --gitCodesigningType=enterprise --configuration=release_arm64 - python3 -u build-system/Make/DeployToAppCenter.py --configuration="$TELEGRAM_PRIVATE_DATA_PATH/appcenter-configurations/appcenter-internal.json" --ipa="build/artifacts/Telegram.ipa" --dsyms="build/artifacts/Telegram.DSYMs.zip" environment: @@ -93,6 +95,8 @@ beta_testflight: except: - tags script: + - export PATH=/opt/homebrew/opt/ruby/bin:$PATH + - export PATH=`gem environment gemdir`/bin:$PATH - python3 -u build-system/Make/Make.py remote-build --darwinContainers="$DARWIN_CONTAINERS" --darwinContainersHost="$DARWIN_CONTAINERS_HOST" --cacheHost="$TELEGRAM_BAZEL_CACHE_HOST" --configurationPath="build-system/appstore-configuration.json" --gitCodesigningRepository="$TELEGRAM_GIT_CODESIGNING_REPOSITORY" --gitCodesigningType=appstore --configuration=release_arm64 environment: name: testflight_llc diff --git a/build-system/Make/BuildConfiguration.py b/build-system/Make/BuildConfiguration.py index 8407dad9c5..6961e9a27f 100644 --- a/build-system/Make/BuildConfiguration.py +++ b/build-system/Make/BuildConfiguration.py @@ -104,7 +104,7 @@ def decrypt_codesigning_directory_recursively(source_base_path, destination_base source_path = source_base_path + '/' + file_name destination_path = destination_base_path + '/' + file_name if os.path.isfile(source_path): - os.system('openssl aes-256-cbc -md md5 -k "{password}" -in "{source_path}" -out "{destination_path}" -a -d 2>/dev/null'.format( + os.system('ruby build-system/decrypt.rb "{password}" "{source_path}" "{destination_path}"'.format( password=password, source_path=source_path, destination_path=destination_path diff --git a/build-system/decrypt.rb b/build-system/decrypt.rb new file mode 100644 index 0000000000..2b8561298a --- /dev/null +++ b/build-system/decrypt.rb @@ -0,0 +1,156 @@ +require 'base64' +require 'openssl' +require 'securerandom' + +class EncryptionV1 + ALGORITHM = 'aes-256-cbc' + + def encrypt(data:, password:, salt:, hash_algorithm: "MD5") + cipher = ::OpenSSL::Cipher.new(ALGORITHM) + cipher.encrypt + + keyivgen(cipher, password, salt, hash_algorithm) + + encrypted_data = cipher.update(data) + encrypted_data << cipher.final + { encrypted_data: encrypted_data } + end + + def decrypt(encrypted_data:, password:, salt:, hash_algorithm: "MD5") + cipher = ::OpenSSL::Cipher.new(ALGORITHM) + cipher.decrypt + + keyivgen(cipher, password, salt, hash_algorithm) + + data = cipher.update(encrypted_data) + data << cipher.final + end + + private + + def keyivgen(cipher, password, salt, hash_algorithm) + cipher.pkcs5_keyivgen(password, salt, 1, hash_algorithm) + end +end + +# The newer encryption mechanism, which features a more secure key and IV generation. +# +# The IV is randomly generated and provided unencrypted. +# The salt should be randomly generated and provided unencrypted (like in the current implementation). +# The key is generated with OpenSSL::KDF::pbkdf2_hmac with properly chosen parameters. +# +# Short explanation about salt and IV: https://stackoverflow.com/a/1950674/6324550 +class EncryptionV2 + ALGORITHM = 'aes-256-gcm' + + def encrypt(data:, password:, salt:) + cipher = ::OpenSSL::Cipher.new(ALGORITHM) + cipher.encrypt + + keyivgen(cipher, password, salt) + + encrypted_data = cipher.update(data) + encrypted_data << cipher.final + + auth_tag = cipher.auth_tag + + { encrypted_data: encrypted_data, auth_tag: auth_tag } + end + + def decrypt(encrypted_data:, password:, salt:, auth_tag:) + cipher = ::OpenSSL::Cipher.new(ALGORITHM) + cipher.decrypt + + keyivgen(cipher, password, salt) + + cipher.auth_tag = auth_tag + + data = cipher.update(encrypted_data) + data << cipher.final + end + + private + + def keyivgen(cipher, password, salt) + keyIv = ::OpenSSL::KDF.pbkdf2_hmac(password, salt: salt, iterations: 10_000, length: 32 + 12 + 24, hash: "sha256") + key = keyIv[0..31] + iv = keyIv[32..43] + auth_data = keyIv[44..-1] + cipher.key = key + cipher.iv = iv + cipher.auth_data = auth_data + end +end + +class MatchDataEncryption + V1_PREFIX = "Salted__" + V2_PREFIX = "match_encrypted_v2__" + + def encrypt(data:, password:, version: 2) + salt = SecureRandom.random_bytes(8) + if version == 2 + e = EncryptionV2.new + encryption = e.encrypt(data: data, password: password, salt: salt) + encrypted_data = V2_PREFIX + salt + encryption[:auth_tag] + encryption[:encrypted_data] + else + e = EncryptionV1.new + encryption = e.encrypt(data: data, password: password, salt: salt) + encrypted_data = V1_PREFIX + salt + encryption[:encrypted_data] + end + Base64.encode64(encrypted_data) + end + + def decrypt(base64encoded_encrypted:, password:) + stored_data = Base64.decode64(base64encoded_encrypted) + if stored_data.start_with?(V2_PREFIX) + salt = stored_data[20..27] + auth_tag = stored_data[28..43] + data_to_decrypt = stored_data[44..-1] + + e = EncryptionV2.new + e.decrypt(encrypted_data: data_to_decrypt, password: password, salt: salt, auth_tag: auth_tag) + else + salt = stored_data[8..15] + data_to_decrypt = stored_data[16..-1] + e = EncryptionV1.new + begin + # Note that we are not guaranteed to catch the decryption errors here if the password or the hash is wrong + # as there's no integrity checks. + # see https://github.com/fastlane/fastlane/issues/21663 + e.decrypt(encrypted_data: data_to_decrypt, password: password, salt: salt) + # With the wrong hash_algorithm, there's here 0.4% chance that the decryption failure will go undetected + rescue => _ex + # With a wrong password, there's a 0.4% chance it will decrypt garbage and not fail + fallback_hash_algorithm = "SHA256" + e.decrypt(encrypted_data: data_to_decrypt, password: password, salt: salt, hash_algorithm: fallback_hash_algorithm) + end + end + end +end + + +class MatchFileEncryption + def encrypt(file_path:, password:, output_path: nil) + output_path = file_path unless output_path + data_to_encrypt = File.binread(file_path) + e = MatchDataEncryption.new + data = e.encrypt(data: data_to_encrypt, password: password) + File.write(output_path, data) + end + + def decrypt(file_path:, password:, output_path: nil) + output_path = file_path unless output_path + content = File.read(file_path) + e = MatchDataEncryption.new + decrypted_data = e.decrypt(base64encoded_encrypted: content, password: password) + File.binwrite(output_path, decrypted_data) + end +end + + +if ARGV.length != 3 + print 'Invalid command line' +else + dec = MatchFileEncryption.new + dec.decrypt(file_path: ARGV[1], password: ARGV[0], output_path: ARGV[2]) +end diff --git a/submodules/FFMpegBinding/Public/FFMpegBinding/FFMpegBinding.h b/submodules/FFMpegBinding/Public/FFMpegBinding/FFMpegBinding.h index 5ed187b5fb..21801d9355 100644 --- a/submodules/FFMpegBinding/Public/FFMpegBinding/FFMpegBinding.h +++ b/submodules/FFMpegBinding/Public/FFMpegBinding/FFMpegBinding.h @@ -10,4 +10,5 @@ #import #import #import +#import #import diff --git a/submodules/FFMpegBinding/Public/FFMpegBinding/FFMpegLiveMuxer.h b/submodules/FFMpegBinding/Public/FFMpegBinding/FFMpegLiveMuxer.h new file mode 100644 index 0000000000..ec480c1f13 --- /dev/null +++ b/submodules/FFMpegBinding/Public/FFMpegBinding/FFMpegLiveMuxer.h @@ -0,0 +1,11 @@ +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface FFMpegLiveMuxer : NSObject + ++ (bool)remux:(NSString * _Nonnull)path to:(NSString * _Nonnull)outPath offsetSeconds:(double)offsetSeconds; + +@end + +NS_ASSUME_NONNULL_END diff --git a/submodules/FFMpegBinding/Sources/FFMpegLiveMuxer.m b/submodules/FFMpegBinding/Sources/FFMpegLiveMuxer.m new file mode 100644 index 0000000000..e54a3d0951 --- /dev/null +++ b/submodules/FFMpegBinding/Sources/FFMpegLiveMuxer.m @@ -0,0 +1,340 @@ +#import +#import + +#include "libavutil/timestamp.h" +#include "libavformat/avformat.h" +#include "libavcodec/avcodec.h" +#include "libswresample/swresample.h" + +#define MOV_TIMESCALE 1000 + +@implementation FFMpegLiveMuxer + ++ (bool)remux:(NSString * _Nonnull)path to:(NSString * _Nonnull)outPath offsetSeconds:(double)offsetSeconds { + AVFormatContext *input_format_context = NULL, *output_format_context = NULL; + AVPacket packet; + const char *in_filename, *out_filename; + int ret, i; + int stream_index = 0; + int *streams_list = NULL; + int number_of_streams = 0; + + struct SwrContext *swr_ctx = NULL; + + in_filename = [path UTF8String]; + out_filename = [outPath UTF8String]; + + if ((ret = avformat_open_input(&input_format_context, in_filename, av_find_input_format("mp4"), NULL)) < 0) { + fprintf(stderr, "Could not open input file '%s'\n", in_filename); + goto end; + } + if ((ret = avformat_find_stream_info(input_format_context, NULL)) < 0) { + fprintf(stderr, "Failed to retrieve input stream information\n"); + goto end; + } + + avformat_alloc_output_context2(&output_format_context, NULL, "mpegts", out_filename); + + if (!output_format_context) { + fprintf(stderr, "Could not create output context\n"); + ret = AVERROR_UNKNOWN; + goto end; + } + + const AVCodec *aac_codec = avcodec_find_encoder(AV_CODEC_ID_AAC); + if (!aac_codec) { + fprintf(stderr, "Could not find AAC encoder\n"); + ret = AVERROR_UNKNOWN; + goto end; + } + + AVCodecContext *aac_codec_context = avcodec_alloc_context3(aac_codec); + if (!aac_codec_context) { + fprintf(stderr, "Could not allocate AAC codec context\n"); + ret = AVERROR_UNKNOWN; + goto end; + } + + const AVCodec *opus_decoder = avcodec_find_decoder(AV_CODEC_ID_OPUS); + if (!opus_decoder) { + fprintf(stderr, "Could not find Opus decoder\n"); + ret = AVERROR_UNKNOWN; + goto end; + } + + AVCodecContext *opus_decoder_context = avcodec_alloc_context3(opus_decoder); + if (!opus_decoder_context) { + fprintf(stderr, "Could not allocate Opus decoder context\n"); + ret = AVERROR_UNKNOWN; + goto end; + } + + number_of_streams = input_format_context->nb_streams; + streams_list = av_malloc_array(number_of_streams, sizeof(*streams_list)); + + if (!streams_list) { + ret = AVERROR(ENOMEM); + goto end; + } + + bool hasAudio = false; + + for (i = 0; i < input_format_context->nb_streams; i++) { + AVStream *out_stream; + AVStream *in_stream = input_format_context->streams[i]; + AVCodecParameters *in_codecpar = in_stream->codecpar; + + if (/*in_codecpar->codec_type != AVMEDIA_TYPE_AUDIO && */in_codecpar->codec_type != AVMEDIA_TYPE_VIDEO) { + streams_list[i] = -1; + continue; + } + + streams_list[i] = stream_index++; + + if (in_codecpar->codec_type == AVMEDIA_TYPE_VIDEO) { + out_stream = avformat_new_stream(output_format_context, NULL); + if (!out_stream) { + fprintf(stderr, "Failed allocating output stream\n"); + ret = AVERROR_UNKNOWN; + goto end; + } + ret = avcodec_parameters_copy(out_stream->codecpar, in_codecpar); + if (ret < 0) { + fprintf(stderr, "Failed to copy codec parameters\n"); + goto end; + } + out_stream->time_base = in_stream->time_base; + out_stream->duration = in_stream->duration; + } else if (in_codecpar->codec_type == AVMEDIA_TYPE_AUDIO) { + out_stream = avformat_new_stream(output_format_context, aac_codec); + if (!out_stream) { + fprintf(stderr, "Failed allocating output stream\n"); + ret = AVERROR_UNKNOWN; + goto end; + } + + hasAudio = true; + + // Set the codec parameters for the AAC encoder + aac_codec_context->sample_rate = in_codecpar->sample_rate; + aac_codec_context->channel_layout = in_codecpar->channel_layout ? in_codecpar->channel_layout : AV_CH_LAYOUT_STEREO; + aac_codec_context->channels = av_get_channel_layout_nb_channels(aac_codec_context->channel_layout); + aac_codec_context->sample_fmt = aac_codec->sample_fmts ? aac_codec->sample_fmts[0] : AV_SAMPLE_FMT_FLTP; // Use the first supported sample format + aac_codec_context->bit_rate = 128000; // Set a default bitrate, you can adjust this as needed + //aac_codec_context->time_base = (AVRational){1, 90000}; + + ret = avcodec_open2(aac_codec_context, aac_codec, NULL); + if (ret < 0) { + fprintf(stderr, "Could not open AAC encoder\n"); + goto end; + } + + ret = avcodec_parameters_from_context(out_stream->codecpar, aac_codec_context); + if (ret < 0) { + fprintf(stderr, "Failed initializing audio output stream\n"); + goto end; + } + + out_stream->time_base = (AVRational){1, 90000}; + out_stream->duration = av_rescale_q(in_stream->duration, in_stream->time_base, out_stream->time_base); + + // Set up the Opus decoder context + ret = avcodec_parameters_to_context(opus_decoder_context, in_codecpar); + if (ret < 0) { + fprintf(stderr, "Could not copy codec parameters to decoder context\n"); + goto end; + } + if (opus_decoder_context->channel_layout == 0) { + opus_decoder_context->channel_layout = av_get_default_channel_layout(opus_decoder_context->channels); + } + ret = avcodec_open2(opus_decoder_context, opus_decoder, NULL); + if (ret < 0) { + fprintf(stderr, "Could not open Opus decoder\n"); + goto end; + } + + // Reset the channel layout if it was unset before opening the codec + if (opus_decoder_context->channel_layout == 0) { + opus_decoder_context->channel_layout = av_get_default_channel_layout(opus_decoder_context->channels); + } + } + } + + if (hasAudio) { + // Set up the resampling context + swr_ctx = swr_alloc_set_opts(NULL, + aac_codec_context->channel_layout, aac_codec_context->sample_fmt, aac_codec_context->sample_rate, + opus_decoder_context->channel_layout, opus_decoder_context->sample_fmt, opus_decoder_context->sample_rate, + 0, NULL); + if (!swr_ctx) { + fprintf(stderr, "Could not allocate resampler context\n"); + ret = AVERROR(ENOMEM); + goto end; + } + + if ((ret = swr_init(swr_ctx)) < 0) { + fprintf(stderr, "Failed to initialize the resampling context\n"); + goto end; + } + } + + if (!(output_format_context->oformat->flags & AVFMT_NOFILE)) { + ret = avio_open(&output_format_context->pb, out_filename, AVIO_FLAG_WRITE); + if (ret < 0) { + fprintf(stderr, "Could not open output file '%s'\n", out_filename); + goto end; + } + } + + AVDictionary* opts = NULL; + ret = avformat_write_header(output_format_context, &opts); + if (ret < 0) { + fprintf(stderr, "Error occurred when opening output file\n"); + goto end; + } + + while (1) { + AVStream *in_stream, *out_stream; + ret = av_read_frame(input_format_context, &packet); + if (ret < 0) + break; + + in_stream = input_format_context->streams[packet.stream_index]; + if (packet.stream_index >= number_of_streams || streams_list[packet.stream_index] < 0) { + av_packet_unref(&packet); + continue; + } + + packet.stream_index = streams_list[packet.stream_index]; + out_stream = output_format_context->streams[packet.stream_index]; + + if (in_stream->codecpar->codec_type == AVMEDIA_TYPE_AUDIO) { + ret = avcodec_send_packet(opus_decoder_context, &packet); + if (ret < 0) { + fprintf(stderr, "Error sending packet to decoder\n"); + av_packet_unref(&packet); + continue; + } + + AVFrame *frame = av_frame_alloc(); + ret = avcodec_receive_frame(opus_decoder_context, frame); + if (ret < 0 && ret != AVERROR(EAGAIN) && ret != AVERROR_EOF) { + fprintf(stderr, "Error receiving frame from decoder\n"); + av_frame_free(&frame); + av_packet_unref(&packet); + continue; + } + + if (ret >= 0) { + frame->pts = frame->best_effort_timestamp; + + AVFrame *resampled_frame = av_frame_alloc(); + resampled_frame->channel_layout = aac_codec_context->channel_layout; + resampled_frame->sample_rate = aac_codec_context->sample_rate; + resampled_frame->format = aac_codec_context->sample_fmt; + resampled_frame->nb_samples = aac_codec_context->frame_size; + + if ((ret = av_frame_get_buffer(resampled_frame, 0)) < 0) { + fprintf(stderr, "Could not allocate resampled frame buffer\n"); + av_frame_free(&resampled_frame); + av_frame_free(&frame); + av_packet_unref(&packet); + continue; + } + + memset(resampled_frame->data[0], 0, resampled_frame->nb_samples * 2 * 2); + //arc4random_buf(resampled_frame->data[0], resampled_frame->nb_samples * 2 * 2); + //memset(frame->data[0], 0, frame->nb_samples * 2 * 2); + + if ((ret = swr_convert(swr_ctx, + resampled_frame->data, resampled_frame->nb_samples, + (const uint8_t **)frame->data, frame->nb_samples)) < 0) { + fprintf(stderr, "Error while converting\n"); + av_frame_free(&resampled_frame); + av_frame_free(&frame); + av_packet_unref(&packet); + continue; + } + + resampled_frame->pts = av_rescale_q(frame->pts, opus_decoder_context->time_base, aac_codec_context->time_base); + + ret = avcodec_send_frame(aac_codec_context, resampled_frame); + if (ret < 0) { + fprintf(stderr, "Error sending frame to encoder\n"); + av_frame_free(&resampled_frame); + av_frame_free(&frame); + av_packet_unref(&packet); + continue; + } + + AVPacket out_packet; + av_init_packet(&out_packet); + out_packet.data = NULL; + out_packet.size = 0; + + ret = avcodec_receive_packet(aac_codec_context, &out_packet); + if (ret >= 0) { + out_packet.pts = av_rescale_q_rnd(packet.pts, in_stream->time_base, out_stream->time_base, AV_ROUND_NEAR_INF | AV_ROUND_PASS_MINMAX); + out_packet.dts = av_rescale_q_rnd(packet.dts, in_stream->time_base, out_stream->time_base, AV_ROUND_NEAR_INF | AV_ROUND_PASS_MINMAX); + out_packet.pts += (int64_t)(offsetSeconds * out_stream->time_base.den); + out_packet.dts += (int64_t)(offsetSeconds * out_stream->time_base.den); + out_packet.duration = av_rescale_q(out_packet.duration, aac_codec_context->time_base, out_stream->time_base); + out_packet.stream_index = packet.stream_index; + + ret = av_interleaved_write_frame(output_format_context, &out_packet); + if (ret < 0) { + fprintf(stderr, "Error muxing packet\n"); + av_packet_unref(&out_packet); + av_frame_free(&resampled_frame); + av_frame_free(&frame); + av_packet_unref(&packet); + break; + } + av_packet_unref(&out_packet); + } + av_frame_free(&resampled_frame); + av_frame_free(&frame); + } + } else { + packet.pts = av_rescale_q_rnd(packet.pts, in_stream->time_base, out_stream->time_base, AV_ROUND_NEAR_INF | AV_ROUND_PASS_MINMAX); + packet.dts = av_rescale_q_rnd(packet.dts, in_stream->time_base, out_stream->time_base, AV_ROUND_NEAR_INF | AV_ROUND_PASS_MINMAX); + packet.pts += (int64_t)(offsetSeconds * out_stream->time_base.den); + packet.dts += (int64_t)(offsetSeconds * out_stream->time_base.den); + packet.duration = av_rescale_q(packet.duration, in_stream->time_base, out_stream->time_base); + packet.pos = -1; + + ret = av_interleaved_write_frame(output_format_context, &packet); + if (ret < 0) { + fprintf(stderr, "Error muxing packet\n"); + av_packet_unref(&packet); + break; + } + } + + av_packet_unref(&packet); + } + + av_write_trailer(output_format_context); + +end: + avformat_close_input(&input_format_context); + if (output_format_context && !(output_format_context->oformat->flags & AVFMT_NOFILE)) { + avio_closep(&output_format_context->pb); + } + avformat_free_context(output_format_context); + avcodec_free_context(&aac_codec_context); + avcodec_free_context(&opus_decoder_context); + av_freep(&streams_list); + if (swr_ctx) { + swr_free(&swr_ctx); + } + if (ret < 0 && ret != AVERROR_EOF) { + fprintf(stderr, "Error occurred: %s\n", av_err2str(ret)); + return false; + } + + printf("Remuxed video into %s\n", outPath.UTF8String); + return true; +} + +@end diff --git a/submodules/InAppPurchaseManager/Sources/InAppPurchaseManager.swift b/submodules/InAppPurchaseManager/Sources/InAppPurchaseManager.swift index 40a29f45e8..0c946f4a64 100644 --- a/submodules/InAppPurchaseManager/Sources/InAppPurchaseManager.swift +++ b/submodules/InAppPurchaseManager/Sources/InAppPurchaseManager.swift @@ -729,6 +729,9 @@ private final class PendingInAppPurchaseState: Codable { self = .giveaway(boostPeer: boostPeer, additionalPeerIds: additionalPeerIds, countries: countries, onlyNewSubscribers: onlyNewSubscribers, showWinners: showWinners, prizeDescription: prizeDescription, randomId: randomId, untilDate: untilDate) case let .stars(count, _, _): self = .stars(count: count) + case let .starsGift(_, count, _, _): + //TODO:localize + self = .stars(count: count) } } diff --git a/submodules/TelegramCallsUI/Sources/Components/MediaStreamComponent.swift b/submodules/TelegramCallsUI/Sources/Components/MediaStreamComponent.swift index 35877a0bd4..0bdfd93fb1 100644 --- a/submodules/TelegramCallsUI/Sources/Components/MediaStreamComponent.swift +++ b/submodules/TelegramCallsUI/Sources/Components/MediaStreamComponent.swift @@ -75,9 +75,9 @@ public final class MediaStreamComponent: CombinedComponent { var videoStalled: Bool = true var videoIsPlayable: Bool { - !videoStalled && hasVideo + return true + //!videoStalled && hasVideo } -// var wantsPiP: Bool = false let deactivatePictureInPictureIfVisible = StoredActionSlot(Void.self) diff --git a/submodules/TelegramCallsUI/Sources/Components/MediaStreamVideoComponent.swift b/submodules/TelegramCallsUI/Sources/Components/MediaStreamVideoComponent.swift index 2debd4072b..46371e9fb7 100644 --- a/submodules/TelegramCallsUI/Sources/Components/MediaStreamVideoComponent.swift +++ b/submodules/TelegramCallsUI/Sources/Components/MediaStreamVideoComponent.swift @@ -6,11 +6,11 @@ import AVKit import MultilineTextComponent import Display import ShimmerEffect - import TelegramCore import SwiftSignalKit import AvatarNode import Postbox +import TelegramVoip final class MediaStreamVideoComponent: Component { let call: PresentationGroupCallImpl @@ -157,6 +157,8 @@ final class MediaStreamVideoComponent: Component { private var lastPresentation: UIView? private var pipTrackDisplayLink: CADisplayLink? + private var livePlayerView: ProxyVideoView? + override init(frame: CGRect) { self.blurTintView = UIView() self.blurTintView.backgroundColor = UIColor(white: 0.0, alpha: 0.55) @@ -211,7 +213,7 @@ final class MediaStreamVideoComponent: Component { let needsFadeInAnimation = hadVideo if loadingBlurView.superview == nil { - addSubview(loadingBlurView) + //addSubview(loadingBlurView) if needsFadeInAnimation { let anim = CABasicAnimation(keyPath: "opacity") anim.duration = 0.5 @@ -542,6 +544,21 @@ final class MediaStreamVideoComponent: Component { videoFrameUpdateTransition.setFrame(layer: self.videoBlurGradientMask, frame: videoBlurView.bounds) videoFrameUpdateTransition.setFrame(layer: self.videoBlurSolidMask, frame: self.videoBlurGradientMask.bounds) } + + if self.livePlayerView == nil { + let livePlayerView = ProxyVideoView(context: component.call.accountContext, call: component.call) + self.livePlayerView = livePlayerView + livePlayerView.layer.masksToBounds = true + self.addSubview(livePlayerView) + livePlayerView.frame = newVideoFrame + livePlayerView.layer.cornerRadius = videoCornerRadius + livePlayerView.update(size: newVideoFrame.size) + } + if let livePlayerView = self.livePlayerView { + videoFrameUpdateTransition.setFrame(view: livePlayerView, frame: newVideoFrame, completion: nil) + videoFrameUpdateTransition.setCornerRadius(layer: livePlayerView.layer, cornerRadius: videoCornerRadius) + livePlayerView.update(size: newVideoFrame.size) + } } else { videoSize = CGSize(width: 16 / 9 * 100.0, height: 100.0).aspectFitted(.init(width: availableSize.width - videoInset * 2, height: availableSize.height)) } @@ -601,7 +618,7 @@ final class MediaStreamVideoComponent: Component { } } - if self.noSignalTimeout { + if self.noSignalTimeout, !"".isEmpty { var noSignalTransition = transition let noSignalView: ComponentHostView if let current = self.noSignalView { @@ -769,3 +786,69 @@ private final class CustomIntensityVisualEffectView: UIVisualEffectView { animator.stopAnimation(true) } } + +private final class ProxyVideoView: UIView { + private let call: PresentationGroupCallImpl + private let id: Int64 + private let player: AVPlayer + private let playerItem: AVPlayerItem + private let playerLayer: AVPlayerLayer + + private var contextDisposable: Disposable? + + private var failureObserverId: AnyObject? + private var errorObserverId: AnyObject? + + private var server: AnyObject? + + init(context: AccountContext, call: PresentationGroupCallImpl) { + self.call = call + + self.id = Int64.random(in: Int64.min ... Int64.max) + + let assetUrl = "http://127.0.0.1:\(SharedHLSServer.shared.port)/\(call.internalId)/master.m3u8" + Logger.shared.log("MediaStreamVideoComponent", "Initializing HLS asset at \(assetUrl)") + #if DEBUG + print("Initializing HLS asset at \(assetUrl)") + #endif + let asset = AVURLAsset(url: URL(string: assetUrl)!, options: [:]) + self.playerItem = AVPlayerItem(asset: asset) + self.player = AVPlayer(playerItem: self.playerItem) + self.player.allowsExternalPlayback = true + self.playerLayer = AVPlayerLayer(player: self.player) + + super.init(frame: CGRect()) + + self.failureObserverId = NotificationCenter.default.addObserver(forName: AVPlayerItem.failedToPlayToEndTimeNotification, object: playerItem, queue: .main, using: { notification in + print("Player Error: \(notification.description)") + }) + self.errorObserverId = NotificationCenter.default.addObserver(forName: AVPlayerItem.newErrorLogEntryNotification, object: playerItem, queue: .main, using: { notification in + print("Player Error: \(notification.description)") + }) + + self.layer.addSublayer(self.playerLayer) + + //self.contextDisposable = ResourceAdaptor.shared.addContext(id: self.id, context: context, fileReference: fileReference) + + self.player.play() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + deinit { + self.contextDisposable?.dispose() + if let failureObserverId = self.failureObserverId { + NotificationCenter.default.removeObserver(failureObserverId) + } + if let errorObserverId = self.errorObserverId { + NotificationCenter.default.removeObserver(errorObserverId) + } + } + + func update(size: CGSize) { + self.playerLayer.frame = CGRect(origin: CGPoint(), size: size) + } +} + diff --git a/submodules/TelegramCallsUI/Sources/PresentationGroupCall.swift b/submodules/TelegramCallsUI/Sources/PresentationGroupCall.swift index 284f939f91..811d2553ef 100644 --- a/submodules/TelegramCallsUI/Sources/PresentationGroupCall.swift +++ b/submodules/TelegramCallsUI/Sources/PresentationGroupCall.swift @@ -276,6 +276,7 @@ private extension PresentationGroupCallState { private enum CurrentImpl { case call(OngoingGroupCallContext) case mediaStream(WrappedMediaStreamingContext) + case externalMediaStream(ExternalMediaStreamingContext) } private extension CurrentImpl { @@ -283,7 +284,7 @@ private extension CurrentImpl { switch self { case let .call(callContext): return callContext.joinPayload - case .mediaStream: + case .mediaStream, .externalMediaStream: let ssrcId = UInt32.random(in: 0 ..< UInt32(Int32.max - 1)) let dict: [String: Any] = [ "fingerprints": [] as [Any], @@ -303,7 +304,7 @@ private extension CurrentImpl { switch self { case let .call(callContext): return callContext.networkState - case .mediaStream: + case .mediaStream, .externalMediaStream: return .single(OngoingGroupCallContext.NetworkState(isConnected: true, isTransitioningFromBroadcastToRtc: false)) } } @@ -312,7 +313,7 @@ private extension CurrentImpl { switch self { case let .call(callContext): return callContext.audioLevels - case .mediaStream: + case .mediaStream, .externalMediaStream: return .single([]) } } @@ -321,7 +322,7 @@ private extension CurrentImpl { switch self { case let .call(callContext): return callContext.isMuted - case .mediaStream: + case .mediaStream, .externalMediaStream: return .single(true) } } @@ -330,7 +331,7 @@ private extension CurrentImpl { switch self { case let .call(callContext): return callContext.isNoiseSuppressionEnabled - case .mediaStream: + case .mediaStream, .externalMediaStream: return .single(false) } } @@ -339,7 +340,7 @@ private extension CurrentImpl { switch self { case let .call(callContext): callContext.stop() - case .mediaStream: + case .mediaStream, .externalMediaStream: break } } @@ -348,7 +349,7 @@ private extension CurrentImpl { switch self { case let .call(callContext): callContext.setIsMuted(isMuted) - case .mediaStream: + case .mediaStream, .externalMediaStream: break } } @@ -357,7 +358,7 @@ private extension CurrentImpl { switch self { case let .call(callContext): callContext.setIsNoiseSuppressionEnabled(isNoiseSuppressionEnabled) - case .mediaStream: + case .mediaStream, .externalMediaStream: break } } @@ -366,7 +367,7 @@ private extension CurrentImpl { switch self { case let .call(callContext): callContext.requestVideo(capturer) - case .mediaStream: + case .mediaStream, .externalMediaStream: break } } @@ -375,7 +376,7 @@ private extension CurrentImpl { switch self { case let .call(callContext): callContext.disableVideo() - case .mediaStream: + case .mediaStream, .externalMediaStream: break } } @@ -384,7 +385,7 @@ private extension CurrentImpl { switch self { case let .call(callContext): callContext.setVolume(ssrc: ssrc, volume: volume) - case .mediaStream: + case .mediaStream, .externalMediaStream: break } } @@ -393,7 +394,7 @@ private extension CurrentImpl { switch self { case let .call(callContext): callContext.setRequestedVideoChannels(channels) - case .mediaStream: + case .mediaStream, .externalMediaStream: break } } @@ -402,17 +403,19 @@ private extension CurrentImpl { switch self { case let .call(callContext): callContext.makeIncomingVideoView(endpointId: endpointId, requestClone: requestClone, completion: completion) - case .mediaStream: + case .mediaStream, .externalMediaStream: break } } - func video(endpointId: String) -> Signal { + func video(endpointId: String) -> Signal? { switch self { case let .call(callContext): return callContext.video(endpointId: endpointId) case let .mediaStream(mediaStreamContext): return mediaStreamContext.video() + case .externalMediaStream: + return .never() } } @@ -420,7 +423,7 @@ private extension CurrentImpl { switch self { case let .call(callContext): callContext.addExternalAudioData(data: data) - case .mediaStream: + case .mediaStream, .externalMediaStream: break } } @@ -429,7 +432,7 @@ private extension CurrentImpl { switch self { case let .call(callContext): callContext.getStats(completion: completion) - case .mediaStream: + case .mediaStream, .externalMediaStream: break } } @@ -438,7 +441,7 @@ private extension CurrentImpl { switch self { case let .call(callContext): callContext.setTone(tone: tone) - case .mediaStream: + case .mediaStream, .externalMediaStream: break } } @@ -647,6 +650,8 @@ public final class PresentationGroupCallImpl: PresentationGroupCall { private var genericCallContext: CurrentImpl? private var currentConnectionMode: OngoingGroupCallContext.ConnectionMode = .none private var didInitializeConnectionMode: Bool = false + + let externalMediaStream = Promise() private var screencastCallContext: OngoingGroupCallContext? private var screencastBufferServerContext: IpcGroupCallBufferAppContext? @@ -1638,7 +1643,7 @@ public final class PresentationGroupCallImpl: PresentationGroupCall { genericCallContext = current } else { if self.isStream, self.accountContext.sharedContext.immediateExperimentalUISettings.liveStreamV2 { - genericCallContext = .mediaStream(WrappedMediaStreamingContext(rejoinNeeded: { [weak self] in + let externalMediaStream = ExternalMediaStreamingContext(id: self.internalId, rejoinNeeded: { [weak self] in Queue.mainQueue().async { guard let strongSelf = self else { return @@ -1650,7 +1655,9 @@ public final class PresentationGroupCallImpl: PresentationGroupCall { strongSelf.requestCall(movingFromBroadcastToRtc: false) } } - })) + }) + genericCallContext = .externalMediaStream(externalMediaStream) + self.externalMediaStream.set(.single(externalMediaStream)) } else { var outgoingAudioBitrateKbit: Int32? let appConfiguration = self.accountContext.currentAppConfiguration.with({ $0 }) @@ -1797,6 +1804,14 @@ public final class PresentationGroupCallImpl: PresentationGroupCall { strongSelf.currentConnectionMode = .broadcast mediaStreamContext.setAudioStreamData(audioStreamData: OngoingGroupCallContext.AudioStreamData(engine: strongSelf.accountContext.engine, callId: callInfo.id, accessHash: callInfo.accessHash, isExternalStream: callInfo.isStream)) } + case let .externalMediaStream(externalMediaStream): + switch joinCallResult.connectionMode { + case .rtc: + strongSelf.currentConnectionMode = .rtc + case .broadcast: + strongSelf.currentConnectionMode = .broadcast + externalMediaStream.setAudioStreamData(audioStreamData: OngoingGroupCallContext.AudioStreamData(engine: strongSelf.accountContext.engine, callId: callInfo.id, accessHash: callInfo.accessHash, isExternalStream: callInfo.isStream)) + } } } @@ -3199,7 +3214,7 @@ public final class PresentationGroupCallImpl: PresentationGroupCall { switch genericCallContext { case let .call(callContext): callContext.setConnectionMode(.none, keepBroadcastConnectedIfWasEnabled: movingFromBroadcastToRtc, isUnifiedBroadcast: false) - case .mediaStream: + case .mediaStream, .externalMediaStream: assertionFailure() break } diff --git a/submodules/TelegramCore/Sources/TelegramEngine/Messages/SearchMessages.swift b/submodules/TelegramCore/Sources/TelegramEngine/Messages/SearchMessages.swift index 84aedac1f2..baac6eb27b 100644 --- a/submodules/TelegramCore/Sources/TelegramEngine/Messages/SearchMessages.swift +++ b/submodules/TelegramCore/Sources/TelegramEngine/Messages/SearchMessages.swift @@ -236,6 +236,59 @@ private func mergedResult(_ state: SearchMessagesState) -> SearchMessagesResult return SearchMessagesResult(messages: messages, readStates: readStates, threadInfo: threadInfo, totalCount: state.main.totalCount + (state.additional?.totalCount ?? 0), completed: state.main.completed && (state.additional?.completed ?? true)) } +func _internal_getSearchMessageCount(account: Account, location: SearchMessagesLocation, query: String) -> Signal { + guard case let .peer(peerId, fromId, _, _, threadId, _, _) = location else { + return .single(nil) + } + return account.postbox.transaction { transaction -> (Api.InputPeer?, Api.InputPeer?) in + var chatPeer = transaction.getPeer(peerId).flatMap(apiInputPeer) + var fromPeer: Api.InputPeer? + if let fromId { + if let value = transaction.getPeer(fromId).flatMap(apiInputPeer) { + fromPeer = value + } else { + chatPeer = nil + } + } + + return (chatPeer, fromPeer) + } + |> mapToSignal { inputPeer, fromPeer -> Signal in + guard let inputPeer else { + return .single(nil) + } + + var flags: Int32 = 0 + + if let _ = fromPeer { + flags |= (1 << 0) + } + + var topMsgId: Int32? + if let threadId = threadId { + flags |= (1 << 1) + topMsgId = Int32(clamping: threadId) + } + + return account.network.request(Api.functions.messages.search(flags: flags, peer: inputPeer, q: query, fromId: fromPeer, savedPeerId: nil, savedReaction: nil, topMsgId: topMsgId, filter: .inputMessagesFilterEmpty, minDate: 0, maxDate: 0, offsetId: 0, addOffset: 0, limit: 1, maxId: 0, minId: 0, hash: 0)) + |> map { result -> Int? in + switch result { + case let .channelMessages(_, _, count, _, _, _, _, _): + return Int(count) + case let .messages(messages, _, _): + return messages.count + case let .messagesNotModified(count): + return Int(count) + case let .messagesSlice(_, count, _, _, _, _, _): + return Int(count) + } + } + |> `catch` { _ -> Signal in + return .single(nil) + } + } +} + func _internal_searchMessages(account: Account, location: SearchMessagesLocation, query: String, state: SearchMessagesState?, centerId: MessageId?, limit: Int32 = 100) -> Signal<(SearchMessagesResult, SearchMessagesState), NoError> { if case let .peer(peerId, fromId, tags, reactions, threadId, minDate, maxDate) = location, fromId == nil, tags == nil, peerId == account.peerId, let reactions, let reaction = reactions.first, (minDate == nil || minDate == 0), (maxDate == nil || maxDate == 0) { return account.postbox.transaction { transaction -> (SearchMessagesResult, SearchMessagesState) in diff --git a/submodules/TelegramCore/Sources/TelegramEngine/Messages/TelegramEngineMessages.swift b/submodules/TelegramCore/Sources/TelegramEngine/Messages/TelegramEngineMessages.swift index c722016d4d..4245cf9907 100644 --- a/submodules/TelegramCore/Sources/TelegramEngine/Messages/TelegramEngineMessages.swift +++ b/submodules/TelegramCore/Sources/TelegramEngine/Messages/TelegramEngineMessages.swift @@ -76,6 +76,10 @@ public extension TelegramEngine { return _internal_searchMessages(account: self.account, location: location, query: query, state: state, centerId: centerId, limit: limit) } + public func getSearchMessageCount(location: SearchMessagesLocation, query: String) -> Signal { + return _internal_getSearchMessageCount(account: self.account, location: location, query: query) + } + public func searchHashtagPosts(hashtag: String, state: SearchMessagesState?, limit: Int32 = 100) -> Signal<(SearchMessagesResult, SearchMessagesState), NoError> { return _internal_searchHashtagPosts(account: self.account, hashtag: hashtag, state: state, limit: limit) } diff --git a/submodules/TelegramStringFormatting/Sources/ServiceMessageStrings.swift b/submodules/TelegramStringFormatting/Sources/ServiceMessageStrings.swift index cf7fd66196..e3c6cfc2d7 100644 --- a/submodules/TelegramStringFormatting/Sources/ServiceMessageStrings.swift +++ b/submodules/TelegramStringFormatting/Sources/ServiceMessageStrings.swift @@ -994,6 +994,8 @@ public func universalServiceMessageString(presentationData: (PresentationTheme, } case .unknown: attributedString = nil + case .paymentRefunded, .giftStars: + attributedString = nil } break diff --git a/submodules/TelegramUI/Components/AdminUserActionsSheet/Sources/AdminUserActionsSheet.swift b/submodules/TelegramUI/Components/AdminUserActionsSheet/Sources/AdminUserActionsSheet.swift index b85e764df7..439be5c6bf 100644 --- a/submodules/TelegramUI/Components/AdminUserActionsSheet/Sources/AdminUserActionsSheet.swift +++ b/submodules/TelegramUI/Components/AdminUserActionsSheet/Sources/AdminUserActionsSheet.swift @@ -194,6 +194,7 @@ private final class AdminUserActionsSheetComponent: Component { let chatPeer: EnginePeer let peers: [RenderedChannelParticipant] let messageCount: Int + let deleteAllMessageCount: Int? let completion: (AdminUserActionsSheet.Result) -> Void init( @@ -201,12 +202,14 @@ private final class AdminUserActionsSheetComponent: Component { chatPeer: EnginePeer, peers: [RenderedChannelParticipant], messageCount: Int, + deleteAllMessageCount: Int?, completion: @escaping (AdminUserActionsSheet.Result) -> Void ) { self.context = context self.chatPeer = chatPeer self.peers = peers self.messageCount = messageCount + self.deleteAllMessageCount = deleteAllMessageCount self.completion = completion } @@ -223,6 +226,9 @@ private final class AdminUserActionsSheetComponent: Component { if lhs.messageCount != rhs.messageCount { return false } + if lhs.deleteAllMessageCount != rhs.deleteAllMessageCount { + return false + } return true } @@ -642,7 +648,7 @@ private final class AdminUserActionsSheetComponent: Component { let sectionId: AnyHashable let selectedPeers: Set let isExpanded: Bool - let title: String + var title: String switch section { case .report: @@ -870,7 +876,14 @@ private final class AdminUserActionsSheetComponent: Component { ))) } - let titleString: String = environment.strings.Chat_AdminActionSheet_DeleteTitle(Int32(component.messageCount)) + var titleString: String = environment.strings.Chat_AdminActionSheet_DeleteTitle(Int32(component.messageCount)) + + if let deleteAllMessageCount = component.deleteAllMessageCount { + if self.optionDeleteAllSelectedPeers == Set(component.peers.map(\.peer.id)) { + titleString = environment.strings.Chat_AdminActionSheet_DeleteTitle(Int32(deleteAllMessageCount)) + } + } + let titleSize = self.title.update( transition: .immediate, component: AnyComponent(MultilineTextComponent( @@ -884,7 +897,9 @@ private final class AdminUserActionsSheetComponent: Component { if titleView.superview == nil { self.navigationBarContainer.addSubview(titleView) } - transition.setFrame(view: titleView, frame: titleFrame) + //transition.setPosition(view: titleView, position: titleFrame.center) + titleView.center = titleFrame.center + titleView.bounds = CGRect(origin: CGPoint(), size: titleFrame.size) } let navigationBackgroundFrame = CGRect(origin: CGPoint(), size: CGSize(width: availableSize.width, height: 54.0)) @@ -1424,10 +1439,10 @@ public class AdminUserActionsSheet: ViewControllerComponentContainer { private var isDismissed: Bool = false - public init(context: AccountContext, chatPeer: EnginePeer, peers: [RenderedChannelParticipant], messageCount: Int, completion: @escaping (Result) -> Void) { + public init(context: AccountContext, chatPeer: EnginePeer, peers: [RenderedChannelParticipant], messageCount: Int, deleteAllMessageCount: Int?, completion: @escaping (Result) -> Void) { self.context = context - super.init(context: context, component: AdminUserActionsSheetComponent(context: context, chatPeer: chatPeer, peers: peers, messageCount: messageCount, completion: completion), navigationBarAppearance: .none) + super.init(context: context, component: AdminUserActionsSheetComponent(context: context, chatPeer: chatPeer, peers: peers, messageCount: messageCount, deleteAllMessageCount: deleteAllMessageCount, completion: completion), navigationBarAppearance: .none) self.statusBar.statusBarStyle = .Ignore self.navigationPresentation = .flatModal diff --git a/submodules/TelegramUI/Components/Chat/ChatMessageBubbleItemNode/Sources/ChatMessageBubbleItemNode.swift b/submodules/TelegramUI/Components/Chat/ChatMessageBubbleItemNode/Sources/ChatMessageBubbleItemNode.swift index a261f2b647..ae17904f04 100644 --- a/submodules/TelegramUI/Components/Chat/ChatMessageBubbleItemNode/Sources/ChatMessageBubbleItemNode.swift +++ b/submodules/TelegramUI/Components/Chat/ChatMessageBubbleItemNode/Sources/ChatMessageBubbleItemNode.swift @@ -4747,25 +4747,31 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI } if let forwardInfoNode = self.forwardInfoNode, forwardInfoNode.frame.contains(location) { if let item = self.item, let forwardInfo = item.message.forwardInfo { - let performAction: () -> Void = { + let performAction: () -> Void = { [weak forwardInfoNode] in if let sourceMessageId = forwardInfo.sourceMessageId { if let channel = forwardInfo.author as? TelegramChannel, channel.addressName == nil { if case let .broadcast(info) = channel.info, info.flags.contains(.hasDiscussionGroup) { } else if case .member = channel.participationStatus { } else if !item.message.id.peerId.isReplies { - item.controllerInteraction.displayMessageTooltip(item.message.id, item.presentationData.strings.Conversation_PrivateChannelTooltip, false, forwardInfoNode, nil) + if let forwardInfoNode { + item.controllerInteraction.displayMessageTooltip(item.message.id, item.presentationData.strings.Conversation_PrivateChannelTooltip, false, forwardInfoNode, nil) + } return } } - item.controllerInteraction.navigateToMessage(item.message.id, sourceMessageId, NavigateToMessageParams(timestamp: nil, quote: nil)) + if let forwardInfoNode { + item.controllerInteraction.navigateToMessage(item.message.id, sourceMessageId, NavigateToMessageParams(timestamp: nil, quote: nil, progress: forwardInfoNode.makeActivate()?())) + } } else if let peer = forwardInfo.source ?? forwardInfo.author { item.controllerInteraction.openPeer(EnginePeer(peer), peer is TelegramUser ? .info(nil) : .chat(textInputState: nil, subject: nil, peekData: nil), nil, .default) } else if let _ = forwardInfo.authorSignature { - var subRect: CGRect? - if let textNode = forwardInfoNode.nameNode { - subRect = textNode.frame + if let forwardInfoNode { + var subRect: CGRect? + if let textNode = forwardInfoNode.nameNode { + subRect = textNode.frame + } + item.controllerInteraction.displayMessageTooltip(item.message.id, item.presentationData.strings.Conversation_ForwardAuthorHiddenTooltip, false, forwardInfoNode, subRect) } - item.controllerInteraction.displayMessageTooltip(item.message.id, item.presentationData.strings.Conversation_ForwardAuthorHiddenTooltip, false, forwardInfoNode, subRect) } } diff --git a/submodules/TelegramUI/Components/Chat/ChatMessageForwardInfoNode/BUILD b/submodules/TelegramUI/Components/Chat/ChatMessageForwardInfoNode/BUILD index dd8301c5f5..9eb97d563e 100644 --- a/submodules/TelegramUI/Components/Chat/ChatMessageForwardInfoNode/BUILD +++ b/submodules/TelegramUI/Components/Chat/ChatMessageForwardInfoNode/BUILD @@ -25,6 +25,7 @@ swift_library( "//submodules/TelegramUI/Components/TextNodeWithEntities", "//submodules/TelegramUI/Components/AnimationCache", "//submodules/TelegramUI/Components/MultiAnimationRenderer", + "//submodules/TelegramUI/Components/TextLoadingEffect", "//submodules/AvatarNode", ], visibility = [ diff --git a/submodules/TelegramUI/Components/Chat/ChatMessageForwardInfoNode/Sources/ChatMessageForwardInfoNode.swift b/submodules/TelegramUI/Components/Chat/ChatMessageForwardInfoNode/Sources/ChatMessageForwardInfoNode.swift index 8d79dea5e7..33121fb337 100644 --- a/submodules/TelegramUI/Components/Chat/ChatMessageForwardInfoNode/Sources/ChatMessageForwardInfoNode.swift +++ b/submodules/TelegramUI/Components/Chat/ChatMessageForwardInfoNode/Sources/ChatMessageForwardInfoNode.swift @@ -8,6 +8,8 @@ import TelegramPresentationData import LocalizedPeerData import AccountContext import AvatarNode +import TextLoadingEffect +import SwiftSignalKit public enum ChatMessageForwardInfoType: Equatable { case bubble(incoming: Bool) @@ -85,6 +87,10 @@ public class ChatMessageForwardInfoNode: ASDisplayNode { private var highlightColor: UIColor? private var linkHighlightingNode: LinkHighlightingNode? + private var hasLinkProgress: Bool = false + private var linkProgressView: TextLoadingEffectView? + private var linkProgressDisposable: Disposable? + private var previousPeer: Peer? public var openPsa: ((String, ASDisplayNode) -> Void)? @@ -93,6 +99,10 @@ public class ChatMessageForwardInfoNode: ASDisplayNode { super.init() } + deinit { + self.linkProgressDisposable?.dispose() + } + public func hasAction(at point: CGPoint) -> Bool { if let infoNode = self.infoNode, infoNode.frame.contains(point) { return true @@ -172,7 +182,6 @@ public class ChatMessageForwardInfoNode: ASDisplayNode { if isHighlighted, !initialRects.isEmpty, let highlightColor = self.highlightColor { let rects = initialRects - let linkHighlightingNode: LinkHighlightingNode if let current = self.linkHighlightingNode { linkHighlightingNode = current @@ -191,6 +200,85 @@ public class ChatMessageForwardInfoNode: ASDisplayNode { } } + public func makeActivate() -> (() -> Promise?)? { + return { [weak self] in + guard let self else { + return nil + } + + let promise = Promise() + self.linkProgressDisposable?.dispose() + + if self.hasLinkProgress { + self.hasLinkProgress = false + self.updateLinkProgressState() + } + + self.linkProgressDisposable = (promise.get() |> deliverOnMainQueue).startStrict(next: { [weak self] value in + guard let self else { + return + } + if self.hasLinkProgress != value { + self.hasLinkProgress = value + self.updateLinkProgressState() + } + }) + + return promise + } + } + + private func updateLinkProgressState() { + guard let highlightColor = self.highlightColor else { + return + } + + if self.hasLinkProgress, let titleNode = self.titleNode, let nameNode = self.nameNode { + var initialRects: [CGRect] = [] + let addRects: (TextNode, CGPoint, CGFloat) -> Void = { textNode, offset, additionalWidth in + guard let cachedLayout = textNode.cachedLayout else { + return + } + for rect in cachedLayout.linesRects() { + var rect = rect + rect.size.width += rect.origin.x + additionalWidth + rect.origin.x = 0.0 + initialRects.append(rect.offsetBy(dx: offset.x, dy: offset.y)) + } + } + + let offsetY: CGFloat = -12.0 + if let titleNode = self.titleNode { + addRects(titleNode, CGPoint(x: titleNode.frame.minX, y: offsetY + titleNode.frame.minY), 0.0) + + if let nameNode = self.nameNode { + addRects(nameNode, CGPoint(x: titleNode.frame.minX, y: offsetY + nameNode.frame.minY), nameNode.frame.minX - titleNode.frame.minX) + } + } + + let linkProgressView: TextLoadingEffectView + if let current = self.linkProgressView { + linkProgressView = current + } else { + linkProgressView = TextLoadingEffectView(frame: CGRect()) + self.linkProgressView = linkProgressView + self.view.addSubview(linkProgressView) + } + linkProgressView.frame = titleNode.frame + + let progressColor: UIColor = highlightColor + + linkProgressView.update(color: progressColor, size: CGRectUnion(titleNode.frame, nameNode.frame).size, rects: initialRects) + } else { + if let linkProgressView = self.linkProgressView { + self.linkProgressView = nil + linkProgressView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false, completion: { [weak linkProgressView] _ in + linkProgressView?.removeFromSuperview() + }) + } + } + } + public static func asyncLayout(_ maybeNode: ChatMessageForwardInfoNode?) -> (_ context: AccountContext, _ presentationData: ChatPresentationData, _ strings: PresentationStrings, _ type: ChatMessageForwardInfoType, _ peer: Peer?, _ authorName: String?, _ psaType: String?, _ storyData: StoryData?, _ constrainedSize: CGSize) -> (CGSize, (CGFloat) -> ChatMessageForwardInfoNode) { let titleNodeLayout = TextNode.asyncLayout(maybeNode?.titleNode) let nameNodeLayout = TextNode.asyncLayout(maybeNode?.nameNode) diff --git a/submodules/TelegramUI/Components/Chat/ReplyAccessoryPanelNode/Sources/ReplyAccessoryPanelNode.swift b/submodules/TelegramUI/Components/Chat/ReplyAccessoryPanelNode/Sources/ReplyAccessoryPanelNode.swift index 5e54ec3215..d1210c4e14 100644 --- a/submodules/TelegramUI/Components/Chat/ReplyAccessoryPanelNode/Sources/ReplyAccessoryPanelNode.swift +++ b/submodules/TelegramUI/Components/Chat/ReplyAccessoryPanelNode/Sources/ReplyAccessoryPanelNode.swift @@ -370,6 +370,7 @@ public final class ReplyAccessoryPanelNode: AccessoryPanelNode { super.didLoad() self.view.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(self.tapGesture(_:)))) + self.view.addGestureRecognizer(UILongPressGestureRecognizer(target: self, action: #selector(self.longPressGesture(_:)))) } override public func animateIn() { @@ -491,9 +492,9 @@ public final class ReplyAccessoryPanelNode: AccessoryPanelNode { } } - /*@objc func tapGesture(_ recognizer: UITapGestureRecognizer) { - if case .ended = recognizer.state { - self.interfaceInteraction?.navigateToMessage(self.messageId, false, true, .generic) + @objc func longPressGesture(_ recognizer: UILongPressGestureRecognizer) { + if case .began = recognizer.state { + self.interfaceInteraction?.navigateToMessage(self.messageId, false, true, ChatLoadingMessageSubject.generic) } - }*/ + } } diff --git a/submodules/TelegramUI/Components/PeerAllowedReactionsScreen/Sources/PeerAllowedReactionsScreen.swift b/submodules/TelegramUI/Components/PeerAllowedReactionsScreen/Sources/PeerAllowedReactionsScreen.swift index ba062d1d2a..28d1151a16 100644 --- a/submodules/TelegramUI/Components/PeerAllowedReactionsScreen/Sources/PeerAllowedReactionsScreen.swift +++ b/submodules/TelegramUI/Components/PeerAllowedReactionsScreen/Sources/PeerAllowedReactionsScreen.swift @@ -868,7 +868,7 @@ final class PeerAllowedReactionsScreenComponent: Component { } contentHeight += reactionCountSectionSize.height - if "".isEmpty { + if !"".isEmpty { contentHeight += 32.0 let paidReactionsSection: ComponentView diff --git a/submodules/TelegramUI/Components/TextLoadingEffect/Sources/TextLoadingEffect.swift b/submodules/TelegramUI/Components/TextLoadingEffect/Sources/TextLoadingEffect.swift index 953953dafa..9b5bacdb47 100644 --- a/submodules/TelegramUI/Components/TextLoadingEffect/Sources/TextLoadingEffect.swift +++ b/submodules/TelegramUI/Components/TextLoadingEffect/Sources/TextLoadingEffect.swift @@ -171,4 +171,34 @@ public final class TextLoadingEffectView: UIView { self.updateAnimations(size: maskFrame.size) } } + + public func update(color: UIColor, size: CGSize, rects: [CGRect]) { + let rectsSet: [CGRect] = rects + + let maskFrame = CGRect(origin: CGPoint(), size: size).insetBy(dx: -4.0, dy: -4.0) + + self.maskContentsView.backgroundColor = color.withAlphaComponent(0.1) + self.maskBorderContentsView.backgroundColor = color.withAlphaComponent(0.12) + + self.backgroundView.tintColor = color + self.borderBackgroundView.tintColor = color + + self.maskContentsView.frame = maskFrame + self.maskBorderContentsView.frame = maskFrame + + self.maskHighlightNode.updateRects(rectsSet) + self.maskHighlightNode.frame = CGRect(origin: CGPoint(x: -maskFrame.minX, y: -maskFrame.minY), size: CGSize()) + + self.maskBorderHighlightNode.updateRects(rectsSet) + self.maskBorderHighlightNode.frame = CGRect(origin: CGPoint(x: -maskFrame.minX, y: -maskFrame.minY), size: CGSize()) + + if self.size != maskFrame.size { + self.size = maskFrame.size + + self.backgroundView.frame = CGRect(origin: CGPoint(x: -self.gradientWidth, y: 0.0), size: CGSize(width: self.gradientWidth, height: maskFrame.height)) + self.borderBackgroundView.frame = CGRect(origin: CGPoint(x: -self.gradientWidth, y: 0.0), size: CGSize(width: self.gradientWidth, height: maskFrame.height)) + + self.updateAnimations(size: maskFrame.size) + } + } } diff --git a/submodules/TelegramUI/Sources/Chat/ChatControllerNavigateToMessage.swift b/submodules/TelegramUI/Sources/Chat/ChatControllerNavigateToMessage.swift index 9b0d2b23e9..eba9090dd9 100644 --- a/submodules/TelegramUI/Sources/Chat/ChatControllerNavigateToMessage.swift +++ b/submodules/TelegramUI/Sources/Chat/ChatControllerNavigateToMessage.swift @@ -29,7 +29,7 @@ extension ChatControllerImpl { guard let self else { return } - self.navigateToMessage(from: fromId, to: .id(id, params), forceInCurrentChat: fromId.peerId == id.peerId && !params.forceNew, forceNew: params.forceNew) + self.navigateToMessage(from: fromId, to: .id(id, params), forceInCurrentChat: fromId.peerId == id.peerId && !params.forceNew, forceNew: params.forceNew, progress: params.progress) } let _ = (self.context.engine.data.get( @@ -77,6 +77,7 @@ extension ChatControllerImpl { animated: Bool = true, completion: (() -> Void)? = nil, customPresentProgress: ((ViewController, Any?) -> Void)? = nil, + progress: Promise? = nil, statusSubject: ChatLoadingMessageSubject = .generic ) { if !self.isNodeLoaded { @@ -160,31 +161,156 @@ extension ChatControllerImpl { guard let self, let peer = peer else { return } - if let navigationController = self.effectiveNavigationController { - var chatLocation: NavigateToChatControllerParams.Location = .peer(peer) - var displayMessageNotFoundToast = false - if case let .channel(channel) = peer, channel.flags.contains(.isForum) { - if let message = message, let threadId = message.threadId { - chatLocation = .replyThread(ChatReplyThreadMessage(peerId: peer.id, threadId: threadId, channelMessageId: nil, isChannelPost: false, isForumPost: true, maxMessage: nil, maxReadIncomingMessageId: nil, maxReadOutgoingMessageId: nil, unreadCount: 0, initialFilledHoles: IndexSet(), initialAnchor: .automatic, isNotAvailable: false)) + + var quote: ChatControllerSubject.MessageHighlight.Quote? + if case let .id(_, params) = messageLocation { + quote = params.quote.flatMap { quote in ChatControllerSubject.MessageHighlight.Quote(string: quote.string, offset: quote.offset) } + } + var progressValue: Promise? + if let value = progress { + progressValue = value + } else if case let .id(_, params) = messageLocation { + progressValue = params.progress + } + self.loadingMessage.set(.single(statusSubject) |> delay(0.1, queue: .mainQueue())) + + var chatLocation: NavigateToChatControllerParams.Location = .peer(peer) + var preloadChatLocation: ChatLocation = .peer(id: peer.id) + var displayMessageNotFoundToast = false + if case let .channel(channel) = peer, channel.flags.contains(.isForum) { + if let message = message, let threadId = message.threadId { + let replyThreadMessage = ChatReplyThreadMessage(peerId: peer.id, threadId: threadId, channelMessageId: nil, isChannelPost: false, isForumPost: true, maxMessage: nil, maxReadIncomingMessageId: nil, maxReadOutgoingMessageId: nil, unreadCount: 0, initialFilledHoles: IndexSet(), initialAnchor: .automatic, isNotAvailable: false) + chatLocation = .replyThread(replyThreadMessage) + preloadChatLocation = .replyThread(message: replyThreadMessage) + } else { + displayMessageNotFoundToast = true + } + } + + let searchLocation: ChatHistoryInitialSearchLocation + switch messageLocation { + case let .id(id, _): + if case let .replyThread(message) = chatLocation, id == message.effectiveMessageId { + searchLocation = .index(.absoluteLowerBound()) + } else { + searchLocation = .id(id) + } + case let .index(index): + searchLocation = .index(index) + case .upperBound: + searchLocation = .index(MessageIndex.upperBound(peerId: chatLocation.peerId)) + } + var historyView: Signal + + let subject: ChatControllerSubject = .message(id: .id(messageId), highlight: ChatControllerSubject.MessageHighlight(quote: quote), timecode: nil, setupReply: false) + + historyView = preloadedChatHistoryViewForLocation(ChatHistoryLocationInput(content: .InitialSearch(subject: MessageHistoryInitialSearchSubject(location: searchLocation, quote: nil), count: 50, highlight: true, setupReply: false), id: 0), context: self.context, chatLocation: preloadChatLocation, subject: subject, chatLocationContextHolder: Atomic(value: nil), fixedCombinedReadStates: nil, tag: nil, additionalData: []) + + var signal: Signal<(MessageIndex?, Bool), NoError> + signal = historyView + |> mapToSignal { historyView -> Signal<(MessageIndex?, Bool), NoError> in + switch historyView { + case .Loading: + return .single((nil, true)) + case let .HistoryView(view, _, _, _, _, _, _): + for entry in view.entries { + if entry.message.id == messageLocation.messageId { + return .single((entry.message.index, false)) + } + } + if case let .index(index) = searchLocation { + return .single((index, false)) + } + return .single((nil, false)) + } + } + |> take(until: { index in + return SignalTakeAction(passthrough: true, complete: !index.1) + }) + + /*#if DEBUG + signal = .single((nil, true)) |> then(signal |> delay(2.0, queue: .mainQueue())) + #endif*/ + + var cancelImpl: (() -> Void)? + let presentationData = self.presentationData + let displayTime = CACurrentMediaTime() + let progressSignal = Signal { [weak self] subscriber in + if let progressValue { + progressValue.set(.single(true)) + return ActionDisposable { + Queue.mainQueue().async() { + progressValue.set(.single(false)) + } + } + } else if case .generic = statusSubject { + let controller = OverlayStatusController(theme: presentationData.theme, type: .loading(cancelled: { + if CACurrentMediaTime() - displayTime > 1.5 { + cancelImpl?() + } + })) + if let customPresentProgress = customPresentProgress { + customPresentProgress(controller, nil) } else { - displayMessageNotFoundToast = true + self?.present(controller, in: .window(.root)) } + return ActionDisposable { [weak controller] in + Queue.mainQueue().async() { + controller?.dismiss() + } + } + } else { + return EmptyDisposable + } + } + |> runOn(Queue.mainQueue()) + |> delay(progressValue == nil ? 0.05 : 0.0, queue: Queue.mainQueue()) + let progressDisposable = MetaDisposable() + var progressStarted = false + self.messageIndexDisposable.set((signal + |> afterDisposed { + Queue.mainQueue().async { + progressDisposable.dispose() + } + } + |> deliverOnMainQueue).startStrict(next: { [weak self] index in + guard let self else { + return } - var quote: ChatControllerSubject.MessageHighlight.Quote? - if case let .id(_, params) = messageLocation { - quote = params.quote.flatMap { quote in ChatControllerSubject.MessageHighlight.Quote(string: quote.string, offset: quote.offset) } + if let index = index.0 { + let _ = index + //strongSelf.chatDisplayNode.historyNode.scrollToMessage(from: scrollFromIndex, to: index, animated: animated, quote: quote, scrollPosition: scrollPosition) + } else if index.1 { + if !progressStarted { + progressStarted = true + progressDisposable.set(progressSignal.start()) + } + return } - let context = self.context - self.context.sharedContext.navigateToChatController(NavigateToChatControllerParams(navigationController: navigationController, context: self.context, chatLocation: chatLocation, subject: .message(id: .id(messageId), highlight: ChatControllerSubject.MessageHighlight(quote: quote), timecode: nil, setupReply: false), keepStack: .always, chatListCompletion: { chatListController in - if displayMessageNotFoundToast { - let presentationData = context.sharedContext.currentPresentationData.with({ $0 }) - chatListController.present(UndoOverlayController(presentationData: presentationData, content: .info(title: nil, text: presentationData.strings.Conversation_MessageDoesntExist, timeout: nil, customUndoText: nil), elevatedLayout: false, animateInAsReplacement: false, action: { _ in - return true - }), in: .current) - } - })) + if let navigationController = self.effectiveNavigationController { + let context = self.context + self.context.sharedContext.navigateToChatController(NavigateToChatControllerParams(navigationController: navigationController, context: self.context, chatLocation: chatLocation, subject: subject, keepStack: .always, chatListCompletion: { chatListController in + if displayMessageNotFoundToast { + let presentationData = context.sharedContext.currentPresentationData.with({ $0 }) + chatListController.present(UndoOverlayController(presentationData: presentationData, content: .info(title: nil, text: presentationData.strings.Conversation_MessageDoesntExist, timeout: nil, customUndoText: nil), elevatedLayout: false, animateInAsReplacement: false, action: { _ in + return true + }), in: .current) + } + })) + } + }, completed: { [weak self] in + if let strongSelf = self { + strongSelf.loadingMessage.set(.single(nil)) + } + completion?() + })) + cancelImpl = { [weak self] in + if let strongSelf = self { + strongSelf.loadingMessage.set(.single(nil)) + strongSelf.messageIndexDisposable.set(nil) + } } completion?() diff --git a/submodules/TelegramUI/Sources/ChatControllerAdminBanUsers.swift b/submodules/TelegramUI/Sources/ChatControllerAdminBanUsers.swift index e9aa686373..568e230870 100644 --- a/submodules/TelegramUI/Sources/ChatControllerAdminBanUsers.swift +++ b/submodules/TelegramUI/Sources/ChatControllerAdminBanUsers.swift @@ -125,6 +125,14 @@ extension ChatControllerImpl { return } + var deleteAllMessageCount: Signal = .single(nil) + if authors.count == 1 { + deleteAllMessageCount = self.context.engine.messages.searchMessages(location: .peer(peerId: peerId, fromId: authors[0].id, tags: nil, reactions: nil, threadId: self.chatLocation.threadId, minDate: nil, maxDate: nil), query: "", state: nil) + |> map { result, _ -> Int? in + return Int(result.totalCount) + } + } + var signal = combineLatest(authors.map { author in self.context.engine.peers.fetchChannelParticipant(peerId: peerId, participantId: author.id) |> map { result -> (Peer, ChannelParticipant?) in @@ -161,8 +169,8 @@ extension ChatControllerImpl { disposables.set(nil) } - disposables.set((signal - |> deliverOnMainQueue).startStrict(next: { [weak self] authorsAndParticipants in + disposables.set((combineLatest(signal, deleteAllMessageCount) + |> deliverOnMainQueue).startStrict(next: { [weak self] authorsAndParticipants, deleteAllMessageCount in guard let self else { return } @@ -212,6 +220,7 @@ extension ChatControllerImpl { chatPeer: chatPeer, peers: renderedParticipants, messageCount: messageIds.count, + deleteAllMessageCount: deleteAllMessageCount, completion: { [weak self] result in guard let self else { return @@ -259,8 +268,16 @@ extension ChatControllerImpl { disposables.set(nil) } - disposables.set((signal - |> deliverOnMainQueue).startStrict(next: { [weak self] maybeParticipant in + var deleteAllMessageCount: Signal = .single(nil) + do { + deleteAllMessageCount = self.context.engine.messages.getSearchMessageCount(location: .peer(peerId: peerId, fromId: author.id, tags: nil, reactions: nil, threadId: self.chatLocation.threadId, minDate: nil, maxDate: nil), query: "") + |> map { result -> Int? in + return result + } + } + + disposables.set((combineLatest(signal, deleteAllMessageCount) + |> deliverOnMainQueue).startStrict(next: { [weak self] maybeParticipant, deleteAllMessageCount in guard let self else { return } @@ -310,6 +327,7 @@ extension ChatControllerImpl { peer: authorPeer._asPeer() )], messageCount: messageIds.count, + deleteAllMessageCount: deleteAllMessageCount, completion: { [weak self] result in guard let self else { return diff --git a/submodules/TelegramUI/Sources/OpenUrl.swift b/submodules/TelegramUI/Sources/OpenUrl.swift index dc3144464b..e6a021bbfc 100644 --- a/submodules/TelegramUI/Sources/OpenUrl.swift +++ b/submodules/TelegramUI/Sources/OpenUrl.swift @@ -257,10 +257,10 @@ func openExternalUrlImpl(context: AccountContext, urlContext: OpenURLContext, ur if let value = URL(string: "ipfs:/" + parsedUrl.path) { parsedUrl = value } - } - } else if let scheme = parsedUrl.scheme, scheme == "https", parsedUrl.host == "t.me", parsedUrl.path.hasPrefix("/ipfs/") { - if let value = URL(string: "ipfs://" + String(parsedUrl.path[parsedUrl.path.index(parsedUrl.path.startIndex, offsetBy: "/ipfs/".count)...])) { - parsedUrl = value + } else if parsedUrl.host == "ton" { + if let value = URL(string: "ton:/" + parsedUrl.path) { + parsedUrl = value + } } } } @@ -1009,7 +1009,7 @@ func openExternalUrlImpl(context: AccountContext, urlContext: OpenURLContext, ur isInternetUrl = true } if context.sharedContext.immediateExperimentalUISettings.browserExperiment { - if parsedUrl.scheme == "ipfs" || parsedUrl.scheme == "ipns" { + if parsedUrl.scheme == "ipfs" || parsedUrl.scheme == "ipns" || parsedUrl.scheme == "ton" { isInternetUrl = true } } diff --git a/submodules/TelegramUniversalVideoContent/Sources/NativeVideoContent.swift b/submodules/TelegramUniversalVideoContent/Sources/NativeVideoContent.swift index 905d87206b..3df84b6729 100644 --- a/submodules/TelegramUniversalVideoContent/Sources/NativeVideoContent.swift +++ b/submodules/TelegramUniversalVideoContent/Sources/NativeVideoContent.swift @@ -251,6 +251,10 @@ private final class NativeVideoContentNode: ASDisplayNode, UniversalVideoContent } if displayImage { + if captureProtected { + setLayerDisableScreenshots(self.imageNode.layer, captureProtected) + } + self.imageNode.setSignal(internalMediaGridMessageVideo(postbox: postbox, userLocation: userLocation, videoReference: fileReference, imageReference: imageReference, onlyFullSize: onlyFullSizeThumbnail, useLargeThumbnail: useLargeThumbnail, autoFetchFullSizeThumbnail: autoFetchFullSizeThumbnail || fileReference.media.isInstantVideo) |> map { [weak self] getSize, getData in Queue.mainQueue().async { if let strongSelf = self, strongSelf.dimensions == nil { diff --git a/submodules/TelegramVoip/BUILD b/submodules/TelegramVoip/BUILD index ad9406ad93..62595d2fb6 100644 --- a/submodules/TelegramVoip/BUILD +++ b/submodules/TelegramVoip/BUILD @@ -17,6 +17,7 @@ swift_library( "//submodules/TelegramUIPreferences:TelegramUIPreferences", "//submodules/TgVoip:TgVoip", "//submodules/TgVoipWebrtc:TgVoipWebrtc", + "//submodules/FFMpegBinding", ], visibility = [ "//visibility:public", diff --git a/submodules/TelegramVoip/Sources/WrappedMediaStreamingContext.swift b/submodules/TelegramVoip/Sources/WrappedMediaStreamingContext.swift index 3ae1b27e2e..d067b85bab 100644 --- a/submodules/TelegramVoip/Sources/WrappedMediaStreamingContext.swift +++ b/submodules/TelegramVoip/Sources/WrappedMediaStreamingContext.swift @@ -2,6 +2,9 @@ import Foundation import SwiftSignalKit import TgVoipWebrtc import TelegramCore +import Network +import Postbox +import FFMpegBinding public final class WrappedMediaStreamingContext { private final class Impl { @@ -132,3 +135,460 @@ public final class WrappedMediaStreamingContext { } } } + +public final class ExternalMediaStreamingContext { + private final class Impl { + let queue: Queue + + private var broadcastPartsSource: BroadcastPartSource? + + private let resetPlaylistDisposable = MetaDisposable() + private let updatePlaylistDisposable = MetaDisposable() + + let masterPlaylistData = Promise() + let playlistData = Promise() + let mediumPlaylistData = Promise() + + init(queue: Queue, rejoinNeeded: @escaping () -> Void) { + self.queue = queue + } + + deinit { + self.updatePlaylistDisposable.dispose() + } + + func setAudioStreamData(audioStreamData: OngoingGroupCallContext.AudioStreamData?) { + if let audioStreamData { + let broadcastPartsSource = NetworkBroadcastPartSource(queue: self.queue, engine: audioStreamData.engine, callId: audioStreamData.callId, accessHash: audioStreamData.accessHash, isExternalStream: audioStreamData.isExternalStream) + self.broadcastPartsSource = broadcastPartsSource + + self.updatePlaylistDisposable.set(nil) + + let queue = self.queue + self.resetPlaylistDisposable.set(broadcastPartsSource.requestTime(completion: { [weak self] timestamp in + queue.async { + guard let self else { + return + } + + let segmentDuration: Int64 = 1000 + + var adjustedTimestamp: Int64 = 0 + if timestamp > 0 { + adjustedTimestamp = timestamp / segmentDuration * segmentDuration - 4 * segmentDuration + } + + if adjustedTimestamp > 0 { + var masterPlaylistData = "#EXTM3U\n" + + "#EXT-X-VERSION:6\n" + + "#EXT-X-STREAM-INF:BANDWIDTH=3300000,RESOLUTION=1280x720,CODECS=\"avc1.64001f,mp4a.40.2\"\n" + + "hls_level_0.m3u8\n" + + masterPlaylistData += "#EXT-X-STREAM-INF:BANDWIDTH=1000000,RESOLUTION=640x360,CODECS=\"avc1.64001f,mp4a.40.2\"\n" + + "hls_level_1.m3u8\n" + + self.masterPlaylistData.set(.single(masterPlaylistData)) + + self.beginUpdatingPlaylist(initialHeadTimestamp: adjustedTimestamp) + } + } + })) + } + } + + private func beginUpdatingPlaylist(initialHeadTimestamp: Int64) { + let segmentDuration: Int64 = 1000 + + var timestamp = initialHeadTimestamp + self.updatePlaylist(headTimestamp: timestamp, quality: 0) + self.updatePlaylist(headTimestamp: timestamp, quality: 1) + + self.updatePlaylistDisposable.set(( + Signal.single(Void()) + |> delay(1.0, queue: self.queue) + |> restart + |> deliverOn(self.queue) + ).start(next: { [weak self] _ in + guard let self else { + return + } + + timestamp += segmentDuration + self.updatePlaylist(headTimestamp: timestamp, quality: 0) + self.updatePlaylist(headTimestamp: timestamp, quality: 1) + })) + } + + private func updatePlaylist(headTimestamp: Int64, quality: Int) { + let segmentDuration: Int64 = 1000 + let headIndex = headTimestamp / segmentDuration + let minIndex = headIndex - 20 + + var playlistData = "#EXTM3U\n" + + "#EXT-X-VERSION:6\n" + + "#EXT-X-TARGETDURATION:1\n" + + "#EXT-X-MEDIA-SEQUENCE:\(minIndex)\n" + + "#EXT-X-INDEPENDENT-SEGMENTS\n" + + for index in minIndex ... headIndex { + playlistData.append("#EXTINF:1.000000,\n") + playlistData.append("hls_stream\(quality)_\(index).ts\n") + } + + //print("Player: updating playlist \(quality) \(minIndex) ... \(headIndex)") + + if quality == 0 { + self.playlistData.set(.single(playlistData)) + } else { + self.mediumPlaylistData.set(.single(playlistData)) + } + } + + func partData(index: Int, quality: Int) -> Signal { + let segmentDuration: Int64 = 1000 + let timestamp = Int64(index) * segmentDuration + + print("Player: request part(q: \(quality)) \(index) -> \(timestamp)") + + guard let broadcastPartsSource = self.broadcastPartsSource else { + return .single(nil) + } + + return Signal { subscriber in + return broadcastPartsSource.requestPart( + timestampMilliseconds: timestamp, + durationMilliseconds: segmentDuration, + subject: .video(channelId: 1, quality: quality == 0 ? .full : .medium), + completion: { part in + var data = part.oggData + if data.count > 32 { + data = data.subdata(in: 32 ..< data.count) + } + subscriber.putNext(data) + }, + rejoinNeeded: { + //TODO + } + ) + } + } + } + + private let queue = Queue() + let id: CallSessionInternalId + private let impl: QueueLocalObject + private var hlsServerDisposable: Disposable? + + public init(id: CallSessionInternalId, rejoinNeeded: @escaping () -> Void) { + self.id = id + let queue = self.queue + self.impl = QueueLocalObject(queue: queue, generate: { + return Impl(queue: queue, rejoinNeeded: rejoinNeeded) + }) + + self.hlsServerDisposable = SharedHLSServer.shared.registerPlayer(streamingContext: self) + } + + deinit { + self.hlsServerDisposable?.dispose() + } + + public func setAudioStreamData(audioStreamData: OngoingGroupCallContext.AudioStreamData?) { + self.impl.with { impl in + impl.setAudioStreamData(audioStreamData: audioStreamData) + } + } + + public func masterPlaylistData() -> Signal { + return self.impl.signalWith { impl, subscriber in + impl.masterPlaylistData.get().start(next: subscriber.putNext) + } + } + + public func playlistData(quality: Int) -> Signal { + return self.impl.signalWith { impl, subscriber in + if quality == 0 { + impl.playlistData.get().start(next: subscriber.putNext) + } else { + impl.mediumPlaylistData.get().start(next: subscriber.putNext) + } + } + } + + public func partData(index: Int, quality: Int) -> Signal { + return self.impl.signalWith { impl, subscriber in + impl.partData(index: index, quality: quality).start(next: subscriber.putNext) + } + } +} + +public final class SharedHLSServer { + public static let shared: SharedHLSServer = { + return SharedHLSServer() + }() + + private enum ResponseError { + case badRequest + case notFound + case internalServerError + + var httpString: String { + switch self { + case .badRequest: + return "400 Bad Request" + case .notFound: + return "404 Not Found" + case .internalServerError: + return "500 Internal Server Error" + } + } + } + + private final class ContextReference { + weak var streamingContext: ExternalMediaStreamingContext? + + init(streamingContext: ExternalMediaStreamingContext) { + self.streamingContext = streamingContext + } + } + + private final class Impl { + private let queue: Queue + + private let port: NWEndpoint.Port + private var listener: NWListener? + + private var contextReferences = Bag() + + init(queue: Queue, port: UInt16) { + self.queue = queue + self.port = NWEndpoint.Port(rawValue: port)! + self.start() + } + + func start() { + let listener: NWListener + do { + listener = try NWListener(using: .tcp, on: self.port) + } catch { + Logger.shared.log("SharedHLSServer", "Failed to create listener: \(error)") + return + } + self.listener = listener + + listener.newConnectionHandler = { [weak self] connection in + guard let self else { + return + } + self.handleConnection(connection: connection) + } + + listener.stateUpdateHandler = { [weak self] state in + guard let self else { + return + } + switch state { + case .ready: + Logger.shared.log("SharedHLSServer", "Server is ready on port \(self.port)") + case let .failed(error): + Logger.shared.log("SharedHLSServer", "Server failed with error: \(error)") + self.listener?.cancel() + + self.listener?.start(queue: self.queue.queue) + default: + break + } + } + + listener.start(queue: self.queue.queue) + } + + private func handleConnection(connection: NWConnection) { + connection.start(queue: self.queue.queue) + connection.receive(minimumIncompleteLength: 1, maximumLength: 1024, completion: { [weak self] data, _, isComplete, error in + guard let self else { + return + } + if let data, !data.isEmpty { + self.handleRequest(data: data, connection: connection) + } else if isComplete { + connection.cancel() + } else if let error = error { + Logger.shared.log("SharedHLSServer", "Error on connection: \(error)") + connection.cancel() + } + }) + } + + private func handleRequest(data: Data, connection: NWConnection) { + guard let requestString = String(data: data, encoding: .utf8) else { + connection.cancel() + return + } + + if !requestString.hasPrefix("GET /") { + self.sendErrorAndClose(connection: connection) + return + } + guard let firstCrLf = requestString.range(of: "\r\n") else { + self.sendErrorAndClose(connection: connection) + return + } + let firstLine = String(requestString[requestString.index(requestString.startIndex, offsetBy: "GET /".count) ..< firstCrLf.lowerBound]) + if !(firstLine.hasSuffix(" HTTP/1.0") || firstLine.hasSuffix(" HTTP/1.1")) { + self.sendErrorAndClose(connection: connection) + return + } + + let requestPath = String(firstLine[firstLine.startIndex ..< firstLine.index(firstLine.endIndex, offsetBy: -" HTTP/1.1".count)]) + + guard let firstSlash = requestPath.range(of: "/") else { + self.sendErrorAndClose(connection: connection, error: .notFound) + return + } + guard let streamId = UUID(uuidString: String(requestPath[requestPath.startIndex ..< firstSlash.lowerBound])) else { + self.sendErrorAndClose(connection: connection) + return + } + guard let streamingContext = self.contextReferences.copyItems().first(where: { $0.streamingContext?.id == streamId })?.streamingContext else { + self.sendErrorAndClose(connection: connection) + return + } + + let filePath = String(requestPath[firstSlash.upperBound...]) + if filePath == "master.m3u8" { + let _ = (streamingContext.masterPlaylistData() + |> deliverOn(self.queue) + |> take(1)).start(next: { [weak self] result in + guard let self else { + return + } + + self.sendResponseAndClose(connection: connection, data: result.data(using: .utf8)!) + }) + } else if filePath.hasPrefix("hls_level_") && filePath.hasSuffix(".m3u8") { + guard let levelIndex = Int(String(filePath[filePath.index(filePath.startIndex, offsetBy: "hls_level_".count) ..< filePath.index(filePath.endIndex, offsetBy: -".m3u8".count)])) else { + self.sendErrorAndClose(connection: connection) + return + } + + let _ = (streamingContext.playlistData(quality: levelIndex) + |> deliverOn(self.queue) + |> take(1)).start(next: { [weak self] result in + guard let self else { + return + } + + self.sendResponseAndClose(connection: connection, data: result.data(using: .utf8)!) + }) + } else if filePath.hasPrefix("hls_stream") && filePath.hasSuffix(".ts") { + let fileId = String(filePath[filePath.index(filePath.startIndex, offsetBy: "hls_stream".count) ..< filePath.index(filePath.endIndex, offsetBy: -".ts".count)]) + guard let underscoreRange = fileId.range(of: "_") else { + self.sendErrorAndClose(connection: connection) + return + } + guard let levelIndex = Int(String(fileId[fileId.startIndex ..< underscoreRange.lowerBound])) else { + self.sendErrorAndClose(connection: connection) + return + } + guard let partIndex = Int(String(fileId[underscoreRange.upperBound...])) else { + self.sendErrorAndClose(connection: connection) + return + } + let _ = (streamingContext.partData(index: partIndex, quality: levelIndex) + |> deliverOn(self.queue) + |> take(1)).start(next: { [weak self] result in + guard let self else { + return + } + + if let result { + let sourceTempFile = TempBox.shared.tempFile(fileName: "part.mp4") + let tempFile = TempBox.shared.tempFile(fileName: "part.ts") + defer { + TempBox.shared.dispose(sourceTempFile) + TempBox.shared.dispose(tempFile) + } + + guard let _ = try? result.write(to: URL(fileURLWithPath: sourceTempFile.path)) else { + self.sendErrorAndClose(connection: connection, error: .internalServerError) + return + } + + let sourcePath = sourceTempFile.path + FFMpegLiveMuxer.remux(sourcePath, to: tempFile.path, offsetSeconds: Double(partIndex)) + + if let data = try? Data(contentsOf: URL(fileURLWithPath: tempFile.path)) { + self.sendResponseAndClose(connection: connection, data: data) + } else { + self.sendErrorAndClose(connection: connection, error: .internalServerError) + } + } else { + self.sendErrorAndClose(connection: connection, error: .notFound) + } + }) + } else { + self.sendErrorAndClose(connection: connection, error: .notFound) + } + } + + private func sendErrorAndClose(connection: NWConnection, error: ResponseError = .badRequest) { + let errorResponse = "HTTP/1.1 \(error.httpString)\r\nContent-Type: text/html\r\nConnection: close\r\n\r\n" + connection.send(content: errorResponse.data(using: .utf8), completion: .contentProcessed { error in + if let error { + Logger.shared.log("SharedHLSServer", "Failed to send response: \(error)") + } + connection.cancel() + }) + } + + private func sendResponseAndClose(connection: NWConnection, data: Data) { + let responseHeaders = "HTTP/1.1 200 OK\r\nContent-Type: application/octet-stream\r\nConnection: close\r\n\r\n" + var responseData = Data() + responseData.append(responseHeaders.data(using: .utf8)!) + responseData.append(data) + connection.send(content: responseData, completion: .contentProcessed { error in + if let error { + Logger.shared.log("SharedHLSServer", "Failed to send response: \(error)") + } + connection.cancel() + }) + } + + func registerPlayer(streamingContext: ExternalMediaStreamingContext) -> Disposable { + let queue = self.queue + let index = self.contextReferences.add(ContextReference(streamingContext: streamingContext)) + + return ActionDisposable { [weak self] in + queue.async { + guard let self else { + return + } + self.contextReferences.remove(index) + } + } + } + } + + private static let queue = Queue(name: "SharedHLSServer") + public let port: UInt16 = 8016 + private let impl: QueueLocalObject + + private init() { + let queue = SharedHLSServer.queue + let port = self.port + self.impl = QueueLocalObject(queue: queue, generate: { + return Impl(queue: queue, port: port) + }) + } + + fileprivate func registerPlayer(streamingContext: ExternalMediaStreamingContext) -> Disposable { + let disposable = MetaDisposable() + + self.impl.with { impl in + disposable.set(impl.registerPlayer(streamingContext: streamingContext)) + } + + return disposable + } +} diff --git a/submodules/UrlHandling/Sources/UrlHandling.swift b/submodules/UrlHandling/Sources/UrlHandling.swift index b6992a63d9..fd9ace5203 100644 --- a/submodules/UrlHandling/Sources/UrlHandling.swift +++ b/submodules/UrlHandling/Sources/UrlHandling.swift @@ -134,6 +134,9 @@ public func parseInternalUrl(sharedContext: SharedAccountContext, query: String) if query.hasPrefix("ipfs/") { return .externalUrl(url: "ipfs://" + String(query[query.index(query.startIndex, offsetBy: "ipfs/".count)...])) } + if query.hasPrefix("ton/") { + return .externalUrl(url: "ton://" + String(query[query.index(query.startIndex, offsetBy: "ton/".count)...])) + } } if pathComponents[0].hasPrefix("+") || pathComponents[0].hasPrefix("%20") { diff --git a/submodules/ffmpeg/Sources/FFMpeg/build-ffmpeg-bazel.sh b/submodules/ffmpeg/Sources/FFMpeg/build-ffmpeg-bazel.sh index 32239864f5..be58ac5e4f 100755 --- a/submodules/ffmpeg/Sources/FFMpeg/build-ffmpeg-bazel.sh +++ b/submodules/ffmpeg/Sources/FFMpeg/build-ffmpeg-bazel.sh @@ -47,13 +47,13 @@ CONFIGURE_FLAGS="--enable-cross-compile --disable-programs \ --enable-libopus \ --enable-libvpx \ --enable-audiotoolbox \ - --enable-bsf=aac_adtstoasc,vp9_superframe \ + --enable-bsf=aac_adtstoasc,vp9_superframe,h264_mp4toannexb \ --enable-decoder=h264,libvpx_vp9,hevc,libopus,mp3,aac,flac,alac_at,pcm_s16le,pcm_s24le,gsm_ms_at \ - --enable-encoder=libvpx_vp9 \ - --enable-demuxer=aac,mov,m4v,mp3,ogg,libopus,flac,wav,aiff,matroska \ + --enable-encoder=libvpx_vp9,aac_at \ + --enable-demuxer=aac,mov,m4v,mp3,ogg,libopus,flac,wav,aiff,matroska,mpegts \ --enable-parser=aac,h264,mp3,libopus \ --enable-protocol=file \ - --enable-muxer=mp4,matroska \ + --enable-muxer=mp4,matroska,mpegts \ "