// C o p y r i g h t © 2 0 2 3 R a n g e p r o o f P t y L t d . A l l r i g h t s r e s e r v e d .
import Foundation
import GRDB
import SessionUtil
import SessionSnodeKit
import SessionUtilitiesKit
// MARK: - S i z e R e s t r i c t i o n s
public extension LibSession {
static var sizeMaxGroupDescriptionBytes : Int { GROUP_INFO_DESCRIPTION_MAX_LENGTH }
static func isTooLong ( groupDescription : String ) -> Bool {
return ( groupDescription . utf8CString . count > LibSession . sizeMaxGroupDescriptionBytes )
}
}
// MARK: - G r o u p I n f o H a n d l i n g
internal extension LibSession {
static let columnsRelatedToGroupInfo : [ ColumnExpression ] = [
ClosedGroup . Columns . name ,
ClosedGroup . Columns . groupDescription ,
ClosedGroup . Columns . displayPictureUrl ,
ClosedGroup . Columns . displayPictureEncryptionKey ,
DisappearingMessagesConfiguration . Columns . isEnabled ,
DisappearingMessagesConfiguration . Columns . type ,
DisappearingMessagesConfiguration . Columns . durationSeconds
]
}
// MARK: - I n c o m i n g C h a n g e s
private struct InteractionInfo : Codable , FetchableRecord {
public typealias Columns = CodingKeys
public enum CodingKeys : String , CodingKey , ColumnExpression {
case id
case serverHash
}
let id : Int64
let serverHash : String ?
}
internal extension LibSessionCacheType {
func handleGroupInfoUpdate (
_ db : Database ,
in config : LibSession . Config ? ,
groupSessionId : SessionId ,
serverTimestampMs : Int64
) throws {
guard configNeedsDump ( config ) else { return }
guard case . groupInfo ( let conf ) = config else { throw LibSessionError . invalidConfigObject }
// I f t h e g r o u p i s d e s t r o y e d t h e n m a r k t h e g r o u p a s k i c k e d i n t h e U S E R _ G R O U P S c o n f i g a n d r e m o v e
// t h e g r o u p d a t a ( w a n t t o k e e p t h e g r o u p i t s e l f a r o u n d b e c a u s e t h e U X o f c o n v e r s a t i o n s r a n d o m l y
// d i s a p p e a r i n g i s n ' t g r e a t ) - n o o t h e r c h a n g e s m a t t e r a n d t h i s c a n ' t b e r e v e r s e d
guard ! groups_info_is_destroyed ( conf ) else {
try markAsDestroyed ( db , groupSessionIds : [ groupSessionId . hexString ] , using : dependencies )
try ClosedGroup . removeData (
db ,
threadIds : [ groupSessionId . hexString ] ,
dataToRemove : [
. poller , . pushNotifications , . messages , . members ,
. encryptionKeys , . authDetails , . libSessionState
] ,
using : dependencies
)
return
}
// A g r o u p m u s t h a v e a n a m e s o i f t h i s i s n u l l t h e n i t ' s i n v a l i d a n d c a n b e i g n o r e d
guard let groupNamePtr : UnsafePointer < CChar > = groups_info_get_name ( conf ) else { return }
let groupDescPtr : UnsafePointer < CChar > ? = groups_info_get_description ( conf )
let groupName : String = String ( cString : groupNamePtr )
let groupDesc : String ? = groupDescPtr . map { String ( cString : $0 ) }
let formationTimestamp : TimeInterval = TimeInterval ( groups_info_get_created ( conf ) )
// T h e ` d i s p l a y P i c . k e y ` c a n c o n t a i n j u n k d a t a s o i f t h e ` d i s p l a y P i c t u r e U r l ` i s n u l l t h e n j u s t
// s e t t h e ` d i s p l a y P i c t u r e K e y ` t o n u l l a s w e l l
let displayPic : user_profile_pic = groups_info_get_pic ( conf )
let displayPictureUrl : String ? = displayPic . get ( \ . url , nullIfEmpty : true )
let displayPictureKey : Data ? = ( displayPictureUrl = = nil ? nil : displayPic . get ( \ . key , nullIfEmpty : true ) )
// U p d a t e t h e g r o u p n a m e
let existingGroup : ClosedGroup ? = try ? ClosedGroup
. filter ( id : groupSessionId . hexString )
. fetchOne ( db )
let needsDisplayPictureUpdate : Bool = (
existingGroup ? . displayPictureUrl != displayPictureUrl ||
existingGroup ? . displayPictureEncryptionKey != displayPictureKey
)
let groupChanges : [ ConfigColumnAssignment ] = [
( ( existingGroup ? . name = = groupName ) ? nil :
ClosedGroup . Columns . name . set ( to : groupName )
) ,
( ( existingGroup ? . groupDescription = = groupDesc ) ? nil :
ClosedGroup . Columns . groupDescription . set ( to : groupDesc )
) ,
// O n l y u p d a t e t h e ' f o r m a t i o n T i m e s t a m p ' i f w e d o n ' t h a v e o n e ( d o n ' t w a n t t o o v e r r i d e t h e ' j o i n e d A t '
// t i m e s t a m p w i t h t h e g r o u p s c r e a t i o n t i m e s t a m p
( formationTimestamp < ( existingGroup ? . formationTimestamp ? ? 0 ) ? nil :
ClosedGroup . Columns . formationTimestamp . set ( to : formationTimestamp )
) ,
// I f w e a r e r e m o v i n g t h e d i s p l a y p i c t u r e d o s o h e r e
( ! needsDisplayPictureUpdate || displayPictureUrl != nil ? nil :
ClosedGroup . Columns . displayPictureUrl . set ( to : nil )
) ,
( ! needsDisplayPictureUpdate || displayPictureUrl != nil ? nil :
ClosedGroup . Columns . displayPictureFilename . set ( to : nil )
) ,
( ! needsDisplayPictureUpdate || displayPictureUrl != nil ? nil :
ClosedGroup . Columns . displayPictureEncryptionKey . set ( to : nil )
) ,
( ! needsDisplayPictureUpdate || displayPictureUrl != nil ? nil :
ClosedGroup . Columns . lastDisplayPictureUpdate . set ( to : ( serverTimestampMs / 1000 ) )
)
] . compactMap { $0 }
if ! groupChanges . isEmpty {
try ClosedGroup
. filter ( id : groupSessionId . hexString )
. updateAllAndConfig (
db ,
groupChanges ,
using : dependencies
)
}
// I f w e h a v e a d i s p l a y p i c t u r e t h e n s t a r t d o w n l o a d i n g i t
if needsDisplayPictureUpdate , let url : String = displayPictureUrl , let key : Data = displayPictureKey {
dependencies [ singleton : . jobRunner ] . add (
db ,
job : Job (
variant : . displayPictureDownload ,
shouldBeUnique : true ,
details : DisplayPictureDownloadJob . Details (
target : . group ( id : groupSessionId . hexString , url : url , encryptionKey : key ) ,
timestamp : TimeInterval ( Double ( serverTimestampMs ) / 1000 )
)
) ,
canStartJob : true
)
}
// U p d a t e t h e d i s a p p e a r i n g m e s s a g e s c o n f i g u r a t i o n
let targetExpiry : Int32 = groups_info_get_expiry_timer ( conf )
let localConfig : DisappearingMessagesConfiguration = try DisappearingMessagesConfiguration
. fetchOne ( db , id : groupSessionId . hexString )
. defaulting ( to : DisappearingMessagesConfiguration . defaultWith ( groupSessionId . hexString ) )
let updatedConfig : DisappearingMessagesConfiguration = DisappearingMessagesConfiguration
. defaultWith ( groupSessionId . hexString )
. with (
isEnabled : ( targetExpiry > 0 ) ,
durationSeconds : TimeInterval ( targetExpiry ) ,
type : . disappearAfterSend
)
if localConfig != updatedConfig {
try updatedConfig
. saved ( db )
. clearUnrelatedControlMessages (
db ,
threadVariant : . group ,
using : dependencies
)
}
// C h e c k i f t h e u s e r i s a n a d m i n i n t h e g r o u p
var messageHashesToDelete : Set < String > = [ ]
let isAdmin : Bool = ( ( try ? ClosedGroup
. filter ( id : groupSessionId . hexString )
. select ( . groupIdentityPrivateKey )
. asRequest ( of : Data . self )
. fetchOne ( db ) ) != nil )
// I f t h e r e i s a ` d e l e t e _ b e f o r e ` s e t t i n g t h e n d e l e t e a l l m e s s a g e s b e f o r e t h e p r o v i d e d t i m e s t a m p
let deleteBeforeTimestamp : Int64 = groups_info_get_delete_before ( conf )
if deleteBeforeTimestamp > 0 {
let interactionInfo : [ InteractionInfo ] = ( try ? Interaction
. filter ( Interaction . Columns . threadId = = groupSessionId . hexString )
. filter ( Interaction . Columns . timestampMs < ( TimeInterval ( deleteBeforeTimestamp ) * 1000 ) )
. select ( . id , . serverHash )
. asRequest ( of : InteractionInfo . self )
. fetchAll ( db ) )
. defaulting ( to : [ ] )
let interactionIdsToRemove : Set < Int64 > = Set ( interactionInfo . map { $0 . id } )
let reactionHashes : Set < String > = try Reaction
. filter ( interactionIdsToRemove . contains ( Reaction . Columns . interactionId ) )
. filter ( Reaction . Columns . serverHash != nil )
. select ( . serverHash )
. asRequest ( of : String . self )
. fetchSet ( db )
try Interaction . markAsDeleted (
db ,
threadId : groupSessionId . hexString ,
threadVariant : . group ,
interactionIds : Set ( interactionIdsToRemove ) ,
localOnly : false ,
using : dependencies
)
if ! interactionInfo . isEmpty {
Log . info ( . libSession , " Deleted \( interactionInfo . count ) message(s) from \( groupSessionId . hexString ) due to 'delete_before' value. " )
}
if isAdmin {
messageHashesToDelete . insert ( contentsOf : Set ( interactionInfo . compactMap { $0 . serverHash } ) )
messageHashesToDelete . insert ( contentsOf : reactionHashes )
}
}
// I f t h e r e i s a ` a t t a c h _ d e l e t e _ b e f o r e ` s e t t i n g t h e n d e l e t e a l l m e s s a g e s t h a t h a v e a t t a c h m e n t s b e f o r e
// t h e p r o v i d e d t i m e s t a m p a n d s c h e d u l e a g a r b a g e c o l l e c t i o n j o b
let attachDeleteBeforeTimestamp : Int64 = groups_info_get_attach_delete_before ( conf )
if attachDeleteBeforeTimestamp > 0 {
let interactionInfo : [ InteractionInfo ] = ( try ? Interaction
. filter ( Interaction . Columns . threadId = = groupSessionId . hexString )
. filter ( Interaction . Columns . timestampMs < ( TimeInterval ( attachDeleteBeforeTimestamp ) * 1000 ) )
. joining (
required : Interaction . interactionAttachments . joining (
required : InteractionAttachment . attachment
. filter ( Attachment . Columns . variant != Attachment . Variant . voiceMessage )
)
)
. select ( . id , . serverHash )
. asRequest ( of : InteractionInfo . self )
. fetchAll ( db ) )
. defaulting ( to : [ ] )
let interactionIdsToRemove : Set < Int64 > = Set ( interactionInfo . map { $0 . id } )
let reactionHashes : Set < String > = try Reaction
. filter ( interactionIdsToRemove . contains ( Reaction . Columns . interactionId ) )
. filter ( Reaction . Columns . serverHash != nil )
. select ( . serverHash )
. asRequest ( of : String . self )
. fetchSet ( db )
try Interaction . markAsDeleted (
db ,
threadId : groupSessionId . hexString ,
threadVariant : . group ,
interactionIds : Set ( interactionIdsToRemove ) ,
localOnly : false ,
using : dependencies
)
if ! interactionInfo . isEmpty {
Log . info ( . libSession , " Deleted \( interactionInfo . count ) message(s) with attachments from \( groupSessionId . hexString ) due to 'attach_delete_before' value. " )
// S c h e d u l e a g r a b a g e c o l l e c t i o n j o b t o c l e a n u p a n y n o w - o r p h a n e d a t t a c h m e n t f i l e s
dependencies [ singleton : . jobRunner ] . add (
db ,
job : Job (
variant : . garbageCollection ,
details : GarbageCollectionJob . Details (
typesToCollect : [ . orphanedAttachments , . orphanedAttachmentFiles ]
)
) ,
canStartJob : true
)
}
if isAdmin {
messageHashesToDelete . insert ( contentsOf : Set ( interactionInfo . compactMap { $0 . serverHash } ) )
messageHashesToDelete . insert ( contentsOf : reactionHashes )
}
}
// I f t h e c u r r e n t u s e r i s a g r o u p a d m i n a n d t h e r e a r e m e s s a g e h a s h e s w h i c h s h o u l d b e d e l e t e d t h e n
// s e n d a f i r e - a n d - f o r g e t A P I c a l l t o d e l e t e t h e m e s s a g e s f r o m t h e s w a r m
if isAdmin && ! messageHashesToDelete . isEmpty {
( try ? Authentication . with (
db ,
swarmPublicKey : groupSessionId . hexString ,
using : dependencies
) ) . map { authMethod in
try ? SnodeAPI
. preparedDeleteMessages (
serverHashes : Array ( messageHashesToDelete ) ,
requireSuccessfulDeletion : false ,
authMethod : authMethod ,
using : dependencies
)
. send ( using : dependencies )
. subscribe ( on : DispatchQueue . global ( qos : . background ) , using : dependencies )
. sinkUntilComplete ( )
}
}
}
}
// MARK: - O u t g o i n g C h a n g e s
internal extension LibSession {
static func updatingGroupInfo < T > (
_ db : Database ,
_ updated : [ T ] ,
using dependencies : Dependencies
) throws -> [ T ] {
guard let updatedGroups : [ ClosedGroup ] = updated as ? [ ClosedGroup ] else { throw StorageError . generic }
// E x c l u d e l e g a c y g r o u p s a s t h e y a r e n ' t m a n a g e d v i a L i b S e s s i o n a n d g r o u p s w h e r e t h e c u r r e n t u s e r i s n ' t a n
// a d m i n ( n o n - a d m i n s c a n ' t u p d a t e ` G r o u p I n f o ` a n y w a y )
let targetGroups : [ ClosedGroup ] = updatedGroups
. filter { ( try ? SessionId ( from : $0 . id ) ) ? . prefix = = . group }
. filter { isAdmin ( groupSessionId : SessionId ( . group , hex : $0 . id ) , using : dependencies ) }
// I f w e o n l y u p d a t e d t h e c u r r e n t u s e r c o n t a c t t h e n n o n e e d t o c o n t i n u e
guard ! targetGroups . isEmpty else { return updated }
// L o o p t h r o u g h e a c h o f t h e g r o u p s a n d u p d a t e t h e i r s e t t i n g s
try targetGroups . forEach { group in
try dependencies . mutate ( cache : . libSession ) { cache in
let groupSessionId : SessionId = SessionId ( . group , hex : group . threadId )
// / D o n ' t u p d a t e t h e g r o u p i n f o i f t h e c u r r e n t u s e r i s n ' t a n a d m i n ( d o i n g s o w o u l d t h r o w w h i c h w o u l d r e v e r t t h i s d a t a b a s e
// / t r a n s a c t i o n )
guard cache . isAdmin ( groupSessionId : groupSessionId ) else { return }
try cache . performAndPushChange ( db , for : . groupInfo , sessionId : groupSessionId ) { config in
guard case . groupInfo ( let conf ) = config else { throw LibSessionError . invalidConfigObject }
guard
var cGroupName : [ CChar ] = group . name . cString ( using : . utf8 ) ,
var cGroupDesc : [ CChar ] = ( group . groupDescription ? ? " " ) . cString ( using : . utf8 )
else { throw LibSessionError . invalidCConversion }
// / U p d a t e t h e n a m e
// /
// / * * N o t e : * * W e i n d e n t i o n a l l y o n l y u p d a t e t h e ` G R O U P _ I N F O ` a n d n o t t h e ` U S E R _ G R O U P S ` a s o n c e t h e
// / g r o u p i s s y n c e d b e t w e e n d e v i c e s w e w a n t t o r e l y o n t h e p r o p e r g r o u p c o n f i g t o g e t d i s p l a y i n f o
groups_info_set_name ( conf , & cGroupName )
groups_info_set_description ( conf , & cGroupDesc )
// E i t h e r a s s i g n t h e u p d a t e d d i s p l a y p i c , o r s e n t a b l a n k p i c ( t o r e m o v e t h e c u r r e n t o n e )
var displayPic : user_profile_pic = user_profile_pic ( )
displayPic . set ( \ . url , to : group . displayPictureUrl )
displayPic . set ( \ . key , to : group . displayPictureEncryptionKey )
groups_info_set_pic ( conf , displayPic )
}
}
}
return updated
}
static func updatingDisappearingConfigsGroups < T > (
_ db : Database ,
_ updated : [ T ] ,
using dependencies : Dependencies
) throws -> [ T ] {
guard let updatedDisappearingConfigs : [ DisappearingMessagesConfiguration ] = updated as ? [ DisappearingMessagesConfiguration ] else { throw StorageError . generic }
// F i l t e r o u t a n y d i s a p p e a r i n g c o n f i g c h a n g e s n o t r e l a t e d t o u p d a t e d g r o u p s a n d g r o u p s w h e r e
// t h e c u r r e n t u s e r i s n ' t a n a d m i n ( n o n - a d m i n s c a n ' t u p d a t e ` G r o u p I n f o ` a n y w a y )
let targetUpdatedConfigs : [ DisappearingMessagesConfiguration ] = updatedDisappearingConfigs
. filter { ( try ? SessionId . Prefix ( from : $0 . id ) ) = = . group }
. filter { isAdmin ( groupSessionId : SessionId ( . group , hex : $0 . id ) , using : dependencies ) }
guard ! targetUpdatedConfigs . isEmpty else { return updated }
// W e s h o u l d o n l y s y n c d i s a p p e a r i n g m e s s a g e s c o n f i g s w h i c h a r e a s s o c i a t e d t o e x i s t i n g g r o u p s
let existingGroupIds : [ String ] = ( try ? ClosedGroup
. filter ( ids : targetUpdatedConfigs . map { $0 . id } )
. select ( . threadId )
. asRequest ( of : String . self )
. fetchAll ( db ) )
. defaulting ( to : [ ] )
// I f n o n e o f t h e d i s a p p e a r i n g m e s s a g e s c o n f i g s a r e a s s o c i a t e d w i t h e x i s t i n g g r o u p s t h e n i g n o r e
// t h e c h a n g e s ( n o n e e d t o d o a c o n f i g s y n c )
guard ! existingGroupIds . isEmpty else { return updated }
// L o o p t h r o u g h e a c h o f t h e g r o u p s a n d u p d a t e t h e i r s e t t i n g s
try existingGroupIds
. compactMap { groupId in targetUpdatedConfigs . first ( where : { $0 . id = = groupId } ) . map { ( groupId , $0 ) } }
. forEach { groupId , updatedConfig in
try dependencies . mutate ( cache : . libSession ) { cache in
try cache . performAndPushChange ( db , for : . groupInfo , sessionId : SessionId ( . group , hex : groupId ) ) { config in
guard case . groupInfo ( let conf ) = config else { throw LibSessionError . invalidConfigObject }
groups_info_set_expiry_timer ( conf , Int32 ( updatedConfig . durationSeconds ) )
}
}
}
return updated
}
}
// MARK: - E x t e r n a l O u t g o i n g C h a n g e s
public extension LibSession {
static func update (
_ db : Database ,
groupSessionId : SessionId ,
disappearingConfig : DisappearingMessagesConfiguration ? ,
using dependencies : Dependencies
) throws {
try dependencies . mutate ( cache : . libSession ) { cache in
try cache . performAndPushChange ( db , for : . groupInfo , sessionId : groupSessionId ) { config in
guard case . groupInfo ( let conf ) = config else { throw LibSessionError . invalidConfigObject }
if let config : DisappearingMessagesConfiguration = disappearingConfig {
groups_info_set_expiry_timer ( conf , Int32 ( config . durationSeconds ) )
}
}
}
}
static func deleteMessagesBefore (
_ db : Database ,
groupSessionId : SessionId ,
timestamp : TimeInterval ,
using dependencies : Dependencies
) throws {
try dependencies . mutate ( cache : . libSession ) { cache in
try cache . performAndPushChange ( db , for : . groupInfo , sessionId : groupSessionId ) { config in
guard case . groupInfo ( let conf ) = config else { throw LibSessionError . invalidConfigObject }
// D o n o t h i n g i f t h e t i m e s t a m p i s n ' t n e w e r t h a n t h e c u r r e n t v a l u e
guard Int64 ( timestamp ) > groups_info_get_delete_before ( conf ) else { return }
groups_info_set_delete_before ( conf , Int64 ( timestamp ) )
}
}
}
static func deleteAttachmentsBefore (
_ db : Database ,
groupSessionId : SessionId ,
timestamp : TimeInterval ,
using dependencies : Dependencies
) throws {
try dependencies . mutate ( cache : . libSession ) { cache in
try cache . performAndPushChange ( db , for : . groupInfo , sessionId : groupSessionId ) { config in
guard case . groupInfo ( let conf ) = config else { throw LibSessionError . invalidConfigObject }
// D o n o t h i n g i f t h e t i m e s t a m p i s n ' t n e w e r t h a n t h e c u r r e n t v a l u e
guard Int64 ( timestamp ) > groups_info_get_attach_delete_before ( conf ) else { return }
groups_info_set_attach_delete_before ( conf , Int64 ( timestamp ) )
}
}
}
}
public extension LibSessionCacheType {
func deleteGroupForEveryone ( _ db : Database , groupSessionId : SessionId ) throws {
try performAndPushChange ( db , for : . groupInfo , sessionId : groupSessionId ) { config in
guard case . groupInfo ( let conf ) = config else { throw LibSessionError . invalidConfigObject }
groups_info_destroy_group ( conf )
}
}
}
// MARK: - D i r e c t V a l u e s
extension LibSession {
static func groupName ( in config : Config ? ) throws -> String {
guard
case . groupInfo ( let conf ) = config ,
let groupNamePtr : UnsafePointer < CChar > = groups_info_get_name ( conf )
else { throw LibSessionError . invalidConfigObject }
return String ( cString : groupNamePtr )
}
static func groupDeleteBefore ( in config : Config ? ) throws -> TimeInterval {
guard case . groupInfo ( let conf ) = config else { throw LibSessionError . invalidConfigObject }
return TimeInterval ( groups_info_get_delete_before ( conf ) )
}
static func groupAttachmentDeleteBefore ( in config : Config ? ) throws -> TimeInterval {
guard case . groupInfo ( let conf ) = config else { throw LibSessionError . invalidConfigObject }
return TimeInterval ( groups_info_get_attach_delete_before ( conf ) )
}
}