Merge branch 'master' into badges

This commit is contained in:
spaced4ndy
2026-09-14 17:13:58 +04:00
83 changed files with 3718 additions and 998 deletions
@@ -4245,6 +4245,13 @@ enum class MREmojiChar(val value: String) {
@SerialName("✅") Check("✅");
}
// set by the core when the file is above the size the sender's badge allows; badgeStatus is null when no proof was sent
@Serializable
data class FileProhibited(
val maxSize: Long,
val badgeStatus: BadgeStatus? = null
)
@Serializable
data class CIFile(
val fileId: Long,
@@ -4253,7 +4260,8 @@ data class CIFile(
val fileSource: CryptoFile? = null,
val fileStatus: CIFileStatus,
val fileProtocol: FileProtocol,
val fileExpires: Instant? = null
val fileExpires: Instant? = null,
val fileProhibited: FileProhibited? = null
) {
val expired: Boolean = fileExpires != null && fileExpires < Clock.System.now()
@@ -115,7 +115,8 @@ data class ComposeState(
val useLinkPreviews: Boolean,
val mentions: MentionedMembers = emptyMap(),
// the max file size the user may attach, raised by their active badge unless the chat is incognito; kept in sync on chat switch
val maxFileSize: Long = getMaxFileSize(FileProtocol.XFTP)
val maxFileSize: Long = getMaxFileSize(FileProtocol.XFTP),
val sendIncognito: Boolean = false
) {
constructor(editingItem: ChatItem, liveMessage: LiveMessage? = null, useLinkPreviews: Boolean): this(
ComposeMessage(
@@ -331,7 +332,7 @@ fun MutableState<ComposeState>.processPickedFile(uri: URI?, text: String?) {
} else if (fileSize != null) {
AlertManager.shared.showAlertMsg(
generalGetString(MR.strings.large_file),
String.format(generalGetString(MR.strings.maximum_supported_file_size), formatBytes(maxFileSize))
largeFileMessage(fileSize, value.sendIncognito, expiredBadgeReason(fileSize, chatModel.currentUser.value?.profile))
)
} else {
showWrongUriAlert()
@@ -360,7 +361,7 @@ suspend fun MutableState<ComposeState>.processPickedMedia(uris: List<URI>, text:
bitmap = null
AlertManager.shared.showAlertMsg(
generalGetString(MR.strings.large_file),
String.format(generalGetString(MR.strings.maximum_supported_file_size), formatBytes(maxFileSize))
largeFileMessage(fileSize ?: 0, value.sendIncognito, expiredBadgeReason(fileSize ?: 0, chatModel.currentUser.value?.profile))
)
null
}
@@ -1420,7 +1421,10 @@ fun ComposeView(
// keep the attach size limit in sync with the chat: the user's active badge raises it, but not in incognito chats where no badge is presented
LaunchedEffect(chat.chatInfo) {
val incognito = if (chat.chatInfo.profileChangeProhibited) chat.chatInfo.incognito else chatModel.controller.appPrefs.incognito.get()
composeState.value = composeState.value.copy(maxFileSize = getMaxFileSize(FileProtocol.XFTP, if (incognito) null else chatModel.currentUser.value?.profile))
composeState.value = composeState.value.copy(
maxFileSize = getMaxFileSize(FileProtocol.XFTP, if (incognito) null else chatModel.currentUser.value?.profile),
sendIncognito = incognito
)
}
if (appPlatform.isDesktop) {
// the same ComposeView is reused when switching chats, so `chat` captured by onDispose would be the chat opened first, not the current one
@@ -38,7 +38,6 @@ fun CIFileView(
showTimestamp: Boolean,
showMenu: MutableState<Boolean>,
smallView: Boolean = false,
senderProfile: LocalProfile?,
receiveFile: (Long) -> Unit
) {
val saveFileLauncher = rememberSaveFileLauncher(ciFile = file)
@@ -77,13 +76,11 @@ fun CIFileView(
if (file != null) {
when {
file.fileStatus is CIFileStatus.RcvInvitation || file.fileStatus is CIFileStatus.RcvAborted -> {
if (fileSizeValid(file, senderProfile)) {
receiveFile(file.fileId)
val prohibited = file.fileProhibited
if (prohibited != null) {
showProhibitedFileAlert(file, prohibited)
} else {
AlertManager.shared.showAlertMsg(
generalGetString(MR.strings.large_file),
String.format(generalGetString(MR.strings.contact_sent_large_file), formatBytes(getMaxFileSize(file.fileProtocol, senderProfile)))
)
receiveFile(file.fileId)
}
}
file.fileStatus is CIFileStatus.RcvAccepted ->
@@ -157,7 +154,7 @@ fun CIFileView(
is CIFileStatus.SndError -> fileIcon(innerIcon = painterResource(MR.images.ic_close))
is CIFileStatus.SndWarning -> fileIcon(innerIcon = painterResource(MR.images.ic_warning_filled))
is CIFileStatus.RcvInvitation ->
if (!fileSizeValid(file, senderProfile))
if (!fileSizeValid(file))
fileIcon(innerIcon = painterResource(MR.images.ic_priority_high), color = WarningOrange)
else if (file.expired)
fileIcon(innerIcon = painterResource(MR.images.ic_close))
@@ -239,9 +236,21 @@ fun CIFileView(
}
}
// whether a received file is within the size we accept from its sender
fun fileSizeValid(file: CIFile, senderProfile: LocalProfile?): Boolean =
file.fileSize <= getMaxFileSize(file.fileProtocol, senderProfile)
// the core decides whether a received file is above the size the sender's badge allows
fun fileSizeValid(file: CIFile): Boolean = file.fileProhibited == null
fun showProhibitedFileAlert(file: CIFile, prohibited: FileProhibited) {
val badgeIssue = when (prohibited.badgeStatus) {
null, BadgeStatus.Active -> ""
BadgeStatus.Expired, BadgeStatus.ExpiredOld -> generalGetString(MR.strings.badge_expired)
BadgeStatus.Failed -> generalGetString(MR.strings.badge_verification_failed)
BadgeStatus.UnknownKey -> generalGetString(MR.strings.badge_no_key)
}
AlertManager.shared.showAlertMsg(
generalGetString(MR.strings.large_file),
largeFileMessage(file.fileSize, badgeIssue = badgeIssue)
)
}
fun showFileErrorAlert(err: FileError, file: CIFile? = null, temporary: Boolean = false) {
val fileExpires = file?.fileExpires
@@ -38,7 +38,6 @@ fun CIImageView(
imageProvider: () -> ImageGalleryProvider,
showMenu: MutableState<Boolean>,
smallView: Boolean,
senderProfile: LocalProfile?,
receiveFile: (Long) -> Unit
) {
val blurred = remember { mutableStateOf(appPrefs.privacyMediaBlurRadius.get() > 0) }
@@ -84,7 +83,7 @@ fun CIImageView(
is CIFileStatus.SndError -> fileIcon(painterResource(MR.images.ic_close), MR.strings.icon_descr_file)
is CIFileStatus.SndWarning -> fileIcon(painterResource(MR.images.ic_warning_filled), MR.strings.icon_descr_file)
is CIFileStatus.RcvInvitation ->
if (file.expired && fileSizeValid(file, senderProfile))
if (file.expired && fileSizeValid(file))
fileIcon(painterResource(MR.images.ic_close), MR.strings.icon_descr_file)
else
fileIcon(painterResource(MR.images.ic_arrow_downward), MR.strings.icon_descr_asked_to_receive)
@@ -220,13 +219,10 @@ fun CIImageView(
if (file != null) {
when {
file.fileStatus is CIFileStatus.RcvInvitation || file.fileStatus is CIFileStatus.RcvAborted ->
if (fileSizeValid(file, senderProfile)) {
receiveFile(file.fileId)
if (file.fileProhibited != null) {
showProhibitedFileAlert(file, file.fileProhibited)
} else {
AlertManager.shared.showAlertMsg(
generalGetString(MR.strings.large_file),
String.format(generalGetString(MR.strings.contact_sent_large_file), formatBytes(getMaxFileSize(file.fileProtocol, senderProfile)))
)
receiveFile(file.fileId)
}
file.fileStatus is CIFileStatus.RcvAccepted ->
when (file.fileProtocol) {
@@ -35,7 +35,6 @@ fun CIVideoView(
imageProvider: () -> ImageGalleryProvider,
showMenu: MutableState<Boolean>,
smallView: Boolean = false,
senderProfile: LocalProfile?,
receiveFile: (Long) -> Unit
) {
val blurred = remember { mutableStateOf(appPrefs.privacyMediaBlurRadius.get() > 0) }
@@ -99,7 +98,7 @@ fun CIVideoView(
if (file != null) {
when (file.fileStatus) {
CIFileStatus.RcvInvitation, CIFileStatus.RcvAborted ->
receiveFileIfValidSize(file, senderProfile, receiveFile)
receiveFileIfValidSize(file, receiveFile)
CIFileStatus.RcvAccepted ->
when (file.fileProtocol) {
FileProtocol.XFTP ->
@@ -129,16 +128,16 @@ fun CIVideoView(
DurationProgress(file, remember { mutableStateOf(false) }, remember { mutableStateOf(duration * 1000L) }, remember { mutableStateOf(0L) }/*, soundEnabled*/)
}
if (showDownloadButton(file?.fileStatus) && !blurred.value && file != null) {
PlayButton(error = false, sizeMultiplier, { showMenu.value = true }) { receiveFileIfValidSize(file, senderProfile, receiveFile) }
PlayButton(error = false, sizeMultiplier, { showMenu.value = true }) { receiveFileIfValidSize(file, receiveFile) }
}
}
}
// Do not show download icon when the view is blurred
if (!smallView && (!showDownloadButton(file?.fileStatus) || !blurred.value)) {
fileStatusIcon(file, false, senderProfile)
fileStatusIcon(file, false)
} else if (smallView && file?.showStatusIconInSmallView == true) {
Box(Modifier.align(Alignment.Center)) {
fileStatusIcon(file, true, senderProfile)
fileStatusIcon(file, true)
}
}
}
@@ -486,7 +485,7 @@ private fun progressCircle(progress: Long, total: Long) {
}
@Composable
private fun fileStatusIcon(file: CIFile?, smallView: Boolean, senderProfile: LocalProfile?) {
private fun fileStatusIcon(file: CIFile?, smallView: Boolean) {
if (file != null) {
Box(
Modifier
@@ -526,7 +525,7 @@ private fun fileStatusIcon(file: CIFile?, smallView: Boolean, senderProfile: Loc
}
)
is CIFileStatus.RcvInvitation ->
if (file.expired && fileSizeValid(file, senderProfile))
if (file.expired && fileSizeValid(file))
fileIcon(painterResource(MR.images.ic_close), MR.strings.icon_descr_file)
else
fileIcon(painterResource(MR.images.ic_arrow_downward), MR.strings.icon_descr_video_asked_to_receive)
@@ -565,14 +564,11 @@ private fun fileStatusIcon(file: CIFile?, smallView: Boolean, senderProfile: Loc
private fun showDownloadButton(status: CIFileStatus?): Boolean =
status is CIFileStatus.RcvInvitation || status is CIFileStatus.RcvAborted
private fun receiveFileIfValidSize(file: CIFile, senderProfile: LocalProfile?, receiveFile: (Long) -> Unit) {
if (fileSizeValid(file, senderProfile)) {
receiveFile(file.fileId)
private fun receiveFileIfValidSize(file: CIFile, receiveFile: (Long) -> Unit) {
if (file.fileProhibited != null) {
showProhibitedFileAlert(file, file.fileProhibited)
} else {
AlertManager.shared.showAlertMsg(
generalGetString(MR.strings.large_file),
String.format(generalGetString(MR.strings.contact_sent_large_file), formatBytes(getMaxFileSize(file.fileProtocol, senderProfile)))
)
receiveFile(file.fileId)
}
}
@@ -449,7 +449,7 @@ fun ChatItemView(
}
if (cItem.file != null && (getLoadedFilePath(cItem.file) != null || (chatModel.connectedToRemote() && cachedRemoteReqs[cItem.file.fileSource] != false && cItem.file.loaded))) {
SaveContentItemAction(cItem, saveFileLauncher, showMenu)
} else if (cItem.file != null && cItem.file.fileStatus is CIFileStatus.RcvInvitation && fileSizeValid(cItem.file, ciSenderProfile(cItem, chat.chatInfo))) {
} else if (cItem.file != null && cItem.file.fileStatus is CIFileStatus.RcvInvitation && fileSizeValid(cItem.file)) {
ItemAction(stringResource(MR.strings.download_file), painterResource(MR.images.ic_arrow_downward), onClick = {
withBGApi {
Log.d(TAG, "ChatItemView downloadFileAction")
@@ -219,7 +219,7 @@ fun FramedItemView(
@Composable
fun ciFileView(ci: ChatItem, text: String) {
CIFileView(ci.file, ci.meta, chatTTL, showViaProxy, showTimestamp, showMenu, false, ciSenderProfile(ci, chatInfo), receiveFile)
CIFileView(ci.file, ci.meta, chatTTL, showViaProxy, showTimestamp, showMenu, false, receiveFile)
if (text != "" || ci.meta.isLive) {
CIMarkdownText(chatsCtx, ci, chat, chatTTL, linkMode = linkMode, uriHandler, showViaProxy = showViaProxy, showTimestamp = showTimestamp)
}
@@ -364,7 +364,7 @@ fun FramedItemView(
} else {
when (val mc = ci.content.msgContent) {
is MsgContent.MCImage -> {
CIImageView(image = mc.image, file = ci.file, imageProvider ?: return@PriorityLayout, showMenu, false, ciSenderProfile(ci, chatInfo), receiveFile)
CIImageView(image = mc.image, file = ci.file, imageProvider ?: return@PriorityLayout, showMenu, false, receiveFile)
if (mc.text == "" && !ci.meta.isLive) {
metaColor = Color.White
} else {
@@ -372,7 +372,7 @@ fun FramedItemView(
}
}
is MsgContent.MCVideo -> {
CIVideoView(image = mc.image, mc.duration, file = ci.file, imageProvider ?: return@PriorityLayout, showMenu, smallView = false, senderProfile = ciSenderProfile(ci, chatInfo), receiveFile = receiveFile)
CIVideoView(image = mc.image, mc.duration, file = ci.file, imageProvider ?: return@PriorityLayout, showMenu, smallView = false, receiveFile = receiveFile)
if (mc.text == "" && !ci.meta.isLive) {
metaColor = Color.White
} else {
@@ -324,13 +324,13 @@ fun ChatPreviewView(
}
}
is MsgContent.MCImage -> SmallContentPreview {
CIImageView(image = mc.image, file = ci.file, provider, remember { mutableStateOf(false) }, smallView = true, senderProfile = ciSenderProfile(ci, chat.chatInfo)) {
CIImageView(image = mc.image, file = ci.file, provider, remember { mutableStateOf(false) }, smallView = true) {
val user = chatModel.currentUser.value ?: return@CIImageView
withBGApi { chatModel.controller.receiveFile(chat.remoteHostId, user, it) }
}
}
is MsgContent.MCVideo -> SmallContentPreview {
CIVideoView(image = mc.image, mc.duration, file = ci.file, provider, remember { mutableStateOf(false) }, smallView = true, senderProfile = ciSenderProfile(ci, chat.chatInfo)) {
CIVideoView(image = mc.image, mc.duration, file = ci.file, provider, remember { mutableStateOf(false) }, smallView = true) {
val user = chatModel.currentUser.value ?: return@CIVideoView
withBGApi { chatModel.controller.receiveFile(chat.remoteHostId, user, it) }
}
@@ -342,7 +342,7 @@ fun ChatPreviewView(
}
}
is MsgContent.MCFile -> SmallContentPreviewFile {
CIFileView(ci.file, ci.meta, cInfo.timedMessagesTTL, showViaProxy = false, showTimestamp = true, showMenu = remember { mutableStateOf(false) }, smallView = true, senderProfile = ciSenderProfile(ci, chat.chatInfo)) {
CIFileView(ci.file, ci.meta, cInfo.timedMessagesTTL, showViaProxy = false, showTimestamp = true, showMenu = remember { mutableStateOf(false) }, smallView = true) {
val user = chatModel.currentUser.value ?: return@CIFileView
withBGApi { chatModel.controller.receiveFile(chat.remoteHostId, user, it) }
}
@@ -18,6 +18,7 @@ import com.charleskorn.kaml.decodeFromStream
import dev.icerock.moko.resources.StringResource
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
import kotlinx.datetime.Clock
import kotlinx.serialization.encodeToString
import java.io.*
import java.net.URI
@@ -27,6 +28,8 @@ import java.text.SimpleDateFormat
import java.util.*
import java.util.concurrent.Executors
import kotlin.math.*
import kotlin.time.Duration
import kotlin.time.Duration.Companion.days
private val singleThreadDispatcher = Executors.newSingleThreadExecutor().asCoroutineDispatcher()
@@ -123,10 +126,13 @@ const val MAX_FILE_SIZE_SMP: Long = 8000000
const val MAX_FILE_SIZE_XFTP: Long = 1_073_741_824 // 1GB
// raised XFTP receive limits for files from a sender with a supporter badge (also investor) or a legend badge
// raised XFTP limits for a user with a supporter badge (also investor) or a legend badge
const val MAX_FILE_SIZE_XFTP_SUPPORTER: Long = 2_147_483_648 // 2GB
const val MAX_FILE_SIZE_XFTP_LEGEND: Long = 5_368_709_120 // 5GB
// a badge raises the limit at send for this long after its expiry, shorter than the receiver's grace
val BADGE_SND_GRACE_INTERVAL: Duration = 1.days
const val MAX_FILE_SIZE_LOCAL: Long = Long.MAX_VALUE
expect fun getAppFileUri(fileName: String): URI
@@ -474,25 +480,46 @@ fun directoryFileCountAndSize(dir: String): Pair<Int, Long> { // count, size in
return fileCount to bytes
}
fun badgeMaxFileSize(badge: LocalBadge): Long =
if (badge.badge.badgeType == BadgeType.Legend) MAX_FILE_SIZE_XFTP_LEGEND else MAX_FILE_SIZE_XFTP_SUPPORTER
// a badge raises the limit at send until one day past its expiry, as the core applies it
fun badgeActiveForSend(badge: LocalBadge): Boolean =
badge.status == BadgeStatus.Active && badge.badge.badgeExpiry + BADGE_SND_GRACE_INTERVAL >= Clock.System.now()
// in incognito chats and above the largest badge's limit no badge applies, so badgeIssue is not used
fun largeFileMessage(fileSize: Long, incognito: Boolean = false, badgeIssue: String = ""): String =
if (incognito) {
String.format(generalGetString(MR.strings.large_file_incognito), formatBytes(MAX_FILE_SIZE_XFTP))
} else if (fileSize > MAX_FILE_SIZE_XFTP_LEGEND) {
String.format(generalGetString(MR.strings.max_file_size_with_badge), formatBytes(MAX_FILE_SIZE_XFTP_LEGEND), generalGetString(MR.strings.legend_badge))
} else {
val supporter = fileSize <= MAX_FILE_SIZE_XFTP_SUPPORTER
val message = String.format(
generalGetString(MR.strings.large_file_requires_badge),
generalGetString(if (supporter) MR.strings.supporter_badge else MR.strings.legend_badge),
formatBytes(if (supporter) MAX_FILE_SIZE_XFTP else MAX_FILE_SIZE_XFTP_SUPPORTER)
)
if (badgeIssue.isEmpty()) message else message + " " + badgeIssue
}
// the badge lapsed, and while active it would have allowed this file
fun expiredBadgeReason(fileSize: Long, senderProfile: LocalProfile?): String {
val badge = senderProfile?.localBadge
return if (badge != null && !badgeActiveForSend(badge) && badgeMaxFileSize(badge) >= fileSize) {
generalGetString(MR.strings.your_badge_expired)
} else ""
}
fun getMaxFileSize(fileProtocol: FileProtocol, senderProfile: LocalProfile? = null): Long = when (fileProtocol) {
FileProtocol.SMP -> MAX_FILE_SIZE_SMP
FileProtocol.LOCAL -> MAX_FILE_SIZE_LOCAL
// a sender's active badge raises the XFTP limit: legend to 5GB, any other (supporter/investor) to 2GB
FileProtocol.XFTP -> {
val badge = senderProfile?.localBadge
if (badge == null || badge.status != BadgeStatus.Active) MAX_FILE_SIZE_XFTP
else if (badge.badge.badgeType == BadgeType.Legend) MAX_FILE_SIZE_XFTP_LEGEND
else MAX_FILE_SIZE_XFTP_SUPPORTER
if (badge != null && badgeActiveForSend(badge)) badgeMaxFileSize(badge) else MAX_FILE_SIZE_XFTP
}
}
// the profile of whoever sent a received chat item - the group member, or the direct chat's contact
fun ciSenderProfile(ci: ChatItem, chatInfo: ChatInfo): LocalProfile? = when (val dir = ci.chatDir) {
is CIDirection.GroupRcv -> dir.groupMember.memberProfile
is CIDirection.DirectRcv -> (chatInfo as? ChatInfo.Direct)?.contact?.profile
else -> null
}
expect suspend fun getBitmapFromVideo(uri: URI, timestamp: Long? = null, random: Boolean = true, withAlertOnException: Boolean = true): VideoPlayerInterface.PreviewAndDuration
// Whether the file really contains a video track. Reads container metadata only, without decoding a frame.
@@ -687,6 +687,15 @@
<string name="icon_descr_file">File</string>
<string name="large_file">Large file!</string>
<string name="contact_sent_large_file">Your contact sent a file that is larger than currently supported maximum size (%1$s).</string>
<string name="supporter_badge">supporter badge</string>
<string name="legend_badge">legend badge</string>
<string name="large_file_requires_badge">You need a %1$s to send files larger than %2$s.</string>
<string name="max_file_size_with_badge">Maximum supported file size is %1$s, with a %2$s.</string>
<string name="badge_expired">Contact\'s badge expired.</string>
<string name="badge_verification_failed">Contact\'s badge verification failed.</string>
<string name="badge_no_key">No key to verify contact\'s badge.</string>
<string name="your_badge_expired">Your badge expired.</string>
<string name="large_file_incognito">Files larger than %1$s cannot be sent in incognito chats.</string>
<string name="maximum_supported_file_size">Currently maximum supported file size is %1$s.</string>
<string name="waiting_for_file">Waiting for file</string>
<string name="file_will_be_received_when_contact_completes_uploading">File will be received when your contact completes uploading it.</string>