diff --git a/app/src/main/java/org/monogram/app/MainActivity.kt b/app/src/main/java/org/monogram/app/MainActivity.kt index 7c596d699..c5632cad3 100644 --- a/app/src/main/java/org/monogram/app/MainActivity.kt +++ b/app/src/main/java/org/monogram/app/MainActivity.kt @@ -1,6 +1,5 @@ package org.monogram.app -import android.app.ForegroundServiceStartNotAllowedException import android.content.Intent import android.os.Build import android.os.Bundle @@ -148,8 +147,6 @@ class MainActivity : FragmentActivity() { } else { startService(intent) } - } catch (_: ForegroundServiceStartNotAllowedException) { - Log.w(TAG, "Foreground notification service start was blocked by the system") } catch (error: IllegalStateException) { Log.w(TAG, "Foreground notification service start failed", error) } diff --git a/data/src/main/java/org/monogram/data/datasource/remote/SettingsRemoteDataSource.kt b/data/src/main/java/org/monogram/data/datasource/remote/SettingsRemoteDataSource.kt index be0b27bd2..9cc7333fe 100644 --- a/data/src/main/java/org/monogram/data/datasource/remote/SettingsRemoteDataSource.kt +++ b/data/src/main/java/org/monogram/data/datasource/remote/SettingsRemoteDataSource.kt @@ -11,6 +11,7 @@ interface SettingsRemoteDataSource { suspend fun getStorageStatisticsFast(): TdApi.StorageStatisticsFast? suspend fun getNetworkStatistics(): TdApi.NetworkStatistics? suspend fun getOption(name: String): TdApi.OptionValue? + suspend fun getPremiumLimit(limitType: TdApi.PremiumLimitType): TdApi.PremiumLimit? suspend fun getChatNotificationSettingsExceptions( scope: TdApi.NotificationSettingsScope, compareSound: Boolean diff --git a/data/src/main/java/org/monogram/data/datasource/remote/TdAuthRemoteDataSource.kt b/data/src/main/java/org/monogram/data/datasource/remote/TdAuthRemoteDataSource.kt index bf4350d81..0e0775bf8 100644 --- a/data/src/main/java/org/monogram/data/datasource/remote/TdAuthRemoteDataSource.kt +++ b/data/src/main/java/org/monogram/data/datasource/remote/TdAuthRemoteDataSource.kt @@ -19,14 +19,14 @@ class TdAuthRemoteDataSource( val settings = TdApi.PhoneNumberAuthenticationSettings().apply { isCurrentPhoneNumber = false allowFlashCall = false - allowMissedCall = false + allowMissedCall = true allowSmsRetrieverApi = false } gateway.execute(TdApi.SetAuthenticationPhoneNumber(phone, settings)) } override suspend fun resendCode() { - gateway.execute(TdApi.ResendAuthenticationCode()) + gateway.execute(TdApi.ResendAuthenticationCode(TdApi.ResendCodeReasonUserRequest())) } override suspend fun setAuthCode(code: String) { diff --git a/data/src/main/java/org/monogram/data/datasource/remote/TdMessageRemoteDataSource.kt b/data/src/main/java/org/monogram/data/datasource/remote/TdMessageRemoteDataSource.kt index ee15eafdb..6cd8573e2 100644 --- a/data/src/main/java/org/monogram/data/datasource/remote/TdMessageRemoteDataSource.kt +++ b/data/src/main/java/org/monogram/data/datasource/remote/TdMessageRemoteDataSource.kt @@ -58,6 +58,7 @@ import org.monogram.domain.models.MessageSendOptions import org.monogram.domain.models.MessageUploadProgressEvent import org.monogram.domain.models.MessageViewerModel import org.monogram.domain.models.PollDraft +import org.monogram.domain.models.TdLibLimits import org.monogram.domain.models.UserModel import org.monogram.domain.models.webapp.ThemeParams import org.monogram.domain.models.webapp.WebAppInfoModel @@ -68,6 +69,7 @@ import org.monogram.domain.repository.PollRepository import org.monogram.domain.repository.ReadUpdate import org.monogram.domain.repository.RichTextParseMode import org.monogram.domain.repository.SearchChatMessagesResult +import org.monogram.domain.repository.TdLibLimitsRepository import org.monogram.domain.repository.UserRepository import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.TimeUnit @@ -84,7 +86,8 @@ class TdMessageRemoteDataSource( private val webPageMapper: WebPageMapper, private val draftLinkPreviewResolver: DraftLinkPreviewResolver, private val dispatcherProvider: DispatcherProvider, - val scope: CoroutineScope + val scope: CoroutineScope, + private val tdLibLimitsRepository: TdLibLimitsRepository ) : MessageRemoteDataSource { private val chatRequests = ConcurrentHashMap>() @@ -92,11 +95,22 @@ class TdMessageRemoteDataSource( private val refreshJobs = ConcurrentHashMap, Job>() private val missingMessageCooldownUntil = ConcurrentHashMap, Long>() private val sendQueue = Channel Unit>(Channel.BUFFERED) - override val newMessageFlow = MutableSharedFlow() - override val messageEditedFlow = MutableSharedFlow() + // These are fed from `scope.launch { ... }` inside update handling. With the default + // arguments (replay 0, no buffer, SUSPEND) a MutableSharedFlow is a rendezvous channel: + // every emit parks until *all* subscribers have taken the value, which piled emitters up + // without bound during bursts. OrderedEventFlow.enqueue is non-suspending and lossless, + // so the event streams go through it; progress ticks are conflatable and get an explicit + // bounded buffer instead. + private val newMessages = OrderedEventFlow(scope) + override val newMessageFlow = newMessages.events + private val messageEdits = OrderedEventFlow(scope) + override val messageEditedFlow = messageEdits.events private val messageReads = OrderedEventFlow(scope) override val messageReadFlow = messageReads.events - override val messageUploadProgressFlow = MutableSharedFlow() + override val messageUploadProgressFlow = MutableSharedFlow( + extraBufferCapacity = 64, + onBufferOverflow = BufferOverflow.DROP_OLDEST + ) private val fileDownloads = OrderedEventFlow(scope) override val fileDownloadFlow = fileDownloads.events private val messageDownloads = OrderedEventFlow(scope) @@ -151,10 +165,26 @@ class TdMessageRemoteDataSource( throw e } catch (e: Exception) { Log.e("TdMessageRemote", "Error executing ${function.javaClass.simpleName}", e) + if (e.isLikelyLimitViolation()) { + scope.launch { + runCatching { tdLibLimitsRepository.refresh() } + } + } null } } + private fun Throwable.isLikelyLimitViolation(): Boolean { + val error = (this as? TdLibException)?.error ?: return false + if (error.code !in 400..499) return false + val message = error.message.orEmpty().lowercase() + return "too long" in message || + "length" in message || + "limit" in message || + "maximum" in message || + "max_" in message + } + override suspend fun getMessage(chatId: Long, messageId: Long): TdApi.Message? { cache.getMessage(chatId, messageId)?.let { return it } @@ -841,7 +871,11 @@ class TdMessageRemoteDataSource( val replyTo = if (replyToMsgId != null && replyToMsgId != 0L) TdApi.InputMessageReplyToMessage(replyToMsgId, null, 0, "") else null val topicId = resolveTopicId(chatId, threadId) var lastMessage: TdApi.Message? = null - explodeTextContent(content, MAX_TEXT_MESSAGE_CODE_POINTS).forEach { messageContent -> + explodeTextContent( + content, + tdLibLimitsRepository.limits.value.messageTextLengthMax + ?: TdLibLimits.DEFAULT_MESSAGE_TEXT_LENGTH_MAX + ).forEach { messageContent -> val req = TdApi.SendMessage().apply { this.chatId = chatId this.topicId = topicId @@ -1992,7 +2026,7 @@ class TdMessageRemoteDataSource( scope.launch(dispatcherProvider.io) { try { val model = mapMessageToModel(message) - newMessageFlow.emit(model) + newMessages.enqueue(model) } catch (e: Exception) { Log.e("TdMessageRemote", "Error mapping NewMessage", e) } } } @@ -2039,7 +2073,7 @@ class TdMessageRemoteDataSource( errorCode = update.error?.code ?: 0 ) ) - messageEditedFlow.emit(model) + messageEdits.enqueue(model) } } is TdApi.UpdateMessageContent -> { @@ -2216,7 +2250,7 @@ class TdMessageRemoteDataSource( if (messageId == 0L) return val msg = cache.getMessage(chatId, messageId) ?: return val model = mapMessageToModel(msg) - messageEditedFlow.emit(model) + messageEdits.enqueue(model) } private suspend fun mapMessageToModel(message: TdApi.Message): MessageModel { @@ -2445,7 +2479,7 @@ class TdMessageRemoteDataSource( delay(150) val msg = cache.getMessage(chatId, messageId) ?: return@launch try { - messageEditedFlow.emit(mapMessageToModel(msg)) + messageEdits.enqueue(mapMessageToModel(msg)) } catch (e: CancellationException) { throw e } catch (e: Exception) { @@ -2492,6 +2526,5 @@ class TdMessageRemoteDataSource( private companion object { private val MISSING_MESSAGE_COOLDOWN_MS = TimeUnit.MINUTES.toMillis(2) private const val DRAFT_LINK_PREVIEW_TAG = "DraftLinkPreview" - private const val MAX_TEXT_MESSAGE_CODE_POINTS = 4096 } } diff --git a/data/src/main/java/org/monogram/data/datasource/remote/TdSettingsRemoteDataSource.kt b/data/src/main/java/org/monogram/data/datasource/remote/TdSettingsRemoteDataSource.kt index 3b143e201..4f1f5bc73 100644 --- a/data/src/main/java/org/monogram/data/datasource/remote/TdSettingsRemoteDataSource.kt +++ b/data/src/main/java/org/monogram/data/datasource/remote/TdSettingsRemoteDataSource.kt @@ -83,6 +83,9 @@ class TdSettingsRemoteDataSource( override suspend fun getOption(name: String): TdApi.OptionValue? = coRunCatching { gateway.execute(TdApi.GetOption(name)) }.getOrNull() + override suspend fun getPremiumLimit(limitType: TdApi.PremiumLimitType): TdApi.PremiumLimit? = + coRunCatching { gateway.execute(TdApi.GetPremiumLimit(limitType)) }.getOrNull() + override suspend fun getArchiveChatListSettings(): TdApi.ArchiveChatListSettings? = coRunCatching { gateway.execute(TdApi.GetArchiveChatListSettings()) }.getOrNull() diff --git a/data/src/main/java/org/monogram/data/di/TdLibClient.kt b/data/src/main/java/org/monogram/data/di/TdLibClient.kt index e5c0070fc..69650ce93 100644 --- a/data/src/main/java/org/monogram/data/di/TdLibClient.kt +++ b/data/src/main/java/org/monogram/data/di/TdLibClient.kt @@ -1,9 +1,8 @@ package org.monogram.data.di import android.util.Log -import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.asStateFlow @@ -16,16 +15,21 @@ import org.monogram.data.BuildConfig import org.monogram.data.gateway.TdLibException import org.monogram.data.gateway.isExpectedProxyFailure import java.util.concurrent.ConcurrentHashMap +import kotlin.coroutines.CoroutineContext +import kotlin.coroutines.EmptyCoroutineContext import kotlin.coroutines.resume internal class TdLibClient { private val TAG = "TdLibClient" private val retryAfterUntilMsByScope = ConcurrentHashMap() - private val _updates = MutableSharedFlow( - replay = 3, - extraBufferCapacity = 64, - onBufferOverflow = BufferOverflow.DROP_OLDEST - ) + + /** + * Authoritative ingestion for TDLib updates. + * + * Consumers that own durable state subscribe with [lane] (lossless, ordered); + * consumers that only render subscribe to [updates] (conflating). + */ + private val pipeline = TdUpdatePipeline() private val _isAuthenticated = MutableStateFlow(false) val isAuthenticated = _isAuthenticated.asStateFlow() @@ -44,17 +48,40 @@ internal class TdLibClient { } } - val updates: SharedFlow = _updates + /** + * Observation stream. Conflates under load, so it must not be used to drive durable + * state; use [lane] for that. + */ + val updates: SharedFlow = pipeline.updates + + /** + * Lossless, strictly ordered subscription for consumers that own durable state: + * Room writes, [org.monogram.data.chats.ChatCache] mutation, or TDLib requests. + * + * Register during startup — updates delivered before the lane exists are not replayed. + */ + fun lane( + name: String, + scope: CoroutineScope, + context: CoroutineContext = EmptyCoroutineContext, + filter: (TdApi.Update) -> Boolean = { true }, + handler: suspend (TdApi.Update) -> Unit, + ): TdUpdatePipeline.Lane = pipeline.lane(name, scope, context, filter, handler) + + /** Diagnostics: ingest/lane backlogs, processed counts and handler failures. */ + fun updateMetrics(): String = pipeline.metrics() private val client = Client.create( { result -> if (result is TdApi.Update) { + // Kept on the callback thread on purpose: these gate sendSuspend, so they + // must not depend on the update pipeline making progress. if (result is TdApi.UpdateAuthorizationState) { val state = result.authorizationState _isInitialized.value = state !is TdApi.AuthorizationStateWaitTdlibParameters _isAuthenticated.value = state is TdApi.AuthorizationStateReady } - _updates.tryEmit(result) + pipeline.submit(result) } }, { error -> diff --git a/data/src/main/java/org/monogram/data/di/TdNotificationManager.kt b/data/src/main/java/org/monogram/data/di/TdNotificationManager.kt index 11b956853..7215bd7b6 100644 --- a/data/src/main/java/org/monogram/data/di/TdNotificationManager.kt +++ b/data/src/main/java/org/monogram/data/di/TdNotificationManager.kt @@ -167,14 +167,13 @@ class TdNotificationManager( } } - scope.launch { - updates.all.collect { update -> - runCatching { - handleCoreUpdate(update) - }.onFailure { - Log.e(TAG, "Failed to handle update ${update.javaClass.simpleName}", it) - } - } + // handleCoreUpdate issues TDLib requests inline (getChat, membership checks), so + // this consumer is orders of magnitude slower than the update rate and would be + // the first to be conflated away on the observation flow. updateNotificationGroup + // carries added/removed deltas and updateActiveNotifications arrives exactly once + // before them, so none of it may be dropped or reordered. + updates.lane(name = "notifications", scope = scope) { update -> + handleCoreUpdate(update) } scope.launch { diff --git a/data/src/main/java/org/monogram/data/di/TdUpdatePipeline.kt b/data/src/main/java/org/monogram/data/di/TdUpdatePipeline.kt new file mode 100644 index 000000000..8032f1977 --- /dev/null +++ b/data/src/main/java/org/monogram/data/di/TdUpdatePipeline.kt @@ -0,0 +1,232 @@ +package org.monogram.data.di + +import android.util.Log +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.asCoroutineDispatcher +import kotlinx.coroutines.cancel +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.launch +import org.drinkless.tdlib.TdApi +import java.util.concurrent.CopyOnWriteArrayList +import java.util.concurrent.Executors +import java.util.concurrent.atomic.AtomicLong +import kotlin.coroutines.CoroutineContext +import kotlin.coroutines.EmptyCoroutineContext + +/** + * Fan-out for TDLib updates, with two deliberately different delivery contracts. + * + * [submit] is called on TDLib's single "TDLib thread" (`Client.ResponseReceiver`, see + * `Client.java`). That same thread also delivers every query result, so it must never + * block and must stay O(1): blocking it stalls all in-flight requests, and TDLib's + * native output queue then grows without bound. [submit] therefore performs exactly one + * non-suspending enqueue and nothing else; a dedicated pump thread does the fan-out. + * (Fanning out on the callback thread instead was measured at ~38us/update with eight + * lanes attached, against a ~8us total budget.) + * + * Two ways to consume: + * + * - [lane] — **lossless and strictly ordered.** Private unbounded queue, private worker, + * per-update exception isolation. Use it whenever the handler writes to Room, mutates + * [org.monogram.data.chats.ChatCache], or issues a TDLib request. The order a lane sees + * is exactly TDLib's delivery order. + * - [updates] — **lossy by design.** A shared [SharedFlow] with `DROP_OLDEST`. Use it only + * for consumers that render state they can re-read; a drop there costs a redraw, never a + * state transition. + * + * There is deliberately no bounded lane. A bounded lane would silently discard updates, + * which is the defect this class exists to remove. + */ +internal class TdUpdatePipeline { + + private val ingest = Channel(Channel.UNLIMITED) + + private val _updates = MutableSharedFlow( + replay = OBSERVER_REPLAY, + extraBufferCapacity = OBSERVER_BUFFER, + onBufferOverflow = BufferOverflow.DROP_OLDEST + ) + + /** Observation only — may conflate. Durable consumers must use [lane]. */ + val updates: SharedFlow = _updates.asSharedFlow() + + private val lanes = CopyOnWriteArrayList() + + private val submitted = AtomicLong() + private val dispatched = AtomicLong() + private val rejected = AtomicLong() + + private val pumpExecutor = Executors.newSingleThreadExecutor { runnable -> + Thread(runnable, PUMP_THREAD_NAME).apply { isDaemon = true } + } + private val pumpScope = CoroutineScope(SupervisorJob() + pumpExecutor.asCoroutineDispatcher()) + + init { + pumpScope.launch { + for (update in ingest) { + // CopyOnWriteArrayList: indexed access, no iterator allocation on the hot path. + for (index in lanes.indices) { + lanes.getOrNull(index)?.offer(update) + } + _updates.tryEmit(update) + val count = dispatched.incrementAndGet() + if (count % BACKLOG_SAMPLE_EVERY == 0L) reportBacklogIfHigh() + } + } + } + + /** + * Called on the TDLib callback thread for every update. One unbounded enqueue, + * measured at single-digit nanoseconds and independent of the number of lanes. + */ + fun submit(update: TdApi.Update) { + submitted.incrementAndGet() + // UNLIMITED: only fails once the pipeline has been shut down. + if (ingest.trySend(update).isFailure) { + rejected.incrementAndGet() + } + } + + /** + * Registers a lossless, strictly ordered consumer. The lane stops and deregisters + * when [scope] is cancelled. + * + * Register lanes during application startup. Updates delivered before a lane exists + * are not retained for it, so a durable consumer that is constructed lazily will miss + * everything TDLib sent beforehand. + * + * @param filter evaluated on the pump thread; keep it to cheap type checks. + * @param context extra context for the worker, e.g. `Dispatchers.IO` for a lane that + * writes to Room. Defaults to [scope]'s dispatcher. + */ + fun lane( + name: String, + scope: CoroutineScope, + context: CoroutineContext = EmptyCoroutineContext, + filter: (TdApi.Update) -> Boolean = { true }, + handler: suspend (TdApi.Update) -> Unit, + ): Lane { + val lane = Lane(name, filter) + // Register before starting the worker: if `scope` is already cancelled the worker + // completes immediately, and its completion handler must be able to find the lane. + // Otherwise the lane would linger with nothing draining its queue. + lanes.add(lane) + val job = scope.launch(context) { + for (update in lane.queue) { + // Per-update isolation. `.catch { }` on a Flow ends the subscription for + // good; this keeps the lane alive and counts the failure instead. + try { + handler(update) + } catch (e: CancellationException) { + throw e + } catch (e: Throwable) { + lane.failures.incrementAndGet() + Log.e(TAG, "lane '$name' failed on ${update.javaClass.simpleName}", e) + } + lane.processed.incrementAndGet() + } + } + lane.worker = job + job.invokeOnCompletion { cause -> + lanes.remove(lane) + lane.queue.close() + if (cause != null && cause !is CancellationException) { + Log.e(TAG, "lane '$name' terminated unexpectedly", cause) + } + } + return lane + } + + /** Snapshot for diagnostics; safe to call from any thread. */ + fun metrics(): String = buildString { + append("submitted=").append(submitted.get()) + append(" dispatched=").append(dispatched.get()) + append(" ingestBacklog=").append(submitted.get() - dispatched.get()) + if (rejected.get() > 0) append(" rejected=").append(rejected.get()) + append(" observers=").append(_updates.subscriptionCount.value) + for (lane in lanes) { + append(" | ").append(lane.name) + .append(": backlog=").append(lane.backlog()) + .append(" processed=").append(lane.processed.get()) + .append(" failures=").append(lane.failures.get()) + } + } + + fun shutdown() { + ingest.close() + lanes.forEach { it.cancel() } + pumpScope.cancel() + pumpExecutor.shutdown() + } + + private fun reportBacklogIfHigh() { + val ingestBacklog = submitted.get() - dispatched.get() + var worstLane: Lane? = null + var worstBacklog = 0L + for (lane in lanes) { + val backlog = lane.backlog() + if (backlog > worstBacklog) { + worstBacklog = backlog + worstLane = lane + } + } + if (worstBacklog < BACKLOG_WARN_AT && ingestBacklog < BACKLOG_WARN_AT) return + Log.w(TAG, "update backlog is high (worst lane '${worstLane?.name}'): ${metrics()}") + } + + internal class Lane( + val name: String, + private val filter: (TdApi.Update) -> Boolean, + ) { + // Unbounded on purpose: a lane exists precisely because its consumer must not lose + // updates. Backlog is reported through [metrics] rather than being discarded. + val queue = Channel(Channel.UNLIMITED) + val queued = AtomicLong() + val processed = AtomicLong() + val failures = AtomicLong() + + @Volatile + var worker: Job? = null + + fun offer(update: TdApi.Update) { + if (!filter(update)) return + // Count only what was actually accepted: trySend fails once the lane has been + // closed, and counting those would leave backlog() permanently non-zero. + if (queue.trySend(update).isSuccess) queued.incrementAndGet() + } + + fun backlog(): Long = queued.get() - processed.get() + + fun cancel() { + worker?.cancel() + queue.close() + } + } + + private companion object { + private const val TAG = "TdUpdatePipeline" + private const val PUMP_THREAD_NAME = "td-update-pump" + + /** + * Observation buffer. Large enough that a collector doing only in-memory work + * cannot realistically fall behind; anything slower belongs on a lane. + */ + private const val OBSERVER_BUFFER = 1024 + + /** + * Kept for late observers of cheap, replaceable state. It is not a correctness + * mechanism: durable consumers use [lane], which never drops. + */ + private const val OBSERVER_REPLAY = 3 + + private const val BACKLOG_WARN_AT = 2048L + private const val BACKLOG_SAMPLE_EVERY = 512L + } +} diff --git a/data/src/main/java/org/monogram/data/di/dataModule.kt b/data/src/main/java/org/monogram/data/di/dataModule.kt index 1364c7263..0028214b6 100644 --- a/data/src/main/java/org/monogram/data/di/dataModule.kt +++ b/data/src/main/java/org/monogram/data/di/dataModule.kt @@ -127,6 +127,7 @@ import org.monogram.data.repository.StickerRepositoryImpl import org.monogram.data.repository.StorageRepositoryImpl import org.monogram.data.repository.StoryRepositoryImpl import org.monogram.data.repository.StreamingRepositoryImpl +import org.monogram.data.repository.TdLibLimitsRepositoryImpl import org.monogram.data.repository.TelegramLinkRepositoryImpl import org.monogram.data.repository.UpdateRepositoryImpl import org.monogram.data.repository.UserProfileEditRepositoryImpl @@ -177,6 +178,7 @@ import org.monogram.domain.repository.StorageRepository import org.monogram.domain.repository.StoryRepository import org.monogram.domain.repository.StreamingRepository import org.monogram.domain.repository.StringProvider +import org.monogram.domain.repository.TdLibLimitsRepository import org.monogram.domain.repository.TelegramLinkRepository import org.monogram.domain.repository.UpdateRepository import org.monogram.domain.repository.UserProfileEditRepository @@ -614,6 +616,15 @@ val dataModule = module { ) } + single(createdAtStart = true) { + TdLibLimitsRepositoryImpl( + remote = get(), + updates = get(), + authRepository = get(), + scope = get() + ) + } + single { NotificationSettingsRepositoryImpl( remote = get(), @@ -701,7 +712,8 @@ val dataModule = module { webPageMapper = get(), draftLinkPreviewResolver = get(), dispatcherProvider = get(), - scope = get() + scope = get(), + tdLibLimitsRepository = get() ) } @@ -725,7 +737,8 @@ val dataModule = module { userLocalDataSource = get(), stickerPathDao = get(), keyValueDao = get(), - textCompositionStyleDao = get() + textCompositionStyleDao = get(), + tdLibLimitsRepository = get() ) } @@ -788,7 +801,7 @@ val dataModule = module { FileUpdateHandler( registry = get(), queue = get(), - fileUpdatesSource = get().file, + updates = get(), scope = get() ) } @@ -905,7 +918,7 @@ val dataModule = module { updates = get(), scope = get(), fileDataSource = get(), - settingsRemoteDataSource = get() + tdLibLimitsRepository = get() ) } diff --git a/data/src/main/java/org/monogram/data/gateway/TdLibException.kt b/data/src/main/java/org/monogram/data/gateway/TdLibException.kt index 50b23d086..96d1c1c5a 100644 --- a/data/src/main/java/org/monogram/data/gateway/TdLibException.kt +++ b/data/src/main/java/org/monogram/data/gateway/TdLibException.kt @@ -51,12 +51,16 @@ fun Throwable.toAuthError(): AuthError { val normalizedMessage = tdError?.message.orEmpty().uppercase() return when { - normalizedMessage.contains("PHONE_CODE_INVALID") -> AuthError.InvalidCode + normalizedMessage.contains("PHONE_CODE_INVALID") || + normalizedMessage.contains("EMAIL_CODE_INVALID") -> AuthError.InvalidCode normalizedMessage.contains("PASSWORD_HASH_INVALID") -> AuthError.InvalidPassword normalizedMessage.contains("PHONE_CODE_EXPIRED") || normalizedMessage.contains("EMAIL_CODE_EXPIRED") || normalizedMessage.contains("CODE_EXPIRED") -> AuthError.CodeExpired + normalizedMessage.startsWith("FLOOD_WAIT_") -> + AuthError.RateLimited(normalizedMessage.substringAfterLast('_').toIntOrNull()) + else -> AuthError.Unexpected } } diff --git a/data/src/main/java/org/monogram/data/gateway/TelegramGateway.kt b/data/src/main/java/org/monogram/data/gateway/TelegramGateway.kt index 511bf1c1a..629393072 100644 --- a/data/src/main/java/org/monogram/data/gateway/TelegramGateway.kt +++ b/data/src/main/java/org/monogram/data/gateway/TelegramGateway.kt @@ -1,11 +1,32 @@ package org.monogram.data.gateway +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow import org.drinkless.tdlib.TdApi +import kotlin.coroutines.CoroutineContext +import kotlin.coroutines.EmptyCoroutineContext interface TelegramGateway { suspend fun execute(function: TdApi.Function): T + + /** + * Observation stream. Conflates when a collector falls behind; use [lane] for + * anything that drives durable state. + */ val updates: SharedFlow + val isAuthenticated: StateFlow + + /** + * Lossless, strictly ordered, exception-isolated update subscription. + * See [UpdateDispatcher.lane]. + */ + fun lane( + name: String, + scope: CoroutineScope, + context: CoroutineContext = EmptyCoroutineContext, + filter: (TdApi.Update) -> Boolean = { true }, + handler: suspend (TdApi.Update) -> Unit, + ) } diff --git a/data/src/main/java/org/monogram/data/gateway/TelegramGatewayImpl.kt b/data/src/main/java/org/monogram/data/gateway/TelegramGatewayImpl.kt index 6d2fe735c..5af3b0755 100644 --- a/data/src/main/java/org/monogram/data/gateway/TelegramGatewayImpl.kt +++ b/data/src/main/java/org/monogram/data/gateway/TelegramGatewayImpl.kt @@ -1,9 +1,11 @@ package org.monogram.data.gateway +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow import org.drinkless.tdlib.TdApi import org.monogram.data.di.TdLibClient +import kotlin.coroutines.CoroutineContext internal class TelegramGatewayImpl( private val client: TdLibClient @@ -16,4 +18,17 @@ internal class TelegramGatewayImpl( override val isAuthenticated: StateFlow get() = client.isAuthenticated + + override fun lane( + name: String, + scope: CoroutineScope, + context: CoroutineContext, + filter: (TdApi.Update) -> Boolean, + handler: suspend (TdApi.Update) -> Unit, + ) { + client.lane(name, scope, context, filter, handler) + } + + /** Diagnostics: ingest/lane backlogs, processed counts and handler failures. */ + fun updateMetrics(): String = client.updateMetrics() } diff --git a/data/src/main/java/org/monogram/data/gateway/UpdateDispatcher.kt b/data/src/main/java/org/monogram/data/gateway/UpdateDispatcher.kt index 651246045..e9d88c6dd 100644 --- a/data/src/main/java/org/monogram/data/gateway/UpdateDispatcher.kt +++ b/data/src/main/java/org/monogram/data/gateway/UpdateDispatcher.kt @@ -1,12 +1,39 @@ package org.monogram.data.gateway +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.emptyFlow import org.drinkless.tdlib.TdApi +import kotlin.coroutines.CoroutineContext +import kotlin.coroutines.EmptyCoroutineContext interface UpdateDispatcher { + /** + * Observation stream. Conflates when a collector falls behind, so it must only be + * used by consumers that render state they can re-read. Anything that writes to Room, + * mutates [org.monogram.data.chats.ChatCache], or issues a TDLib request must use + * [lane] instead. + */ val all: Flow + /** + * Lossless, strictly ordered, exception-isolated subscription. + * + * The lane owns a private unbounded queue and a private worker, so a slow handler + * delays only itself, and a handler that throws does not end the subscription. + * Register during startup: updates delivered before the lane exists are not replayed. + * + * @param filter evaluated on the update pump thread; keep it to cheap type checks. + * @param context extra worker context, e.g. `Dispatchers.IO` for lanes that hit Room. + */ + fun lane( + name: String, + scope: CoroutineScope, + context: CoroutineContext = EmptyCoroutineContext, + filter: (TdApi.Update) -> Boolean = { true }, + handler: suspend (TdApi.Update) -> Unit, + ) + // Auth val authorizationState: Flow diff --git a/data/src/main/java/org/monogram/data/gateway/UpdateDispatcherImpl.kt b/data/src/main/java/org/monogram/data/gateway/UpdateDispatcherImpl.kt index 8e2a938ee..b82343456 100644 --- a/data/src/main/java/org/monogram/data/gateway/UpdateDispatcherImpl.kt +++ b/data/src/main/java/org/monogram/data/gateway/UpdateDispatcherImpl.kt @@ -1,18 +1,30 @@ package org.monogram.data.gateway +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filterIsInstance import org.drinkless.tdlib.TdApi +import kotlin.coroutines.CoroutineContext class UpdateDispatcherImpl( - gateway: TelegramGateway + private val gateway: TelegramGateway ) : UpdateDispatcher { private val updates = gateway.updates override val all: SharedFlow = updates + override fun lane( + name: String, + scope: CoroutineScope, + context: CoroutineContext, + filter: (TdApi.Update) -> Boolean, + handler: suspend (TdApi.Update) -> Unit, + ) { + gateway.lane(name, scope, context, filter, handler) + } + private inline fun flow(): Flow = updates.filterIsInstance() @@ -56,36 +68,44 @@ class UpdateDispatcherImpl( override val installedStickerSets = flow() override val newChat = flow() override val attachmentMenuBots = flow() - override val chatsListUpdates = updates.filter { - it is TdApi.UpdateNewChat || - it is TdApi.UpdateChatTitle || - it is TdApi.UpdateChatPhoto || - it is TdApi.UpdateChatLastMessage || - it is TdApi.UpdateChatPosition || - it is TdApi.UpdateChatReadInbox || - it is TdApi.UpdateChatReadOutbox || - it is TdApi.UpdateChatUnreadMentionCount || - it is TdApi.UpdateChatUnreadReactionCount || - it is TdApi.UpdateChatDraftMessage || - it is TdApi.UpdateChatNotificationSettings || - it is TdApi.UpdateChatPermissions || - it is TdApi.UpdateChatViewAsTopics || - it is TdApi.UpdateChatIsTranslatable || - it is TdApi.UpdateChatOnlineMemberCount || - it is TdApi.UpdateChatFolders || - it is TdApi.UpdateUserStatus || - it is TdApi.UpdateUser || - it is TdApi.UpdateSupergroup || - it is TdApi.UpdateBasicGroup || - it is TdApi.UpdateSupergroupFullInfo || - it is TdApi.UpdateBasicGroupFullInfo || - it is TdApi.UpdateSecretChat || - it is TdApi.UpdateChatAction || - it is TdApi.UpdateFile || - it is TdApi.UpdateDeleteMessages || - it is TdApi.UpdateMessageMentionRead || - it is TdApi.UpdateMessageReactions || - it is TdApi.UpdateAuthorizationState || - it is TdApi.UpdateConnectionState - } + override val chatsListUpdates = updates.filter(CHATS_LIST_LANE_FILTER) +} + +/** + * The update set that drives the canonical chat cache. + * + * Shared by [UpdateDispatcher.chatsListUpdates] and by the lossless "chat-list" lane in + * `ChatsListRepositoryImpl`, so the two can never drift apart. + */ +val CHATS_LIST_LANE_FILTER: (TdApi.Update) -> Boolean = { + it is TdApi.UpdateNewChat || + it is TdApi.UpdateChatTitle || + it is TdApi.UpdateChatPhoto || + it is TdApi.UpdateChatLastMessage || + it is TdApi.UpdateChatPosition || + it is TdApi.UpdateChatReadInbox || + it is TdApi.UpdateChatReadOutbox || + it is TdApi.UpdateChatUnreadMentionCount || + it is TdApi.UpdateChatUnreadReactionCount || + it is TdApi.UpdateChatDraftMessage || + it is TdApi.UpdateChatNotificationSettings || + it is TdApi.UpdateChatPermissions || + it is TdApi.UpdateChatViewAsTopics || + it is TdApi.UpdateChatIsTranslatable || + it is TdApi.UpdateChatOnlineMemberCount || + it is TdApi.UpdateChatFolders || + it is TdApi.UpdateUserStatus || + it is TdApi.UpdateUser || + it is TdApi.UpdateSupergroup || + it is TdApi.UpdateBasicGroup || + it is TdApi.UpdateSupergroupFullInfo || + it is TdApi.UpdateBasicGroupFullInfo || + it is TdApi.UpdateSecretChat || + it is TdApi.UpdateChatAction || + it is TdApi.UpdateFile || + it is TdApi.UpdateDeleteMessages || + it is TdApi.UpdateMessageMentionRead || + it is TdApi.UpdateMessageReactions || + it is TdApi.UpdateAuthorizationState || + it is TdApi.UpdateConnectionState } diff --git a/data/src/main/java/org/monogram/data/infra/FileUpdateHandler.kt b/data/src/main/java/org/monogram/data/infra/FileUpdateHandler.kt index 887b7a1d6..466ddbb4c 100644 --- a/data/src/main/java/org/monogram/data/infra/FileUpdateHandler.kt +++ b/data/src/main/java/org/monogram/data/infra/FileUpdateHandler.kt @@ -2,11 +2,11 @@ package org.monogram.data.infra import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.channels.BufferOverflow -import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.launch import org.drinkless.tdlib.TdApi +import org.monogram.data.gateway.UpdateDispatcher interface FileUpdateQueue { fun updateFileCache(file: TdApi.File) @@ -17,7 +17,7 @@ interface FileUpdateQueue { class FileUpdateHandler( private val registry: FileMessageRegistry, private val queue: FileUpdateQueue, - private val fileUpdatesSource: Flow, + private val updates: UpdateDispatcher, private val scope: CoroutineScope ) { val customEmojiPaths = SynchronizedLruMap(CUSTOM_EMOJI_CACHE_SIZE) @@ -39,8 +39,16 @@ class FileUpdateHandler( val fileUpdates = fileUpdateEvents.events init { - scope.launch { - fileUpdatesSource.collect { update -> handle(update.file) } + // The isDownloadingCompleted / isUploadingCompleted edge is what resolves download + // waiters and records the local path; losing it strands whatever was waiting. + // Progress ticks in between are conflatable, but the edge is not, so the whole + // stream goes through a lossless lane. + updates.lane( + name = "files", + scope = scope, + filter = { it is TdApi.UpdateFile }, + ) { update -> + handle((update as TdApi.UpdateFile).file) } } diff --git a/data/src/main/java/org/monogram/data/mapper/AuthMapper.kt b/data/src/main/java/org/monogram/data/mapper/AuthMapper.kt index 7d8cfda56..2ad92a5a8 100644 --- a/data/src/main/java/org/monogram/data/mapper/AuthMapper.kt +++ b/data/src/main/java/org/monogram/data/mapper/AuthMapper.kt @@ -1,6 +1,8 @@ package org.monogram.data.mapper import org.drinkless.tdlib.TdApi +import org.monogram.domain.repository.AuthCodeDelivery +import org.monogram.domain.repository.AuthCodeInputKind import org.monogram.domain.repository.AuthStep fun TdApi.AuthorizationState.toDomain(): AuthStep = @@ -11,33 +13,34 @@ fun TdApi.AuthorizationState.toDomain(): AuthStep = is TdApi.AuthorizationStateWaitPhoneNumber -> AuthStep.InputPhone - is TdApi.AuthorizationStateWaitCode -> + is TdApi.AuthorizationStateWaitCode -> { + val codeMetadata = this.codeInfo.type.toAuthCodeMetadata() AuthStep.InputCode( - codeType = this.codeInfo.type.javaClass.simpleName, - codeLength = this.codeInfo.type.let { type -> - when (type) { - is TdApi.AuthenticationCodeTypeTelegramMessage -> type.length - is TdApi.AuthenticationCodeTypeSms -> type.length - is TdApi.AuthenticationCodeTypeCall -> type.length - is TdApi.AuthenticationCodeTypeFlashCall -> 0 - is TdApi.AuthenticationCodeTypeMissedCall -> type.length - else -> 5 - } - }, - nextType = this.codeInfo.nextType?.javaClass?.simpleName, - timeout = this.codeInfo.timeout + delivery = codeMetadata.delivery, + codeLength = codeMetadata.codeLength, + inputKind = codeMetadata.inputKind, + codeHint = codeMetadata.hint, + nextDelivery = this.codeInfo.nextType?.toAuthCodeMetadata()?.delivery, + timeout = this.codeInfo.timeout, + canResend = this.codeInfo.nextType != null ) + } is TdApi.AuthorizationStateWaitEmailCode -> AuthStep.InputCode( - codeType = "Email", + delivery = AuthCodeDelivery.EMAIL, codeLength = this.codeInfo.length, isEmailCode = true, - emailPattern = this.codeInfo.emailAddressPattern + emailPattern = this.codeInfo.emailAddressPattern, + canResend = true ) is TdApi.AuthorizationStateWaitPassword -> - AuthStep.InputPassword + AuthStep.InputPassword( + passwordHint = this.passwordHint.takeIf { it.isNotBlank() }, + hasRecoveryEmail = this.hasRecoveryEmailAddress, + recoveryEmailPattern = this.recoveryEmailAddressPattern.takeIf { it.isNotBlank() } + ) is TdApi.AuthorizationStateWaitTdlibParameters -> AuthStep.WaitParameters @@ -49,4 +52,47 @@ fun TdApi.AuthorizationState.toDomain(): AuthStep = else -> AuthStep.Loading - } \ No newline at end of file + } + +private data class AuthCodeMetadata( + val delivery: AuthCodeDelivery, + val codeLength: Int, + val inputKind: AuthCodeInputKind = AuthCodeInputKind.NUMERIC, + val hint: String? = null +) + +private fun TdApi.AuthenticationCodeType.toAuthCodeMetadata(): AuthCodeMetadata = + when (this) { + is TdApi.AuthenticationCodeTypeTelegramMessage -> + AuthCodeMetadata(AuthCodeDelivery.TELEGRAM_MESSAGE, length) + + is TdApi.AuthenticationCodeTypeSms -> + AuthCodeMetadata(AuthCodeDelivery.SMS, length) + + is TdApi.AuthenticationCodeTypeSmsWord -> + AuthCodeMetadata(AuthCodeDelivery.SMS_WORD, 0, AuthCodeInputKind.TEXT, firstLetter) + + is TdApi.AuthenticationCodeTypeSmsPhrase -> + AuthCodeMetadata(AuthCodeDelivery.SMS_PHRASE, 0, AuthCodeInputKind.TEXT, firstWord) + + is TdApi.AuthenticationCodeTypeCall -> + AuthCodeMetadata(AuthCodeDelivery.CALL, length) + + is TdApi.AuthenticationCodeTypeFlashCall -> + AuthCodeMetadata(AuthCodeDelivery.FLASH_CALL, 0, hint = pattern) + + is TdApi.AuthenticationCodeTypeMissedCall -> + AuthCodeMetadata(AuthCodeDelivery.MISSED_CALL, length, hint = phoneNumberPrefix) + + is TdApi.AuthenticationCodeTypeFragment -> + AuthCodeMetadata(AuthCodeDelivery.FRAGMENT, length) + + is TdApi.AuthenticationCodeTypeFirebaseAndroid -> + AuthCodeMetadata(AuthCodeDelivery.FIREBASE_ANDROID, length) + + is TdApi.AuthenticationCodeTypeFirebaseIos -> + AuthCodeMetadata(AuthCodeDelivery.FIREBASE_IOS, length) + + else -> + AuthCodeMetadata(AuthCodeDelivery.UNKNOWN, 0) + } diff --git a/data/src/main/java/org/monogram/data/repository/AuthRepositoryImpl.kt b/data/src/main/java/org/monogram/data/repository/AuthRepositoryImpl.kt index f915693f9..90bbc64b1 100644 --- a/data/src/main/java/org/monogram/data/repository/AuthRepositoryImpl.kt +++ b/data/src/main/java/org/monogram/data/repository/AuthRepositoryImpl.kt @@ -138,7 +138,19 @@ class AuthRepositoryImpl( } override fun resendCode() { - launchAuthAction { remote.resendCode() } + val currentStep = _authState.value as? AuthStep.InputCode + if (currentStep?.canResend != true || + _authUiStatus.value is AuthUiStatus.Submitting + ) { + return + } + + _authUiStatus.value = AuthUiStatus.Submitting(AuthSubmissionStage.RESEND) + scope.launch { + coRunCatching { remote.resendCode() } + .onSuccess { _authUiStatus.value = AuthUiStatus.Idle } + .onFailure(::emitError) + } } override fun sendCode(code: String) { @@ -160,6 +172,7 @@ class AuthRepositoryImpl( else -> when (action.stage) { AuthSubmissionStage.PHONE -> sendPhone(action.payload) AuthSubmissionStage.CODE -> sendCode(action.payload) + AuthSubmissionStage.RESEND -> resendCode() AuthSubmissionStage.PASSWORD -> sendPassword(action.payload) } } @@ -243,6 +256,7 @@ class AuthRepositoryImpl( return when (stage) { AuthSubmissionStage.PHONE -> _authState.value is AuthStep.InputPhone AuthSubmissionStage.CODE -> _authState.value is AuthStep.InputCode + AuthSubmissionStage.RESEND -> _authState.value is AuthStep.InputCode AuthSubmissionStage.PASSWORD -> _authState.value is AuthStep.InputPassword } } @@ -258,6 +272,7 @@ class AuthRepositoryImpl( state !is AuthStep.Closing AuthSubmissionStage.CODE -> state is AuthStep.InputPassword || state is AuthStep.Ready + AuthSubmissionStage.RESEND -> state is AuthStep.InputCode AuthSubmissionStage.PASSWORD -> state is AuthStep.Ready } } @@ -273,6 +288,8 @@ class AuthRepositoryImpl( "checkAuthenticationEmailCode" ) + AuthSubmissionStage.RESEND -> listOf("resendAuthenticationCode") + AuthSubmissionStage.PASSWORD -> listOf("checkAuthenticationPassword") } diff --git a/data/src/main/java/org/monogram/data/repository/ChatsListRepositoryImpl.kt b/data/src/main/java/org/monogram/data/repository/ChatsListRepositoryImpl.kt index 2ec968daf..e95b38a95 100644 --- a/data/src/main/java/org/monogram/data/repository/ChatsListRepositoryImpl.kt +++ b/data/src/main/java/org/monogram/data/repository/ChatsListRepositoryImpl.kt @@ -38,6 +38,7 @@ import org.monogram.data.datasource.remote.ChatsRemoteDataSource import org.monogram.data.db.dao.ChatFolderDao import org.monogram.data.db.dao.SearchHistoryDao import org.monogram.data.db.dao.UserFullInfoDao +import org.monogram.data.gateway.CHATS_LIST_LANE_FILTER import org.monogram.data.gateway.TdLibException import org.monogram.data.gateway.TelegramGateway import org.monogram.data.gateway.UpdateDispatcher @@ -305,10 +306,16 @@ class ChatsListRepositoryImpl( } } - scope.launch { - updates.chatsListUpdates.collect { update -> - updateHandler.handle(update) - } + // ChatUpdateHandler.handle is the canonical ChatCache mutation. Missing an + // updateNewChat leaves no row for the chat, and every later cache.updateChat for + // it becomes a silent no-op (ChatCache.updateChat), permanently blackholing the + // chat. This must be lossless and ordered. + updates.lane( + name = "chat-list", + scope = scope, + filter = CHATS_LIST_LANE_FILTER, + ) { update -> + updateHandler.handle(update) } scope.launch { diff --git a/data/src/main/java/org/monogram/data/repository/MessageRepositoryImpl.kt b/data/src/main/java/org/monogram/data/repository/MessageRepositoryImpl.kt index 777443333..a684d40ec 100644 --- a/data/src/main/java/org/monogram/data/repository/MessageRepositoryImpl.kt +++ b/data/src/main/java/org/monogram/data/repository/MessageRepositoryImpl.kt @@ -6,10 +6,6 @@ import android.util.Log import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.catch -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock @@ -84,6 +80,7 @@ import org.monogram.domain.repository.ProfileMediaFilter import org.monogram.domain.repository.RichTextParseMode import org.monogram.domain.repository.RichTextParsingRepository import org.monogram.domain.repository.SearchChatMessagesResult +import org.monogram.domain.repository.TdLibLimitsRepository import org.monogram.domain.repository.TextCompositionStyleModel import java.io.File import java.util.concurrent.ConcurrentHashMap @@ -107,7 +104,8 @@ internal class MessageRepositoryImpl( private val userLocalDataSource: UserLocalDataSource, private val stickerPathDao: StickerPathDao, private val keyValueDao: KeyValueDao, - private val textCompositionStyleDao: TextCompositionStyleDao + private val textCompositionStyleDao: TextCompositionStyleDao, + private val tdLibLimitsRepository: TdLibLimitsRepository ) : MessageRepository, RichTextParsingRepository { private data class RichMessageCacheKey(val chatId: Long, val messageId: Long) @@ -154,18 +152,19 @@ internal class MessageRepositoryImpl( } } - updates.all - .map { update -> - messageRemoteDataSource.handleUpdate(update) - update - } - .onEach { update -> - processCachedUpdate(update) - } - .catch { error -> - Log.e("TdLibUpdates", "CRITICAL: Update loop died", error) - } - .launchIn(scope) + // Owns the message cache and the Room message mirror, so it must not miss updates: + // updateNewMessage, updateDeleteMessages and updateMessageSendSucceeded are deltas + // that TDLib never re-sends. A lane is lossless and strictly ordered, and isolates + // handler exceptions per update — the previous `.catch { }` ended the subscription + // for the rest of the process on the first failure. + updates.lane( + name = "messages", + scope = scope, + context = dispatcherProvider.io, + ) { update -> + messageRemoteDataSource.handleUpdate(update) + processCachedUpdate(update) + } scope.launch(dispatcherProvider.io) { val ninetyDaysAgo = System.currentTimeMillis() - (90L * 24 * 60 * 60 * 1000) @@ -697,15 +696,21 @@ internal class MessageRepositoryImpl( } override suspend fun forwardMessages(request: ForwardRequest) { + val maxForwardedCount = tdLibLimitsRepository.limits.value.forwardedMessageCountMax + ?.takeIf { it > 0 } request.targets.forEach { target -> - messageRemoteDataSource.forwardMessages( - toChatId = target.chatId, - fromChatId = request.fromChatId, - messageIds = request.messageIds.toLongArray(), - forumTopicId = target.forumTopicId, - removeCaption = request.options.removeCaption, - sendCopy = request.options.sendCopy - ) + request.messageIds + .chunked(maxForwardedCount ?: request.messageIds.size.coerceAtLeast(1)) + .forEach { batch -> + messageRemoteDataSource.forwardMessages( + toChatId = target.chatId, + fromChatId = request.fromChatId, + messageIds = batch.toLongArray(), + forumTopicId = target.forumTopicId, + removeCaption = request.options.removeCaption, + sendCopy = request.options.sendCopy + ) + } } } diff --git a/data/src/main/java/org/monogram/data/repository/NotificationSettingsRepositoryImpl.kt b/data/src/main/java/org/monogram/data/repository/NotificationSettingsRepositoryImpl.kt index c3ff61666..5fd9416db 100644 --- a/data/src/main/java/org/monogram/data/repository/NotificationSettingsRepositoryImpl.kt +++ b/data/src/main/java/org/monogram/data/repository/NotificationSettingsRepositoryImpl.kt @@ -35,47 +35,55 @@ class NotificationSettingsRepositoryImpl( private val exceptionsCacheMutex = Mutex() init { - scope.launch { - updates.newChat.collect { update -> - cache.putChat(update.chat) - syncChatWithExceptionsCache(update.chat) - } - } + // All four branches write to the exceptions cache and the Room exception table, + // and the last three are no-ops unless updateNewChat was seen first, so they must + // be lossless and mutually ordered — hence one lane instead of four subscriptions. + updates.lane( + name = "notification-settings", + scope = scope, + filter = { + it is TdApi.UpdateNewChat || + it is TdApi.UpdateChatTitle || + it is TdApi.UpdateChatPhoto || + it is TdApi.UpdateChatNotificationSettings + }, + ) { update -> + when (update) { + is TdApi.UpdateNewChat -> { + cache.putChat(update.chat) + syncChatWithExceptionsCache(update.chat) + } - scope.launch { - updates.chatTitle.collect { update -> - cache.getChat(update.chatId)?.let { chat -> - synchronized(chat) { - chat.title = update.title + is TdApi.UpdateChatTitle -> { + cache.getChat(update.chatId)?.let { chat -> + synchronized(chat) { + chat.title = update.title + } + syncChatWithExceptionsCache(chat) } - syncChatWithExceptionsCache(chat) } - } - } - scope.launch { - updates.chatPhoto.collect { update -> - cache.getChat(update.chatId)?.let { chat -> - synchronized(chat) { - chat.photo = update.photo + is TdApi.UpdateChatPhoto -> { + cache.getChat(update.chatId)?.let { chat -> + synchronized(chat) { + chat.photo = update.photo + } + syncChatWithExceptionsCache(chat) } - syncChatWithExceptionsCache(chat) } - } - } - scope.launch { - updates.chatNotificationSettings.collect { update -> - cache.getChat(update.chatId)?.let { chat -> - synchronized(chat) { - chat.notificationSettings = update.notificationSettings - } - syncChatWithExceptionsCache(chat) - } ?: run { - if (update.notificationSettings.isException(compareSound = true)) { - invalidateExceptionsCache() - } else { - removeFromExceptionsCache(update.chatId) + is TdApi.UpdateChatNotificationSettings -> { + cache.getChat(update.chatId)?.let { chat -> + synchronized(chat) { + chat.notificationSettings = update.notificationSettings + } + syncChatWithExceptionsCache(chat) + } ?: run { + if (update.notificationSettings.isException(compareSound = true)) { + invalidateExceptionsCache() + } else { + removeFromExceptionsCache(update.chatId) + } } } } diff --git a/data/src/main/java/org/monogram/data/repository/StoryRepositoryImpl.kt b/data/src/main/java/org/monogram/data/repository/StoryRepositoryImpl.kt index 94c1ab322..ff5a2bc59 100644 --- a/data/src/main/java/org/monogram/data/repository/StoryRepositoryImpl.kt +++ b/data/src/main/java/org/monogram/data/repository/StoryRepositoryImpl.kt @@ -10,7 +10,6 @@ import kotlinx.coroutines.launch import org.drinkless.tdlib.TdApi import org.json.JSONObject import org.monogram.data.datasource.FileDataSource -import org.monogram.data.datasource.remote.SettingsRemoteDataSource import org.monogram.data.gateway.TelegramGateway import org.monogram.data.gateway.UpdateDispatcher import org.monogram.data.mapper.StoryInteractionMapper @@ -34,13 +33,14 @@ import org.monogram.domain.models.stories.StoryReactionModel import org.monogram.domain.models.stories.StoryStatisticsModel import org.monogram.domain.models.stories.StoryStealthModeModel import org.monogram.domain.repository.StoryRepository +import org.monogram.domain.repository.TdLibLimitsRepository class StoryRepositoryImpl( private val gateway: TelegramGateway, private val updates: UpdateDispatcher, private val scope: CoroutineScope, private val fileDataSource: FileDataSource, - private val settingsRemoteDataSource: SettingsRemoteDataSource + private val tdLibLimitsRepository: TdLibLimitsRepository ) : StoryRepository { private val state = MutableStateFlow(StoryRepositoryState()) @@ -67,6 +67,25 @@ class StoryRepositoryImpl( scope.launch { updates.all.collect(::handleUpdate) } + scope.launch { + tdLibLimitsRepository.limits.collect { limits -> + applyState( + StoryRepositoryStateReducer.withStoryOptions( + state.value, + StoryOptionsModel( + captionLengthMax = limits.storyCaptionLengthMax ?: 0, + linkAreaCountMax = limits.storyLinkAreaCountMax ?: 0, + stealthModeCooldownPeriod = limits.storyStealthModeCooldownPeriod ?: 0, + stealthModeFuturePeriod = limits.storyStealthModeFuturePeriod ?: 0, + stealthModePastPeriod = limits.storyStealthModePastPeriod ?: 0, + suggestedReactionAreaCountMax = limits.storySuggestedReactionAreaCountMax + ?: 0, + viewersExpirationDelay = limits.storyViewersExpirationDelay ?: 0 + ) + ) + ) + } + } } override suspend fun loadActiveStories(listType: StoryListType) { @@ -81,14 +100,15 @@ class StoryRepositoryImpl( } override suspend fun refreshStoryOptions() { + val limits = tdLibLimitsRepository.limits.value val options = StoryOptionsModel( - captionLengthMax = getIntegerOption("story_caption_length_max"), - linkAreaCountMax = getIntegerOption("story_link_area_count_max"), - stealthModeCooldownPeriod = getIntegerOption("story_stealth_mode_cooldown_period"), - stealthModeFuturePeriod = getIntegerOption("story_stealth_mode_future_period"), - stealthModePastPeriod = getIntegerOption("story_stealth_mode_past_period"), - suggestedReactionAreaCountMax = getIntegerOption("story_suggested_reaction_area_count_max"), - viewersExpirationDelay = getIntegerOption("story_viewers_expiration_delay") + captionLengthMax = limits.storyCaptionLengthMax ?: 0, + linkAreaCountMax = limits.storyLinkAreaCountMax ?: 0, + stealthModeCooldownPeriod = limits.storyStealthModeCooldownPeriod ?: 0, + stealthModeFuturePeriod = limits.storyStealthModeFuturePeriod ?: 0, + stealthModePastPeriod = limits.storyStealthModePastPeriod ?: 0, + suggestedReactionAreaCountMax = limits.storySuggestedReactionAreaCountMax ?: 0, + viewersExpirationDelay = limits.storyViewersExpirationDelay ?: 0 ) applyState(StoryRepositoryStateReducer.withStoryOptions(state.value, options)) } @@ -559,12 +579,6 @@ class StoryRepositoryImpl( } } - private suspend fun getIntegerOption(name: String): Int { - return (settingsRemoteDataSource.getOption(name) as? TdApi.OptionValueInteger)?.value - ?.toInt() - ?: 0 - } - private suspend fun resolveStoryMedia(content: TdApi.StoryContent): StoryMediaModel { return when (content) { is TdApi.StoryContentPhoto -> { diff --git a/data/src/main/java/org/monogram/data/repository/TdLibLimitsRepositoryImpl.kt b/data/src/main/java/org/monogram/data/repository/TdLibLimitsRepositoryImpl.kt new file mode 100644 index 000000000..146639059 --- /dev/null +++ b/data/src/main/java/org/monogram/data/repository/TdLibLimitsRepositoryImpl.kt @@ -0,0 +1,176 @@ +package org.monogram.data.repository + +import android.util.Log +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.supervisorScope +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import org.drinkless.tdlib.TdApi +import org.monogram.data.datasource.remote.SettingsRemoteDataSource +import org.monogram.data.gateway.UpdateDispatcher +import org.monogram.domain.models.TdLibLimitOptionNames +import org.monogram.domain.models.TdLibLimits +import org.monogram.domain.repository.AuthRepository +import org.monogram.domain.repository.AuthStep +import org.monogram.domain.repository.TdLibLimitsRepository + +class TdLibLimitsRepositoryImpl( + private val remote: SettingsRemoteDataSource, + private val updates: UpdateDispatcher, + private val authRepository: AuthRepository, + private val scope: CoroutineScope +) : TdLibLimitsRepository { + private val _limits = MutableStateFlow(TdLibLimits.DEFAULTS) + override val limits: StateFlow = _limits.asStateFlow() + + private val refreshMutex = Mutex() + private val limitsMutex = Mutex() + private var isAuthorized = false + private var premiumAccount: Boolean? = null + private var cachedPremiumTextLimit: TdApi.PremiumLimit? = null + private var cachedPremiumCaptionLimit: TdApi.PremiumLimit? = null + + init { + scope.launch { + updates.option.collect { update -> + if (update.name in OPTION_NAMES) { + limitsMutex.withLock { + _limits.update { + it.withOption( + update.name, + resolveOptionUpdateValue(update.name, update.value.toIntOrNull()) + ) + } + } + } else if (update.name == TdLibLimitOptionNames.IS_PREMIUM) { + refresh() + } + } + } + scope.launch { + authRepository.authState.collect { authState -> + when (authState) { + is AuthStep.Ready -> { + if (!isAuthorized) { + isAuthorized = true + refresh() + } + } + + else -> { + isAuthorized = false + limitsMutex.withLock { + premiumAccount = null + cachedPremiumTextLimit = null + cachedPremiumCaptionLimit = null + _limits.value = TdLibLimits.DEFAULTS + } + } + } + } + } + } + + override suspend fun refresh() { + refreshMutex.withLock { + val values = supervisorScope { + OPTION_NAMES.map { name -> + async { name to readIntegerOption(name) } + }.awaitAll().toMap() + } + val isPremium = readBooleanOption(TdLibLimitOptionNames.IS_PREMIUM) + val premiumTextLimit = readPremiumLimit(TdApi.PremiumLimitTypeMessageTextLength()) + val premiumCaptionLimit = readPremiumLimit(TdApi.PremiumLimitTypeCaptionLength()) + val resolvedValues = values.toMutableMap().apply { + this[TdLibLimitOptionNames.MESSAGE_TEXT_LENGTH_MAX] = resolvePremiumLimit( + optionValue = values[TdLibLimitOptionNames.MESSAGE_TEXT_LENGTH_MAX], + premiumLimit = premiumTextLimit, + isPremium = isPremium, + premiumFallback = TdLibLimits.DEFAULT_PREMIUM_MESSAGE_TEXT_LENGTH_MAX + ) + this[TdLibLimitOptionNames.MESSAGE_CAPTION_LENGTH_MAX] = resolvePremiumLimit( + optionValue = values[TdLibLimitOptionNames.MESSAGE_CAPTION_LENGTH_MAX], + premiumLimit = premiumCaptionLimit, + isPremium = isPremium, + premiumFallback = TdLibLimits.DEFAULT_PREMIUM_MESSAGE_CAPTION_LENGTH_MAX + ) + } + Log.d( + TAG, + "TDLib limits loaded: ${resolvedValues.toSortedMap()} " + + "premium=$isPremium " + + "premiumLimits={message_text_length_max=${premiumTextLimit?.defaultValue}/${premiumTextLimit?.premiumValue}, " + + "message_caption_length_max=${premiumCaptionLimit?.defaultValue}/${premiumCaptionLimit?.premiumValue}}" + ) + limitsMutex.withLock { + premiumAccount = isPremium + cachedPremiumTextLimit = premiumTextLimit + cachedPremiumCaptionLimit = premiumCaptionLimit + _limits.update { current -> + resolvedValues.entries.fold(current) { limits, (name, value) -> + limits.withOption(name, value) + } + } + } + } + } + + private fun resolveOptionUpdateValue(name: String, optionValue: Int?): Int? = when (name) { + TdLibLimitOptionNames.MESSAGE_TEXT_LENGTH_MAX -> resolvePremiumLimit( + optionValue = optionValue, + premiumLimit = cachedPremiumTextLimit, + isPremium = premiumAccount, + premiumFallback = TdLibLimits.DEFAULT_PREMIUM_MESSAGE_TEXT_LENGTH_MAX + ) + + TdLibLimitOptionNames.MESSAGE_CAPTION_LENGTH_MAX -> resolvePremiumLimit( + optionValue = optionValue, + premiumLimit = cachedPremiumCaptionLimit, + isPremium = premiumAccount, + premiumFallback = TdLibLimits.DEFAULT_PREMIUM_MESSAGE_CAPTION_LENGTH_MAX + ) + + else -> optionValue + } + + private suspend fun readIntegerOption(name: String): Int? = + runCatching { remote.getOption(name) } + .getOrNull() + .toIntOrNull() + + private suspend fun readBooleanOption(name: String): Boolean? = + runCatching { remote.getOption(name) } + .getOrNull() + ?.let { (it as? TdApi.OptionValueBoolean)?.value } + + private suspend fun readPremiumLimit(limitType: TdApi.PremiumLimitType): TdApi.PremiumLimit? = + runCatching { remote.getPremiumLimit(limitType) }.getOrNull() + + private fun resolvePremiumLimit( + optionValue: Int?, + premiumLimit: TdApi.PremiumLimit?, + isPremium: Boolean?, + premiumFallback: Int + ): Int? = when (isPremium) { + true -> premiumLimit?.premiumValue ?: premiumFallback + false -> premiumLimit?.defaultValue ?: optionValue + null -> optionValue + } + + private fun TdApi.OptionValue?.toIntOrNull(): Int? { + val value = (this as? TdApi.OptionValueInteger)?.value ?: return null + return value.coerceIn(0L, Int.MAX_VALUE.toLong()).toInt() + } + + private companion object { + const val TAG = "TdLibLimits" + val OPTION_NAMES = TdLibLimitOptionNames.ALL + } +} diff --git a/data/src/main/java/org/monogram/data/repository/user/UserUpdateSynchronizer.kt b/data/src/main/java/org/monogram/data/repository/user/UserUpdateSynchronizer.kt index e85c85417..d92c6c8ba 100644 --- a/data/src/main/java/org/monogram/data/repository/user/UserUpdateSynchronizer.kt +++ b/data/src/main/java/org/monogram/data/repository/user/UserUpdateSynchronizer.kt @@ -40,16 +40,27 @@ internal class UserUpdateSynchronizer( } } - scope.launch { - updates.user.collect { update -> - updateAvatarIndex(update.user) - onUserUpdated(update.user) - } - } + // updateUser is documented as arriving before the user id is handed to the + // application, and it is the only introduction of the user object, so it must be + // lossless. Status goes through the same lane rather than a second subscription: + // the batcher only conflates per user id, so a lost update here would still lose + // that user's presence entirely. + updates.lane( + name = "users", + scope = scope, + filter = { it is TdApi.UpdateUser || it is TdApi.UpdateUserStatus }, + ) { update -> + when (update) { + is TdApi.UpdateUser -> { + updateAvatarIndex(update.user) + onUserUpdated(update.user) + } - scope.launch { - updates.userStatus.collect { update -> - userStatusBatcher.offer(update.userId, update.status) + is TdApi.UpdateUserStatus -> { + // Non-suspending, keeps the latest status per user; the batch applies + // it to the store off the lane. + userStatusBatcher.offer(update.userId, update.status) + } } } diff --git a/data/src/test/java/org/monogram/data/datasource/remote/TdChatRemoteSourceTest.kt b/data/src/test/java/org/monogram/data/datasource/remote/TdChatRemoteSourceTest.kt index 6e92d727b..dabc15148 100644 --- a/data/src/test/java/org/monogram/data/datasource/remote/TdChatRemoteSourceTest.kt +++ b/data/src/test/java/org/monogram/data/datasource/remote/TdChatRemoteSourceTest.kt @@ -31,6 +31,14 @@ class TdChatRemoteSourceTest { } private class CapturingTelegramGateway : TelegramGateway { + override fun lane( + name: String, + scope: kotlinx.coroutines.CoroutineScope, + context: kotlin.coroutines.CoroutineContext, + filter: (TdApi.Update) -> Boolean, + handler: suspend (TdApi.Update) -> Unit, + ) = org.monogram.data.testing.fakeUpdateLane(updates, scope, context, filter, handler) + override val updates = MutableSharedFlow() override val isAuthenticated = MutableStateFlow(false) var lastSetChatDescription: TdApi.SetChatDescription? = null diff --git a/data/src/test/java/org/monogram/data/datasource/remote/TdGifRemoteSourceTest.kt b/data/src/test/java/org/monogram/data/datasource/remote/TdGifRemoteSourceTest.kt index 6cc974712..02868f990 100644 --- a/data/src/test/java/org/monogram/data/datasource/remote/TdGifRemoteSourceTest.kt +++ b/data/src/test/java/org/monogram/data/datasource/remote/TdGifRemoteSourceTest.kt @@ -40,6 +40,14 @@ class TdGifRemoteSourceTest { } private class CapturingTelegramGateway : TelegramGateway { + override fun lane( + name: String, + scope: kotlinx.coroutines.CoroutineScope, + context: kotlin.coroutines.CoroutineContext, + filter: (TdApi.Update) -> Boolean, + handler: suspend (TdApi.Update) -> Unit, + ) = org.monogram.data.testing.fakeUpdateLane(updates, scope, context, filter, handler) + override val updates = MutableSharedFlow() override val isAuthenticated = MutableStateFlow(false) diff --git a/data/src/test/java/org/monogram/data/datasource/remote/TdStickerRemoteSourceTest.kt b/data/src/test/java/org/monogram/data/datasource/remote/TdStickerRemoteSourceTest.kt index 1f97c437e..35276a2c6 100644 --- a/data/src/test/java/org/monogram/data/datasource/remote/TdStickerRemoteSourceTest.kt +++ b/data/src/test/java/org/monogram/data/datasource/remote/TdStickerRemoteSourceTest.kt @@ -51,6 +51,14 @@ class TdStickerRemoteSourceTest { } private class CapturingTelegramGateway : TelegramGateway { + override fun lane( + name: String, + scope: kotlinx.coroutines.CoroutineScope, + context: kotlin.coroutines.CoroutineContext, + filter: (TdApi.Update) -> Boolean, + handler: suspend (TdApi.Update) -> Unit, + ) = org.monogram.data.testing.fakeUpdateLane(updates, scope, context, filter, handler) + override val updates = MutableSharedFlow() override val isAuthenticated = MutableStateFlow(false) diff --git a/data/src/test/java/org/monogram/data/di/TdUpdatePipelineTest.kt b/data/src/test/java/org/monogram/data/di/TdUpdatePipelineTest.kt new file mode 100644 index 000000000..eb7de34f9 --- /dev/null +++ b/data/src/test/java/org/monogram/data/di/TdUpdatePipelineTest.kt @@ -0,0 +1,298 @@ +package org.monogram.data.di + +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import org.drinkless.tdlib.TdApi +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import java.util.Collections +import java.util.concurrent.atomic.AtomicInteger + +/** + * Contract of [TdUpdatePipeline]: + * + * - a lane never loses an update and never reorders one; + * - a slow, blocked or throwing lane affects only itself; + * - the observation flow is allowed to conflate, and does; + * - ingestion from the TDLib callback thread never waits for a consumer. + * + * Progress is driven by explicit gates rather than by sleeping, so runtime does not + * depend on the platform's timer resolution. `delay` appears only as a polling interval + * in [awaitCount], never once per update. + */ +class TdUpdatePipelineTest { + + private val scopes = mutableListOf() + private val pipelines = mutableListOf() + + private fun pipeline(): TdUpdatePipeline = TdUpdatePipeline().also { pipelines.add(it) } + + private fun scope(): CoroutineScope = + CoroutineScope(SupervisorJob() + Dispatchers.Default).also { scopes.add(it) } + + @After + fun tearDown() { + scopes.forEach { it.cancel() } + pipelines.forEach { it.shutdown() } + } + + /** Updates carry their sequence number in chatId so ordering is checkable. */ + private fun update(seq: Int): TdApi.Update = TdApi.UpdateChatTitle(seq.toLong(), "t$seq") + + private fun seqOf(update: TdApi.Update) = (update as TdApi.UpdateChatTitle).chatId.toInt() + + /** Pushes updates the way TDLib does: from one plain thread that is not a coroutine. */ + private fun submitFromCallbackThread(pipeline: TdUpdatePipeline, count: Int, from: Int = 0) { + val thread = Thread({ repeat(count) { pipeline.submit(update(from + it)) } }, "TDLib thread") + thread.start() + thread.join() + } + + /** + * Polls until [actual] reaches [expected]. Uses an explicit deadline rather than + * `withTimeout` so a failure reports what was actually reached. + */ + private suspend fun awaitCount( + expected: Int, + what: String, + timeoutMs: Long = 30_000, + actual: () -> Int, + ) { + val deadline = System.nanoTime() + timeoutMs * 1_000_000 + while (actual() < expected) { + if (System.nanoTime() > deadline) { + throw AssertionError("timed out after ${timeoutMs}ms waiting for $expected $what, reached ${actual()}") + } + delay(2) + } + } + + /** Number of updates the pump has fanned out, as opposed to merely accepted. */ + private fun dispatched(pipeline: TdUpdatePipeline): Int = + Regex("dispatched=(\\d+)").find(pipeline.metrics())?.groupValues?.get(1)?.toInt() ?: 0 + + private suspend fun awaitObserverCount(pipeline: TdUpdatePipeline, expected: Int) { + val deadline = System.nanoTime() + 30_000L * 1_000_000 + while (!pipeline.metrics().contains("observers=$expected")) { + if (System.nanoTime() > deadline) { + throw AssertionError("observer never subscribed: ${pipeline.metrics()}") + } + delay(2) + } + } + + @Test(timeout = 60_000) + fun `lane receives every update exactly once and in order`() = runBlocking { + val pipeline = pipeline() + val received = Collections.synchronizedList(mutableListOf()) + pipeline.lane("state", scope()) { received.add(seqOf(it)) } + + val total = 5_000 + submitFromCallbackThread(pipeline, total) + awaitCount(total, "updates on the lane") { received.size } + + assertEquals(total, received.size) + assertEquals((0 until total).toList(), received.toList()) + } + + @Test(timeout = 60_000) + fun `lane filter is applied and does not create gaps`() = runBlocking { + val pipeline = pipeline() + val received = Collections.synchronizedList(mutableListOf()) + pipeline.lane("even", scope(), filter = { seqOf(it) % 2 == 0 }) { received.add(seqOf(it)) } + + submitFromCallbackThread(pipeline, 1_000) + awaitCount(500, "even updates") { received.size } + + assertEquals(500, received.size) + assertEquals((0 until 1_000 step 2).toList(), received.toList()) + } + + @Test(timeout = 60_000) + fun `a handler that throws does not end the lane`() = runBlocking { + val pipeline = pipeline() + val handled = AtomicInteger() + val failed = AtomicInteger() + pipeline.lane("flaky", scope()) { + if (seqOf(it) % 10 == 0) { + failed.incrementAndGet() + error("boom on ${seqOf(it)}") + } + handled.incrementAndGet() + } + + val total = 1_000 + submitFromCallbackThread(pipeline, total) + awaitCount(total, "handled or failed updates") { handled.get() + failed.get() } + + assertEquals(100, failed.get()) + assertEquals(900, handled.get()) + assertTrue("lane must still be registered", pipeline.metrics().contains("flaky")) + } + + /** + * A lane that makes no progress at all is the extreme case of a slow lane, and it is + * reached by a gate rather than by sleeping, so the test cannot become slow. + */ + @Test(timeout = 60_000) + fun `a blocked lane does not stop another lane and catches up losslessly`() = runBlocking { + val pipeline = pipeline() + val gate = CompletableDeferred() + val blocked = Collections.synchronizedList(mutableListOf()) + val healthy = Collections.synchronizedList(mutableListOf()) + + pipeline.lane("blocked", scope()) { gate.await(); blocked.add(seqOf(it)) } + pipeline.lane("healthy", scope()) { healthy.add(seqOf(it)) } + + val total = 2_000 + submitFromCallbackThread(pipeline, total) + + awaitCount(total, "updates on the healthy lane") { healthy.size } + assertEquals((0 until total).toList(), healthy.toList()) + assertEquals("the blocked lane must not have progressed", 0, blocked.size) + + gate.complete(Unit) + awaitCount(total, "updates on the unblocked lane") { blocked.size } + assertEquals( + "a lane must catch up losslessly and in order", + (0 until total).toList(), + blocked.toList() + ) + } + + @Test(timeout = 60_000) + fun `ingestion does not wait for consumers`() = runBlocking { + val pipeline = pipeline() + val gate = CompletableDeferred() + val processed = AtomicInteger() + pipeline.lane("blocked", scope()) { + gate.await() + processed.incrementAndGet() + } + + // Every submit returns even though the lane handler has never returned. + val total = 10_000 + submitFromCallbackThread(pipeline, total) + assertEquals("ingestion must not block on a consumer", 0, processed.get()) + + gate.complete(Unit) + awaitCount(total, "updates drained after unblocking") { processed.get() } + assertEquals(total, processed.get()) + } + + @Test(timeout = 60_000) + fun `lane deregisters when its scope is cancelled`() = runBlocking { + val pipeline = pipeline() + val ownScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + val seen = AtomicInteger() + pipeline.lane("transient", ownScope) { seen.incrementAndGet() } + + submitFromCallbackThread(pipeline, 10) + awaitCount(10, "updates before cancellation") { seen.get() } + + ownScope.cancel() + val deadline = System.nanoTime() + 30_000L * 1_000_000 + while (pipeline.metrics().contains("transient")) { + if (System.nanoTime() > deadline) { + throw AssertionError("lane was not deregistered: ${pipeline.metrics()}") + } + delay(2) + } + + // Further updates must neither reach nor accumulate for the dead lane. + submitFromCallbackThread(pipeline, 100, from = 10) + awaitCount(110, "the pump to dispatch the remaining updates") { dispatched(pipeline) } + assertFalse(pipeline.metrics().contains("transient")) + assertEquals("a cancelled lane must not keep consuming", 10, seen.get()) + } + + /** + * The reason lanes exist. Under a burst larger than the observation buffer, the flow + * conflates and the lane does not. + * + * Deterministic: the observer is frozen on its first element for the whole burst, and + * the burst is several times the flow's buffer, so conflation is guaranteed by + * SharedFlow's semantics rather than by winning a race. + */ + @Test(timeout = 60_000) + fun `observation flow conflates under load while a lane does not`() = runBlocking { + val pipeline = pipeline() + val laneSeen = AtomicInteger() + val observerSeen = AtomicInteger() + val observerFrozen = CompletableDeferred() + val release = CompletableDeferred() + val sentinelSeen = CompletableDeferred() + + pipeline.lane("durable", scope()) { laneSeen.incrementAndGet() } + + val observer = scope().launch { + pipeline.updates.collect { u -> + if (!observerFrozen.isCompleted) { + observerFrozen.complete(Unit) + release.await() + } + observerSeen.incrementAndGet() + if (seqOf(u) == SENTINEL) sentinelSeen.complete(Unit) + } + } + awaitObserverCount(pipeline, 1) + + // Freeze the observer, then push far more than its buffer can hold. + submitFromCallbackThread(pipeline, 1) + observerFrozen.await() + + val burst = 3_000 + submitFromCallbackThread(pipeline, burst, from = 1) + pipeline.submit(update(SENTINEL)) + val submitted = 1 + burst + 1 + + // submit() only fills the ingest queue. Wait for the pump to have emitted all of + // it, so the observer is provably frozen across the whole emission sequence and + // its buffer has certainly overflowed before it is allowed to run again. + awaitCount(submitted, "the pump to dispatch the burst") { dispatched(pipeline) } + + // The sentinel is the newest value, so it is always in the buffer: once the + // observer has seen it, nothing more is coming and its count is final. + release.complete(Unit) + sentinelSeen.await() + + awaitCount(submitted, "updates on the lane") { laneSeen.get() } + assertEquals("a lane must never drop", submitted, laneSeen.get()) + assertTrue( + "the observation flow must conflate: it saw ${observerSeen.get()} of $submitted", + observerSeen.get() < submitted + ) + observer.cancel() + } + + @Test(timeout = 60_000) + fun `metrics expose the backlog of a stalled lane`() = runBlocking { + val pipeline = pipeline() + val gate = CompletableDeferred() + pipeline.lane("stalled", scope()) { gate.await() } + + submitFromCallbackThread(pipeline, 250) + val backlogPattern = Regex("stalled: backlog=(\\d+)") + awaitCount(249, "the stalled lane's backlog to be reported") { + backlogPattern.find(pipeline.metrics())?.groupValues?.get(1)?.toInt() ?: 0 + } + + val metrics = pipeline.metrics() + gate.complete(Unit) + assertTrue(metrics, metrics.contains("submitted=250")) + } + + private companion object { + /** Distinct from every generated sequence number. */ + private const val SENTINEL = -1 + } +} diff --git a/data/src/test/java/org/monogram/data/gateway/TdLibExceptionAuthMappingTest.kt b/data/src/test/java/org/monogram/data/gateway/TdLibExceptionAuthMappingTest.kt index cd4583f77..f72ab0292 100644 --- a/data/src/test/java/org/monogram/data/gateway/TdLibExceptionAuthMappingTest.kt +++ b/data/src/test/java/org/monogram/data/gateway/TdLibExceptionAuthMappingTest.kt @@ -16,6 +16,13 @@ class TdLibExceptionAuthMappingTest { assertEquals(AuthError.InvalidCode, error.toAuthError()) } + @Test + fun `maps invalid email code error`() { + val error = TdLibException(TdApi.Error(400, "EMAIL_CODE_INVALID")) + + assertEquals(AuthError.InvalidCode, error.toAuthError()) + } + @Test fun `maps invalid password error`() { val error = TdLibException(TdApi.Error(400, "PASSWORD_HASH_INVALID")) @@ -30,6 +37,13 @@ class TdLibExceptionAuthMappingTest { assertEquals(AuthError.CodeExpired, error.toAuthError()) } + @Test + fun `maps flood wait with retry timeout`() { + val error = TdLibException(TdApi.Error(429, "FLOOD_WAIT_42")) + + assertEquals(AuthError.RateLimited(42), error.toAuthError()) + } + @Test fun `maps unknown tdlib error to unexpected`() { val error = TdLibException(TdApi.Error(400, "SOMETHING_ELSE")) diff --git a/data/src/test/java/org/monogram/data/gateway/UpdateDispatcherImplTest.kt b/data/src/test/java/org/monogram/data/gateway/UpdateDispatcherImplTest.kt index f43acde56..7a3fe0c74 100644 --- a/data/src/test/java/org/monogram/data/gateway/UpdateDispatcherImplTest.kt +++ b/data/src/test/java/org/monogram/data/gateway/UpdateDispatcherImplTest.kt @@ -14,6 +14,8 @@ import kotlinx.coroutines.test.runTest import org.drinkless.tdlib.TdApi import org.junit.Assert.assertEquals import org.junit.Test +import org.monogram.data.testing.fakeUpdateLane +import kotlin.coroutines.CoroutineContext @OptIn(ExperimentalCoroutinesApi::class) class UpdateDispatcherImplTest { @@ -103,6 +105,14 @@ class UpdateDispatcherImplTest { override suspend fun execute(function: TdApi.Function): T { error("Not used") } + + override fun lane( + name: String, + scope: CoroutineScope, + context: CoroutineContext, + filter: (TdApi.Update) -> Boolean, + handler: suspend (TdApi.Update) -> Unit, + ) = fakeUpdateLane(updates, scope, context, filter, handler) } private companion object { diff --git a/data/src/test/java/org/monogram/data/infra/ConnectionManagerTest.kt b/data/src/test/java/org/monogram/data/infra/ConnectionManagerTest.kt index d1b68e5df..8be7a5ed7 100644 --- a/data/src/test/java/org/monogram/data/infra/ConnectionManagerTest.kt +++ b/data/src/test/java/org/monogram/data/infra/ConnectionManagerTest.kt @@ -538,6 +538,14 @@ class ConnectionManagerTest { override val connectionState: Flow ) : UpdateDispatcher { override val all: Flow = MutableSharedFlow() + override fun lane( + name: String, + scope: kotlinx.coroutines.CoroutineScope, + context: kotlin.coroutines.CoroutineContext, + filter: (TdApi.Update) -> Boolean, + handler: suspend (TdApi.Update) -> Unit, + ) = org.monogram.data.testing.fakeUpdateLane(all, scope, context, filter, handler) + override val newMessage: Flow = MutableSharedFlow() override val activeNotifications: Flow = MutableSharedFlow() diff --git a/data/src/test/java/org/monogram/data/infra/FileDownloadQueueTest.kt b/data/src/test/java/org/monogram/data/infra/FileDownloadQueueTest.kt index 6a2871353..128551920 100644 --- a/data/src/test/java/org/monogram/data/infra/FileDownloadQueueTest.kt +++ b/data/src/test/java/org/monogram/data/infra/FileDownloadQueueTest.kt @@ -83,6 +83,14 @@ class FileDownloadQueueTest { } private class FakeTelegramGateway : TelegramGateway { + override fun lane( + name: String, + scope: kotlinx.coroutines.CoroutineScope, + context: kotlin.coroutines.CoroutineContext, + filter: (TdApi.Update) -> Boolean, + handler: suspend (TdApi.Update) -> Unit, + ) = org.monogram.data.testing.fakeUpdateLane(updates, scope, context, filter, handler) + override val updates = MutableSharedFlow() override val isAuthenticated = MutableStateFlow(false) diff --git a/data/src/test/java/org/monogram/data/infra/FileUpdateHandlerTest.kt b/data/src/test/java/org/monogram/data/infra/FileUpdateHandlerTest.kt index e3031ce90..8e2536445 100644 --- a/data/src/test/java/org/monogram/data/infra/FileUpdateHandlerTest.kt +++ b/data/src/test/java/org/monogram/data/infra/FileUpdateHandlerTest.kt @@ -5,6 +5,7 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.take import kotlinx.coroutines.launch import kotlinx.coroutines.test.advanceUntilIdle @@ -13,6 +14,10 @@ import kotlinx.coroutines.test.runTest import org.drinkless.tdlib.TdApi import org.junit.Assert.assertEquals import org.junit.Test +import org.monogram.data.gateway.TelegramGateway +import org.monogram.data.gateway.UpdateDispatcherImpl +import org.monogram.data.testing.fakeUpdateLane +import kotlin.coroutines.CoroutineContext @OptIn(ExperimentalCoroutinesApi::class) class FileUpdateHandlerTest { @@ -20,13 +25,13 @@ class FileUpdateHandlerTest { @Test fun `burst file completions preserve every terminal path and order`() = runTest { val scope = CoroutineScope(coroutineContext + SupervisorJob()) - val updates = MutableSharedFlow() + val updates = MutableSharedFlow() val queue = RecordingQueue() val registry = FileMessageRegistry() val handler = FileUpdateHandler( registry = registry, queue = queue, - fileUpdatesSource = updates, + updates = UpdateDispatcherImpl(FakeTelegramGateway(updates)), scope = scope ) val fileCompleted = mutableListOf>() @@ -68,6 +73,24 @@ class FileUpdateHandlerTest { remote = TdApi.RemoteFile() } + private class FakeTelegramGateway( + override val updates: MutableSharedFlow + ) : TelegramGateway { + override val isAuthenticated = MutableStateFlow(false) + + override suspend fun execute(function: TdApi.Function): T { + error("Not used") + } + + override fun lane( + name: String, + scope: CoroutineScope, + context: CoroutineContext, + filter: (TdApi.Update) -> Boolean, + handler: suspend (TdApi.Update) -> Unit, + ) = fakeUpdateLane(updates, scope, context, filter, handler) + } + private class RecordingQueue : FileUpdateQueue { val completedDownloads = mutableListOf() diff --git a/data/src/test/java/org/monogram/data/infra/SponsorSyncManagerTest.kt b/data/src/test/java/org/monogram/data/infra/SponsorSyncManagerTest.kt index edfc53e27..54918a7f8 100644 --- a/data/src/test/java/org/monogram/data/infra/SponsorSyncManagerTest.kt +++ b/data/src/test/java/org/monogram/data/infra/SponsorSyncManagerTest.kt @@ -168,6 +168,14 @@ class SponsorSyncManagerTest { } private class FakeTelegramGateway : TelegramGateway { + override fun lane( + name: String, + scope: kotlinx.coroutines.CoroutineScope, + context: kotlin.coroutines.CoroutineContext, + filter: (TdApi.Update) -> Boolean, + handler: suspend (TdApi.Update) -> Unit, + ) = org.monogram.data.testing.fakeUpdateLane(_updates, scope, context, filter, handler) + private val _updates = MutableSharedFlow() private val _isAuthenticated = MutableStateFlow(true) var historyProvider: suspend () -> TdApi.Messages = { TdApi.Messages(0, emptyArray()) } diff --git a/data/src/test/java/org/monogram/data/repository/TelegramLinkRepositoryImplTest.kt b/data/src/test/java/org/monogram/data/repository/TelegramLinkRepositoryImplTest.kt index b767b72e8..d90494b74 100644 --- a/data/src/test/java/org/monogram/data/repository/TelegramLinkRepositoryImplTest.kt +++ b/data/src/test/java/org/monogram/data/repository/TelegramLinkRepositoryImplTest.kt @@ -126,6 +126,14 @@ private class FakeTelegramGateway( private val authState = MutableStateFlow(authenticated) override val isAuthenticated: StateFlow = authState var executeCalls: Int = 0 + override fun lane( + name: String, + scope: kotlinx.coroutines.CoroutineScope, + context: kotlin.coroutines.CoroutineContext, + filter: (TdApi.Update) -> Boolean, + handler: suspend (TdApi.Update) -> Unit, + ) = org.monogram.data.testing.fakeUpdateLane(updates, scope, context, filter, handler) + fun setAuthenticated(value: Boolean) { authState.value = value @@ -140,6 +148,14 @@ private class FakeTelegramGateway( private class FakeUpdateDispatcher : UpdateDispatcher { override val all: Flow = MutableSharedFlow() + override fun lane( + name: String, + scope: kotlinx.coroutines.CoroutineScope, + context: kotlin.coroutines.CoroutineContext, + filter: (TdApi.Update) -> Boolean, + handler: suspend (TdApi.Update) -> Unit, + ) = org.monogram.data.testing.fakeUpdateLane(all, scope, context, filter, handler) + override val authorizationState: Flow = MutableSharedFlow() override val newMessage: Flow = MutableSharedFlow() override val activeNotifications: Flow = MutableSharedFlow() diff --git a/data/src/test/java/org/monogram/data/testing/FakeUpdateLane.kt b/data/src/test/java/org/monogram/data/testing/FakeUpdateLane.kt new file mode 100644 index 000000000..5a5dc63d5 --- /dev/null +++ b/data/src/test/java/org/monogram/data/testing/FakeUpdateLane.kt @@ -0,0 +1,28 @@ +package org.monogram.data.testing + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.launch +import org.drinkless.tdlib.TdApi +import kotlin.coroutines.CoroutineContext + +/** + * Lane implementation for test doubles of `TelegramGateway` / `UpdateDispatcher`. + * + * Backs the lane with a plain collector on the fake's update flow. That is enough for + * tests, which control emission rate and never overflow anything; the production + * implementation in `TdUpdatePipeline` is what provides the losslessness and ordering + * guarantees, and it is covered by `TdUpdatePipelineTest`. + */ +internal fun fakeUpdateLane( + source: Flow, + scope: CoroutineScope, + context: CoroutineContext, + filter: (TdApi.Update) -> Boolean, + handler: suspend (TdApi.Update) -> Unit, +) { + scope.launch(context) { + source.filter(filter).collect { handler(it) } + } +} diff --git a/domain/src/main/java/org/monogram/domain/models/TdLibLimitOptionNames.kt b/domain/src/main/java/org/monogram/domain/models/TdLibLimitOptionNames.kt new file mode 100644 index 000000000..d9a4eb359 --- /dev/null +++ b/domain/src/main/java/org/monogram/domain/models/TdLibLimitOptionNames.kt @@ -0,0 +1,99 @@ +package org.monogram.domain.models + +/** Stable TDLib option names used by the limits integration. */ +object TdLibLimitOptionNames { + const val IS_PREMIUM = "is_premium" + const val MESSAGE_TEXT_LENGTH_MAX = "message_text_length_max" + const val MESSAGE_CAPTION_LENGTH_MAX = "message_caption_length_max" + const val MESSAGE_REPLY_QUOTE_LENGTH_MAX = "message_reply_quote_length_max" + const val STORY_CAPTION_LENGTH_MAX = "story_caption_length_max" + const val BIO_LENGTH_MAX = "bio_length_max" + const val BUSINESS_START_PAGE_TITLE_LENGTH_MAX = "business_start_page_title_length_max" + const val BUSINESS_START_PAGE_MESSAGE_LENGTH_MAX = "business_start_page_message_length_max" + const val FORWARDED_MESSAGE_COUNT_MAX = "forwarded_message_count_max" + + const val RICH_MESSAGE_TEXT_LENGTH_MAX = "rich_message_text_length_max" + const val RICH_MESSAGE_BLOCK_COUNT_MAX = "rich_message_block_count_max" + const val RICH_MESSAGE_DEPTH_MAX = "rich_message_depth_max" + const val RICH_MESSAGE_MEDIA_COUNT_MAX = "rich_message_media_count_max" + const val RICH_MESSAGE_TABLE_COLUMN_COUNT_MAX = "rich_message_table_column_count_max" + + const val CHECKLIST_TASK_COUNT_MAX = "checklist_task_count_max" + const val CHECKLIST_TASK_TEXT_LENGTH_MAX = "checklist_task_text_length_max" + const val CHECKLIST_TITLE_LENGTH_MAX = "checklist_title_length_max" + const val POLL_ANSWER_COUNT_MAX = "poll_answer_count_max" + const val POLL_OPEN_PERIOD_MAX = "poll_open_period_max" + + const val CHAT_FOLDER_COUNT_MAX = "chat_folder_count_max" + const val CHAT_FOLDER_CHOSEN_CHAT_COUNT_MAX = "chat_folder_chosen_chat_count_max" + const val CHAT_FOLDER_INVITE_LINK_COUNT_MAX = "chat_folder_invite_link_count_max" + const val PINNED_CHAT_COUNT_MAX = "pinned_chat_count_max" + const val PINNED_ARCHIVED_CHAT_COUNT_MAX = "pinned_archived_chat_count_max" + const val PINNED_FORUM_TOPIC_COUNT_MAX = "pinned_forum_topic_count_max" + const val PINNED_SAVED_MESSAGES_TOPIC_COUNT_MAX = "pinned_saved_messages_topic_count_max" + + const val ACTIVE_STORY_COUNT_MAX = "active_story_count_max" + const val WEEKLY_SENT_STORY_COUNT_MAX = "weekly_sent_story_count_max" + const val MONTHLY_SENT_STORY_COUNT_MAX = "monthly_sent_story_count_max" + const val STORY_LINK_AREA_COUNT_MAX = "story_link_area_count_max" + const val STORY_SUGGESTED_REACTION_AREA_COUNT_MAX = "story_suggested_reaction_area_count_max" + const val STORY_STEALTH_MODE_COOLDOWN_PERIOD = "story_stealth_mode_cooldown_period" + const val STORY_STEALTH_MODE_FUTURE_PERIOD = "story_stealth_mode_future_period" + const val STORY_STEALTH_MODE_PAST_PERIOD = "story_stealth_mode_past_period" + const val STORY_VIEWERS_EXPIRATION_DELAY = "story_viewers_expiration_delay" + + const val FAVORITE_STICKERS_LIMIT = "favorite_stickers_limit" + const val SAVED_ANIMATIONS_LIMIT = "saved_animations_limit" + const val NOTIFICATION_SOUND_COUNT_MAX = "notification_sound_count_max" + const val NOTIFICATION_SOUND_DURATION_MAX = "notification_sound_duration_max" + const val NOTIFICATION_SOUND_SIZE_MAX = "notification_sound_size_max" + + const val GIFT_TEXT_LENGTH_MAX = "gift_text_length_max" + const val USER_NOTE_TEXT_LENGTH_MAX = "user_note_text_length_max" + const val GROUP_CALL_MESSAGE_TEXT_LENGTH_MAX = "group_call_message_text_length_max" + + val ALL: Set = setOf( + MESSAGE_TEXT_LENGTH_MAX, + MESSAGE_CAPTION_LENGTH_MAX, + MESSAGE_REPLY_QUOTE_LENGTH_MAX, + STORY_CAPTION_LENGTH_MAX, + BIO_LENGTH_MAX, + BUSINESS_START_PAGE_TITLE_LENGTH_MAX, + BUSINESS_START_PAGE_MESSAGE_LENGTH_MAX, + FORWARDED_MESSAGE_COUNT_MAX, + RICH_MESSAGE_TEXT_LENGTH_MAX, + RICH_MESSAGE_BLOCK_COUNT_MAX, + RICH_MESSAGE_DEPTH_MAX, + RICH_MESSAGE_MEDIA_COUNT_MAX, + RICH_MESSAGE_TABLE_COLUMN_COUNT_MAX, + CHECKLIST_TASK_COUNT_MAX, + CHECKLIST_TASK_TEXT_LENGTH_MAX, + CHECKLIST_TITLE_LENGTH_MAX, + POLL_ANSWER_COUNT_MAX, + POLL_OPEN_PERIOD_MAX, + CHAT_FOLDER_COUNT_MAX, + CHAT_FOLDER_CHOSEN_CHAT_COUNT_MAX, + CHAT_FOLDER_INVITE_LINK_COUNT_MAX, + PINNED_CHAT_COUNT_MAX, + PINNED_ARCHIVED_CHAT_COUNT_MAX, + PINNED_FORUM_TOPIC_COUNT_MAX, + PINNED_SAVED_MESSAGES_TOPIC_COUNT_MAX, + ACTIVE_STORY_COUNT_MAX, + WEEKLY_SENT_STORY_COUNT_MAX, + MONTHLY_SENT_STORY_COUNT_MAX, + STORY_LINK_AREA_COUNT_MAX, + STORY_SUGGESTED_REACTION_AREA_COUNT_MAX, + STORY_STEALTH_MODE_COOLDOWN_PERIOD, + STORY_STEALTH_MODE_FUTURE_PERIOD, + STORY_STEALTH_MODE_PAST_PERIOD, + STORY_VIEWERS_EXPIRATION_DELAY, + FAVORITE_STICKERS_LIMIT, + SAVED_ANIMATIONS_LIMIT, + NOTIFICATION_SOUND_COUNT_MAX, + NOTIFICATION_SOUND_DURATION_MAX, + NOTIFICATION_SOUND_SIZE_MAX, + GIFT_TEXT_LENGTH_MAX, + USER_NOTE_TEXT_LENGTH_MAX, + GROUP_CALL_MESSAGE_TEXT_LENGTH_MAX + ) +} diff --git a/domain/src/main/java/org/monogram/domain/models/TdLibLimits.kt b/domain/src/main/java/org/monogram/domain/models/TdLibLimits.kt new file mode 100644 index 000000000..b83697e57 --- /dev/null +++ b/domain/src/main/java/org/monogram/domain/models/TdLibLimits.kt @@ -0,0 +1,241 @@ +package org.monogram.domain.models + +/** + * Effective account limits reported by TDLib. A null value means that the + * option is unavailable or has not been loaded for the current account. + */ +data class TdLibLimits( + val messageTextLengthMax: Int? = null, + val messageCaptionLengthMax: Int? = null, + val messageReplyQuoteLengthMax: Int? = null, + val storyCaptionLengthMax: Int? = null, + val bioLengthMax: Int? = null, + val businessStartPageTitleLengthMax: Int? = null, + val businessStartPageMessageLengthMax: Int? = null, + val forwardedMessageCountMax: Int? = null, + + val richMessageTextLengthMax: Int? = null, + val richMessageBlockCountMax: Int? = null, + val richMessageDepthMax: Int? = null, + val richMessageMediaCountMax: Int? = null, + val richMessageTableColumnCountMax: Int? = null, + + val checklistTaskCountMax: Int? = null, + val checklistTaskTextLengthMax: Int? = null, + val checklistTitleLengthMax: Int? = null, + val pollAnswerCountMax: Int? = null, + val pollOpenPeriodMax: Int? = null, + + val chatFolderCountMax: Int? = null, + val chatFolderChosenChatCountMax: Int? = null, + val chatFolderInviteLinkCountMax: Int? = null, + val pinnedChatCountMax: Int? = null, + val pinnedArchivedChatCountMax: Int? = null, + val pinnedForumTopicCountMax: Int? = null, + val pinnedSavedMessagesTopicCountMax: Int? = null, + + val activeStoryCountMax: Int? = null, + val weeklySentStoryCountMax: Int? = null, + val monthlySentStoryCountMax: Int? = null, + val storyLinkAreaCountMax: Int? = null, + val storySuggestedReactionAreaCountMax: Int? = null, + val storyStealthModeCooldownPeriod: Int? = null, + val storyStealthModeFuturePeriod: Int? = null, + val storyStealthModePastPeriod: Int? = null, + val storyViewersExpirationDelay: Int? = null, + + val favoriteStickersLimit: Int? = null, + val savedAnimationCountMax: Int? = null, + val notificationSoundCountMax: Int? = null, + val notificationSoundDurationMax: Int? = null, + val notificationSoundSizeMax: Int? = null, + + val giftTextLengthMax: Int? = null, + val userNoteTextLengthMax: Int? = null, + val groupCallMessageTextLengthMax: Int? = null +) { + companion object { + const val DEFAULT_MESSAGE_TEXT_LENGTH_MAX = 4096 + const val DEFAULT_MESSAGE_CAPTION_LENGTH_MAX = 1024 + const val DEFAULT_PREMIUM_MESSAGE_TEXT_LENGTH_MAX = 8192 + const val DEFAULT_PREMIUM_MESSAGE_CAPTION_LENGTH_MAX = 4096 + + /** + * Conservative non-Premium defaults used until TDLib returns the + * account-specific values. They mirror the defaults in the bundled + * TDLib OptionManager; the server value always wins after refresh. + */ + val DEFAULTS = TdLibLimits( + messageTextLengthMax = DEFAULT_MESSAGE_TEXT_LENGTH_MAX, + messageCaptionLengthMax = DEFAULT_MESSAGE_CAPTION_LENGTH_MAX, + messageReplyQuoteLengthMax = 1024, + storyCaptionLengthMax = 200, + bioLengthMax = 70, + businessStartPageTitleLengthMax = 32, + businessStartPageMessageLengthMax = 70, + forwardedMessageCountMax = 100, + richMessageTextLengthMax = 32768, + richMessageBlockCountMax = 500, + richMessageDepthMax = 16, + richMessageMediaCountMax = 50, + richMessageTableColumnCountMax = 20, + checklistTaskCountMax = 30, + checklistTaskTextLengthMax = 100, + checklistTitleLengthMax = 255, + pollAnswerCountMax = 12, + pollOpenPeriodMax = 730 * 3600, + chatFolderCountMax = 10, + chatFolderChosenChatCountMax = 100, + chatFolderInviteLinkCountMax = 3, + pinnedChatCountMax = 5, + pinnedArchivedChatCountMax = 100, + pinnedForumTopicCountMax = 5, + pinnedSavedMessagesTopicCountMax = 5, + activeStoryCountMax = 3, + weeklySentStoryCountMax = 7, + monthlySentStoryCountMax = 30, + storyLinkAreaCountMax = 3, + storySuggestedReactionAreaCountMax = 1, + storyStealthModeCooldownPeriod = 3 * 3600, + storyStealthModeFuturePeriod = 1500, + storyStealthModePastPeriod = 300, + storyViewersExpirationDelay = 86400, + favoriteStickersLimit = 5, + savedAnimationCountMax = 200, + notificationSoundCountMax = 100, + notificationSoundDurationMax = 5, + notificationSoundSizeMax = 307200, + giftTextLengthMax = 128, + userNoteTextLengthMax = 128, + groupCallMessageTextLengthMax = 128 + ) + } + + fun withOption(name: String, value: Int?): TdLibLimits { + val effectiveValue = value ?: fallbackValue(name) + return when (name) { + TdLibLimitOptionNames.MESSAGE_TEXT_LENGTH_MAX -> copy(messageTextLengthMax = effectiveValue) + TdLibLimitOptionNames.MESSAGE_CAPTION_LENGTH_MAX -> copy(messageCaptionLengthMax = effectiveValue) + TdLibLimitOptionNames.MESSAGE_REPLY_QUOTE_LENGTH_MAX -> copy(messageReplyQuoteLengthMax = effectiveValue) + TdLibLimitOptionNames.STORY_CAPTION_LENGTH_MAX -> copy(storyCaptionLengthMax = effectiveValue) + TdLibLimitOptionNames.BIO_LENGTH_MAX -> copy(bioLengthMax = effectiveValue) + TdLibLimitOptionNames.BUSINESS_START_PAGE_TITLE_LENGTH_MAX -> copy( + businessStartPageTitleLengthMax = effectiveValue + ) + + TdLibLimitOptionNames.BUSINESS_START_PAGE_MESSAGE_LENGTH_MAX -> copy( + businessStartPageMessageLengthMax = effectiveValue + ) + + TdLibLimitOptionNames.FORWARDED_MESSAGE_COUNT_MAX -> copy(forwardedMessageCountMax = effectiveValue) + TdLibLimitOptionNames.RICH_MESSAGE_TEXT_LENGTH_MAX -> copy(richMessageTextLengthMax = effectiveValue) + TdLibLimitOptionNames.RICH_MESSAGE_BLOCK_COUNT_MAX -> copy(richMessageBlockCountMax = effectiveValue) + TdLibLimitOptionNames.RICH_MESSAGE_DEPTH_MAX -> copy(richMessageDepthMax = effectiveValue) + TdLibLimitOptionNames.RICH_MESSAGE_MEDIA_COUNT_MAX -> copy(richMessageMediaCountMax = effectiveValue) + TdLibLimitOptionNames.RICH_MESSAGE_TABLE_COLUMN_COUNT_MAX -> copy( + richMessageTableColumnCountMax = effectiveValue + ) + + TdLibLimitOptionNames.CHECKLIST_TASK_COUNT_MAX -> copy(checklistTaskCountMax = effectiveValue) + TdLibLimitOptionNames.CHECKLIST_TASK_TEXT_LENGTH_MAX -> copy(checklistTaskTextLengthMax = effectiveValue) + TdLibLimitOptionNames.CHECKLIST_TITLE_LENGTH_MAX -> copy(checklistTitleLengthMax = effectiveValue) + TdLibLimitOptionNames.POLL_ANSWER_COUNT_MAX -> copy(pollAnswerCountMax = effectiveValue) + TdLibLimitOptionNames.POLL_OPEN_PERIOD_MAX -> copy(pollOpenPeriodMax = effectiveValue) + TdLibLimitOptionNames.CHAT_FOLDER_COUNT_MAX -> copy(chatFolderCountMax = effectiveValue) + TdLibLimitOptionNames.CHAT_FOLDER_CHOSEN_CHAT_COUNT_MAX -> copy( + chatFolderChosenChatCountMax = effectiveValue + ) + + TdLibLimitOptionNames.CHAT_FOLDER_INVITE_LINK_COUNT_MAX -> copy( + chatFolderInviteLinkCountMax = effectiveValue + ) + + TdLibLimitOptionNames.PINNED_CHAT_COUNT_MAX -> copy(pinnedChatCountMax = effectiveValue) + TdLibLimitOptionNames.PINNED_ARCHIVED_CHAT_COUNT_MAX -> copy(pinnedArchivedChatCountMax = effectiveValue) + TdLibLimitOptionNames.PINNED_FORUM_TOPIC_COUNT_MAX -> copy(pinnedForumTopicCountMax = effectiveValue) + TdLibLimitOptionNames.PINNED_SAVED_MESSAGES_TOPIC_COUNT_MAX -> copy( + pinnedSavedMessagesTopicCountMax = effectiveValue + ) + + TdLibLimitOptionNames.ACTIVE_STORY_COUNT_MAX -> copy(activeStoryCountMax = effectiveValue) + TdLibLimitOptionNames.WEEKLY_SENT_STORY_COUNT_MAX -> copy(weeklySentStoryCountMax = effectiveValue) + TdLibLimitOptionNames.MONTHLY_SENT_STORY_COUNT_MAX -> copy(monthlySentStoryCountMax = effectiveValue) + TdLibLimitOptionNames.STORY_LINK_AREA_COUNT_MAX -> copy(storyLinkAreaCountMax = effectiveValue) + TdLibLimitOptionNames.STORY_SUGGESTED_REACTION_AREA_COUNT_MAX -> copy( + storySuggestedReactionAreaCountMax = effectiveValue + ) + + TdLibLimitOptionNames.STORY_STEALTH_MODE_COOLDOWN_PERIOD -> copy( + storyStealthModeCooldownPeriod = effectiveValue + ) + + TdLibLimitOptionNames.STORY_STEALTH_MODE_FUTURE_PERIOD -> copy( + storyStealthModeFuturePeriod = effectiveValue + ) + + TdLibLimitOptionNames.STORY_STEALTH_MODE_PAST_PERIOD -> copy(storyStealthModePastPeriod = effectiveValue) + TdLibLimitOptionNames.STORY_VIEWERS_EXPIRATION_DELAY -> copy(storyViewersExpirationDelay = effectiveValue) + TdLibLimitOptionNames.FAVORITE_STICKERS_LIMIT -> copy(favoriteStickersLimit = effectiveValue) + TdLibLimitOptionNames.SAVED_ANIMATIONS_LIMIT -> copy(savedAnimationCountMax = effectiveValue) + TdLibLimitOptionNames.NOTIFICATION_SOUND_COUNT_MAX -> copy(notificationSoundCountMax = effectiveValue) + TdLibLimitOptionNames.NOTIFICATION_SOUND_DURATION_MAX -> copy( + notificationSoundDurationMax = effectiveValue + ) + + TdLibLimitOptionNames.NOTIFICATION_SOUND_SIZE_MAX -> copy(notificationSoundSizeMax = effectiveValue) + TdLibLimitOptionNames.GIFT_TEXT_LENGTH_MAX -> copy(giftTextLengthMax = effectiveValue) + TdLibLimitOptionNames.USER_NOTE_TEXT_LENGTH_MAX -> copy(userNoteTextLengthMax = effectiveValue) + TdLibLimitOptionNames.GROUP_CALL_MESSAGE_TEXT_LENGTH_MAX -> copy( + groupCallMessageTextLengthMax = effectiveValue + ) + + else -> this + } + } + + private fun fallbackValue(name: String): Int? = when (name) { + TdLibLimitOptionNames.MESSAGE_TEXT_LENGTH_MAX -> DEFAULTS.messageTextLengthMax + TdLibLimitOptionNames.MESSAGE_CAPTION_LENGTH_MAX -> DEFAULTS.messageCaptionLengthMax + TdLibLimitOptionNames.MESSAGE_REPLY_QUOTE_LENGTH_MAX -> DEFAULTS.messageReplyQuoteLengthMax + TdLibLimitOptionNames.STORY_CAPTION_LENGTH_MAX -> DEFAULTS.storyCaptionLengthMax + TdLibLimitOptionNames.BIO_LENGTH_MAX -> DEFAULTS.bioLengthMax + TdLibLimitOptionNames.BUSINESS_START_PAGE_TITLE_LENGTH_MAX -> DEFAULTS.businessStartPageTitleLengthMax + TdLibLimitOptionNames.BUSINESS_START_PAGE_MESSAGE_LENGTH_MAX -> DEFAULTS.businessStartPageMessageLengthMax + TdLibLimitOptionNames.FORWARDED_MESSAGE_COUNT_MAX -> DEFAULTS.forwardedMessageCountMax + TdLibLimitOptionNames.RICH_MESSAGE_TEXT_LENGTH_MAX -> DEFAULTS.richMessageTextLengthMax + TdLibLimitOptionNames.RICH_MESSAGE_BLOCK_COUNT_MAX -> DEFAULTS.richMessageBlockCountMax + TdLibLimitOptionNames.RICH_MESSAGE_DEPTH_MAX -> DEFAULTS.richMessageDepthMax + TdLibLimitOptionNames.RICH_MESSAGE_MEDIA_COUNT_MAX -> DEFAULTS.richMessageMediaCountMax + TdLibLimitOptionNames.RICH_MESSAGE_TABLE_COLUMN_COUNT_MAX -> DEFAULTS.richMessageTableColumnCountMax + TdLibLimitOptionNames.CHECKLIST_TASK_COUNT_MAX -> DEFAULTS.checklistTaskCountMax + TdLibLimitOptionNames.CHECKLIST_TASK_TEXT_LENGTH_MAX -> DEFAULTS.checklistTaskTextLengthMax + TdLibLimitOptionNames.CHECKLIST_TITLE_LENGTH_MAX -> DEFAULTS.checklistTitleLengthMax + TdLibLimitOptionNames.POLL_ANSWER_COUNT_MAX -> DEFAULTS.pollAnswerCountMax + TdLibLimitOptionNames.POLL_OPEN_PERIOD_MAX -> DEFAULTS.pollOpenPeriodMax + TdLibLimitOptionNames.CHAT_FOLDER_COUNT_MAX -> DEFAULTS.chatFolderCountMax + TdLibLimitOptionNames.CHAT_FOLDER_CHOSEN_CHAT_COUNT_MAX -> DEFAULTS.chatFolderChosenChatCountMax + TdLibLimitOptionNames.CHAT_FOLDER_INVITE_LINK_COUNT_MAX -> DEFAULTS.chatFolderInviteLinkCountMax + TdLibLimitOptionNames.PINNED_CHAT_COUNT_MAX -> DEFAULTS.pinnedChatCountMax + TdLibLimitOptionNames.PINNED_ARCHIVED_CHAT_COUNT_MAX -> DEFAULTS.pinnedArchivedChatCountMax + TdLibLimitOptionNames.PINNED_FORUM_TOPIC_COUNT_MAX -> DEFAULTS.pinnedForumTopicCountMax + TdLibLimitOptionNames.PINNED_SAVED_MESSAGES_TOPIC_COUNT_MAX -> DEFAULTS.pinnedSavedMessagesTopicCountMax + TdLibLimitOptionNames.ACTIVE_STORY_COUNT_MAX -> DEFAULTS.activeStoryCountMax + TdLibLimitOptionNames.WEEKLY_SENT_STORY_COUNT_MAX -> DEFAULTS.weeklySentStoryCountMax + TdLibLimitOptionNames.MONTHLY_SENT_STORY_COUNT_MAX -> DEFAULTS.monthlySentStoryCountMax + TdLibLimitOptionNames.STORY_LINK_AREA_COUNT_MAX -> DEFAULTS.storyLinkAreaCountMax + TdLibLimitOptionNames.STORY_SUGGESTED_REACTION_AREA_COUNT_MAX -> DEFAULTS.storySuggestedReactionAreaCountMax + TdLibLimitOptionNames.STORY_STEALTH_MODE_COOLDOWN_PERIOD -> DEFAULTS.storyStealthModeCooldownPeriod + TdLibLimitOptionNames.STORY_STEALTH_MODE_FUTURE_PERIOD -> DEFAULTS.storyStealthModeFuturePeriod + TdLibLimitOptionNames.STORY_STEALTH_MODE_PAST_PERIOD -> DEFAULTS.storyStealthModePastPeriod + TdLibLimitOptionNames.STORY_VIEWERS_EXPIRATION_DELAY -> DEFAULTS.storyViewersExpirationDelay + TdLibLimitOptionNames.FAVORITE_STICKERS_LIMIT -> DEFAULTS.favoriteStickersLimit + TdLibLimitOptionNames.SAVED_ANIMATIONS_LIMIT -> DEFAULTS.savedAnimationCountMax + TdLibLimitOptionNames.NOTIFICATION_SOUND_COUNT_MAX -> DEFAULTS.notificationSoundCountMax + TdLibLimitOptionNames.NOTIFICATION_SOUND_DURATION_MAX -> DEFAULTS.notificationSoundDurationMax + TdLibLimitOptionNames.NOTIFICATION_SOUND_SIZE_MAX -> DEFAULTS.notificationSoundSizeMax + TdLibLimitOptionNames.GIFT_TEXT_LENGTH_MAX -> DEFAULTS.giftTextLengthMax + TdLibLimitOptionNames.USER_NOTE_TEXT_LENGTH_MAX -> DEFAULTS.userNoteTextLengthMax + TdLibLimitOptionNames.GROUP_CALL_MESSAGE_TEXT_LENGTH_MAX -> DEFAULTS.groupCallMessageTextLengthMax + else -> null + } +} diff --git a/domain/src/main/java/org/monogram/domain/repository/AuthRepository.kt b/domain/src/main/java/org/monogram/domain/repository/AuthRepository.kt index 54d30d0c4..dad750b79 100644 --- a/domain/src/main/java/org/monogram/domain/repository/AuthRepository.kt +++ b/domain/src/main/java/org/monogram/domain/repository/AuthRepository.kt @@ -9,20 +9,49 @@ sealed class AuthStep { object Closing : AuthStep() object InputPhone : AuthStep() data class InputCode( - val codeType: String, + val delivery: AuthCodeDelivery, val codeLength: Int, - val nextType: String? = null, + val inputKind: AuthCodeInputKind = AuthCodeInputKind.NUMERIC, + val codeHint: String? = null, + val nextDelivery: AuthCodeDelivery? = null, val timeout: Int = 0, val isEmailCode: Boolean = false, - val emailPattern: String? = null + val emailPattern: String? = null, + val canResend: Boolean = false + ) : AuthStep() + + data class InputPassword( + val passwordHint: String? = null, + val hasRecoveryEmail: Boolean = false, + val recoveryEmailPattern: String? = null ) : AuthStep() - object InputPassword : AuthStep() object Ready : AuthStep() } +enum class AuthCodeDelivery { + TELEGRAM_MESSAGE, + SMS, + SMS_WORD, + SMS_PHRASE, + CALL, + FLASH_CALL, + MISSED_CALL, + FRAGMENT, + FIREBASE_ANDROID, + FIREBASE_IOS, + EMAIL, + UNKNOWN +} + +enum class AuthCodeInputKind { + NUMERIC, + TEXT +} + enum class AuthSubmissionStage { PHONE, CODE, + RESEND, PASSWORD } @@ -37,6 +66,7 @@ sealed class AuthError { object InvalidCode : AuthError() object InvalidPassword : AuthError() object CodeExpired : AuthError() + data class RateLimited(val retryAfterSeconds: Int?) : AuthError() object NetworkTimeout : AuthError() object Unexpected : AuthError() } diff --git a/domain/src/main/java/org/monogram/domain/repository/TdLibLimitsRepository.kt b/domain/src/main/java/org/monogram/domain/repository/TdLibLimitsRepository.kt new file mode 100644 index 000000000..fb9a66170 --- /dev/null +++ b/domain/src/main/java/org/monogram/domain/repository/TdLibLimitsRepository.kt @@ -0,0 +1,10 @@ +package org.monogram.domain.repository + +import kotlinx.coroutines.flow.StateFlow +import org.monogram.domain.models.TdLibLimits + +interface TdLibLimitsRepository { + val limits: StateFlow + + suspend fun refresh() +} diff --git a/presentation/src/main/java/org/monogram/presentation/di/AppContainer.kt b/presentation/src/main/java/org/monogram/presentation/di/AppContainer.kt index 1babe9ae3..aa618567d 100644 --- a/presentation/src/main/java/org/monogram/presentation/di/AppContainer.kt +++ b/presentation/src/main/java/org/monogram/presentation/di/AppContainer.kt @@ -55,6 +55,7 @@ import org.monogram.domain.repository.StickerRepository import org.monogram.domain.repository.StorageRepository import org.monogram.domain.repository.StoryRepository import org.monogram.domain.repository.StringProvider +import org.monogram.domain.repository.TdLibLimitsRepository import org.monogram.domain.repository.TelegramLinkRepository import org.monogram.domain.repository.UpdateRepository import org.monogram.domain.repository.UserProfileEditRepository @@ -121,6 +122,7 @@ interface RepositoriesContainer { val proxyDiagnosticsRepository: ProxyDiagnosticsRepository val stickerRepository: StickerRepository val storyRepository: StoryRepository + val tdLibLimitsRepository: TdLibLimitsRepository val gifRepository: GifRepository val emojiRepository: EmojiRepository val telegramLinkRepository: TelegramLinkRepository diff --git a/presentation/src/main/java/org/monogram/presentation/di/KoinAppContainer.kt b/presentation/src/main/java/org/monogram/presentation/di/KoinAppContainer.kt index f9a91e957..a783e99d4 100644 --- a/presentation/src/main/java/org/monogram/presentation/di/KoinAppContainer.kt +++ b/presentation/src/main/java/org/monogram/presentation/di/KoinAppContainer.kt @@ -56,6 +56,7 @@ import org.monogram.domain.repository.StickerRepository import org.monogram.domain.repository.StorageRepository import org.monogram.domain.repository.StoryRepository import org.monogram.domain.repository.StringProvider +import org.monogram.domain.repository.TdLibLimitsRepository import org.monogram.domain.repository.TelegramLinkRepository import org.monogram.domain.repository.UpdateRepository import org.monogram.domain.repository.UserProfileEditRepository @@ -122,6 +123,7 @@ class KoinRepositoriesContainer(private val koin: Koin) : RepositoriesContainer override val proxyDiagnosticsRepository: ProxyDiagnosticsRepository by lazy { koin.get() } override val stickerRepository: StickerRepository by lazy { koin.get() } override val storyRepository: StoryRepository by lazy { koin.get() } + override val tdLibLimitsRepository: TdLibLimitsRepository by lazy { koin.get() } override val gifRepository: GifRepository by lazy { koin.get() } override val emojiRepository: EmojiRepository by lazy { koin.get() } override val telegramLinkRepository: TelegramLinkRepository by lazy { koin.get() } diff --git a/presentation/src/main/java/org/monogram/presentation/features/auth/AuthComponent.kt b/presentation/src/main/java/org/monogram/presentation/features/auth/AuthComponent.kt index bc7983447..030d93d14 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/auth/AuthComponent.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/auth/AuthComponent.kt @@ -1,6 +1,8 @@ package org.monogram.presentation.features.auth import com.arkivanov.decompose.value.Value +import org.monogram.domain.repository.AuthCodeDelivery +import org.monogram.domain.repository.AuthCodeInputKind import org.monogram.domain.repository.AuthError import org.monogram.domain.repository.AuthUiStatus @@ -29,12 +31,19 @@ interface AuthComponent { object InputPhone : AuthState() data class InputCode( val codeLength: Int, - val codeType: String, - val nextCodeType: String? = null, + val delivery: AuthCodeDelivery, + val inputKind: AuthCodeInputKind, + val codeHint: String? = null, + val nextDelivery: AuthCodeDelivery? = null, val timeout: Int = 0, - val emailPattern: String? = null + val emailPattern: String? = null, + val canResend: Boolean = false ) : AuthState() - object InputPassword : AuthState() + data class InputPassword( + val passwordHint: String? = null, + val hasRecoveryEmail: Boolean = false, + val recoveryEmailPattern: String? = null + ) : AuthState() } } diff --git a/presentation/src/main/java/org/monogram/presentation/features/auth/AuthContent.kt b/presentation/src/main/java/org/monogram/presentation/features/auth/AuthContent.kt index 55a6c6daa..cdf61fcf2 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/auth/AuthContent.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/auth/AuthContent.kt @@ -153,10 +153,13 @@ fun AuthContent(component: AuthComponent) { is AuthComponent.AuthState.InputCode -> CodeInputScreen( phoneNumber = model.phoneNumber ?: "", codeLength = targetState.codeLength, - codeType = targetState.codeType, - nextCodeType = targetState.nextCodeType, + delivery = targetState.delivery, + inputKind = targetState.inputKind, + codeHint = targetState.codeHint, + nextDelivery = targetState.nextDelivery, timeout = targetState.timeout, emailPattern = targetState.emailPattern, + canResend = targetState.canResend, onConfirm = component::onCodeEntered, onResend = component::onResendCode, onBack = component::onBackToPhone, @@ -165,6 +168,7 @@ fun AuthContent(component: AuthComponent) { ) is AuthComponent.AuthState.InputPassword -> PasswordInputScreen( + passwordHint = targetState.passwordHint, onConfirm = component::onPasswordEntered, isSubmitting = model.isSubmitting, uiStatus = model.uiStatus diff --git a/presentation/src/main/java/org/monogram/presentation/features/auth/DefaultAuthComponent.kt b/presentation/src/main/java/org/monogram/presentation/features/auth/DefaultAuthComponent.kt index 8f57d1a20..68ee951c4 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/auth/DefaultAuthComponent.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/auth/DefaultAuthComponent.kt @@ -31,12 +31,20 @@ class DefaultAuthComponent( is AuthStep.InputPhone -> AuthComponent.AuthState.InputPhone is AuthStep.InputCode -> AuthComponent.AuthState.InputCode( codeLength = step.codeLength, - codeType = step.codeType, - nextCodeType = step.nextType, + delivery = step.delivery, + inputKind = step.inputKind, + codeHint = step.codeHint, + nextDelivery = step.nextDelivery, timeout = step.timeout, - emailPattern = step.emailPattern + emailPattern = step.emailPattern, + canResend = step.canResend + ) + + is AuthStep.InputPassword -> AuthComponent.AuthState.InputPassword( + passwordHint = step.passwordHint, + hasRecoveryEmail = step.hasRecoveryEmail, + recoveryEmailPattern = step.recoveryEmailPattern ) - is AuthStep.InputPassword -> AuthComponent.AuthState.InputPassword else -> null } if (newAuthState != null) { diff --git a/presentation/src/main/java/org/monogram/presentation/features/auth/components/AuthErrorDialog.kt b/presentation/src/main/java/org/monogram/presentation/features/auth/components/AuthErrorDialog.kt index ee3d407fb..4a2e476ce 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/auth/components/AuthErrorDialog.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/auth/components/AuthErrorDialog.kt @@ -42,6 +42,10 @@ fun AuthErrorDialog( AuthError.InvalidCode -> stringResource(R.string.auth_phone_code_invalid_error) AuthError.InvalidPassword -> stringResource(R.string.auth_password_hash_invalid) AuthError.CodeExpired -> stringResource(R.string.auth_code_expired_error) + is AuthError.RateLimited -> stringResource( + R.string.auth_rate_limited_error, + error.retryAfterSeconds ?: 0 + ) AuthError.Unexpected -> stringResource(R.string.unexpected_error) } Text(text = errorText) diff --git a/presentation/src/main/java/org/monogram/presentation/features/auth/components/CodeInputScreen.kt b/presentation/src/main/java/org/monogram/presentation/features/auth/components/CodeInputScreen.kt index a948bc338..09cd22871 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/auth/components/CodeInputScreen.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/auth/components/CodeInputScreen.kt @@ -54,6 +54,7 @@ import androidx.compose.material3.Icon import androidx.compose.material3.LoadingIndicator import androidx.compose.material3.MaterialShapes import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton @@ -87,6 +88,8 @@ import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import kotlinx.coroutines.delay +import org.monogram.domain.repository.AuthCodeDelivery +import org.monogram.domain.repository.AuthCodeInputKind import org.monogram.domain.repository.AuthUiStatus import org.monogram.presentation.R import org.monogram.presentation.core.ui.ExpressiveDefaults @@ -97,18 +100,27 @@ import java.util.Locale fun CodeInputScreen( phoneNumber: String, codeLength: Int, - codeType: String, - nextCodeType: String? = null, + delivery: AuthCodeDelivery, + inputKind: AuthCodeInputKind, + codeHint: String? = null, + nextDelivery: AuthCodeDelivery? = null, timeout: Int = 0, emailPattern: String? = null, + canResend: Boolean, onConfirm: (String) -> Unit, onResend: () -> Unit, onBack: () -> Unit, isSubmitting: Boolean, uiStatus: AuthUiStatus ) { - var code by remember { mutableStateOf("") } - val maxCodeLength = if (codeLength > 0) codeLength else 5 + var code by remember(delivery, inputKind) { mutableStateOf("") } + val expectedCodeLength = codeLength.takeIf { it > 0 } + val usesOtpBoxes = inputKind == AuthCodeInputKind.NUMERIC && expectedCodeLength != null + val isCodeComplete = when { + inputKind == AuthCodeInputKind.TEXT -> code.isNotBlank() + expectedCodeLength != null -> code.length == expectedCodeLength + else -> code.isNotBlank() + } val configuration = LocalConfiguration.current val isLandscape = configuration.orientation == Configuration.ORIENTATION_LANDSCAPE val scrollState = rememberScrollState() @@ -206,24 +218,22 @@ fun CodeInputScreen( Spacer(modifier = Modifier.height(if (isInputMode) 4.dp else 12.dp)) - val deliveryMessage = when { - codeType.contains("Email", ignoreCase = true) -> + val deliveryMessage = when (delivery) { + AuthCodeDelivery.EMAIL -> stringResource(R.string.verification_delivery_email, emailPattern ?: "") - codeType.contains( - "TelegramMessage", - ignoreCase = true - ) -> stringResource(R.string.verification_delivery_telegram) + AuthCodeDelivery.TELEGRAM_MESSAGE -> stringResource(R.string.verification_delivery_telegram) + + AuthCodeDelivery.SMS, + AuthCodeDelivery.SMS_WORD, + AuthCodeDelivery.SMS_PHRASE -> stringResource(R.string.verification_delivery_sms) - codeType.contains( - "Sms", - ignoreCase = true - ) -> stringResource(R.string.verification_delivery_sms) + AuthCodeDelivery.CALL, + AuthCodeDelivery.FLASH_CALL, + AuthCodeDelivery.MISSED_CALL -> stringResource(R.string.verification_delivery_call) - codeType.contains( - "Call", - ignoreCase = true - ) -> stringResource(R.string.verification_delivery_call) + AuthCodeDelivery.FIREBASE_ANDROID, + AuthCodeDelivery.FIREBASE_IOS -> stringResource(R.string.verification_delivery_firebase) else -> stringResource(R.string.verification_delivery_default) } @@ -235,28 +245,43 @@ fun CodeInputScreen( textAlign = TextAlign.Center ) + if (!codeHint.isNullOrBlank()) { + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = codeHint, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center + ) + } + Spacer(modifier = Modifier.height(middleSpacerHeight)) - Box(contentAlignment = Alignment.Center) { + val onCodeChanged: (String) -> Unit = { input -> + val acceptedInput = when (inputKind) { + AuthCodeInputKind.NUMERIC -> input.filter(Char::isDigit) + AuthCodeInputKind.TEXT -> input + } + val limitedInput = expectedCodeLength?.let(acceptedInput::take) ?: acceptedInput + isPasted = (limitedInput.length - code.length) > 1 + code = limitedInput + if (usesOtpBoxes && limitedInput.length == expectedCodeLength) { + onConfirm(limitedInput) + } + } + + if (usesOtpBoxes) { + Box(contentAlignment = Alignment.Center) { BasicTextField( value = code, - onValueChange = { - isPasted = (it.length - code.length) > 1 - - if (it.length <= maxCodeLength && it.all { char -> char.isDigit() }) { - code = it - if (code.length == maxCodeLength) { - onConfirm(code) - } - } - }, + onValueChange = onCodeChanged, keyboardOptions = KeyboardOptions( keyboardType = KeyboardType.Number, imeAction = ImeAction.Done ), keyboardActions = KeyboardActions( onDone = { - if (code.length == maxCodeLength) { + if (isCodeComplete) { onConfirm(code) } else { focusManager.clearFocus() @@ -288,7 +313,7 @@ fun CodeInputScreen( } ) ) { - repeat(maxCodeLength) { index -> + repeat(expectedCodeLength) { index -> val char = code.getOrNull(index)?.toString() ?: "" val isBoxFocused = code.length == index && isFocused @@ -310,18 +335,40 @@ fun CodeInputScreen( text = { Text(stringResource(R.string.paste_action)) }, onClick = { val pastedText = nativeClipboard.primaryClip?.getItemAt(0)?.text?.toString() ?: "" - val digits = pastedText.filter { it.isDigit() }.take(maxCodeLength) + val digits = pastedText.filter { it.isDigit() }.take(expectedCodeLength) if (digits.isNotEmpty()) { - isPasted = true - code = digits - if (code.length == maxCodeLength) { - onConfirm(code) - } + onCodeChanged(digits) } showPasteMenu = false } ) } + } + } else { + OutlinedTextField( + value = code, + onValueChange = onCodeChanged, + modifier = Modifier + .fillMaxWidth() + .focusRequester(focusRequester) + .onFocusChanged { isFocused = it.isFocused }, + label = { Text(stringResource(R.string.code_label)) }, + placeholder = codeHint?.takeIf { it.isNotBlank() }?.let { hint -> { Text(hint) } }, + singleLine = true, + keyboardOptions = KeyboardOptions( + keyboardType = if (inputKind == AuthCodeInputKind.NUMERIC) { + KeyboardType.Number + } else { + KeyboardType.Text + }, + imeAction = ImeAction.Done + ), + keyboardActions = KeyboardActions( + onDone = { + if (isCodeComplete) onConfirm(code) else focusManager.clearFocus() + } + ) + ) } Spacer(modifier = Modifier.height(middleSpacerHeight)) @@ -345,7 +392,7 @@ fun CodeInputScreen( modifier = Modifier .fillMaxWidth() .height(56.dp), - enabled = code.length == maxCodeLength + enabled = isCodeComplete ) { Text( stringResource(R.string.confirm_button), @@ -369,7 +416,7 @@ fun CodeInputScreen( modifier = Modifier.fillMaxWidth(), textAlign = TextAlign.Center ) - } else if (nextCodeType != null) { + } else if (canResend) { TextButton( onClick = onResend, shapes = ExpressiveDefaults.largeButtonShapes(), @@ -381,16 +428,14 @@ fun CodeInputScreen( modifier = Modifier.size(18.dp) ) Spacer(modifier = Modifier.width(8.dp)) - val resendText = when { - nextCodeType.contains( - "Sms", - ignoreCase = true - ) -> stringResource(R.string.resend_via_sms) + val resendText = when (nextDelivery) { + AuthCodeDelivery.SMS, + AuthCodeDelivery.SMS_WORD, + AuthCodeDelivery.SMS_PHRASE -> stringResource(R.string.resend_via_sms) - nextCodeType.contains( - "Call", - ignoreCase = true - ) -> stringResource(R.string.resend_via_call) + AuthCodeDelivery.CALL, + AuthCodeDelivery.FLASH_CALL, + AuthCodeDelivery.MISSED_CALL -> stringResource(R.string.resend_via_call) else -> stringResource(R.string.resend_code) } @@ -464,12 +509,15 @@ fun CodeInputScreen( } } - LaunchedEffect(isPasted) { - if (isPasted) { - val totalDelay = (maxCodeLength * PASTE_CASCADE_DELAY_MS) + SCALE_ANIMATION_DURATION_MS + LaunchedEffect(isPasted, usesOtpBoxes) { + if (!isPasted) return@LaunchedEffect + + if (usesOtpBoxes) { + val totalDelay = + (expectedCodeLength * PASTE_CASCADE_DELAY_MS) + SCALE_ANIMATION_DURATION_MS delay(totalDelay) - isPasted = false } + isPasted = false } } diff --git a/presentation/src/main/java/org/monogram/presentation/features/auth/components/PasswordInputScreen.kt b/presentation/src/main/java/org/monogram/presentation/features/auth/components/PasswordInputScreen.kt index 9ce121cb3..60a048930 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/auth/components/PasswordInputScreen.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/auth/components/PasswordInputScreen.kt @@ -123,6 +123,7 @@ private val passwordShapes = listOf( @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable fun PasswordInputScreen( + passwordHint: String?, onConfirm: (String) -> Unit, isSubmitting: Boolean, uiStatus: AuthUiStatus @@ -223,6 +224,7 @@ fun PasswordInputScreen( iconAlpha = iconAlpha, topSpacerHeight = topSpacerHeight, middleSpacerHeight = middleSpacerHeight, + passwordHint = passwordHint, isSubmitting = isSubmitting, uiStatus = uiStatus, onConfirm = onConfirm, @@ -251,6 +253,7 @@ private fun PasswordContent( iconAlpha: Float, topSpacerHeight: Dp, middleSpacerHeight: Dp, + passwordHint: String?, isSubmitting: Boolean, uiStatus: AuthUiStatus, onConfirm: (String) -> Unit, @@ -327,6 +330,16 @@ private fun PasswordContent( textAlign = TextAlign.Center ) + if (!passwordHint.isNullOrBlank()) { + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = stringResource(R.string.two_step_verification_hint, passwordHint), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center + ) + } + Spacer(modifier = Modifier.height(middleSpacerHeight)) BasicTextField( diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ChatComponent.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ChatComponent.kt index 2514e652d..5054da0e9 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ChatComponent.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ChatComponent.kt @@ -20,6 +20,7 @@ import org.monogram.domain.models.MessageViewerModel import org.monogram.domain.models.PollDraft import org.monogram.domain.models.SponsoredMessagesFeedModel import org.monogram.domain.models.StickerSetModel +import org.monogram.domain.models.TdLibLimits import org.monogram.domain.models.TopicModel import org.monogram.domain.models.UserModel import org.monogram.domain.models.WallpaperModel @@ -279,6 +280,7 @@ interface ChatComponent { val isGroup: Boolean = false, val isChannel: Boolean = false, val isSecretChat: Boolean = false, + val tdLibLimits: TdLibLimits = TdLibLimits(), val isOnline: Boolean = false, val isVerified: Boolean = false, val isSponsor: Boolean = false, diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ChatStoreFactory.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ChatStoreFactory.kt index 531677880..52a67ef69 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ChatStoreFactory.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ChatStoreFactory.kt @@ -203,12 +203,16 @@ class ChatStoreFactory( is Intent.RepeatMessage -> component.handleRepeatMessage(intent.message) is Intent.DeleteMessage -> component.handleDeleteMessage(intent.message, intent.revoke) - is Intent.EditMessage -> component._state.update { - it.copy( - editingMessage = intent.message, - replyMessage = null, - draftText = "" - ) + is Intent.EditMessage -> { + component.cleanupTempAttachments(component._state.value.stagedAttachments) + component._state.update { + it.copy( + editingMessage = intent.message, + replyMessage = null, + draftText = "", + stagedAttachments = emptyList() + ) + } } is Intent.CancelEdit -> component._state.update { it.copy(editingMessage = null) } diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/DefaultChatComponent.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/DefaultChatComponent.kt index b26162d93..baf69ba0e 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/DefaultChatComponent.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/DefaultChatComponent.kt @@ -312,6 +312,7 @@ class DefaultChatComponent( container.repositories.pinnedMessageVisibilityRepository internal val inlineBotRepository: InlineBotRepository = container.repositories.inlineBotRepository internal val paymentRepository: PaymentRepository = container.repositories.paymentRepository + internal val tdLibLimitsRepository = container.repositories.tdLibLimitsRepository override val appPreferences: AppPreferences = container.preferences.appPreferences internal val cacheProvider: CacheProvider = container.cacheProvider internal val cacheController: CacheController = container.utils.cacheController @@ -381,6 +382,7 @@ class DefaultChatComponent( isWhitelistedInAdBlock = appPreferences.adBlockWhitelistedChannels.value.contains(chatId), scrollToMessageId = initialMessageId, currentTopicId = initialTopicId, + tdLibLimits = tdLibLimitsRepository.limits.value, initialShare = initialShare, lastScrollPosition = cacheProvider.getChatScrollPosition(chatId), lastSavedViewport = cacheProvider.getChatViewport(chatId, null), @@ -470,6 +472,9 @@ class DefaultChatComponent( } private fun setupCollectors() { + tdLibLimitsRepository.limits + .onEach { limits -> _state.update { it.copy(tdLibLimits = limits) } } + .launchIn(scope) setupMessageCollectors() setupPinnedMessageCollector() observeUserUpdates() diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/logic/message-actions/MessageActions.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/logic/message-actions/MessageActions.kt index ac19015cf..e5e32c8a7 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/logic/message-actions/MessageActions.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/logic/message-actions/MessageActions.kt @@ -35,6 +35,37 @@ import kotlin.math.max import kotlin.math.roundToInt private const val MaxCompressedPhotoLongSide = 3840 + +internal fun DefaultChatComponent.ensureTdLibTextLimit( + text: String, + limit: Int?, + label: String +): Boolean { + if (limit != null && text.length > limit) { + toastMessageDisplayer.show("$label is too long. Maximum is $limit characters") + return false + } + return true +} + +internal fun DefaultChatComponent.ensureTdLibMessageLimit( + text: String, + rich: Boolean +): Boolean { + val limits = tdLibLimitsRepository.limits.value + return ensureTdLibTextLimit( + text = text, + limit = if (rich) limits.richMessageTextLengthMax else limits.messageTextLengthMax, + label = if (rich) "Rich message" else "Message" + ) +} + +internal fun DefaultChatComponent.ensureTdLibCaptionLimit(caption: String): Boolean = + ensureTdLibTextLimit( + text = caption, + limit = tdLibLimitsRepository.limits.value.messageCaptionLengthMax, + label = "Caption" + ) internal data class PhotoCompressionProfile( val targetWidth: Int, val targetHeight: Int, @@ -159,6 +190,7 @@ internal fun DefaultChatComponent.handleSendMessage( sendOptions: MessageSendOptions = MessageSendOptions(), parseMode: RichTextParseMode? = null ) { + if (!ensureTdLibMessageLimit(text, rich = parseMode != null)) return scope.launch { val currentState = _state.value val replyId = currentState.replyMessage?.id @@ -223,6 +255,7 @@ internal fun DefaultChatComponent.handleSendPhoto( captionEntities: List = emptyList(), sendOptions: MessageSendOptions = MessageSendOptions() ) { + if (!ensureTdLibCaptionLimit(caption)) return launchPendingAttachmentSend( operation = "photo", paths = listOf(photoPath), @@ -281,6 +314,7 @@ internal fun DefaultChatComponent.handleSendVideo( captionEntities: List = emptyList(), sendOptions: MessageSendOptions = MessageSendOptions() ) { + if (!ensureTdLibCaptionLimit(caption)) return launchPendingAttachmentSend( operation = "video", paths = listOf(videoPath), @@ -385,6 +419,7 @@ internal fun DefaultChatComponent.handleSendDocument( captionEntities: List = emptyList(), sendOptions: MessageSendOptions = MessageSendOptions() ) { + if (!ensureTdLibCaptionLimit(caption)) return launchPendingAttachmentSend( operation = "document", paths = listOf(path), @@ -431,6 +466,17 @@ internal fun DefaultChatComponent.handleSendPoll( poll: PollDraft, sendOptions: MessageSendOptions = MessageSendOptions() ) { + val limits = tdLibLimitsRepository.limits.value + val pollAnswerCountMax = limits.pollAnswerCountMax + if (pollAnswerCountMax != null && poll.options.size > pollAnswerCountMax) { + toastMessageDisplayer.show("Poll has too many answers. Maximum is $pollAnswerCountMax") + return + } + val pollOpenPeriodMax = limits.pollOpenPeriodMax + if (pollOpenPeriodMax != null && poll.openPeriod > pollOpenPeriodMax) { + toastMessageDisplayer.show("Poll open period exceeds $pollOpenPeriodMax seconds") + return + } scope.launch { val currentState = _state.value val replyId = currentState.replyMessage?.id @@ -462,6 +508,7 @@ internal fun DefaultChatComponent.handleSendGifFile( captionEntities: List = emptyList(), sendOptions: MessageSendOptions = MessageSendOptions() ) { + if (!ensureTdLibCaptionLimit(caption)) return launchPendingAttachmentSend( operation = "gif_file", paths = listOf(path), @@ -510,6 +557,7 @@ internal fun DefaultChatComponent.handleSendAlbum( captionEntities: List = emptyList(), sendOptions: MessageSendOptions = MessageSendOptions() ) { + if (!ensureTdLibCaptionLimit(caption)) return launchPendingAttachmentSend( operation = "album", paths = paths, diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/logic/message-actions/MessageOperations.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/logic/message-actions/MessageOperations.kt index b9bb71282..603ef5e40 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/logic/message-actions/MessageOperations.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/logic/message-actions/MessageOperations.kt @@ -133,6 +133,21 @@ internal fun DefaultChatComponent.handleSaveEditedMessage( parseMode: RichTextParseMode? ) { val editingMsg = _state.value.editingMessage ?: return + val isRichMessage = editingMsg.content is MessageContent.RichMessage + val isCaption = when (editingMsg.content) { + is MessageContent.Photo, + is MessageContent.Video, + is MessageContent.Document, + is MessageContent.Audio, + is MessageContent.Gif -> true + + else -> false + } + val isAllowed = when { + isCaption -> ensureTdLibCaptionLimit(text) + else -> ensureTdLibMessageLimit(text, rich = isRichMessage) + } + if (!isAllowed) return val targetChatId = editingMsg.chatId val optimisticMessage = editingMsg.withOptimisticEdit(text, entities) _state.update { state -> @@ -171,6 +186,22 @@ internal fun DefaultChatComponent.handleSaveEditedMessage( } internal fun DefaultChatComponent.handleSaveChecklistDraft(draft: ChecklistDraft) { + val limits = tdLibLimitsRepository.limits.value + val checklistTitleLengthMax = limits.checklistTitleLengthMax + if (checklistTitleLengthMax != null && draft.title.length > checklistTitleLengthMax) { + toastMessageDisplayer.show("Checklist title is too long. Maximum is $checklistTitleLengthMax") + return + } + val checklistTaskCountMax = limits.checklistTaskCountMax + if (checklistTaskCountMax != null && draft.tasks.size > checklistTaskCountMax) { + toastMessageDisplayer.show("Checklist has too many tasks. Maximum is $checklistTaskCountMax") + return + } + val taskTextLimit = limits.checklistTaskTextLengthMax + if (taskTextLimit != null && draft.tasks.any { it.text.length > taskTextLimit }) { + toastMessageDisplayer.show("Checklist task is too long. Maximum is $taskTextLimit") + return + } val checklistMessage = _state.value.checklistMessage Log.d( "ChecklistFlow", diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/ChatInputBar.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/ChatInputBar.kt index 738cb5dd6..ae56380b2 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/ChatInputBar.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/ChatInputBar.kt @@ -50,6 +50,7 @@ import kotlinx.coroutines.delay import org.monogram.domain.models.MessageContent import org.monogram.domain.models.MessageSendOptions import org.monogram.domain.models.StickerModel +import org.monogram.domain.models.TdLibLimits import org.monogram.domain.repository.RichTextParseMode import org.monogram.domain.repository.StickerRepository import org.monogram.presentation.core.util.AppPreferences @@ -97,6 +98,38 @@ private enum class AttachmentPickerMode { MediaOnly } +private fun MessageContent.usesCaptionLengthLimit(): Boolean = when (this) { + is MessageContent.Photo, + is MessageContent.Video, + is MessageContent.Document, + is MessageContent.Audio, + is MessageContent.Gif -> true + + else -> false +} + +internal fun resolveChatInputMaxMessageLength( + editingContent: MessageContent?, + hasPendingMedia: Boolean, + hasPendingDocuments: Boolean, + limits: TdLibLimits +): Int { + val usesCaptionLimit = editingContent?.usesCaptionLengthLimit() + ?: (hasPendingMedia || hasPendingDocuments) + + return when { + usesCaptionLimit -> limits.messageCaptionLengthMax + ?: TdLibLimits.DEFAULT_MESSAGE_CAPTION_LENGTH_MAX + + editingContent is MessageContent.RichMessage -> limits.richMessageTextLengthMax + ?: limits.messageTextLengthMax + ?: TdLibLimits.DEFAULT_MESSAGE_TEXT_LENGTH_MAX + + else -> limits.messageTextLengthMax + ?: TdLibLimits.DEFAULT_MESSAGE_TEXT_LENGTH_MAX + } +} + private fun List.mergeAttachments(newAttachments: List): List { if (newAttachments.isEmpty()) return this val merged = toMutableList() @@ -359,11 +392,16 @@ internal fun ChatInputBar( val maxMessageLength by remember( state.pendingMediaPaths, state.pendingDocumentPaths, - state.isPremiumUser + state.editingMessage, + state.tdLibLimits ) { derivedStateOf { - if ((state.pendingMediaPaths.isNotEmpty() || state.pendingDocumentPaths.isNotEmpty()) && !state.isPremiumUser) 1024 - else 4096 + resolveChatInputMaxMessageLength( + editingContent = state.editingMessage?.content, + hasPendingMedia = state.pendingMediaPaths.isNotEmpty(), + hasPendingDocuments = state.pendingDocumentPaths.isNotEmpty(), + limits = state.tdLibLimits + ) } } val currentMessageLength by remember(textValue.text) { @@ -392,7 +430,12 @@ internal fun ChatInputBar( value: TextFieldValue = textValue, richTextParseMode: RichTextParseMode? = null ) { - val isValueOverMessageLimit = value.text.length > maxMessageLength + val effectiveMaxMessageLength = if (richTextParseMode != null) { + state.tdLibLimits.richMessageTextLengthMax ?: maxMessageLength + } else { + maxMessageLength + } + val isValueOverMessageLimit = value.text.length > effectiveMaxMessageLength if (isValueOverMessageLimit) return val isTextEmpty = value.text.isBlank() val captionEntities = extractEntities(value.annotatedString, knownCustomEmojis) @@ -1039,6 +1082,7 @@ internal fun ChatInputBar( emojiFontFamily = emojiFontFamily, isKeyboardVisible = isKeyboardVisible, maxMessageLength = maxMessageLength, + richMessageLengthMax = state.tdLibLimits.richMessageTextLengthMax, initialParseMode = if (state.editingMessage?.content is MessageContent.RichMessage) { EditorParseMode.Markdown } else { diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/content/ChatContentInputConfiguration.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/content/ChatContentInputConfiguration.kt index 1f4270c41..2a98502e4 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/content/ChatContentInputConfiguration.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/content/ChatContentInputConfiguration.kt @@ -62,7 +62,8 @@ internal fun rememberChatInputBarState( state.attachMenuBots, state.scheduledMessages, state.currentUser?.isPremium, - state.isSecretChat + state.isSecretChat, + state.tdLibLimits ) { ChatInputBarState( replyMessage = state.replyMessage, @@ -100,7 +101,8 @@ internal fun rememberChatInputBarState( attachBots = state.attachMenuBots, scheduledMessages = state.scheduledMessages, isPremiumUser = state.currentUser?.isPremium == true, - isSecretChat = state.isSecretChat + isSecretChat = state.isSecretChat, + tdLibLimits = state.tdLibLimits ) } } diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/content/ChatContentSearchOverlay.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/content/ChatContentSearchOverlay.kt index e0ada1458..bb83d3cdc 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/content/ChatContentSearchOverlay.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/content/ChatContentSearchOverlay.kt @@ -54,10 +54,8 @@ import org.monogram.domain.models.MessageModel import org.monogram.domain.models.UserModel import org.monogram.presentation.R import org.monogram.presentation.core.ui.AvatarForChat -import java.time.Instant -import java.time.LocalDate -import java.time.ZoneId -import java.time.format.DateTimeFormatter +import java.util.Calendar +import java.util.Locale @Composable internal fun ChatContentSearchOverlay( @@ -182,15 +180,15 @@ internal fun ChatContentSearchOverlay( toEpochSeconds = toEpochSeconds, onToggleSenderPicker = onToggleSenderPicker, onApplyToday = { - val now = LocalDate.now() + val now = currentSearchDate() onApplyDateRange( toStartOfDayEpochSeconds(now), toEndOfDayEpochSeconds(now) ) }, onApplyLastDays = { days -> - val now = LocalDate.now() - val from = now.minusDays((days - 1).toLong()) + val now = currentSearchDate() + val from = now.plusDays(-(days - 1)) onApplyDateRange( toStartOfDayEpochSeconds(from), toEndOfDayEpochSeconds(now) @@ -204,9 +202,9 @@ internal fun ChatContentSearchOverlay( onDateSelected = { date -> val nextFrom = toStartOfDayEpochSeconds(date) val nextTo = toEpochSeconds - ?.let(::epochSecondsToLocalDate) + ?.let(::epochSecondsToSearchDate) ?.let { currentTo -> - if (currentTo.isBefore(date)) { + if (currentTo < date) { toEndOfDayEpochSeconds(date) } else { toEndOfDayEpochSeconds(currentTo) @@ -223,9 +221,9 @@ internal fun ChatContentSearchOverlay( onDateSelected = { date -> val nextTo = toEndOfDayEpochSeconds(date) val nextFrom = fromEpochSeconds - ?.let(::epochSecondsToLocalDate) + ?.let(::epochSecondsToSearchDate) ?.let { currentFrom -> - if (currentFrom.isAfter(date)) { + if (currentFrom > date) { toStartOfDayEpochSeconds(date) } else { toStartOfDayEpochSeconds(currentFrom) @@ -731,52 +729,90 @@ private fun SearchRangeChip( } private fun isTodayRange(fromEpochSeconds: Int?, toEpochSeconds: Int?): Boolean { - val today = LocalDate.now() - return fromEpochSeconds?.let(::epochSecondsToLocalDate) == today && - toEpochSeconds?.let(::epochSecondsToLocalDate) == today + val today = currentSearchDate() + return fromEpochSeconds?.let(::epochSecondsToSearchDate) == today && + toEpochSeconds?.let(::epochSecondsToSearchDate) == today } private fun matchesLastDaysRange(fromEpochSeconds: Int?, toEpochSeconds: Int?, days: Int): Boolean { - val today = LocalDate.now() - return fromEpochSeconds?.let(::epochSecondsToLocalDate) == today.minusDays((days - 1).toLong()) && - toEpochSeconds?.let(::epochSecondsToLocalDate) == today + val today = currentSearchDate() + return fromEpochSeconds?.let(::epochSecondsToSearchDate) == today.plusDays(-(days - 1)) && + toEpochSeconds?.let(::epochSecondsToSearchDate) == today } private fun showSearchDatePicker( context: Context, initialEpochSeconds: Int?, - onDateSelected: (LocalDate) -> Unit + onDateSelected: (SearchDate) -> Unit ) { - val initialDate = initialEpochSeconds?.let(::epochSecondsToLocalDate) ?: LocalDate.now() + val initialDate = initialEpochSeconds?.let(::epochSecondsToSearchDate) ?: currentSearchDate() DatePickerDialog( context, { _, year, month, dayOfMonth -> - onDateSelected(LocalDate.of(year, month + 1, dayOfMonth)) + onDateSelected(SearchDate(year, month, dayOfMonth)) }, initialDate.year, - initialDate.monthValue - 1, + initialDate.month, initialDate.dayOfMonth ).show() } -private fun epochSecondsToLocalDate(epochSeconds: Int): LocalDate { - return Instant.ofEpochSecond(epochSeconds.toLong()) - .atZone(ZoneId.systemDefault()) - .toLocalDate() +private fun currentSearchDate(): SearchDate = Calendar.getInstance().toSearchDate() + +private fun epochSecondsToSearchDate(epochSeconds: Int): SearchDate { + return Calendar.getInstance().apply { + timeInMillis = epochSeconds.toLong() * 1000L + }.toSearchDate() } -private fun toStartOfDayEpochSeconds(date: LocalDate): Int { - return date.atStartOfDay(ZoneId.systemDefault()).toEpochSecond().toInt() +private fun toStartOfDayEpochSeconds(date: SearchDate): Int { + return (date.toCalendar().timeInMillis / 1000L).toInt() } -private fun toEndOfDayEpochSeconds(date: LocalDate): Int { - return date.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toEpochSecond().toInt() - 1 +private fun toEndOfDayEpochSeconds(date: SearchDate): Int { + return (date.plusDays(1).toCalendar().timeInMillis / 1000L).toInt() - 1 } private fun formatSearchDate(epochSeconds: Int): String { - return epochSecondsToLocalDate(epochSeconds).format(DateTimeFormatter.ofPattern("dd.MM.yyyy")) + val date = epochSecondsToSearchDate(epochSeconds) + return String.format( + Locale.getDefault(), + "%02d.%02d.%04d", + date.dayOfMonth, + date.month + 1, + date.year + ) +} + +private data class SearchDate( + val year: Int, + val month: Int, + val dayOfMonth: Int +) : Comparable { + override fun compareTo(other: SearchDate): Int = compareValuesBy( + this, + other, + SearchDate::year, + SearchDate::month, + SearchDate::dayOfMonth + ) } +private fun Calendar.toSearchDate(): SearchDate = SearchDate( + year = get(Calendar.YEAR), + month = get(Calendar.MONTH), + dayOfMonth = get(Calendar.DAY_OF_MONTH) +) + +private fun SearchDate.toCalendar(): Calendar = Calendar.getInstance().apply { + clear() + set(year, month, dayOfMonth, 0, 0, 0) +} + +private fun SearchDate.plusDays(days: Int): SearchDate = toCalendar().apply { + add(Calendar.DAY_OF_MONTH, days) +}.toSearchDate() + @Composable private fun SearchResultsListOverlay( query: String, diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/inputbar/ChatInputBarContract.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/inputbar/ChatInputBarContract.kt index e02ce1746..408ff233f 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/inputbar/ChatInputBarContract.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/inputbar/ChatInputBarContract.kt @@ -15,6 +15,7 @@ import org.monogram.domain.models.MessageModel import org.monogram.domain.models.MessageSendOptions import org.monogram.domain.models.PollDraft import org.monogram.domain.models.ReplyMarkupModel +import org.monogram.domain.models.TdLibLimits import org.monogram.domain.models.UserModel import org.monogram.domain.models.WebPage import org.monogram.domain.repository.InlineBotResultsModel @@ -59,6 +60,7 @@ data class ChatInputBarState( val scheduledMessages: List = emptyList(), val isPremiumUser: Boolean = false, val isSecretChat: Boolean = false, + val tdLibLimits: TdLibLimits = TdLibLimits(), ) @Immutable @@ -151,7 +153,7 @@ internal data class ComposerRowState( val stickerMenuHeight: Dp, val showFullScreenEditor: Boolean = false, val currentMessageLength: Int = 0, - val maxMessageLength: Int = 4096, + val maxMessageLength: Int = TdLibLimits.DEFAULT_MESSAGE_TEXT_LENGTH_MAX, val isOverMessageLimit: Boolean = false, val showSendOptionsSheet: Boolean = false, val isVideoMessageMode: Boolean = false, diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/inputbar/FullScreenEditorSheet.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/inputbar/FullScreenEditorSheet.kt index 8acc1f342..65547adf4 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/inputbar/FullScreenEditorSheet.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/inputbar/FullScreenEditorSheet.kt @@ -210,6 +210,7 @@ internal fun FullScreenEditorSheet( emojiFontFamily: FontFamily, isKeyboardVisible: Boolean, maxMessageLength: Int, + richMessageLengthMax: Int? = null, initialParseMode: EditorParseMode, sendAsRichMessage: Boolean, stickerRepository: StickerRepository, @@ -505,6 +506,11 @@ internal fun FullScreenEditorSheet( } } + val effectiveMaxMessageLength = if (parseMode != EditorParseMode.Plain) { + richMessageLengthMax ?: maxMessageLength + } else { + maxMessageLength + } val displayTextValue = remember(textValue, parseMode) { applyEditorFormatting(textValue, parseMode) } @@ -512,7 +518,7 @@ internal fun FullScreenEditorSheet( previewRevealedSpoilers.clear() } val displayMessageLength = displayTextValue.text.length - val isDisplayOverMessageLimit = displayMessageLength > maxMessageLength + val isDisplayOverMessageLimit = displayMessageLength > effectiveMaxMessageLength val wordCount = remember(displayTextValue.text) { Regex("\\S+").findAll(displayTextValue.text).count() } @@ -780,7 +786,7 @@ internal fun FullScreenEditorSheet( text = stringResource( R.string.message_length_counter, displayMessageLength, - maxMessageLength + effectiveMaxMessageLength ), color = if (isDisplayOverMessageLimit) MaterialTheme.colorScheme.error.copy( alpha = 0.22f diff --git a/presentation/src/main/java/org/monogram/presentation/features/gallery/GalleryMediaQueries.kt b/presentation/src/main/java/org/monogram/presentation/features/gallery/GalleryMediaQueries.kt index af51b7bd3..78f531e1a 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/gallery/GalleryMediaQueries.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/gallery/GalleryMediaQueries.kt @@ -2,16 +2,12 @@ package org.monogram.presentation.features.gallery import android.content.ContentUris import android.content.Context +import android.os.Build import android.provider.MediaStore fun queryImages(context: Context): List { val result = mutableListOf() - val projection = arrayOf( - MediaStore.Images.Media._ID, - MediaStore.Images.Media.DATE_ADDED, - MediaStore.Images.Media.BUCKET_DISPLAY_NAME, - MediaStore.Images.Media.RELATIVE_PATH - ) + val projection = imageProjection(Build.VERSION.SDK_INT) context.contentResolver.query( MediaStore.Images.Media.EXTERNAL_CONTENT_URI, projection, @@ -48,13 +44,7 @@ fun queryImages(context: Context): List { fun queryVideos(context: Context): List { val result = mutableListOf() - val projection = arrayOf( - MediaStore.Video.Media._ID, - MediaStore.Video.Media.DATE_ADDED, - MediaStore.Video.Media.BUCKET_DISPLAY_NAME, - MediaStore.Video.Media.RELATIVE_PATH, - MediaStore.Video.Media.DURATION - ) + val projection = videoProjection(Build.VERSION.SDK_INT) context.contentResolver.query( MediaStore.Video.Media.EXTERNAL_CONTENT_URI, projection, @@ -90,6 +80,25 @@ fun queryVideos(context: Context): List { return result } +internal fun imageProjection(sdkInt: Int): Array = buildList { + add(MediaStore.Images.Media._ID) + add(MediaStore.Images.Media.DATE_ADDED) + add(MediaStore.Images.Media.BUCKET_DISPLAY_NAME) + if (sdkInt >= Build.VERSION_CODES.Q) { + add(MediaStore.Images.Media.RELATIVE_PATH) + } +}.toTypedArray() + +internal fun videoProjection(sdkInt: Int): Array = buildList { + add(MediaStore.Video.Media._ID) + add(MediaStore.Video.Media.DATE_ADDED) + add(MediaStore.Video.Media.BUCKET_DISPLAY_NAME) + if (sdkInt >= Build.VERSION_CODES.Q) { + add(MediaStore.Video.Media.RELATIVE_PATH) + } + add(MediaStore.Video.Media.DURATION) +}.toTypedArray() + private fun isCameraBucket(bucket: String, relativePath: String): Boolean { val b = bucket.lowercase() val p = relativePath.lowercase() diff --git a/presentation/src/main/java/org/monogram/presentation/settings/profile/DefaultEditProfileComponent.kt b/presentation/src/main/java/org/monogram/presentation/settings/profile/DefaultEditProfileComponent.kt index 678c99bdc..f5ec1e4db 100644 --- a/presentation/src/main/java/org/monogram/presentation/settings/profile/DefaultEditProfileComponent.kt +++ b/presentation/src/main/java/org/monogram/presentation/settings/profile/DefaultEditProfileComponent.kt @@ -9,6 +9,7 @@ import org.monogram.domain.models.BusinessOpeningHoursModel import org.monogram.domain.repository.ChatInfoRepository import org.monogram.domain.repository.ChatListRepository import org.monogram.domain.repository.LocationRepository +import org.monogram.domain.repository.TdLibLimitsRepository import org.monogram.domain.repository.UserProfileEditRepository import org.monogram.domain.repository.UserRepository import org.monogram.presentation.core.util.componentScope @@ -25,6 +26,8 @@ class DefaultEditProfileComponent( private val chatInfoRepository: ChatInfoRepository = container.repositories.chatInfoRepository private val chatListRepository: ChatListRepository = container.repositories.chatListRepository private val locationRepository: LocationRepository = container.repositories.locationRepository + private val tdLibLimitsRepository: TdLibLimitsRepository = + container.repositories.tdLibLimitsRepository private val _state = MutableValue(EditProfileComponent.State()) override val state: Value = _state @@ -72,6 +75,7 @@ class DefaultEditProfileComponent( businessLatitude = fullInfo?.businessInfo?.location?.latitude ?: 0.0, businessLongitude = fullInfo?.businessInfo?.location?.longitude ?: 0.0, businessOpeningHours = fullInfo?.businessInfo?.openingHours, + tdLibLimits = tdLibLimitsRepository.limits.value, avatarPath = me.avatarPath, isLoading = false ) @@ -80,6 +84,11 @@ class DefaultEditProfileComponent( _state.update { it.copy(isLoading = false, error = e.message) } } } + scope.launch { + tdLibLimitsRepository.limits.collect { limits -> + _state.update { it.copy(tdLibLimits = limits) } + } + } } override fun onBack() { @@ -287,6 +296,7 @@ class DefaultEditProfileComponent( } override fun onSave() { + if (!_state.value.canSave) return scope.launch { _state.update { it.copy( @@ -296,6 +306,7 @@ class DefaultEditProfileComponent( } try { val currentState = _state.value + if (!currentState.canSave) return@launch val user = currentState.user ?: return@launch if (currentState.firstName != user.firstName || currentState.lastName != (user.lastName ?: "")) { diff --git a/presentation/src/main/java/org/monogram/presentation/settings/profile/EditProfileComponent.kt b/presentation/src/main/java/org/monogram/presentation/settings/profile/EditProfileComponent.kt index 2adf2fcd2..36b105ed4 100644 --- a/presentation/src/main/java/org/monogram/presentation/settings/profile/EditProfileComponent.kt +++ b/presentation/src/main/java/org/monogram/presentation/settings/profile/EditProfileComponent.kt @@ -4,6 +4,7 @@ import com.arkivanov.decompose.value.Value import org.monogram.domain.models.BirthdateModel import org.monogram.domain.models.BusinessOpeningHoursModel import org.monogram.domain.models.ChatModel +import org.monogram.domain.models.TdLibLimits import org.monogram.domain.models.UserModel import org.monogram.presentation.features.editing.EditorScreenState @@ -43,12 +44,23 @@ interface EditProfileComponent { val businessLatitude: Double = 0.0, val businessLongitude: Double = 0.0, val businessOpeningHours: BusinessOpeningHoursModel? = null, + val tdLibLimits: TdLibLimits = TdLibLimits(), val avatarPath: String? = null, val isLoading: Boolean = false, val error: String? = null, val editor: EditorScreenState = EditorScreenState(), val showAvatarPicker: Boolean = false - ) + ) { + val isBioOverLimit: Boolean + get() = tdLibLimits.bioLengthMax?.let { bio.length > it } == true + + val isBusinessBioOverLimit: Boolean + get() = tdLibLimits.businessStartPageMessageLengthMax + ?.let { businessBio.length > it } == true + + val canSave: Boolean + get() = editor.canSave && !isBioOverLimit && !isBusinessBioOverLimit + } fun onShowAvatarPicker(show: Boolean) } diff --git a/presentation/src/main/java/org/monogram/presentation/settings/profile/EditProfileContent.kt b/presentation/src/main/java/org/monogram/presentation/settings/profile/EditProfileContent.kt index 3fe959d2e..1f7944b12 100644 --- a/presentation/src/main/java/org/monogram/presentation/settings/profile/EditProfileContent.kt +++ b/presentation/src/main/java/org/monogram/presentation/settings/profile/EditProfileContent.kt @@ -859,7 +859,7 @@ fun EditProfileContent(component: EditProfileComponent) { .padding(end = 16.dp), ) } else if (state.user != null) { - IconButton(onClick = component::onSave, enabled = state.editor.canSave) { + IconButton(onClick = component::onSave, enabled = state.canSave) { Icon( Icons.Rounded.Check, contentDescription = stringResource(R.string.action_save), @@ -963,6 +963,18 @@ fun EditProfileContent(component: EditProfileComponent) { color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp) ) + state.tdLibLimits.bioLengthMax?.let { maxLength -> + Text( + text = stringResource( + R.string.message_length_counter, + state.bio.length, + maxLength + ), + color = if (state.isBioOverLimit) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 2.dp) + ) + } } item { @@ -1095,6 +1107,18 @@ fun EditProfileContent(component: EditProfileComponent) { icon = Icons.Rounded.Business, position = ItemPosition.MIDDLE ) + state.tdLibLimits.businessStartPageMessageLengthMax?.let { maxLength -> + Text( + text = stringResource( + R.string.message_length_counter, + state.businessBio.length, + maxLength + ), + color = if (state.isBusinessBioOverLimit) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 2.dp) + ) + } SettingsTextField( value = state.businessAddress, diff --git a/presentation/src/main/res/values/string.xml b/presentation/src/main/res/values/string.xml index febbc6a99..53f706531 100644 --- a/presentation/src/main/res/values/string.xml +++ b/presentation/src/main/res/values/string.xml @@ -120,6 +120,7 @@ We\'ve sent the code via SMS. We\'re calling you with the code. We\'ve sent the code to %1$s. + This verification method requires an official Telegram Android app. You can request another method when it becomes available. We\'ve sent the verification code. Confirm Resend code in %1$s @@ -129,6 +130,7 @@ Wrong number? Two-Step Verification Your account is protected with an additional password. + Hint: %1$s Password Unlock Paste @@ -2809,6 +2811,7 @@ Delete Invalid confirmation code Confirmation code expired + Too many attempts. Try again in %1$d seconds. Invalid password An unexpected error occurred %1$d unread messages