Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions data/src/main/java/org/monogram/data/chats/ChatCache.kt
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import org.monogram.data.compat.buildTdChatPermissions
import org.monogram.data.datasource.cache.ChatsCacheDataSource
import org.monogram.data.datasource.cache.UserCacheDataSource
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.TimeUnit

class ChatCache : ChatsCacheDataSource, UserCacheDataSource {
// Chats and their positions in lists
Expand Down Expand Up @@ -49,6 +50,7 @@ class ChatCache : ChatsCacheDataSource, UserCacheDataSource {
val pendingSecretChats = ConcurrentHashMap.newKeySet<Int>()
val pendingChatPermissions = ConcurrentHashMap.newKeySet<Long>()
val pendingMyChatMember = ConcurrentHashMap.newKeySet<Long>()
private val missingSupergroupCooldownUntil = ConcurrentHashMap<Long, Long>()

override fun getChat(chatId: Long): TdApi.Chat? = allChats[chatId]
override fun putChat(chat: TdApi.Chat) {
Expand Down Expand Up @@ -238,14 +240,31 @@ class ChatCache : ChatsCacheDataSource, UserCacheDataSource {
existing.hasTimestampedMedia = message.hasTimestampedMedia
existing.isChannelPost = message.isChannelPost
existing.containsUnreadMention = message.containsUnreadMention
existing.unreadReactions = message.unreadReactions
existing.isPinned = message.isPinned
existing.containsUnreadPollVotes = message.containsUnreadPollVotes
existing.sendingState = message.sendingState
existing.schedulingState = message.schedulingState
existing.factCheck = message.factCheck
existing.suggestedPostInfo = message.suggestedPostInfo
}
} else {
chatMessages[message.id] = message
}
}

fun updateMessage(
chatId: Long,
messageId: Long,
action: (TdApi.Message) -> Unit
): TdApi.Message? {
val message = messages[chatId]?.get(messageId) ?: return null
synchronized(message) {
action(message)
}
return message
}

override fun removeMessage(chatId: Long, messageId: Long) {
messages[chatId]?.remove(messageId)
}
Expand Down Expand Up @@ -274,6 +293,27 @@ class ChatCache : ChatsCacheDataSource, UserCacheDataSource {
chatMessages[newId] = message
}

fun isSupergroupTemporarilyMissing(supergroupId: Long): Boolean {
if (supergroupId == 0L) return false
val cooldownUntil = missingSupergroupCooldownUntil[supergroupId] ?: return false
if (cooldownUntil > System.currentTimeMillis()) {
return true
}
missingSupergroupCooldownUntil.remove(supergroupId, cooldownUntil)
return false
}

fun markSupergroupTemporarilyMissing(supergroupId: Long) {
if (supergroupId == 0L) return
missingSupergroupCooldownUntil[supergroupId] =
System.currentTimeMillis() + MISSING_SUPERGROUP_COOLDOWN_MS
}

fun clearTemporarilyMissingSupergroup(supergroupId: Long) {
if (supergroupId == 0L) return
missingSupergroupCooldownUntil.remove(supergroupId)
}

override fun clearAll() {
allChats.clear()
activeListPositions.clear()
Expand Down Expand Up @@ -305,6 +345,7 @@ class ChatCache : ChatsCacheDataSource, UserCacheDataSource {
pendingSecretChats.clear()
pendingChatPermissions.clear()
pendingMyChatMember.clear()
missingSupergroupCooldownUntil.clear()
}

fun putChatFromEntity(entity: org.monogram.data.db.model.ChatEntity) {
Expand Down Expand Up @@ -529,4 +570,8 @@ class ChatCache : ChatsCacheDataSource, UserCacheDataSource {

return parsed.takeIf { it.isNotEmpty() }?.toTypedArray()
}

private companion object {
private val MISSING_SUPERGROUP_COOLDOWN_MS = TimeUnit.MINUTES.toMillis(30)
}
}
81 changes: 57 additions & 24 deletions data/src/main/java/org/monogram/data/chats/ChatModelFactory.kt
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,9 @@ class ChatModelFactory(
chat: TdApi.Chat,
order: Long,
isPinned: Boolean,
allowMediaDownloads: Boolean = true
allowAvatarDownloads: Boolean = true,
allowMediaDownloads: Boolean = true,
allowRemoteLookups: Boolean = true
): ChatModel {
val cachedCounts = parseCachedCounts(chat.clientData)
var smallPhoto = chat.photo?.small
Expand Down Expand Up @@ -91,30 +93,34 @@ class ChatModelFactory(
isMember = it.status !is TdApi.ChatMemberStatusLeft
isAdmin = it.status is TdApi.ChatMemberStatusAdministrator ||
it.status is TdApi.ChatMemberStatusCreator
} ?: lazyLoad(cache.pendingBasicGroups, type.basicGroupId) {
} ?: if (allowRemoteLookups) lazyLoad(cache.pendingBasicGroups, type.basicGroupId) {
if (type.basicGroupId == 0L) return@lazyLoad
val result = gateway.execute(TdApi.GetBasicGroup(type.basicGroupId))
cache.basicGroups[result.id] = result
scheduleUpdate(chat.id)
}
} else Unit

cache.basicGroupFullInfoCache[type.basicGroupId]?.let { fullInfo ->
description = fullInfo.description
inviteLink = fullInfo.inviteLink?.inviteLink
personalAvatarPath = null
} ?: lazyLoad(cache.pendingBasicGroupFullInfo, type.basicGroupId) {
} ?: if (allowRemoteLookups) lazyLoad(
cache.pendingBasicGroupFullInfo,
type.basicGroupId
) {
if (type.basicGroupId == 0L) return@lazyLoad
val result = gateway.execute(TdApi.GetBasicGroupFullInfo(type.basicGroupId))
cache.basicGroupFullInfoCache[type.basicGroupId] = result
scheduleUpdate(chat.id)
}
} else Unit
}

