Add emoji reacts support (#889)

* feat: Add emoji reacts support

* Remove message multi-selection

* Add emoji reaction model

* Add emoji reaction panel

* Blur reacts panel background

* Show emoji keyboard

* Add emoji sprites

* Update reaction proto

* Emoji database updates

* Emoji database refactor

* Emoji reaction persistence

* Optimize reactions retrieval

* Fix emoji group query

* Display emojis

* Fix emoji persistence

* Cleanup

* Persistence refactor

* Add reactions bottom sheet

* Cleanup

* Ui tweaks

* React with any emoji

* Show emoji react notifications

* Remove reaction

* Show reactions modal on long press

* Click to react (+1) with an emoji

* Click to react with an emoji

* Enable emoji expand/collapse

* fix: some compile issues from merge conflicts

* fix: compile issues merging quote and media message UI

* fix: xml IDs and adding in legacy is selected for future inclusion

* Fix view constraints

* Fix merge issue

* Add message selection option in conversation context menu

* Add sogs emoji integration

* Handle sogs emoji reactions

* Enable sending/deleting sogs emojis

* fix: improve the visible message layout

* fix: add file IDs to request parameters for message send (#940)

* Fix open group polling from seqno instead of last hash (#939)

* fix: reset seqno to get recent messages from open groups

* build: upgrade build numbers

* fix: actually run the migration

* Using StringBuilder to construct request url

* Fix reaction filter

* fix: is_mms added in second projection query

* Update default emojis

* fix: include legacy and new open groups in server ID tracking (#941)

* feat: add hidden moderator and admin roles, separated as they may be used independently in future (#942)

* Cleanup

* Fix view constraints

* Add reactions capability check

* Fix reactions alignment

* Ui fixes

* Display reactions list

* feat: add formatted count strings

* fix: account for negatives and add tests

* Migrate old official open group locations for polling and adding (#932)

* feat: adding in first part of open group migrations and tests for migration logic / helpers

* feat: test code and migration logic for open groups in the case of no conflicts

* feat: add in extra test cases and refactor code for migrator

* refactor: migrate open group join URLs and references to server in adding new open groups to catch legacy and re-write it

* refactor: joining open groups using OpenGroupUrlParser.kt now

* fix: add in compile issues for renamed OpenGroupApi.kt from OpenGroupV2

* fix: prevent duplicates of http/https for new open group DNS and prevent adding new groups based on public key

* fix: room and server swapped parameters

* fix: replace default server for config messages

* fix: actually using public key to de-dupe didn't work for rooms

* build: bump version code and name

* Display reactions list on open groups for moderators

* Ui tweaks

* Ui tweaks for moderation

* Refactor

* fix: compile issue

* fix: de-duping joined queries in the get X from cursor

* Restore import

* fix: colouring the reaction overlay scrubber

* fix: highlight colour, show reaction count if 1 or above

* Cleanup

* fix: light mode accent

* fix: light / dark mode themeing in reactions dialog fragment

* Emoji notification blinded id check

* fix: show reaction list correctly and pass isUserModerator to bind methods

* fix: remove unnecessary places for the moderator

* fix: X button for removing own react not showing up properly

* feat: add clear all header view

* fix: migrate the clear all to the correct location

* fix: use display instead of base

* Truncate emoji sender ids

* feat: add notify thread function in thread db

* Notify threads on reaction received

* fix: design fixes for the reaction list

* fix: emoji reactions bottom sheet dialog UI designs

* feat: add unsupported emoji reaction

* fix: crash and doing vector properly

* Fix reaction database queries

* Fix background open group adder job

* Show new open group reactions

* Fetch a maximum of 5 reactors

* Handle open group reactions polling conflicts

* Add count to user reaction

* Show number of additional reactors

* fix: unreads set same as the unread query

* fix: design changes

* fix: update dependency to improve flexboxlayout behaviour, design consistencies

* Add select message icon and update long press menu items order and wording

* Fix crash on reactors dialog

* fix: colours and backgrounds to match designs

* fix: add header in recipient item

* fix: margins

* fix: alignments and layout issues for emoji reactions view

* feat: add overflow previews and logic for overflow

* Dim action bar

* Add emoji search

* Search index fix

* Set count for 1:1 and closed group reactions when inserting in local database

* Use on screen toolbar to allow overlaying

* Show/hide scroll to bottom button

* feat: add extended properties so it doesn't collapse on re-bind

* Cleanup

* feat: prevent keeping extended on rebinding if we get a new message ID

* fix: long press works on devices now, fix release lint issue and crash for emoji search DBs from emoji builds

* Display message timestamp

* Fix modal items alignment

* fix: sort order and emoji count in compareTo

* Scale down really large messages to fit

* Prevent closed group crash

* Fix reaction author

Co-authored-by: charles <charles@oxen.io>
Co-authored-by: jubb <hjubb@users.noreply.github.com>
This commit is contained in:
ceokot
2022-09-04 21:03:32 +10:00
committed by GitHub
parent 2bfc8215d4
commit 16ca97d2d3
230 changed files with 11280 additions and 1004 deletions

View File

@@ -33,7 +33,7 @@ interface MessageDataProvider {
fun insertAttachment(messageId: Long, attachmentId: AttachmentId, stream : InputStream)
fun updateAudioAttachmentDuration(attachmentId: AttachmentId, durationMs: Long, threadId: Long)
fun isMmsOutgoing(mmsMessageId: Long): Boolean
fun isOutgoingMessage(mmsId: Long): Boolean
fun isOutgoingMessage(timestamp: Long): Boolean
fun handleSuccessfulAttachmentUpload(attachmentId: Long, attachmentStream: SignalServiceAttachmentStream, attachmentKey: ByteArray, uploadResult: UploadResult)
fun handleFailedAttachmentUpload(attachmentId: Long)
fun getMessageForQuote(timestamp: Long, author: Address): Pair<Long, Boolean>?

View File

@@ -7,9 +7,11 @@ import org.session.libsession.messaging.contacts.Contact
import org.session.libsession.messaging.jobs.AttachmentUploadJob
import org.session.libsession.messaging.jobs.Job
import org.session.libsession.messaging.jobs.MessageSendJob
import org.session.libsession.messaging.messages.Message
import org.session.libsession.messaging.messages.control.ConfigurationMessage
import org.session.libsession.messaging.messages.control.MessageRequestResponse
import org.session.libsession.messaging.messages.visible.Attachment
import org.session.libsession.messaging.messages.visible.Reaction
import org.session.libsession.messaging.messages.visible.VisibleMessage
import org.session.libsession.messaging.open_groups.OpenGroup
import org.session.libsession.messaging.sending_receiving.attachments.AttachmentId
@@ -65,7 +67,7 @@ interface StorageProtocol {
fun updateOpenGroup(openGroup: OpenGroup)
fun getOpenGroup(threadId: Long): OpenGroup?
fun addOpenGroup(urlAsString: String)
fun onOpenGroupAdded(urlAsString: String)
fun onOpenGroupAdded(server: String)
fun hasBackgroundGroupAddJob(groupJoinUrl: String): Boolean
fun setOpenGroupServerMessageID(messageID: Long, serverID: Long, threadID: Long, isSms: Boolean)
fun getOpenGroup(room: String, server: String): OpenGroup?
@@ -188,4 +190,8 @@ interface StorageProtocol {
fun removeLastOutboxMessageId(server: String)
fun getOrCreateBlindedIdMapping(blindedId: String, server: String, serverPublicKey: String, fromOutbox: Boolean = false): BlindedIdMapping
fun addReaction(reaction: Reaction)
fun removeReaction(emoji: String, messageTimestamp: Long, author: String)
fun updateReactionIfNeeded(message: Message, sender: String, openGroupSentTimestamp: Long)
fun deleteReactions(messageId: Long, mms: Boolean)
}

View File

@@ -6,6 +6,7 @@ import org.session.libsession.messaging.open_groups.OpenGroup
import org.session.libsession.messaging.open_groups.OpenGroupApi
import org.session.libsession.messaging.utilities.Data
import org.session.libsession.utilities.GroupUtil
import org.session.libsession.utilities.OpenGroupUrlParser
import org.session.libsignal.utilities.Log
class BackgroundGroupAddJob(val joinUrl: String): Job {
@@ -30,31 +31,28 @@ class BackgroundGroupAddJob(val joinUrl: String): Job {
override fun execute() {
try {
val openGroup = OpenGroupUrlParser.parseUrl(joinUrl)
val storage = MessagingModuleConfiguration.shared.storage
val allOpenGroups = storage.getAllOpenGroups().map { it.value.joinURL }
if (allOpenGroups.contains(joinUrl)) {
if (allOpenGroups.contains(openGroup.joinUrl())) {
Log.e("OpenGroupDispatcher", "Failed to add group because", DuplicateGroupException())
delegate?.handleJobFailed(this, DuplicateGroupException())
return
}
// get image
val url = HttpUrl.parse(joinUrl) ?: throw Exception("Group joinUrl isn't valid")
val server = OpenGroup.getServer(joinUrl)
val serverString = server.toString().removeSuffix("/")
val publicKey = url.queryParameter("public_key") ?: throw Exception("Group public key isn't valid")
val room = url.pathSegments().firstOrNull() ?: throw Exception("Group room isn't valid")
storage.setOpenGroupPublicKey(serverString, publicKey)
// get info and auth token
storage.addOpenGroup(joinUrl)
val info = OpenGroupApi.getRoomInfo(room, serverString).get()
storage.setOpenGroupPublicKey(openGroup.server, openGroup.serverPublicKey)
val (capabilities, info) = OpenGroupApi.getCapabilitiesAndRoomInfo(openGroup.room, openGroup.server, false).get()
storage.setServerCapabilities(openGroup.server, capabilities.capabilities)
val imageId = info.imageId
storage.addOpenGroup(openGroup.joinUrl())
if (imageId != null) {
val bytes = OpenGroupApi.downloadOpenGroupProfilePicture(serverString, room, imageId).get()
val groupId = GroupUtil.getEncodedOpenGroupID("$server.$room".toByteArray())
val bytes = OpenGroupApi.downloadOpenGroupProfilePicture(openGroup.server, openGroup.room, imageId).get()
val groupId = GroupUtil.getEncodedOpenGroupID("${openGroup.server}.${openGroup.room}".toByteArray())
storage.updateProfilePicture(groupId, bytes)
storage.updateTimestampUpdated(groupId, System.currentTimeMillis())
}
storage.onOpenGroupAdded(joinUrl)
Log.d(KEY, "onOpenGroupAdded(${openGroup.server})")
storage.onOpenGroupAdded(openGroup.server)
} catch (e: Exception) {
Log.e("OpenGroupDispatcher", "Failed to add group because",e)
delegate?.handleJobFailed(this, e)

View File

@@ -13,8 +13,10 @@ import org.session.libsession.messaging.messages.Message
import org.session.libsession.messaging.messages.control.ExpirationTimerUpdate
import org.session.libsession.messaging.messages.visible.ParsedMessage
import org.session.libsession.messaging.messages.visible.VisibleMessage
import org.session.libsession.messaging.open_groups.OpenGroupApi
import org.session.libsession.messaging.sending_receiving.MessageReceiver
import org.session.libsession.messaging.sending_receiving.handle
import org.session.libsession.messaging.sending_receiving.handleOpenGroupReactions
import org.session.libsession.messaging.sending_receiving.handleVisibleMessage
import org.session.libsession.messaging.utilities.Data
import org.session.libsession.messaging.utilities.SessionId
@@ -27,7 +29,8 @@ import org.session.libsignal.utilities.Log
data class MessageReceiveParameters(
val data: ByteArray,
val serverHash: String? = null,
val openGroupMessageServerID: Long? = null
val openGroupMessageServerID: Long? = null,
val reactions: Map<String, OpenGroupApi.Reaction>? = null
)
class BatchMessageReceiveJob(
@@ -114,11 +117,14 @@ class BatchMessageReceiveJob(
runThreadUpdate = false,
runProfileUpdate = true
)
if (messageId != null) {
if (messageId != null && message.reaction == null) {
val isUserBlindedSender = message.sender == serverPublicKey?.let { SodiumUtilities.blindedKeyPair(it, MessagingModuleConfiguration.shared.getUserED25519KeyPair()!!) }?.let { SessionId(
IdPrefix.BLINDED, it.publicKey.asBytes).hexString }
messageIds += messageId to (message.sender == localUserPublicKey || isUserBlindedSender)
}
parameters.openGroupMessageServerID?.let {
MessageReceiver.handleOpenGroupReactions(threadId, it, parameters.reactions)
}
} else {
MessageReceiver.handle(message, proto, openGroupID)
}

View File

@@ -44,7 +44,7 @@ class MessageSendJob(val message: Message, val destination: Destination) : Job {
}
if (message != null) {
if (!messageDataProvider.isOutgoingMessage(message.sentTimestamp!!)) return // The message has been deleted
if (!messageDataProvider.isOutgoingMessage(message.sentTimestamp!!) && message.reaction == null) return // The message has been deleted
val attachmentIDs = mutableListOf<Long>()
attachmentIDs.addAll(message.attachmentIDs)
message.quote?.let { it.attachmentID?.let { attachmentID -> attachmentIDs.add(attachmentID) } }

View File

@@ -0,0 +1,72 @@
package org.session.libsession.messaging.messages.visible
import org.session.libsignal.protos.SignalServiceProtos
import org.session.libsignal.protos.SignalServiceProtos.DataMessage.Reaction.Action
import org.session.libsignal.utilities.Log
class Reaction() {
var timestamp: Long? = 0
var localId: Long? = 0
var isMms: Boolean? = false
var publicKey: String? = null
var emoji: String? = null
var react: Boolean? = true
var serverId: String? = null
var count: Long? = 0
var index: Long? = 0
var dateSent: Long? = 0
var dateReceived: Long? = 0
fun isValid(): Boolean {
return (timestamp != null && publicKey != null)
}
companion object {
const val TAG = "Quote"
fun fromProto(proto: SignalServiceProtos.DataMessage.Reaction): Reaction? {
val react = proto.action == Action.REACT
return Reaction(publicKey = proto.author, emoji = proto.emoji, react = react, timestamp = proto.id, count = 1)
}
fun from(timestamp: Long, author: String, emoji: String, react: Boolean): Reaction? {
return Reaction(author, emoji, react, timestamp)
}
}
internal constructor(publicKey: String, emoji: String, react: Boolean, timestamp: Long? = 0, localId: Long? = 0, isMms: Boolean? = false, serverId: String? = null, count: Long? = 0, index: Long? = 0) : this() {
this.timestamp = timestamp
this.publicKey = publicKey
this.emoji = emoji
this.react = react
this.serverId = serverId
this.localId = localId
this.isMms = isMms
this.count = count
this.index = index
}
fun toProto(): SignalServiceProtos.DataMessage.Reaction? {
val timestamp = timestamp
val publicKey = publicKey
val emoji = emoji
val react = react ?: true
if (timestamp == null || publicKey == null || emoji == null) {
Log.w(TAG, "Couldn't construct reaction proto from: $this")
return null
}
val reactionProto = SignalServiceProtos.DataMessage.Reaction.newBuilder()
reactionProto.id = timestamp
reactionProto.author = publicKey
reactionProto.emoji = emoji
reactionProto.action = if (react) Action.REACT else Action.REMOVE
// Build
return try {
reactionProto.build()
} catch (e: Exception) {
Log.w(TAG, "Couldn't construct reaction proto from: $this")
null
}
}
}

View File

@@ -23,6 +23,7 @@ class VisibleMessage : Message() {
var linkPreview: LinkPreview? = null
var profile: Profile? = null
var openGroupInvitation: OpenGroupInvitation? = null
var reaction: Reaction? = null
override val isSelfSendValid: Boolean = true
@@ -31,9 +32,9 @@ class VisibleMessage : Message() {
if (!super.isValid()) return false
if (attachmentIDs.isNotEmpty()) return true
if (openGroupInvitation != null) return true
if (reaction != null) return true
val text = text?.trim() ?: return false
if (text.isNotEmpty()) return true
return false
return text.isNotEmpty()
}
// endregion
@@ -65,6 +66,11 @@ class VisibleMessage : Message() {
// TODO Contact
val profile = Profile.fromProto(dataMessage)
if (profile != null) { result.profile = profile }
val reactionProto = if (dataMessage.hasReaction()) dataMessage.reaction else null
if (reactionProto != null) {
val reaction = Reaction.fromProto(reactionProto)
result.reaction = reaction
}
return result
}
}
@@ -74,10 +80,10 @@ class VisibleMessage : Message() {
val dataMessage: SignalServiceProtos.DataMessage.Builder
// Profile
val profileProto = profile?.toProto()
if (profileProto != null) {
dataMessage = profileProto.toBuilder()
dataMessage = if (profileProto != null) {
profileProto.toBuilder()
} else {
dataMessage = SignalServiceProtos.DataMessage.newBuilder()
SignalServiceProtos.DataMessage.newBuilder()
}
// Text
if (text != null) { dataMessage.body = text }
@@ -86,6 +92,11 @@ class VisibleMessage : Message() {
if (quoteProto != null) {
dataMessage.quote = quoteProto
}
// Reaction
val reactionProto = reaction?.toProto()
if (reactionProto != null) {
dataMessage.reaction = reactionProto
}
// Link preview
val linkPreviewProto = linkPreview?.toProto()
if (linkPreviewProto != null) {
@@ -132,12 +143,12 @@ class VisibleMessage : Message() {
dataMessage.syncTarget = syncTarget
}
// Build
try {
return try {
proto.dataMessage = dataMessage.build()
return proto.build()
proto.build()
} catch (e: Exception) {
Log.w(TAG, "Couldn't construct visible message proto from: $this")
return null
null
}
}
// endregion
@@ -151,6 +162,6 @@ class VisibleMessage : Message() {
}
fun isMediaMessage(): Boolean {
return attachmentIDs.isNotEmpty() || quote != null || linkPreview != null
return attachmentIDs.isNotEmpty() || quote != null || linkPreview != null || reaction != null
}
}

View File

@@ -33,6 +33,15 @@ sealed class Endpoint(val value: String) {
data class RoomDeleteMessages(val roomToken: String, val sessionId: String) :
Endpoint("room/$roomToken/all/$sessionId")
data class Reactors(val roomToken: String, val messageId: Long, val emoji: String):
Endpoint("room/$roomToken/reactors/$messageId/$emoji")
data class Reaction(val roomToken: String, val messageId: Long, val emoji: String):
Endpoint("room/$roomToken/reaction/$messageId/$emoji")
data class ReactionDelete(val roomToken: String, val messageId: Long, val emoji: String):
Endpoint("room/$roomToken/reactions/$messageId/$emoji")
// Pinning
data class RoomPinMessage(val roomToken: String, val messageId: Long) :

View File

@@ -7,5 +7,5 @@ data class GroupMember(
)
enum class GroupMemberRole {
STANDARD, ZOOMBIE, MODERATOR, ADMIN
STANDARD, ZOOMBIE, MODERATOR, ADMIN, HIDDEN_MODERATOR, HIDDEN_ADMIN
}

View File

@@ -55,9 +55,16 @@ object OpenGroupApi {
now - lastOpenDate
}
const val defaultServerPublicKey =
"a03c383cf63c3c4efe67acc52112a6dd734b3a946b9545f488aaa93da7991238"
const val defaultServer = "http://116.203.70.33"
const val defaultServerPublicKey = "a03c383cf63c3c4efe67acc52112a6dd734b3a946b9545f488aaa93da7991238"
const val legacyServerIP = "116.203.70.33"
const val legacyDefaultServer = "http://116.203.70.33" // TODO: migrate all references to use new value
/** For migration purposes only, don't use this value in joining groups */
const val httpDefaultServer = "http://open.getsession.org"
const val defaultServer = "https://open.getsession.org"
val pendingReactions = mutableListOf<PendingReaction>()
sealed class Error(message: String) : Exception(message) {
object Generic : Error("An error occurred.")
@@ -114,6 +121,7 @@ object OpenGroupApi {
data class BatchRequestInfo<T>(
val request: BatchRequest,
val endpoint: Endpoint,
val queryParameters: Map<String, String> = mapOf(),
val responseType: TypeReference<T>
)
@@ -139,6 +147,10 @@ object OpenGroupApi {
val missing: List<String> = emptyList()
)
enum class Capability {
BLIND, REACTIONS
}
@JsonNaming(PropertyNamingStrategy.SnakeCaseStrategy::class)
data class RoomPollInfo(
val token: String = "",
@@ -179,7 +191,39 @@ object OpenGroupApi {
val whisperMods: String = "",
val whisperTo: String = "",
val data: String? = null,
val signature: String? = null
val signature: String? = null,
val reactions: Map<String, Reaction>? = null,
)
data class Reaction(
val count: Long = 0,
val reactors: List<String> = emptyList(),
val you: Boolean = false,
val index: Long = 0
)
data class AddReactionResponse(
val seqNo: Long,
val added: Boolean
)
data class DeleteReactionResponse(
val seqNo: Long,
val removed: Boolean
)
data class DeleteAllReactionsResponse(
val seqNo: Long,
val removed: Boolean
)
data class PendingReaction(
val server: String,
val room: String,
val messageId: Long,
val emoji: String,
val add: Boolean,
var seqNo: Long? = null
)
@JsonNaming(PropertyNamingStrategy.SnakeCaseStrategy::class)
@@ -240,15 +284,12 @@ object OpenGroupApi {
}
private fun send(request: Request): Promise<OnionResponse, Exception> {
val url = HttpUrl.parse(request.server) ?: return Promise.ofFail(Error.InvalidURL)
val urlBuilder = HttpUrl.Builder()
.scheme(url.scheme())
.host(url.host())
.port(url.port())
.addPathSegments(request.endpoint.value)
if (request.verb == GET) {
HttpUrl.parse(request.server) ?: return Promise.ofFail(Error.InvalidURL)
val urlBuilder = StringBuilder("${request.server}/${request.endpoint.value}")
if (request.verb == GET && request.queryParameters.isNotEmpty()) {
urlBuilder.append("?")
for ((key, value) in request.queryParameters) {
urlBuilder.addQueryParameter(key, value)
urlBuilder.append("$key=$value")
}
}
fun execute(): Promise<OnionResponse, Exception> {
@@ -258,7 +299,7 @@ object OpenGroupApi {
?: return Promise.ofFail(Error.NoPublicKey)
val ed25519KeyPair = MessagingModuleConfiguration.shared.getUserED25519KeyPair()
?: return Promise.ofFail(Error.NoEd25519KeyPair)
val urlRequest = urlBuilder.build()
val urlRequest = urlBuilder.toString()
val headers = request.headers.toMutableMap()
if (request.isAuthRequired) {
val nonce = sodium.nonce(16)
@@ -294,9 +335,9 @@ object OpenGroupApi {
.plus(nonce)
.plus("$timestamp".toByteArray(Charsets.US_ASCII))
.plus(request.verb.rawValue.toByteArray())
.plus(urlRequest.encodedPath().toByteArray())
.plus("/${request.endpoint.value}".toByteArray())
.plus(bodyHash)
if (serverCapabilities.contains("blind")) {
if (serverCapabilities.contains(Capability.BLIND.name.lowercase())) {
SodiumUtilities.blindedKeyPair(publicKey, ed25519KeyPair)?.let { keyPair ->
pubKey = SessionId(
IdPrefix.BLINDED,
@@ -404,12 +445,19 @@ object OpenGroupApi {
fileIds: List<String>? = null
): Promise<OpenGroupMessage, Exception> {
val signedMessage = message.sign(room, server, fallbackSigningType = IdPrefix.STANDARD) ?: return Promise.ofFail(Error.SigningFailed)
val parameters = signedMessage.toJSON().toMutableMap()
// add file IDs if there are any (from attachments)
if (!fileIds.isNullOrEmpty()) {
parameters += "files" to fileIds
}
val request = Request(
verb = POST,
room = room,
server = server,
endpoint = Endpoint.RoomMessage(room),
parameters = signedMessage.toJSON()
parameters = parameters
)
return getResponseBodyJson(request).map { json ->
@Suppress("UNCHECKED_CAST") val rawMessage = json as? Map<String, Any>
@@ -470,6 +518,63 @@ object OpenGroupApi {
}
return messages
}
fun getReactors(room: String, server: String, messageId: Long, emoji: String): Promise<Map<*, *>, Exception> {
val request = Request(
verb = GET,
room = room,
server = server,
endpoint = Endpoint.Reactors(room, messageId, emoji)
)
return getResponseBody(request).map { response ->
JsonUtil.fromJson(response, Map::class.java)
}
}
fun addReaction(room: String, server: String, messageId: Long, emoji: String): Promise<AddReactionResponse, Exception> {
val request = Request(
verb = PUT,
room = room,
server = server,
endpoint = Endpoint.Reaction(room, messageId, emoji),
parameters = emptyMap<String, String>()
)
val pendingReaction = PendingReaction(server, room, messageId, emoji, true)
return getResponseBody(request).map { response ->
JsonUtil.fromJson(response, AddReactionResponse::class.java).also {
val index = pendingReactions.indexOf(pendingReaction)
pendingReactions[index].seqNo = it.seqNo
}
}
}
fun deleteReaction(room: String, server: String, messageId: Long, emoji: String): Promise<DeleteReactionResponse, Exception> {
val request = Request(
verb = DELETE,
room = room,
server = server,
endpoint = Endpoint.Reaction(room, messageId, emoji)
)
val pendingReaction = PendingReaction(server, room, messageId, emoji, true)
return getResponseBody(request).map { response ->
JsonUtil.fromJson(response, DeleteReactionResponse::class.java).also {
val index = pendingReactions.indexOf(pendingReaction)
pendingReactions[index].seqNo = it.seqNo
}
}
}
fun deleteAllReactions(room: String, server: String, messageId: Long, emoji: String): Promise<DeleteAllReactionsResponse, Exception> {
val request = Request(
verb = DELETE,
room = room,
server = server,
endpoint = Endpoint.ReactionDelete(room, messageId, emoji)
)
return getResponseBody(request).map { response ->
JsonUtil.fromJson(response, DeleteAllReactionsResponse::class.java)
}
}
// endregion
// region Message Deletion
@@ -608,7 +713,7 @@ object OpenGroupApi {
BatchRequestInfo(
request = BatchRequest(
method = GET,
path = "/room/$room/messages/recent"
path = "/room/$room/messages/recent?t=r&reactors=5"
),
endpoint = Endpoint.RoomMessagesRecent(room),
responseType = object : TypeReference<List<Message>>(){}
@@ -617,7 +722,7 @@ object OpenGroupApi {
BatchRequestInfo(
request = BatchRequest(
method = GET,
path = "/room/$room/messages/since/$lastMessageServerId"
path = "/room/$room/messages/since/$lastMessageServerId?t=r&reactors=5"
),
endpoint = Endpoint.RoomMessagesSince(room, lastMessageServerId),
responseType = object : TypeReference<List<Message>>(){}
@@ -626,7 +731,7 @@ object OpenGroupApi {
)
}
val serverCapabilities = storage.getServerCapabilities(server)
if (serverCapabilities.contains("blind")) {
if (serverCapabilities.contains(Capability.BLIND.name.lowercase())) {
requests.add(
if (lastInboxMessageId == null) {
BatchRequestInfo(
@@ -689,14 +794,16 @@ object OpenGroupApi {
private fun sequentialBatch(
server: String,
requests: MutableList<BatchRequestInfo<*>>
requests: MutableList<BatchRequestInfo<*>>,
authRequired: Boolean = true
): Promise<List<BatchResponse<*>>, Exception> {
val request = Request(
verb = POST,
room = null,
server = server,
endpoint = Endpoint.Sequence,
parameters = requests.map { it.request }
parameters = requests.map { it.request },
isAuthRequired = authRequired
)
return getBatchResponseJson(request, requests)
}
@@ -803,7 +910,11 @@ object OpenGroupApi {
}
}
fun getCapabilitiesAndRoomInfo(room: String, server: String): Promise<Pair<Capabilities, RoomInfo>, Exception> {
fun getCapabilitiesAndRoomInfo(
room: String,
server: String,
authRequired: Boolean = true
): Promise<Pair<Capabilities, RoomInfo>, Exception> {
val requests = mutableListOf<BatchRequestInfo<*>>(
BatchRequestInfo(
request = BatchRequest(
@@ -822,7 +933,7 @@ object OpenGroupApi {
responseType = object : TypeReference<RoomInfo>(){}
)
)
return sequentialBatch(server, requests).map {
return sequentialBatch(server, requests, authRequired).map {
val capabilities = it.firstOrNull()?.body as? Capabilities ?: throw Error.ParsingFailed
val roomInfo = it.lastOrNull()?.body as? RoomInfo ?: throw Error.ParsingFailed
capabilities to roomInfo

View File

@@ -1,6 +1,7 @@
package org.session.libsession.messaging.open_groups
import org.session.libsession.messaging.MessagingModuleConfiguration
import org.session.libsession.messaging.open_groups.OpenGroupApi.Capability
import org.session.libsession.messaging.utilities.SodiumUtilities
import org.session.libsignal.crypto.PushTransportDetails
import org.session.libsignal.protos.SignalServiceProtos
@@ -19,12 +20,13 @@ data class OpenGroupMessage(
/**
* The serialized protobuf in base64 encoding.
*/
val base64EncodedData: String,
val base64EncodedData: String?,
/**
* When sending a message, the sender signs the serialized protobuf with their private key so that
* a receiving user can verify that the message wasn't tampered with.
*/
val base64EncodedSignature: String? = null
val base64EncodedSignature: String? = null,
val reactions: Map<String, OpenGroupApi.Reaction>? = null
) {
companion object {
@@ -47,12 +49,12 @@ data class OpenGroupMessage(
}
fun sign(room: String, server: String, fallbackSigningType: IdPrefix): OpenGroupMessage? {
if (base64EncodedData.isEmpty()) return null
if (base64EncodedData.isNullOrEmpty()) return null
val userEdKeyPair = MessagingModuleConfiguration.shared.getUserED25519KeyPair() ?: return null
val openGroup = MessagingModuleConfiguration.shared.storage.getOpenGroup(room, server) ?: return null
val serverCapabilities = MessagingModuleConfiguration.shared.storage.getServerCapabilities(server)
val signature = when {
serverCapabilities.contains("blind") -> {
serverCapabilities.contains(Capability.BLIND.name.lowercase()) -> {
val blindedKeyPair = SodiumUtilities.blindedKeyPair(openGroup.publicKey, userEdKeyPair) ?: return null
SodiumUtilities.sogsSignature(
decode(base64EncodedData),
@@ -78,7 +80,7 @@ data class OpenGroupMessage(
return copy(base64EncodedSignature = Base64.encodeBytes(signature))
}
fun toJSON(): Map<String, Any> {
fun toJSON(): Map<String, Any?> {
val json = mutableMapOf( "data" to base64EncodedData, "timestamp" to sentTimestamp )
serverID?.let { json["server_id"] = it }
sender?.let { json["public_key"] = it }

View File

@@ -0,0 +1,9 @@
package org.session.libsession.messaging.open_groups
fun String.migrateLegacyServerUrl() = if (contains(OpenGroupApi.legacyServerIP)) {
OpenGroupApi.defaultServer
} else if (contains(OpenGroupApi.httpDefaultServer)) {
OpenGroupApi.defaultServer
} else {
this
}

View File

@@ -1,6 +1,5 @@
package org.session.libsession.messaging.sending_receiving
import com.goterl.lazysodium.utils.KeyPair
import nl.komponents.kovenant.Promise
import nl.komponents.kovenant.deferred
import org.session.libsession.messaging.MessagingModuleConfiguration
@@ -19,6 +18,7 @@ import org.session.libsession.messaging.messages.visible.Profile
import org.session.libsession.messaging.messages.visible.Quote
import org.session.libsession.messaging.messages.visible.VisibleMessage
import org.session.libsession.messaging.open_groups.OpenGroupApi
import org.session.libsession.messaging.open_groups.OpenGroupApi.Capability
import org.session.libsession.messaging.open_groups.OpenGroupMessage
import org.session.libsession.messaging.utilities.MessageWrapper
import org.session.libsession.messaging.utilities.SessionId
@@ -243,7 +243,7 @@ object MessageSender {
}
else -> {}
}
val messageSender = if (serverCapabilities.contains("blind") && blindedPublicKey != null) {
val messageSender = if (serverCapabilities.contains(Capability.BLIND.name.lowercase()) && blindedPublicKey != null) {
SessionId(IdPrefix.BLINDED, blindedPublicKey!!).hexString
} else {
SessionId(IdPrefix.UN_BLINDED, userEdKeyPair.publicKey.asBytes).hexString
@@ -338,8 +338,23 @@ object MessageSender {
storage.setMessageServerHash(messageID, it)
}
// Track the open group server message ID
if (message.openGroupServerMessageID != null && destination is Destination.LegacyOpenGroup) {
val encoded = GroupUtil.getEncodedOpenGroupID("${destination.server}.${destination.roomToken}".toByteArray())
if (message.openGroupServerMessageID != null && (destination is Destination.LegacyOpenGroup || destination is Destination.OpenGroup)) {
val server: String
val room: String
when (destination) {
is Destination.LegacyOpenGroup -> {
server = destination.server
room = destination.roomToken
}
is Destination.OpenGroup -> {
server = destination.server
room = destination.roomToken
}
else -> {
throw Exception("Destination was a different destination than we were expecting")
}
}
val encoded = GroupUtil.getEncodedOpenGroupID("$server.$room".toByteArray())
val threadID = storage.getThreadId(Address.fromSerialized(encoded))
if (threadID != null && threadID >= 0) {
storage.setOpenGroupServerMessageID(messageID, message.openGroupServerMessageID!!, threadID, !(message as VisibleMessage).isMediaMessage())
@@ -352,6 +367,8 @@ object MessageSender {
if (message is VisibleMessage && !isSyncMessage) {
SSKEnvironment.shared.messageExpirationManager.startAnyExpiration(message.sentTimestamp!!, userPublicKey)
}
} ?: run {
storage.updateReactionIfNeeded(message, message.sender?:userPublicKey, openGroupSentTimestamp)
}
// Sync the message if:
// • it's a visible message
@@ -432,4 +449,5 @@ object MessageSender {
fun explicitLeave(groupPublicKey: String, notifyUser: Boolean): Promise<Unit, Exception> {
return leave(groupPublicKey, notifyUser)
}
}

View File

@@ -16,7 +16,9 @@ import org.session.libsession.messaging.messages.control.ReadReceipt
import org.session.libsession.messaging.messages.control.TypingIndicator
import org.session.libsession.messaging.messages.control.UnsendRequest
import org.session.libsession.messaging.messages.visible.Attachment
import org.session.libsession.messaging.messages.visible.Reaction
import org.session.libsession.messaging.messages.visible.VisibleMessage
import org.session.libsession.messaging.open_groups.OpenGroupApi
import org.session.libsession.messaging.sending_receiving.attachments.PointerAttachment
import org.session.libsession.messaging.sending_receiving.data_extraction.DataExtractionNotificationInfoMessage
import org.session.libsession.messaging.sending_receiving.link_preview.LinkPreview
@@ -47,6 +49,7 @@ import org.session.libsignal.utilities.removingIdPrefixIfNeeded
import org.session.libsignal.utilities.toHexString
import java.security.MessageDigest
import java.util.LinkedList
import kotlin.math.min
internal fun MessageReceiver.isBlocked(publicKey: String): Boolean {
val context = MessagingModuleConfiguration.shared.context
@@ -157,7 +160,10 @@ private fun handleConfigurationMessage(message: ConfigurationMessage) {
}
}
val allV2OpenGroups = storage.getAllOpenGroups().map { it.value.joinURL }
for (openGroup in message.openGroups) {
for (openGroup in message.openGroups.map {
it.replace(OpenGroupApi.legacyDefaultServer, OpenGroupApi.defaultServer)
.replace(OpenGroupApi.httpDefaultServer, OpenGroupApi.defaultServer)
}) {
if (allV2OpenGroups.contains(openGroup)) continue
Log.d("OpenGroup", "All open groups doesn't contain $openGroup")
if (!storage.hasBackgroundGroupAddJob(openGroup)) {
@@ -256,11 +262,11 @@ fun MessageReceiver.handleVisibleMessage(message: VisibleMessage,
val author = Address.fromSerialized(quote.author)
val messageDataProvider = MessagingModuleConfiguration.shared.messageDataProvider
val messageInfo = messageDataProvider.getMessageForQuote(quote.id, author)
if (messageInfo != null) {
quoteModel = if (messageInfo != null) {
val attachments = if (messageInfo.second) messageDataProvider.getAttachmentsAndLinkPreviewFor(messageInfo.first) else ArrayList()
quoteModel = QuoteModel(quote.id, author,null,false, attachments)
QuoteModel(quote.id, author,null,false, attachments)
} else {
quoteModel = QuoteModel(quote.id, author,null, true, PointerAttachment.forPointers(proto.dataMessage.quote.attachmentsList))
QuoteModel(quote.id, author,null, true, PointerAttachment.forPointers(proto.dataMessage.quote.attachmentsList))
}
}
// Parse link preview if needed
@@ -288,22 +294,104 @@ fun MessageReceiver.handleVisibleMessage(message: VisibleMessage,
return@mapNotNull attachment
}
}
// Persist the message
message.threadID = threadID
val messageID = storage.persist(
message, quoteModel, linkPreviews,
message.groupPublicKey, openGroupID,
attachments, runIncrement, runThreadUpdate
) ?: return null
val openGroupServerID = message.openGroupServerMessageID
if (openGroupServerID != null) {
val isSms = !(message.isMediaMessage() || attachments.isNotEmpty())
storage.setOpenGroupServerMessageID(messageID, openGroupServerID, threadID, isSms)
// Parse reaction if needed
message.reaction?.let { reaction ->
if (reaction.react == true) {
reaction.serverId = message.openGroupServerMessageID?.toString() ?: message.serverHash.orEmpty()
reaction.dateSent = message.sentTimestamp ?: 0
reaction.dateReceived = message.receivedTimestamp ?: 0
storage.addReaction(reaction)
} else {
storage.removeReaction(reaction.emoji!!, reaction.timestamp!!, reaction.publicKey!!)
}
} ?: run {
// Persist the message
message.threadID = threadID
val messageID =
storage.persist(message, quoteModel, linkPreviews, message.groupPublicKey, openGroupID,
attachments, runIncrement, runThreadUpdate
) ?: return null
val openGroupServerID = message.openGroupServerMessageID
if (openGroupServerID != null) {
val isSms = !(message.isMediaMessage() || attachments.isNotEmpty())
storage.setOpenGroupServerMessageID(messageID, openGroupServerID, threadID, isSms)
}
return messageID
}
// Cancel any typing indicators if needed
cancelTypingIndicatorsIfNeeded(message.sender!!)
return messageID
return null
}
fun MessageReceiver.handleOpenGroupReactions(
threadId: Long,
openGroupMessageServerID: Long,
reactions: Map<String, OpenGroupApi.Reaction>?
) {
if (reactions.isNullOrEmpty()) return
val storage = MessagingModuleConfiguration.shared.storage
val (messageId, isSms) = MessagingModuleConfiguration.shared.messageDataProvider.getMessageID(openGroupMessageServerID, threadId) ?: return
storage.deleteReactions(messageId, !isSms)
val userPublicKey = storage.getUserPublicKey()!!
val openGroup = storage.getOpenGroup(threadId)
val blindedPublicKey = openGroup?.publicKey?.let { serverPublicKey ->
SodiumUtilities.blindedKeyPair(serverPublicKey, MessagingModuleConfiguration.shared.getUserED25519KeyPair()!!)
?.let { SessionId(IdPrefix.BLINDED, it.publicKey.asBytes).hexString }
}
for ((emoji, reaction) in reactions) {
val pendingUserReaction = OpenGroupApi.pendingReactions
.filter { it.server == openGroup?.server && it.room == openGroup.room && it.messageId == openGroupMessageServerID && it.add }
.sortedByDescending { it.seqNo }
.any { it.emoji == emoji }
val shouldAddUserReaction = pendingUserReaction || reaction.you || reaction.reactors.contains(userPublicKey)
val reactorIds = reaction.reactors.filter { it != blindedPublicKey && it != userPublicKey }
val count = if (reaction.you) reaction.count - 1 else reaction.count
// Add the first reaction (with the count)
reactorIds.firstOrNull()?.let {
storage.addReaction(Reaction(
localId = messageId,
isMms = !isSms,
publicKey = it,
emoji = emoji,
react = true,
serverId = "$openGroupMessageServerID",
count = count,
index = reaction.index
))
}
// Add all other reactions
val maxAllowed = if (shouldAddUserReaction) 4 else 5
val lastIndex = min(maxAllowed, reactorIds.size)
reactorIds.slice(1 until lastIndex).map { reactor ->
storage.addReaction(Reaction(
localId = messageId,
isMms = !isSms,
publicKey = reactor,
emoji = emoji,
react = true,
serverId = "$openGroupMessageServerID",
count = 0, // Only want this on the first reaction
index = reaction.index
))
}
// Add the current user reaction (if applicable and not already included)
if (shouldAddUserReaction) {
storage.addReaction(Reaction(
localId = messageId,
isMms = !isSms,
publicKey = userPublicKey,
emoji = emoji,
react = true,
serverId = "$openGroupMessageServerID",
count = 1,
index = reaction.index
))
}
}
}
//endregion
// region Closed Groups

View File

@@ -22,6 +22,7 @@ import org.session.libsession.messaging.open_groups.OpenGroupApi
import org.session.libsession.messaging.open_groups.OpenGroupMessage
import org.session.libsession.messaging.sending_receiving.MessageReceiver
import org.session.libsession.messaging.sending_receiving.handle
import org.session.libsession.messaging.sending_receiving.handleOpenGroupReactions
import org.session.libsession.snode.OnionRequestAPI
import org.session.libsession.utilities.Address
import org.session.libsession.utilities.GroupUtil
@@ -136,10 +137,16 @@ class OpenGroupPoller(private val server: String, private val executorService: S
pollInfo.details?.moderators?.forEach {
storage.addGroupMember(GroupMember(groupId, it, GroupMemberRole.MODERATOR))
}
pollInfo.details?.hiddenModerators?.forEach {
storage.addGroupMember(GroupMember(groupId, it, GroupMemberRole.HIDDEN_MODERATOR))
}
// - Admins
pollInfo.details?.admins?.forEach {
storage.addGroupMember(GroupMember(groupId, it, GroupMemberRole.ADMIN))
}
pollInfo.details?.hiddenAdmins?.forEach {
storage.addGroupMember(GroupMember(groupId, it, GroupMemberRole.HIDDEN_ADMIN))
}
}
private fun handleMessages(
@@ -147,22 +154,23 @@ class OpenGroupPoller(private val server: String, private val executorService: S
roomToken: String,
messages: List<OpenGroupApi.Message>
) {
val openGroupId = "$server.$roomToken"
val sortedMessages = messages.sortedBy { it.seqno }
sortedMessages.maxOfOrNull { it.seqno }?.let {
MessagingModuleConfiguration.shared.storage.setLastMessageServerID(roomToken, server, it)
sortedMessages.maxOfOrNull { it.seqno }?.let { seqNo ->
MessagingModuleConfiguration.shared.storage.setLastMessageServerID(roomToken, server, seqNo)
OpenGroupApi.pendingReactions.removeAll { !(it.seqNo == null || it.seqNo!! > seqNo) }
}
val (deletions, additions) = sortedMessages.partition { it.deleted || it.data.isNullOrBlank() }
handleNewMessages(openGroupId, additions.map {
val (deletions, additions) = sortedMessages.partition { it.deleted }
handleNewMessages(server, roomToken, additions.map {
OpenGroupMessage(
serverID = it.id,
sender = it.sessionId,
sentTimestamp = (it.posted * 1000).toLong(),
base64EncodedData = it.data!!,
base64EncodedSignature = it.signature
base64EncodedData = it.data,
base64EncodedSignature = it.signature,
reactions = it.reactions
)
})
handleDeletedMessages(openGroupId, deletions.map { it.id })
handleDeletedMessages(server, roomToken, deletions.map { it.id })
}
private fun handleDirectMessages(
@@ -219,43 +227,52 @@ class OpenGroupPoller(private val server: String, private val executorService: S
}
}
private fun handleNewMessages(openGroupID: String, messages: List<OpenGroupMessage>) {
private fun handleNewMessages(server: String, roomToken: String, messages: List<OpenGroupMessage>) {
val storage = MessagingModuleConfiguration.shared.storage
val openGroupID = "$server.$roomToken"
val groupID = GroupUtil.getEncodedOpenGroupID(openGroupID.toByteArray())
// check thread still exists
val threadId = storage.getThreadId(Address.fromSerialized(groupID)) ?: -1
val threadExists = threadId >= 0
if (!hasStarted || !threadExists) { return }
val envelopes = messages.sortedBy { it.serverID!! }.map { message ->
val senderPublicKey = message.sender!!
val builder = SignalServiceProtos.Envelope.newBuilder()
builder.type = SignalServiceProtos.Envelope.Type.SESSION_MESSAGE
builder.source = senderPublicKey
builder.sourceDevice = 1
builder.content = message.toProto().toByteString()
builder.timestamp = message.sentTimestamp
builder.build() to message.serverID
val envelopes = mutableListOf<Triple<Long?, SignalServiceProtos.Envelope, Map<String, OpenGroupApi.Reaction>?>>()
messages.sortedBy { it.serverID!! }.forEach { message ->
if (!message.base64EncodedData.isNullOrEmpty()) {
val envelope = SignalServiceProtos.Envelope.newBuilder()
.setType(SignalServiceProtos.Envelope.Type.SESSION_MESSAGE)
.setSource(message.sender!!)
.setSourceDevice(1)
.setContent(message.toProto().toByteString())
.setTimestamp(message.sentTimestamp)
.build()
envelopes.add(Triple( message.serverID, envelope, message.reactions))
} else if (!message.reactions.isNullOrEmpty()) {
message.serverID?.let {
MessageReceiver.handleOpenGroupReactions(threadId, it, message.reactions)
}
}
}
envelopes.chunked(BatchMessageReceiveJob.BATCH_DEFAULT_NUMBER).forEach { list ->
val parameters = list.map { (message, serverId) ->
MessageReceiveParameters(message.toByteArray(), openGroupMessageServerID = serverId)
val parameters = list.map { (serverId, message, reactions) ->
MessageReceiveParameters(message.toByteArray(), openGroupMessageServerID = serverId, reactions = reactions)
}
JobQueue.shared.add(BatchMessageReceiveJob(parameters, openGroupID))
}
if (envelopes.isNotEmpty()) {
JobQueue.shared.add(TrimThreadJob(threadId,openGroupID))
JobQueue.shared.add(TrimThreadJob(threadId, openGroupID))
}
}
private fun handleDeletedMessages(openGroupID: String, serverIds: List<Long>) {
private fun handleDeletedMessages(server: String, roomToken: String, serverIds: List<Long>) {
val openGroupId = "$server.$roomToken"
val storage = MessagingModuleConfiguration.shared.storage
val groupID = GroupUtil.getEncodedOpenGroupID(openGroupID.toByteArray())
val groupID = GroupUtil.getEncodedOpenGroupID(openGroupId.toByteArray())
val threadID = storage.getThreadId(Address.fromSerialized(groupID)) ?: return
if (serverIds.isNotEmpty()) {
val deleteJob = OpenGroupDeleteJob(serverIds.toLongArray(), threadID, openGroupID)
val deleteJob = OpenGroupDeleteJob(serverIds.toLongArray(), threadID, openGroupId)
JobQueue.shared.add(deleteJob)
}
}

View File

@@ -55,6 +55,7 @@ object SodiumUtilities {
}
/* Constructs a "blinded" key pair (`ka, kA`) based on an open group server `publicKey` and an ed25519 `keyPair` */
@JvmStatic
fun blindedKeyPair(serverPublicKey: String, edKeyPair: KeyPair): KeyPair? {
if (edKeyPair.publicKey.asBytes.size != PUBLIC_KEY_LENGTH || edKeyPair.secretKey.asBytes.size != SECRET_KEY_LENGTH) return null
val kBytes = generateBlindingFactor(serverPublicKey) ?: return null

View File

@@ -1,6 +1,7 @@
package org.session.libsession.utilities
import okhttp3.HttpUrl
import org.session.libsession.messaging.open_groups.migrateLegacyServerUrl
object OpenGroupUrlParser {
@@ -20,7 +21,7 @@ object OpenGroupUrlParser {
// If the URL is malformed, throw an exception
val url = HttpUrl.parse(urlWithPrefix) ?: throw Error.MalformedURL
// Parse components
val server = HttpUrl.Builder().scheme(url.scheme()).host(url.host()).port(url.port()).build().toString().removeSuffix(suffix)
val server = HttpUrl.Builder().scheme(url.scheme()).host(url.host()).port(url.port()).build().toString().removeSuffix(suffix).migrateLegacyServerUrl()
val room = url.pathSegments().firstOrNull { !it.isNullOrEmpty() } ?: throw Error.NoRoom
val publicKey = url.queryParameter(queryPrefix) ?: throw Error.NoPublicKey
if (publicKey.length != 64) throw Error.InvalidPublicKey
@@ -33,4 +34,6 @@ object OpenGroupUrlParser {
}
}
class V2OpenGroupInfo(val server: String, val room: String, val serverPublicKey: String)
class V2OpenGroupInfo(val server: String, val room: String, val serverPublicKey: String) {
fun joinUrl() = "$server/$room?public_key=$serverPublicKey"
}

View File

@@ -3,6 +3,7 @@ package org.session.libsession.utilities;
import android.content.Context;
import android.content.res.Resources;
import android.graphics.Color;
import android.graphics.drawable.Drawable;
import android.util.TypedValue;
import android.view.LayoutInflater;
@@ -10,7 +11,9 @@ import androidx.annotation.AttrRes;
import androidx.annotation.ColorInt;
import androidx.annotation.DrawableRes;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.StyleRes;
import androidx.appcompat.content.res.AppCompatResources;
import androidx.appcompat.view.ContextThemeWrapper;
import org.session.libsignal.utilities.Log;
@@ -24,6 +27,17 @@ public class ThemeUtil {
return getAttributeText(context, R.attr.theme_type, "light").equals("dark");
}
public static boolean getThemedBoolean(@NonNull Context context, @AttrRes int attr) {
TypedValue typedValue = new TypedValue();
Resources.Theme theme = context.getTheme();
if (theme.resolveAttribute(attr, typedValue, true)) {
return typedValue.data != 0;
}
return false;
}
@ColorInt
public static int getThemedColor(@NonNull Context context, @AttrRes int attr) {
TypedValue typedValue = new TypedValue();
@@ -50,6 +64,18 @@ public class ThemeUtil {
}
}
public static @Nullable
Drawable getThemedDrawable(@NonNull Context context, @AttrRes int attr) {
TypedValue typedValue = new TypedValue();
Resources.Theme theme = context.getTheme();
if (theme.resolveAttribute(attr, typedValue, true)) {
return AppCompatResources.getDrawable(context, typedValue.resourceId);
}
return null;
}
public static LayoutInflater getThemedInflater(@NonNull Context context, @NonNull LayoutInflater inflater, @StyleRes int theme) {
Context contextThemeWrapper = new ContextThemeWrapper(context, theme);
return inflater.cloneInContext(contextThemeWrapper);

View File

@@ -69,6 +69,7 @@
<attr name="emoji_category_flags" format="reference"/>
<attr name="emoji_category_emoticons" format="reference"/>
<attr name="emoji_variation_selector_background" format="reference|color" />
<attr name="emoji_show_less_icon" format="reference|color" />
<attr name="quick_camera_icon" format="reference"/>
<attr name="quick_mic_icon" format="reference"/>
@@ -108,6 +109,7 @@
<attr name="menu_popup_expand" format="reference"/>
<attr name="menu_trash_icon" format="reference" />
<attr name="menu_select_icon" format="reference" />
<attr name="menu_selectall_icon" format="reference" />
<attr name="menu_split_icon" format="reference" />
<attr name="menu_accept_icon" format="reference" />
@@ -303,4 +305,13 @@
<enum name="bottom" value="3" />
</attr>
</declare-styleable>
<declare-styleable name="KeyboardPageSearchView">
<attr name="show_always" format="boolean" />
<attr name="search_bar_tint" format="color|reference" />
<attr name="search_icon_tint" format="color|reference" />
<attr name="search_hint" format="string|reference" />
<attr name="click_only" format="boolean" />
</declare-styleable>
</resources>