283 lines
11 KiB
Swift
283 lines
11 KiB
Swift
//
|
|
// Copyright 2021 Signal Messenger, LLC
|
|
// SPDX-License-Identifier: AGPL-3.0-only
|
|
//
|
|
|
|
public import GRDB
|
|
public import LibSignalClient
|
|
|
|
/// Represents a full member of a group.
|
|
///
|
|
/// Importantly, this means that invited and requesting group members are
|
|
/// **not** represented by a ``TSGroupMember``. See the notes below for more
|
|
/// details.
|
|
///
|
|
/// - Note
|
|
/// A ``TSGroupMember`` stores both a serviceId and phone number. Full members
|
|
/// of a V2 group can only be represented by their ACI - so for V2 group
|
|
/// members the serviceId can be expected to be an ACI. However, phone
|
|
/// number-only members of legacy V1 groups may end up with a PNI; for example,
|
|
/// that phone-number-only member may become re-registered, and we may
|
|
/// subsequently learn about their PNI.
|
|
///
|
|
/// - Note
|
|
/// At the time of writing there exists a `UNIQUE INDEX` on the phone number and
|
|
/// group thread ID columns of this model. This is currently safe, as it's
|
|
/// impossible for a single phone number (a single account) to be in a group as
|
|
/// two different full members. However, it **is** possible for the same account
|
|
/// to be both an invited member (by their PNI) and a full member (by their
|
|
/// ACI). Take care if this model is ever extended to include invited members.
|
|
public final class TSGroupMember: NSObject, SDSCodableModel, Decodable {
|
|
public static let databaseTableName = "model_TSGroupMember"
|
|
public static var recordType: UInt { SDSRecordType.groupMember.rawValue }
|
|
|
|
public enum CodingKeys: String, CodingKey, ColumnExpression, CaseIterable {
|
|
case id
|
|
case recordType
|
|
case uniqueId
|
|
case groupThreadId
|
|
case phoneNumber
|
|
case serviceId = "uuidString"
|
|
case lastInteractionTimestamp
|
|
}
|
|
|
|
public var id: Int64?
|
|
public let uniqueId: String
|
|
public let serviceId: ServiceId?
|
|
public let phoneNumber: String?
|
|
public let groupThreadId: String
|
|
public private(set) var lastInteractionTimestamp: UInt64
|
|
|
|
public init(
|
|
address: NormalizedDatabaseRecordAddress,
|
|
groupThreadId: String,
|
|
lastInteractionTimestamp: UInt64
|
|
) {
|
|
self.uniqueId = UUID().uuidString
|
|
self.serviceId = address.serviceId
|
|
self.phoneNumber = address.phoneNumber
|
|
self.groupThreadId = groupThreadId
|
|
self.lastInteractionTimestamp = lastInteractionTimestamp
|
|
}
|
|
|
|
// MARK: - Codable
|
|
|
|
public init(from decoder: Decoder) throws {
|
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
|
|
|
let decodedRecordType = try container.decode(Int.self, forKey: .recordType)
|
|
owsAssertDebug(decodedRecordType == Self.recordType, "Unexpectedly decoded record with wrong type.")
|
|
|
|
id = try container.decodeIfPresent(RowId.self, forKey: .id)
|
|
uniqueId = try container.decode(String.self, forKey: .uniqueId)
|
|
groupThreadId = try container.decode(String.self, forKey: .groupThreadId)
|
|
serviceId = try container.decodeIfPresent(String.self, forKey: .serviceId)
|
|
.flatMap { try? ServiceId.parseFrom(serviceIdString: $0) }
|
|
phoneNumber = try container.decodeIfPresent(String.self, forKey: .phoneNumber)
|
|
lastInteractionTimestamp = try container.decode(UInt64.self, forKey: .lastInteractionTimestamp)
|
|
}
|
|
|
|
public func encode(to encoder: Encoder) throws {
|
|
var container = encoder.container(keyedBy: CodingKeys.self)
|
|
try container.encodeIfPresent(id, forKey: .id)
|
|
try container.encode(Self.recordType, forKey: .recordType)
|
|
try container.encode(uniqueId, forKey: .uniqueId)
|
|
try container.encode(groupThreadId, forKey: .groupThreadId)
|
|
try container.encodeIfPresent(serviceId?.serviceIdUppercaseString, forKey: .serviceId)
|
|
try container.encodeIfPresent(phoneNumber, forKey: .phoneNumber)
|
|
try container.encode(lastInteractionTimestamp, forKey: .lastInteractionTimestamp)
|
|
}
|
|
|
|
// MARK: -
|
|
|
|
public func anyUpdateWith(
|
|
lastInteractionTimestamp: UInt64,
|
|
transaction: SDSAnyWriteTransaction
|
|
) {
|
|
anyUpdate(transaction: transaction) { groupMember in
|
|
groupMember.lastInteractionTimestamp = lastInteractionTimestamp
|
|
}
|
|
}
|
|
|
|
public func updateWith(
|
|
lastInteractionTimestamp: UInt64,
|
|
tx: DBWriteTransaction
|
|
) throws {
|
|
self.lastInteractionTimestamp = lastInteractionTimestamp
|
|
try self.update(tx.databaseConnection)
|
|
}
|
|
|
|
public class func groupMember(
|
|
for address: SignalServiceAddress,
|
|
in groupThreadId: String,
|
|
transaction: SDSAnyReadTransaction
|
|
) -> TSGroupMember? {
|
|
let sql = """
|
|
SELECT * FROM \(databaseTableName)
|
|
WHERE (\(columnName(.serviceId)) = ? OR \(columnName(.serviceId)) IS NULL)
|
|
AND (\(columnName(.phoneNumber)) = ? OR \(columnName(.phoneNumber)) IS NULL)
|
|
AND NOT (\(columnName(.serviceId)) IS NULL AND \(columnName(.phoneNumber)) IS NULL)
|
|
AND \(columnName(.groupThreadId)) = ?
|
|
LIMIT 1
|
|
"""
|
|
|
|
do {
|
|
return try fetchOne(
|
|
transaction.unwrapGrdbRead.database,
|
|
sql: sql,
|
|
arguments: [address.serviceIdUppercaseString, address.phoneNumber, groupThreadId]
|
|
)
|
|
} catch {
|
|
DatabaseCorruptionState.flagDatabaseReadCorruptionIfNecessary(
|
|
userDefaults: CurrentAppContext().appUserDefaults(),
|
|
error: error
|
|
)
|
|
owsFailDebug("Failed to fetch group member \(error)")
|
|
return nil
|
|
}
|
|
}
|
|
|
|
public class func groupMember(
|
|
for aci: Aci,
|
|
in groupThread: TSGroupThread,
|
|
tx: DBReadTransaction
|
|
) throws -> TSGroupMember? {
|
|
return try TSGroupMember
|
|
.filter(Column(CodingKeys.serviceId) == aci.serviceIdUppercaseString)
|
|
.filter(Column(CodingKeys.groupThreadId) == groupThread.uniqueId)
|
|
.fetchOne(tx.databaseConnection)
|
|
}
|
|
|
|
public class func enumerateGroupMembers(
|
|
for address: SignalServiceAddress,
|
|
transaction: SDSAnyReadTransaction,
|
|
block: @escaping (TSGroupMember, UnsafeMutablePointer<ObjCBool>
|
|
) -> Void) {
|
|
let sql = """
|
|
SELECT * FROM \(databaseTableName)
|
|
WHERE (\(columnName(.serviceId)) = ? OR \(columnName(.serviceId)) IS NULL)
|
|
AND (\(columnName(.phoneNumber)) = ? OR \(columnName(.phoneNumber)) IS NULL)
|
|
AND NOT (\(columnName(.serviceId)) IS NULL AND \(columnName(.phoneNumber)) IS NULL)
|
|
"""
|
|
|
|
do {
|
|
let cursor = try fetchCursor(
|
|
transaction.unwrapGrdbRead.database,
|
|
sql: sql,
|
|
arguments: [address.serviceIdUppercaseString, address.phoneNumber]
|
|
)
|
|
while let member = try cursor.next() {
|
|
var stop: ObjCBool = false
|
|
block(member, &stop)
|
|
if stop.boolValue { break }
|
|
}
|
|
} catch {
|
|
DatabaseCorruptionState.flagDatabaseReadCorruptionIfNecessary(
|
|
userDefaults: CurrentAppContext().appUserDefaults(),
|
|
error: error
|
|
)
|
|
owsFail("Failed to enumerate group membership.")
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: -
|
|
|
|
public extension TSGroupThread {
|
|
@objc(groupThreadsWithAddress:transaction:)
|
|
class func groupThreads(
|
|
with address: SignalServiceAddress,
|
|
transaction: SDSAnyReadTransaction
|
|
) -> [TSGroupThread] {
|
|
let sql = """
|
|
SELECT \(TSGroupMember.columnName(.groupThreadId)) FROM \(TSGroupMember.databaseTableName)
|
|
WHERE (\(TSGroupMember.columnName(.serviceId)) = ? OR \(TSGroupMember.columnName(.serviceId)) IS NULL)
|
|
AND (\(TSGroupMember.columnName(.phoneNumber)) = ? OR \(TSGroupMember.columnName(.phoneNumber)) IS NULL)
|
|
AND NOT (\(TSGroupMember.columnName(.serviceId)) IS NULL AND \(TSGroupMember.columnName(.phoneNumber)) IS NULL)
|
|
ORDER BY \(TSGroupMember.columnName(.lastInteractionTimestamp)) DESC
|
|
"""
|
|
|
|
var groupThreads = [TSGroupThread]()
|
|
|
|
do {
|
|
let cursor = try String.fetchCursor(
|
|
transaction.unwrapGrdbRead.database,
|
|
sql: sql,
|
|
arguments: [address.serviceIdUppercaseString, address.phoneNumber]
|
|
)
|
|
|
|
while let groupThreadId = try cursor.next() {
|
|
guard let groupThread = TSGroupThread.anyFetchGroupThread(
|
|
uniqueId: groupThreadId,
|
|
transaction: transaction
|
|
) else {
|
|
owsFailDebug("Missing group thread")
|
|
continue
|
|
}
|
|
|
|
groupThreads.append(groupThread)
|
|
}
|
|
} catch {
|
|
DatabaseCorruptionState.flagDatabaseReadCorruptionIfNecessary(
|
|
userDefaults: CurrentAppContext().appUserDefaults(),
|
|
error: error
|
|
)
|
|
owsFail("Failed to find group thread")
|
|
}
|
|
|
|
return groupThreads
|
|
}
|
|
|
|
class func enumerateGroupThreads(
|
|
with address: SignalServiceAddress,
|
|
transaction: SDSAnyReadTransaction,
|
|
block: (TSGroupThread, UnsafeMutablePointer<ObjCBool>) -> Void
|
|
) {
|
|
let sql = """
|
|
SELECT \(TSGroupMember.columnName(.groupThreadId)) FROM \(TSGroupMember.databaseTableName)
|
|
WHERE (\(TSGroupMember.columnName(.serviceId)) = ? OR \(TSGroupMember.columnName(.serviceId)) IS NULL)
|
|
AND (\(TSGroupMember.columnName(.phoneNumber)) = ? OR \(TSGroupMember.columnName(.phoneNumber)) IS NULL)
|
|
AND NOT (\(TSGroupMember.columnName(.serviceId)) IS NULL AND \(TSGroupMember.columnName(.phoneNumber)) IS NULL)
|
|
ORDER BY \(TSGroupMember.columnName(.lastInteractionTimestamp)) DESC
|
|
"""
|
|
|
|
let cursor = try! String.fetchCursor(
|
|
transaction.unwrapGrdbRead.database,
|
|
sql: sql,
|
|
arguments: [address.serviceIdUppercaseString, address.phoneNumber]
|
|
)
|
|
|
|
while let groupThreadId = try! cursor.next() {
|
|
guard let groupThread = TSGroupThread.anyFetchGroupThread(uniqueId: groupThreadId,
|
|
transaction: transaction) else {
|
|
owsFailDebug("Missing group thread")
|
|
continue
|
|
}
|
|
var stop: ObjCBool = false
|
|
block(groupThread, &stop)
|
|
if stop.boolValue { return }
|
|
}
|
|
}
|
|
|
|
class func groupThreadIds(
|
|
with address: SignalServiceAddress,
|
|
transaction: SDSAnyReadTransaction
|
|
) -> [String] {
|
|
let sql = """
|
|
SELECT \(TSGroupMember.columnName(.groupThreadId))
|
|
FROM \(TSGroupMember.databaseTableName)
|
|
WHERE (\(TSGroupMember.columnName(.serviceId)) = ? OR \(TSGroupMember.columnName(.serviceId)) IS NULL)
|
|
AND (\(TSGroupMember.columnName(.phoneNumber)) = ? OR \(TSGroupMember.columnName(.phoneNumber)) IS NULL)
|
|
AND NOT (\(TSGroupMember.columnName(.serviceId)) IS NULL AND \(TSGroupMember.columnName(.phoneNumber)) IS NULL)
|
|
ORDER BY \(TSGroupMember.columnName(.lastInteractionTimestamp)) DESC
|
|
"""
|
|
|
|
return transaction.unwrapGrdbRead.database.strictRead { database in
|
|
try String.fetchAll(
|
|
database,
|
|
sql: sql,
|
|
arguments: [address.serviceIdUppercaseString, address.phoneNumber]
|
|
)
|
|
}
|
|
}
|
|
}
|