is TdApi.ChatTypeSupergroup -> {
isSupergroup = true
isChannel = type.isChannel
val supergroup = cache.supergroups[type.supergroupId]
supergroup?.let {
cache.clearTemporarilyMissingSupergroup(type.supergroupId)
memberCount = it.memberCount
isVerified = (it.verificationStatus?.isVerified ?: false) || isForcedVerifiedChat(chat.id)
isScam = it.verificationStatus?.isScam ?: false
Expand All @@ -137,12 +143,21 @@ class ChatModelFactory(
hasAutomaticTranslation = it.hasAutomaticTranslation
signMessages = it.signMessages
joinToSendMessages = it.joinToSendMessages
} ?: lazyLoad(cache.pendingSupergroups, type.supergroupId) {
if (type.supergroupId == 0L) return@lazyLoad
val result = gateway.execute(TdApi.GetSupergroup(type.supergroupId))
cache.supergroups[result.id] = result
scheduleUpdate(chat.id)
}
?: if (allowRemoteLookups && !cache.isSupergroupTemporarilyMissing(type.supergroupId)) lazyLoad(
cache.pendingSupergroups,
type.supergroupId
) {
if (type.supergroupId == 0L) return@lazyLoad
coRunCatching {
val result = gateway.execute(TdApi.GetSupergroup(type.supergroupId))
cache.supergroups[result.id] = result
cache.clearTemporarilyMissingSupergroup(type.supergroupId)
scheduleUpdate(chat.id)
}.onFailure {
cache.markSupergroupTemporarilyMissing(type.supergroupId)
}
} else Unit

val canLoadSupergroupFullInfo = supergroup?.status?.let {
it !is TdApi.ChatMemberStatusLeft && it !is TdApi.ChatMemberStatusBanned
Expand All @@ -153,12 +168,22 @@ class ChatModelFactory(
description = fullInfo.description
inviteLink = fullInfo.inviteLink?.inviteLink
personalAvatarPath = null
} ?: lazyLoad(cache.pendingSupergroupFullInfo, type.supergroupId) {
if (type.supergroupId == 0L) return@lazyLoad
val result = gateway.execute(TdApi.GetSupergroupFullInfo(type.supergroupId))
cache.supergroupFullInfoCache[type.supergroupId] = result
scheduleUpdate(chat.id)
}
?: if (allowRemoteLookups && !cache.isSupergroupTemporarilyMissing(type.supergroupId)) lazyLoad(
cache.pendingSupergroupFullInfo,
type.supergroupId
) {
if (type.supergroupId == 0L) return@lazyLoad
coRunCatching {
val result =
gateway.execute(TdApi.GetSupergroupFullInfo(type.supergroupId))
cache.supergroupFullInfoCache[type.supergroupId] = result
cache.clearTemporarilyMissingSupergroup(type.supergroupId)
scheduleUpdate(chat.id)
}.onFailure {
cache.markSupergroupTemporarilyMissing(type.supergroupId)
}
} else Unit
}
}

Expand Down Expand Up @@ -188,16 +213,21 @@ class ChatModelFactory(
val hasStablePhotoIdentity =
(user.profilePhoto?.small?.id ?: 0) != 0 || (user.profilePhoto?.big?.id
?: 0) != 0
if (!hasStablePhotoIdentity) {
if (!hasStablePhotoIdentity && allowRemoteLookups) {
fetchUser(type.userId)
}
} ?: run {
if (allowRemoteLookups) {
fetchUser(type.userId)
}
} ?: run { fetchUser(type.userId) }
}

if (user != null) {
cache.userFullInfoCache[type.userId]?.let { fullInfo ->
description = fullInfo.bio?.text
personalAvatarPath = resolvePhotoPath(fullInfo.personalPhoto, chat.id, allowMediaDownloads)
} ?: run {
if (!allowRemoteLookups) return@run
if (!isUserFullInfoTemporarilyMissing(type.userId)) {
lazyLoad(cache.pendingUserFullInfo, type.userId) {
if (type.userId == 0L) return@lazyLoad
Expand Down Expand Up @@ -236,21 +266,24 @@ class ChatModelFactory(
else -> {}
}

if (cache.chatPermissionsCache[chat.id] == null) {
if (allowRemoteLookups && cache.chatPermissionsCache[chat.id] == null) {
lazyLoad(cache.pendingChatPermissions, chat.id) {
val result = gateway.execute(TdApi.GetChat(chat.id))
cache.chatPermissionsCache[chat.id] = result.permissions
scheduleUpdate(chat.id)
}
}

if (cache.myChatMemberCache[chat.id] == null) {
if (allowRemoteLookups && cache.myChatMemberCache[chat.id] == null) {
val canGetMember = when (val type = chat.type) {
is TdApi.ChatTypePrivate, is TdApi.ChatTypeBasicGroup -> true
is TdApi.ChatTypeSupergroup -> !type.isChannel ||
cache.supergroups[type.supergroupId]?.status.let {
it is TdApi.ChatMemberStatusAdministrator || it is TdApi.ChatMemberStatusCreator
}
is TdApi.ChatTypeSupergroup -> if (!type.isChannel) {
true
} else {
cache.supergroups[type.supergroupId]?.status.let {
it is TdApi.ChatMemberStatusAdministrator || it is TdApi.ChatMemberStatusCreator
}
}
else -> false
}
if (canGetMember) {
Expand All @@ -265,7 +298,7 @@ class ChatModelFactory(
}
}

val finalPath = resolvePhotoPath(smallPhoto, chat.id, allowMediaDownloads)
val finalPath = resolvePhotoPath(smallPhoto, chat.id, allowAvatarDownloads)

val emojiStatusId = (chat.emojiStatus?.type as? TdApi.EmojiStatusTypeCustomEmoji)?.customEmojiId ?: 0L
var emojiPath: String? = null
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,9 @@ class ChatPersistenceManager(
chat = chat,
order = position?.order ?: 0L,
isPinned = position?.isPinned ?: false,
allowMediaDownloads = false
allowAvatarDownloads = false,
allowMediaDownloads = false,
allowRemoteLookups = false
)
var entity = chatMapper.mapToEntity(chat, model)
if (position != null && (position.order != entity.order || position.isPinned != entity.isPinned)) {
Expand Down Expand Up @@ -104,7 +106,9 @@ class ChatPersistenceManager(
chat = chat,
order = position.order,
isPinned = position.isPinned,
allowMediaDownloads = false
allowAvatarDownloads = false,
allowMediaDownloads = false,
allowRemoteLookups = false
)
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
package org.monogram.data.datasource.remote

import android.util.Log
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import java.net.HttpURLConnection
import java.net.URI
import java.net.URLEncoder
import java.nio.charset.StandardCharsets

class GitHubRemoteDataSource {
private val json = Json {
ignoreUnknownKeys = true
coerceInputValues = true
isLenient = true
}

suspend fun getRecentCommits(
branchOrSha: String,
limit: Int
): Result<List<GitHubCommitResponse>> = withContext(Dispatchers.IO) {
var connection: HttpURLConnection? = null
try {
val encodedBranch = URLEncoder.encode(branchOrSha, StandardCharsets.UTF_8)
val url = URI("$BASE_URL/commits?sha=$encodedBranch&per_page=$limit").toURL()
connection = (url.openConnection() as HttpURLConnection).apply {
requestMethod = "GET"
connectTimeout = TIMEOUT_MS
readTimeout = TIMEOUT_MS
setRequestProperty("User-Agent", USER_AGENT)
setRequestProperty("Accept", "application/vnd.github+json")
setRequestProperty("X-GitHub-Api-Version", GITHUB_API_VERSION)
}

if (connection.responseCode != HttpURLConnection.HTTP_OK) {
val responseCode = connection.responseCode
Log.w(TAG, "GitHub commits request failed code=$responseCode ref=$branchOrSha")
return@withContext Result.failure(IllegalStateException("GitHub response code=$responseCode"))
}

val responseText = connection.inputStream.bufferedReader().use { it.readText() }
Result.success(json.decodeFromString<List<GitHubCommitResponse>>(responseText))
} catch (error: Exception) {
Log.w(TAG, "GitHub commits request failed ref=$branchOrSha", error)
Result.failure(error)
} finally {
connection?.disconnect()
}
}

@Serializable
data class GitHubCommitResponse(
val sha: String,
@SerialName("html_url")
val htmlUrl: String,
val commit: GitHubCommitDetails
)

@Serializable
data class GitHubCommitDetails(
val message: String,
val author: GitHubCommitAuthor? = null
)

@Serializable
data class GitHubCommitAuthor(
val name: String? = null,
val date: String? = null
)

private companion object {
private const val TAG = "GitHubRemote"
private const val BASE_URL = "https://api.github.com/repos/monogram-android/monogram"
private const val USER_AGENT = "MonoGram-Android-App/1.0"
private const val GITHUB_API_VERSION = "2026-03-10"
private const val TIMEOUT_MS = 15_000
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ interface SettingsRemoteDataSource {
scope: TdApi.NotificationSettingsScope,
compareSound: Boolean
): TdApi.Chats?
suspend fun getArchiveChatListSettings(): TdApi.ArchiveChatListSettings?
suspend fun setDefaultBackground(
background: TdApi.InputBackground?,
type: TdApi.BackgroundType?,
Expand All @@ -27,6 +28,7 @@ interface SettingsRemoteDataSource {
)
suspend fun setChatNotificationSettings(chatId: Long, settings: TdApi.ChatNotificationSettings)
suspend fun setOption(name: String, value: TdApi.OptionValue)
suspend fun setArchiveChatListSettings(settings: TdApi.ArchiveChatListSettings)

// Sessions
suspend fun terminateSession(sessionId: Long): Boolean
Expand Down
Loading