Started working on some config contact pruning logic

Added support for a ConfirmationModal with an input field
Added a mechanism on Debug builds to export the database and it's key
Added logic to catch exceptions thrown within libSession (need to actually plug it in)
Added a debug-only mechanism to export the users database and (encrypted) database key
Added a few unit tests to check the CONTACTS config message size constraints
This commit is contained in:
Morgan Pretty 2023-06-16 19:38:14 +10:00
parent 65bf7d7d82
commit d2c82cb915
19 changed files with 2375 additions and 1530 deletions

View File

@ -0,0 +1,20 @@
// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved.
import Foundation
import CryptoKit
let arguments = CommandLine.arguments
// First argument is the file name
if arguments.count == 3 {
let encryptedData = Data(base64Encoded: arguments[1].data(using: .utf8)!)!
let hash: SHA256.Digest = SHA256.hash(data: arguments[2].data(using: .utf8)!)
let key: SymmetricKey = SymmetricKey(data: Data(hash.makeIterator()))
let sealedBox = try! ChaChaPoly.SealedBox(combined: encryptedData)
let decryptedData = try! ChaChaPoly.open(sealedBox, using: key)
print(Array(decryptedData).map { String(format: "%02x", $0) }.joined())
}
else {
print("Please provide the base64 encoded 'encrypted key' and plain text 'password' as arguments")
}

View File

@ -588,6 +588,8 @@
FD2B4AFF2946C93200AB4848 /* ConfigurationSyncJob.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD2B4AFE2946C93200AB4848 /* ConfigurationSyncJob.swift */; };
FD2B4B042949887A00AB4848 /* QueryInterfaceRequest+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD2B4B032949887A00AB4848 /* QueryInterfaceRequest+Utilities.swift */; };
FD3003662A25D5B300B5A5FB /* ConfigMessageReceiveJob.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3003652A25D5B300B5A5FB /* ConfigMessageReceiveJob.swift */; };
FD30036A2A3ADEC100B5A5FB /* CExceptionHelper.h in Headers */ = {isa = PBXBuildFile; fileRef = FD3003692A3ADD6000B5A5FB /* CExceptionHelper.h */; settings = {ATTRIBUTES = (Public, ); }; };
FD30036E2A3AE26000B5A5FB /* CExceptionHelper.mm in Sources */ = {isa = PBXBuildFile; fileRef = FD30036D2A3AE26000B5A5FB /* CExceptionHelper.mm */; };
FD368A6829DE8F9C000DBF1E /* _012_AddFTSIfNeeded.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD368A6729DE8F9B000DBF1E /* _012_AddFTSIfNeeded.swift */; };
FD368A6A29DE9E30000DBF1E /* UIContextualAction+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD368A6929DE9E30000DBF1E /* UIContextualAction+Utilities.swift */; };
FD37E9C328A1C6F3003AE748 /* ThemeManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD37E9C228A1C6F3003AE748 /* ThemeManager.swift */; };
@ -1727,6 +1729,8 @@
FD2B4AFE2946C93200AB4848 /* ConfigurationSyncJob.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfigurationSyncJob.swift; sourceTree = "<group>"; };
FD2B4B032949887A00AB4848 /* QueryInterfaceRequest+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "QueryInterfaceRequest+Utilities.swift"; sourceTree = "<group>"; };
FD3003652A25D5B300B5A5FB /* ConfigMessageReceiveJob.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfigMessageReceiveJob.swift; sourceTree = "<group>"; };
FD3003692A3ADD6000B5A5FB /* CExceptionHelper.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = CExceptionHelper.h; sourceTree = "<group>"; };
FD30036D2A3AE26000B5A5FB /* CExceptionHelper.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.objcpp; path = CExceptionHelper.mm; sourceTree = "<group>"; };
FD368A6729DE8F9B000DBF1E /* _012_AddFTSIfNeeded.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = _012_AddFTSIfNeeded.swift; sourceTree = "<group>"; };
FD368A6929DE9E30000DBF1E /* UIContextualAction+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIContextualAction+Utilities.swift"; sourceTree = "<group>"; };
FD37E9C228A1C6F3003AE748 /* ThemeManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThemeManager.swift; sourceTree = "<group>"; };
@ -3589,6 +3593,8 @@
isa = PBXGroup;
children = (
FDA8EB0F280F8238002B68E5 /* Codable+Utilities.swift */,
FD3003692A3ADD6000B5A5FB /* CExceptionHelper.h */,
FD30036D2A3AE26000B5A5FB /* CExceptionHelper.mm */,
FD09796A27F6C67500936362 /* Failable.swift */,
FD09797127FAA2F500936362 /* Optional+Utilities.swift */,
FD09797C27FBDB2000936362 /* Notification+Utilities.swift */,
@ -4457,6 +4463,7 @@
B8856E1A256F1700001CE70E /* OWSMath.h in Headers */,
C352A3772557864000338F3E /* NSTimer+Proxying.h in Headers */,
C32C5B51256DC219003C73A2 /* NSNotificationCenter+OWS.h in Headers */,
FD30036A2A3ADEC100B5A5FB /* CExceptionHelper.h in Headers */,
C3C2A67D255388CC00C340D1 /* SessionUtilitiesKit.h in Headers */,
C32C6018256E07F9003C73A2 /* NSUserDefaults+OWS.h in Headers */,
B8856D8D256F1502001CE70E /* UIView+OWS.h in Headers */,
@ -5639,6 +5646,7 @@
C3D9E4D12567777D0040E4F3 /* OWSMediaUtils.swift in Sources */,
C3BBE0AA2554D4DE0050F1E3 /* Dictionary+Utilities.swift in Sources */,
FD37EA1128AB34B3003AE748 /* TypedTableAlteration.swift in Sources */,
FD30036E2A3AE26000B5A5FB /* CExceptionHelper.mm in Sources */,
C3D9E4DA256778410040E4F3 /* UIImage+OWS.m in Sources */,
C32C600F256E07F5003C73A2 /* NSUserDefaults+OWS.m in Sources */,
FDE658A329418E2F00A33BC1 /* KeyPair.swift in Sources */,

View File

@ -1,6 +1,7 @@
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import Foundation
import CryptoKit
import GRDB
import DifferenceKit
import SessionUIKit
@ -9,6 +10,10 @@ import SessionUtilitiesKit
import SignalCoreKit
class HelpViewModel: SessionTableViewModel<NoNav, HelpViewModel.Section, HelpViewModel.Section> {
#if DEBUG
private var databaseKeyEncryptionPassword: String = ""
#endif
// MARK: - Section
public enum Section: SessionTableSection {
@ -17,6 +22,9 @@ class HelpViewModel: SessionTableViewModel<NoNav, HelpViewModel.Section, HelpVie
case feedback
case faq
case support
#if DEBUG
case exportDatabase
#endif
var style: SessionTableSectionStyle { .padding }
}
@ -136,6 +144,28 @@ class HelpViewModel: SessionTableViewModel<NoNav, HelpViewModel.Section, HelpVie
]
)
]
#if DEBUG
.appending(
SectionModel(
model: .exportDatabase,
elements: [
SessionCell.Info(
id: .support,
title: "Export Database",
rightAccessory: .icon(
UIImage(systemName: "square.and.arrow.up.trianglebadge.exclamationmark")?
.withRenderingMode(.alwaysTemplate),
size: .small
),
styling: SessionCell.StyleInfo(
tintColor: .danger
),
onTapView: { [weak self] view in self?.exportDatabase(view) }
)
]
)
)
#endif
}
.removeDuplicates()
.publisher(in: Storage.shared)
@ -197,4 +227,132 @@ class HelpViewModel: SessionTableViewModel<NoNav, HelpViewModel.Section, HelpVie
showShareSheet()
}
}
#if DEBUG
private func exportDatabase(_ targetView: UIView?) {
let generatedPassword: String = UUID().uuidString
self.databaseKeyEncryptionPassword = generatedPassword
self.transitionToScreen(
ConfirmationModal(
info: ConfirmationModal.Info(
title: "Export Database",
body: .input(
explanation: NSAttributedString(
string: """
Sharing the database and key together is dangerous!
We've generated a secure password for you but feel free to provide your own (we will show the generated password again after exporting)
This password will be used to encrypt the database decryption key and will be exported alongside the database
"""
),
placeholder: "Enter a password",
initialValue: generatedPassword,
clearButton: true,
onChange: { [weak self] value in self?.databaseKeyEncryptionPassword = value }
),
confirmTitle: "Export",
dismissOnConfirm: false,
onConfirm: { [weak self] modal in
modal.dismiss(animated: true) {
guard let password: String = self?.databaseKeyEncryptionPassword, password.count >= 6 else {
self?.transitionToScreen(
ConfirmationModal(
info: ConfirmationModal.Info(
title: "Error",
body: .text("Password must be at least 6 characters")
)
),
transitionType: .present
)
return
}
do {
let exportInfo = try Storage.shared.exportInfo(password: password)
let shareVC = UIActivityViewController(
activityItems: [
URL(fileURLWithPath: exportInfo.dbPath),
URL(fileURLWithPath: exportInfo.keyPath)
],
applicationActivities: nil
)
shareVC.completionWithItemsHandler = { [weak self] _, completed, _, _ in
guard
completed &&
generatedPassword == self?.databaseKeyEncryptionPassword
else { return }
self?.transitionToScreen(
ConfirmationModal(
info: ConfirmationModal.Info(
title: "Password",
body: .text("""
The generated password was:
\(generatedPassword)
Avoid sending this via the same means as the database
"""),
confirmTitle: "Share",
dismissOnConfirm: false,
onConfirm: { [weak self] modal in
modal.dismiss(animated: true) {
let passwordShareVC = UIActivityViewController(
activityItems: [generatedPassword],
applicationActivities: nil
)
if UIDevice.current.isIPad {
passwordShareVC.excludedActivityTypes = []
passwordShareVC.popoverPresentationController?.permittedArrowDirections = (targetView != nil ? [.up] : [])
passwordShareVC.popoverPresentationController?.sourceView = targetView
passwordShareVC.popoverPresentationController?.sourceRect = (targetView?.bounds ?? .zero)
}
self?.transitionToScreen(passwordShareVC, transitionType: .present)
}
}
)
),
transitionType: .present
)
}
if UIDevice.current.isIPad {
shareVC.excludedActivityTypes = []
shareVC.popoverPresentationController?.permittedArrowDirections = (targetView != nil ? [.up] : [])
shareVC.popoverPresentationController?.sourceView = targetView
shareVC.popoverPresentationController?.sourceRect = (targetView?.bounds ?? .zero)
}
self?.transitionToScreen(shareVC, transitionType: .present)
}
catch {
let message: String = {
switch error {
case CryptoKitError.incorrectKeySize:
return "The password must be between 6 and 32 characters (padded to 32 bytes)"
default: return "Failed to export database"
}
}()
self?.transitionToScreen(
ConfirmationModal(
info: ConfirmationModal.Info(
title: "Error",
body: .text(message)
)
),
transitionType: .present
)
}
}
}
)
),
transitionType: .present
)
}
#endif
}

View File

@ -55,11 +55,9 @@ enum _013_SessionUtilChanges: Migration {
// shares the same 'id' as the 'groupId') so we can cascade delete automatically
t.column(.groupId, .text)
.notNull()
.indexed() // Quicker querying
.references(SessionThread.self, onDelete: .cascade) // Delete if Thread deleted
t.column(.profileId, .text)
.notNull()
.indexed() // Quicker querying
t.column(.role, .integer).notNull()
t.column(.isHidden, .boolean)
.notNull()
@ -80,6 +78,11 @@ enum _013_SessionUtilChanges: Migration {
try db.drop(table: GroupMember.self)
try db.rename(table: TmpGroupMember.databaseTableName, to: GroupMember.databaseTableName)
// Need to create the indexes separately from creating 'TmpGroupMember' to ensure they
// have the correct names
try db.createIndex(on: GroupMember.self, columns: [.groupId])
try db.createIndex(on: GroupMember.self, columns: [.profileId])
// SQLite doesn't support removing unique constraints so we need to create a new table with
// the setup we want, copy data from the old table over, drop the old table and rename the new table
struct TmpClosedGroupKeyPair: Codable, TableRecord, FetchableRecord, PersistableRecord, ColumnExpressible {
@ -107,18 +110,16 @@ enum _013_SessionUtilChanges: Migration {
try db.create(table: TmpClosedGroupKeyPair.self) { t in
t.column(.threadId, .text)
.notNull()
.indexed() // Quicker querying
.references(ClosedGroup.self, onDelete: .cascade) // Delete if ClosedGroup deleted
t.column(.publicKey, .blob).notNull()
t.column(.secretKey, .blob).notNull()
t.column(.receivedTimestamp, .double)
.notNull()
.indexed() // Quicker querying
t.column(.threadKeyPairHash, .integer)
.notNull()
.unique()
.indexed()
}
// Insert into the new table, drop the old table and rename the new table to be the old one
try ClosedGroupKeyPair
.fetchAll(db)
@ -144,6 +145,12 @@ enum _013_SessionUtilChanges: Migration {
try db.rename(table: TmpClosedGroupKeyPair.databaseTableName, to: ClosedGroupKeyPair.databaseTableName)
// Add an index for the 'ClosedGroupKeyPair' so we can lookup existing keys more easily
//
// Note: Need to create the indexes separately from creating 'TmpClosedGroupKeyPair' to ensure they
// have the correct names
try db.createIndex(on: ClosedGroupKeyPair.self, columns: [.threadId])
try db.createIndex(on: ClosedGroupKeyPair.self, columns: [.receivedTimestamp])
try db.createIndex(on: ClosedGroupKeyPair.self, columns: [.threadKeyPairHash])
try db.createIndex(
on: ClosedGroupKeyPair.self,
columns: [.threadId, .threadKeyPairHash]

View File

@ -54,12 +54,13 @@ public struct Contact: Codable, Identifiable, Equatable, FetchableRecord, Persis
isApproved: Bool = false,
isBlocked: Bool = false,
didApproveMe: Bool = false,
hasBeenBlocked: Bool = false
hasBeenBlocked: Bool = false,
dependencies: Dependencies = Dependencies()
) {
self.id = id
self.isTrusted = (
isTrusted ||
id == getUserHexEncodedPublicKey() // Always trust ourselves
id == getUserHexEncodedPublicKey(dependencies: dependencies) // Always trust ourselves
)
self.isApproved = isApproved
self.isBlocked = isBlocked

View File

@ -36,7 +36,7 @@ public struct SessionThread: Codable, Identifiable, Equatable, FetchableRecord,
case pinnedPriority
}
public enum Variant: Int, Codable, Hashable, DatabaseValueConvertible {
public enum Variant: Int, Codable, Hashable, DatabaseValueConvertible, CaseIterable {
case contact
case legacyGroup
case community

View File

@ -34,63 +34,16 @@ internal extension SessionUtil {
mergeNeedsDump: Bool,
latestConfigSentTimestampMs: Int64
) throws {
typealias ContactData = [
String: (
contact: Contact,
profile: Profile,
priority: Int32,
created: TimeInterval
)
]
guard mergeNeedsDump else { return }
guard conf != nil else { throw SessionUtilError.nilConfigObject }
var contactData: ContactData = [:]
var contact: contacts_contact = contacts_contact()
let contactIterator: UnsafeMutablePointer<contacts_iterator> = contacts_iterator_new(conf)
while !contacts_iterator_done(contactIterator, &contact) {
let contactId: String = String(cString: withUnsafeBytes(of: contact.session_id) { [UInt8]($0) }
.map { CChar($0) }
.nullTerminated()
)
let contactResult: Contact = Contact(
id: contactId,
isApproved: contact.approved,
isBlocked: contact.blocked,
didApproveMe: contact.approved_me
)
let profilePictureUrl: String? = String(libSessionVal: contact.profile_pic.url, nullIfEmpty: true)
let profileResult: Profile = Profile(
id: contactId,
name: String(libSessionVal: contact.name),
lastNameUpdate: (TimeInterval(latestConfigSentTimestampMs) / 1000),
nickname: String(libSessionVal: contact.nickname, nullIfEmpty: true),
profilePictureUrl: profilePictureUrl,
profileEncryptionKey: (profilePictureUrl == nil ? nil :
Data(
libSessionVal: contact.profile_pic.key,
count: ProfileManager.avatarAES256KeyByteLength
)
),
lastProfilePictureUpdate: (TimeInterval(latestConfigSentTimestampMs) / 1000)
)
contactData[contactId] = (
contactResult,
profileResult,
contact.priority,
TimeInterval(contact.created)
)
contacts_iterator_advance(contactIterator)
}
contacts_iterator_free(contactIterator) // Need to free the iterator
// The current users contact data is handled separately so exclude it if it's present (as that's
// actually a bug)
let userPublicKey: String = getUserHexEncodedPublicKey(db)
let targetContactData: ContactData = contactData.filter { $0.key != userPublicKey }
let targetContactData: [String: ContactData] = extractContacts(
from: conf,
latestConfigSentTimestampMs: latestConfigSentTimestampMs
).filter { $0.key != userPublicKey }
// Since we don't sync 100% of the data stored against the contact and profile objects we
// need to only update the data we do have to ensure we don't overwrite anything that doesn't
@ -490,6 +443,121 @@ internal extension SessionUtil {
return updated
}
// MARK: - Pruning
static func pruningIfNeeded(
_ db: Database,
conf: UnsafeMutablePointer<config_object>?
) throws {
// First make sure we are actually thowing the correct size constraint error (don't want to prune contacts
// as a result of some other random error
do {
try CExceptionHelper.performSafely { config_push(conf).deallocate() }
return // If we didn't error then no need to prune
}
catch {
guard (error as NSError).userInfo["NSLocalizedDescription"] as? String == "Config data is too large" else {
throw error
}
}
// Extract the contact data from the config
var allContactData: [String: ContactData] = extractContacts(
from: conf,
latestConfigSentTimestampMs: 0
)
// Remove the current user profile info (shouldn't be in there but just in case)
let userPublicKey: String = getUserHexEncodedPublicKey(db)
var cUserPublicKey: [CChar] = userPublicKey.cArray.nullTerminated()
contacts_erase(conf, &cUserPublicKey)
/// Do the following in stages (we want to prune as few contacts as possible because we are essentially deleting data and removing these
/// contacts will result in not just contact data but also associated conversation data for the contact being removed from linked devices
///
///
/// **Step 1** First of all we want to try to detect spam-attacks (ie. if someone creates a bunch of accounts and messages you, and you
/// systematically block every one of those accounts - this can quickly add up)
///
/// We will do this by filtering the contact data to only include blocked contacts, grouping those contacts into contacts created within the
/// same hour and then only including groups that have more than 10 contacts (ie. if you blocked 20 users within an hour we expect those
/// contacts were spammers)
let blockSpamBatchingResolution: TimeInterval = (60 * 60)
// TODO: Do we want to only do this case for contacts that were created over X time ago? (to avoid unintentionally unblocking accounts that were recently blocked
let likelySpammerContacts: [ContactData] = allContactData
.values
.filter { $0.contact.isBlocked }
.grouped(by: { $0.created / blockSpamBatchingResolution })
.filter { _, values in values.count > 20 }
.values
.flatMap { $0 }
if !likelySpammerContacts.isEmpty {
likelySpammerContacts.forEach { contact in
var cSessionId: [CChar] = contact.contact.id.cArray.nullTerminated()
contacts_erase(conf, &cSessionId)
allContactData.removeValue(forKey: contact.contact.id)
}
// If we are no longer erroring then we can stop here
do { return try CExceptionHelper.performSafely { config_push(conf).deallocate() } }
catch {}
}
/// We retrieve the `CONVO_INFO_VOLATILE` records and one-to-one conversation message counts as they will be relevant for subsequent checks
let volatileThreadInfo: [String: VolatileThreadInfo] = SessionUtil
.config(for: .convoInfoVolatile, publicKey: userPublicKey)
.wrappedValue
.map { SessionUtil.extractConvoVolatileInfo(from: $0) }
.defaulting(to: [])
.reduce(into: [:]) { result, next in result[next.threadId] = next }
let conversationMessageCounts: [String: Int] = try SessionThread
.filter(SessionThread.Columns.variant == SessionThread.Variant.contact)
.select(.id)
.annotated(with: SessionThread.interactions.count)
.asRequest(of: ThreadCount.self)
.fetchAll(db)
.reduce(into: [:]) { result, next in result[next.id] = next.interactionCount }
/// **Step 2** Next up we want to remove contact records which are likely to be invalid, this means contacts which:
/// - Aren't blocked
/// - Have no `name` value
/// - Have no `CONVO_INFO_VOLATILE` record
/// - Have no messages in their one-to-one conversations
///
/// Any contacts that meet the above criteria are likely either invalid contacts or are contacts which the user hasn't seen or interacted
/// with for 30+ days
let likelyInvalidContacts: [ContactData] = allContactData
.values
.filter { !$0.contact.isBlocked }
.filter { $0.profile.name.isEmpty }
.filter { volatileThreadInfo[$0.contact.id] == nil }
.filter { (conversationMessageCounts[$0.contact.id] ?? 0) == 0 }
if !likelyInvalidContacts.isEmpty {
likelyInvalidContacts.forEach { contact in
var cSessionId: [CChar] = contact.contact.id.cArray.nullTerminated()
contacts_erase(conf, &cSessionId)
allContactData.removeValue(forKey: contact.contact.id)
}
// If we are no longer erroring then we can stop here
do { return try CExceptionHelper.performSafely { config_push(conf).deallocate() } }
catch {}
}
// TODO: Exclude contacts that have no profile info(?)
// TODO: Exclude contacts that have a CONVO_INFO_VOLATILE record
// TODO: Exclude contacts that have a conversation with messages in the database (ie. only delete "empty" contacts)
// TODO: Start pruning valid contacts which have really old conversations...
print("RAWR")
}
}
// MARK: - External Outgoing Changes
@ -555,3 +623,71 @@ extension SessionUtil {
}
}
}
// MARK: - ContactData
private struct ContactData {
let contact: Contact
let profile: Profile
let priority: Int32
let created: TimeInterval
}
// MARK: - ThreadCount
private struct ThreadCount: Codable, FetchableRecord {
let id: String
let interactionCount: Int
}
// MARK: - Convenience
private extension SessionUtil {
static func extractContacts(
from conf: UnsafeMutablePointer<config_object>?,
latestConfigSentTimestampMs: Int64
) -> [String: ContactData] {
var result: [String: ContactData] = [:]
var contact: contacts_contact = contacts_contact()
let contactIterator: UnsafeMutablePointer<contacts_iterator> = contacts_iterator_new(conf)
while !contacts_iterator_done(contactIterator, &contact) {
let contactId: String = String(cString: withUnsafeBytes(of: contact.session_id) { [UInt8]($0) }
.map { CChar($0) }
.nullTerminated()
)
let contactResult: Contact = Contact(
id: contactId,
isApproved: contact.approved,
isBlocked: contact.blocked,
didApproveMe: contact.approved_me
)
let profilePictureUrl: String? = String(libSessionVal: contact.profile_pic.url, nullIfEmpty: true)
let profileResult: Profile = Profile(
id: contactId,
name: String(libSessionVal: contact.name),
lastNameUpdate: (TimeInterval(latestConfigSentTimestampMs) / 1000),
nickname: String(libSessionVal: contact.nickname, nullIfEmpty: true),
profilePictureUrl: profilePictureUrl,
profileEncryptionKey: (profilePictureUrl == nil ? nil :
Data(
libSessionVal: contact.profile_pic.key,
count: ProfileManager.avatarAES256KeyByteLength
)
),
lastProfilePictureUpdate: (TimeInterval(latestConfigSentTimestampMs) / 1000)
)
result[contactId] = ContactData(
contact: contactResult,
profile: profileResult,
priority: contact.priority,
created: TimeInterval(contact.created)
)
contacts_iterator_advance(contactIterator)
}
contacts_iterator_free(contactIterator) // Need to free the iterator
return result
}
}

View File

@ -22,71 +22,8 @@ internal extension SessionUtil {
guard mergeNeedsDump else { return }
guard conf != nil else { throw SessionUtilError.nilConfigObject }
var volatileThreadInfo: [VolatileThreadInfo] = []
var oneToOne: convo_info_volatile_1to1 = convo_info_volatile_1to1()
var community: convo_info_volatile_community = convo_info_volatile_community()
var legacyGroup: convo_info_volatile_legacy_group = convo_info_volatile_legacy_group()
let convoIterator: OpaquePointer = convo_info_volatile_iterator_new(conf)
while !convo_info_volatile_iterator_done(convoIterator) {
if convo_info_volatile_it_is_1to1(convoIterator, &oneToOne) {
volatileThreadInfo.append(
VolatileThreadInfo(
threadId: String(libSessionVal: oneToOne.session_id),
variant: .contact,
changes: [
.markedAsUnread(oneToOne.unread),
.lastReadTimestampMs(oneToOne.last_read)
]
)
)
}
else if convo_info_volatile_it_is_community(convoIterator, &community) {
let server: String = String(libSessionVal: community.base_url)
let roomToken: String = String(libSessionVal: community.room)
let publicKey: String = Data(
libSessionVal: community.pubkey,
count: OpenGroup.pubkeyByteLength
).toHexString()
volatileThreadInfo.append(
VolatileThreadInfo(
threadId: OpenGroup.idFor(roomToken: roomToken, server: server),
variant: .community,
openGroupUrlInfo: OpenGroupUrlInfo(
threadId: OpenGroup.idFor(roomToken: roomToken, server: server),
server: server,
roomToken: roomToken,
publicKey: publicKey
),
changes: [
.markedAsUnread(community.unread),
.lastReadTimestampMs(community.last_read)
]
)
)
}
else if convo_info_volatile_it_is_legacy_group(convoIterator, &legacyGroup) {
volatileThreadInfo.append(
VolatileThreadInfo(
threadId: String(libSessionVal: legacyGroup.group_id),
variant: .legacyGroup,
changes: [
.markedAsUnread(legacyGroup.unread),
.lastReadTimestampMs(legacyGroup.last_read)
]
)
)
}
else {
SNLog("Ignoring unknown conversation type when iterating through volatile conversation info update")
}
convo_info_volatile_iterator_advance(convoIterator)
}
convo_info_volatile_iterator_free(convoIterator) // Need to free the iterator
// Get the local volatile thread info from all conversations
// Get the volatile thread info from the conf and local conversations
let volatileThreadInfo: [VolatileThreadInfo] = extractConvoVolatileInfo(from: conf)
let localVolatileThreadInfo: [String: VolatileThreadInfo] = VolatileThreadInfo.fetchAll(db)
.reduce(into: [:]) { result, next in result[next.threadId] = next }
@ -572,6 +509,76 @@ public extension SessionUtil {
}
}
}
internal static func extractConvoVolatileInfo(
from conf: UnsafeMutablePointer<config_object>?
) -> [VolatileThreadInfo] {
var result: [VolatileThreadInfo] = []
var oneToOne: convo_info_volatile_1to1 = convo_info_volatile_1to1()
var community: convo_info_volatile_community = convo_info_volatile_community()
var legacyGroup: convo_info_volatile_legacy_group = convo_info_volatile_legacy_group()
let convoIterator: OpaquePointer = convo_info_volatile_iterator_new(conf)
while !convo_info_volatile_iterator_done(convoIterator) {
if convo_info_volatile_it_is_1to1(convoIterator, &oneToOne) {
result.append(
VolatileThreadInfo(
threadId: String(libSessionVal: oneToOne.session_id),
variant: .contact,
changes: [
.markedAsUnread(oneToOne.unread),
.lastReadTimestampMs(oneToOne.last_read)
]
)
)
}
else if convo_info_volatile_it_is_community(convoIterator, &community) {
let server: String = String(libSessionVal: community.base_url)
let roomToken: String = String(libSessionVal: community.room)
let publicKey: String = Data(
libSessionVal: community.pubkey,
count: OpenGroup.pubkeyByteLength
).toHexString()
result.append(
VolatileThreadInfo(
threadId: OpenGroup.idFor(roomToken: roomToken, server: server),
variant: .community,
openGroupUrlInfo: OpenGroupUrlInfo(
threadId: OpenGroup.idFor(roomToken: roomToken, server: server),
server: server,
roomToken: roomToken,
publicKey: publicKey
),
changes: [
.markedAsUnread(community.unread),
.lastReadTimestampMs(community.last_read)
]
)
)
}
else if convo_info_volatile_it_is_legacy_group(convoIterator, &legacyGroup) {
result.append(
VolatileThreadInfo(
threadId: String(libSessionVal: legacyGroup.group_id),
variant: .legacyGroup,
changes: [
.markedAsUnread(legacyGroup.unread),
.lastReadTimestampMs(legacyGroup.last_read)
]
)
)
}
else {
SNLog("Ignoring unknown conversation type when iterating through volatile conversation info update")
}
convo_info_volatile_iterator_advance(convoIterator)
}
convo_info_volatile_iterator_free(convoIterator) // Need to free the iterator
return result
}
}
fileprivate extension [SessionUtil.VolatileThreadInfo.Change] {
@ -597,3 +604,4 @@ fileprivate extension [SessionUtil.VolatileThreadInfo.Change] {
return nil
}
}

View File

@ -1,6 +1,7 @@
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import Foundation
import GRDB
import Sodium
import SessionUtil
import SessionUtilitiesKit
@ -8,12 +9,248 @@ import SessionUtilitiesKit
import Quick
import Nimble
@testable import SessionMessagingKit
/// This spec is designed to replicate the initial test cases for the libSession-util to ensure the behaviour matches
class ConfigContactsSpec {
enum ContactProperty: CaseIterable {
case name
case nickname
case approved
case approved_me
case blocked
case profile_pic
case created
case notifications
case mute_until
}
// MARK: - Spec
static func spec() {
it("generates Contact configs correctly") {
context("CONTACTS") {
// MARK: - when checking error catching
context("when checking error catching") {
var seed: Data!
var identity: (ed25519KeyPair: KeyPair, x25519KeyPair: KeyPair)!
var edSK: [UInt8]!
var error: UnsafeMutablePointer<CChar>?
var conf: UnsafeMutablePointer<config_object>?
beforeEach {
seed = Data(hex: "0123456789abcdef0123456789abcdef")
// FIXME: Would be good to move these into the libSession-util instead of using Sodium separately
identity = try! Identity.generate(from: seed)
edSK = identity.ed25519KeyPair.secretKey
// Initialize a brand new, empty config because we have no dump data to deal with.
error = nil
conf = nil
_ = contacts_init(&conf, &edSK, nil, 0, error)
error?.deallocate()
}
// MARK: -- it can catch size limit errors thrown when pushing
it("can catch size limit errors thrown when pushing") {
try (0..<10000).forEach { index in
var contact: contacts_contact = try createContact(for: index, in: conf, maxing: .allProperties)
contacts_set(conf, &contact)
}
expect(contacts_size(conf)).to(equal(10000))
expect(config_needs_push(conf)).to(beTrue())
expect(config_needs_dump(conf)).to(beTrue())
expect {
try CExceptionHelper.performSafely { config_push(conf).deallocate() }
}
.to(throwError(NSError(domain: "cpp_exception", code: -2, userInfo: ["NSLocalizedDescription": "Config data is too large"])))
}
// MARK: -- can catch size limit errors thrown when dumping
it("can catch size limit errors thrown when dumping") {
try (0..<10000).forEach { index in
var contact: contacts_contact = try createContact(for: index, in: conf, maxing: .allProperties)
contacts_set(conf, &contact)
}
expect(contacts_size(conf)).to(equal(10000))
expect(config_needs_push(conf)).to(beTrue())
expect(config_needs_dump(conf)).to(beTrue())
expect {
try CExceptionHelper.performSafely {
var dump: UnsafeMutablePointer<UInt8>? = nil
var dumpLen: Int = 0
config_dump(conf, &dump, &dumpLen)
dump?.deallocate()
}
}
.to(throwError(NSError(domain: "cpp_exception", code: -2, userInfo: ["NSLocalizedDescription": "Config data is too large"])))
}
}
// MARK: - when checking size limits
context("when checking size limits") {
var numRecords: Int!
var seed: Data!
var identity: (ed25519KeyPair: KeyPair, x25519KeyPair: KeyPair)!
var edSK: [UInt8]!
var error: UnsafeMutablePointer<CChar>?
var conf: UnsafeMutablePointer<config_object>?
beforeEach {
numRecords = 0
seed = Data(hex: "0123456789abcdef0123456789abcdef")
// FIXME: Would be good to move these into the libSession-util instead of using Sodium separately
identity = try! Identity.generate(from: seed)
edSK = identity.ed25519KeyPair.secretKey
// Initialize a brand new, empty config because we have no dump data to deal with.
error = nil
conf = nil
_ = contacts_init(&conf, &edSK, nil, 0, error)
error?.deallocate()
}
// MARK: -- has not changed the max empty records
it("has not changed the max empty records") {
for index in (0..<10000) {
var contact: contacts_contact = try createContact(for: index, in: conf)
contacts_set(conf, &contact)
do { try CExceptionHelper.performSafely { config_push(conf).deallocate() } }
catch { break }
// We successfully inserted a contact and didn't hit the limit so increment the counter
numRecords += 1
}
// Check that the record count matches the maximum when we last checked
expect(numRecords).to(equal(1775))
}
// MARK: -- has not changed the max name only records
it("has not changed the max name only records") {
for index in (0..<10000) {
var contact: contacts_contact = try createContact(for: index, in: conf, maxing: [.name])
contacts_set(conf, &contact)
do { try CExceptionHelper.performSafely { config_push(conf).deallocate() } }
catch { break }
// We successfully inserted a contact and didn't hit the limit so increment the counter
numRecords += 1
}
// Check that the record count matches the maximum when we last checked
expect(numRecords).to(equal(526))
}
// MARK: -- has not changed the max name and profile pic only records
it("has not changed the max name and profile pic only records") {
for index in (0..<10000) {
var contact: contacts_contact = try createContact(for: index, in: conf, maxing: [.name, .profile_pic])
contacts_set(conf, &contact)
do { try CExceptionHelper.performSafely { config_push(conf).deallocate() } }
catch { break }
// We successfully inserted a contact and didn't hit the limit so increment the counter
numRecords += 1
}
// Check that the record count matches the maximum when we last checked
expect(numRecords).to(equal(184))
}
// MARK: -- has not changed the max filled records
it("has not changed the max filled records") {
for index in (0..<10000) {
var contact: contacts_contact = try createContact(for: index, in: conf, maxing: .allProperties)
contacts_set(conf, &contact)
do { try CExceptionHelper.performSafely { config_push(conf).deallocate() } }
catch { break }
// We successfully inserted a contact and didn't hit the limit so increment the counter
numRecords += 1
}
// Check that the record count matches the maximum when we last checked
expect(numRecords).to(equal(134))
}
}
// MARK: - when pruning
context("when pruning") {
var mockStorage: Storage!
var seed: Data!
var identity: (ed25519KeyPair: KeyPair, x25519KeyPair: KeyPair)!
var edSK: [UInt8]!
var error: UnsafeMutablePointer<CChar>?
var conf: UnsafeMutablePointer<config_object>?
beforeEach {
mockStorage = Storage(
customWriter: try! DatabaseQueue(),
customMigrations: [
SNUtilitiesKit.migrations(),
SNMessagingKit.migrations()
]
)
seed = Data(hex: "0123456789abcdef0123456789abcdef")
// FIXME: Would be good to move these into the libSession-util instead of using Sodium separately
identity = try! Identity.generate(from: seed)
edSK = identity.ed25519KeyPair.secretKey
// Initialize a brand new, empty config because we have no dump data to deal with.
error = nil
conf = nil
_ = contacts_init(&conf, &edSK, nil, 0, error)
error?.deallocate()
}
it("does something") {
mockStorage.write { db in
try SessionThread.fetchOrCreate(db, id: "1", variant: .contact, shouldBeVisible: true)
try SessionThread.fetchOrCreate(db, id: "2", variant: .contact, shouldBeVisible: true)
try SessionThread.fetchOrCreate(db, id: "3", variant: .contact, shouldBeVisible: true)
_ = try Interaction(
threadId: "1",
authorId: "1",
variant: .standardIncoming,
body: "Test1"
).inserted(db)
_ = try Interaction(
threadId: "1",
authorId: "2",
variant: .standardIncoming,
body: "Test2"
).inserted(db)
_ = try Interaction(
threadId: "3",
authorId: "3",
variant: .standardIncoming,
body: "Test3"
).inserted(db)
try SessionUtil.pruningIfNeeded(
db,
conf: conf
)
expect(contacts_size(conf)).to(equal(0))
}
}
}
// MARK: - generates config correctly
it("generates config correctly") {
let createdTs: Int64 = 1680064059
let nowTs: Int64 = Int64(Date().timeIntervalSince1970)
let seed: Data = Data(hex: "0123456789abcdef0123456789abcdef")
@ -304,3 +541,69 @@ class ConfigContactsSpec {
}
}
}
// MARK: - Convenience
private static func createContact(
for index: Int,
in conf: UnsafeMutablePointer<config_object>?,
maxing properties: [ContactProperty] = []
) throws -> contacts_contact {
let postPrefixId: String = "050000000000000000000000000000000000000000000000000000000000000000"
let sessionId: String = ("05\(index)a" + postPrefixId.suffix(postPrefixId.count - "05\(index)a".count))
var cSessionId: [CChar] = sessionId.cArray.nullTerminated()
var contact: contacts_contact = contacts_contact()
guard contacts_get_or_construct(conf, &contact, &cSessionId) else {
throw SessionUtilError.getOrConstructFailedUnexpectedly
}
// Set the values to the maximum data that can fit
properties.forEach { property in
switch property {
case .approved: contact.approved = true
case .approved_me: contact.approved_me = true
case .blocked: contact.blocked = true
case .created: contact.created = Int64.max
case .notifications: contact.notifications = CONVO_NOTIFY_MENTIONS_ONLY
case .mute_until: contact.mute_until = Int64.max
case .name:
contact.name = String(
data: Data(
repeating: "A".data(using: .utf8)![0],
count: SessionUtil.libSessionMaxNameByteLength
),
encoding: .utf8
).toLibSession()
case .nickname:
contact.nickname = String(
data: Data(
repeating: "A".data(using: .utf8)![0],
count: SessionUtil.libSessionMaxNameByteLength
),
encoding: .utf8
).toLibSession()
case .profile_pic:
contact.profile_pic = user_profile_pic(
url: String(
data: Data(
repeating: "A".data(using: .utf8)![0],
count: SessionUtil.libSessionMaxProfileUrlByteLength
),
encoding: .utf8
).toLibSession(),
key: "qwerty78901234567890123456789012".data(using: .utf8)!.toLibSession()
)
}
}
return contact
}
}
fileprivate extension Array where Element == ConfigContactsSpec.ContactProperty {
static var allProperties: [ConfigContactsSpec.ContactProperty] = ConfigContactsSpec.ContactProperty.allCases
}

View File

@ -13,7 +13,8 @@ class ConfigConvoInfoVolatileSpec {
// MARK: - Spec
static func spec() {
it("generates ConvoInfoVolatile configs correctly") {
context("CONVO_INFO_VOLATILE") {
it("generates config correctly") {
let seed: Data = Data(hex: "0123456789abcdef0123456789abcdef")
// FIXME: Would be good to move these into the libSession-util instead of using Sodium separately
@ -263,3 +264,4 @@ class ConfigConvoInfoVolatileSpec {
}
}
}
}

View File

@ -83,7 +83,8 @@ class ConfigUserGroupsSpec {
.to(equal("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"))
}
it("generates UserGroup configs correctly") {
context("USER_GROUPS") {
it("generates config correctly") {
let createdTs: Int64 = 1680064059
let nowTs: Int64 = Int64(Date().timeIntervalSince1970)
let seed: Data = Data(hex: "0123456789abcdef0123456789abcdef")
@ -585,3 +586,4 @@ class ConfigUserGroupsSpec {
}
}
}
}

View File

@ -14,7 +14,8 @@ class ConfigUserProfileSpec {
// MARK: - Spec
static func spec() {
it("generates UserProfile configs correctly") {
context("USER_PROFILE") {
it("generates config correctly") {
let seed: Data = Data(hex: "0123456789abcdef0123456789abcdef")
// FIXME: Would be good to move these into the libSession-util instead of using Sodium separately
@ -395,3 +396,4 @@ class ConfigUserProfileSpec {
}
}
}
}

View File

@ -12,9 +12,11 @@ class LibSessionSpec: QuickSpec {
// MARK: - Spec
override func spec() {
describe("libSession") {
ConfigContactsSpec.spec()
ConfigUserProfileSpec.spec()
ConfigConvoInfoVolatileSpec.spec()
ConfigUserGroupsSpec.spec()
}
}
}

View File

@ -53,12 +53,16 @@ class ThreadSettingsViewModelSpec: QuickSpec {
try Profile(
id: "05\(TestConstants.publicKey)",
name: "TestMe"
name: "TestMe",
lastNameUpdate: 0,
lastProfilePictureUpdate: 0
).insert(db)
try Profile(
id: "TestId",
name: "TestUser"
name: "TestUser",
lastNameUpdate: 0,
lastProfilePictureUpdate: 0
).insert(db)
}
viewModel = ThreadSettingsViewModel(

View File

@ -4,15 +4,38 @@ import UIKit
import SessionUtilitiesKit
// FIXME: Refactor as part of the Groups Rebuild
public class ConfirmationModal: Modal {
public class ConfirmationModal: Modal, UITextFieldDelegate {
private static let closeSize: CGFloat = 24
private var internalOnConfirm: ((ConfirmationModal) -> ())? = nil
private var internalOnCancel: ((ConfirmationModal) -> ())? = nil
private var internalOnBodyTap: (() -> ())? = nil
private var internalOnTextChanged: ((String) -> ())? = nil
// MARK: - Components
private lazy var contentTapGestureRecognizer: UITapGestureRecognizer = {
let result: UITapGestureRecognizer = UITapGestureRecognizer(
target: self,
action: #selector(contentViewTapped)
)
contentView.addGestureRecognizer(result)
result.isEnabled = false
return result
}()
private lazy var imageViewTapGestureRecognizer: UITapGestureRecognizer = {
let result: UITapGestureRecognizer = UITapGestureRecognizer(
target: self,
action: #selector(imageViewTapped)
)
imageViewContainer.addGestureRecognizer(result)
result.isEnabled = false
return result
}()
private lazy var titleLabel: UILabel = {
let result: UILabel = UILabel()
result.font = .boldSystemFont(ofSize: Values.mediumFontSize)
@ -36,16 +59,30 @@ public class ConfirmationModal: Modal {
return result
}()
private lazy var textFieldContainer: UIView = {
let result: UIView = UIView()
result.themeBorderColor = .borderSeparator
result.layer.cornerRadius = 11
result.layer.borderWidth = 1
result.isHidden = true
result.set(.height, to: 40)
return result
}()
private lazy var textField: UITextField = {
let result: UITextField = UITextField()
result.font = .systemFont(ofSize: Values.smallFontSize)
result.themeTextColor = .textPrimary
result.delegate = self
return result
}()
private lazy var imageViewContainer: UIView = {
let result: UIView = UIView()
result.isHidden = true
let gestureRecogniser: UITapGestureRecognizer = UITapGestureRecognizer(
target: self,
action: #selector(imageViewTapped)
)
result.addGestureRecognizer(gestureRecogniser)
return result
}()
@ -70,7 +107,7 @@ public class ConfirmationModal: Modal {
}()
private lazy var contentStackView: UIStackView = {
let result = UIStackView(arrangedSubviews: [ titleLabel, explanationLabel, imageViewContainer ])
let result = UIStackView(arrangedSubviews: [ titleLabel, explanationLabel, textFieldContainer, imageViewContainer ])
result.axis = .vertical
result.spacing = Values.smallSpacing
result.isLayoutMarginsRelativeArrangement = true
@ -132,13 +169,22 @@ public class ConfirmationModal: Modal {
}
public override func populateContentView() {
let gestureRecogniser: UITapGestureRecognizer = UITapGestureRecognizer(
target: self,
action: #selector(contentViewTapped)
)
contentView.addGestureRecognizer(gestureRecogniser)
contentView.addSubview(mainStackView)
contentView.addSubview(closeButton)
textFieldContainer.addSubview(textField)
textField.pin(to: textFieldContainer, withInset: 12)
imageViewContainer.addSubview(profileView)
profileView.center(.horizontal, in: imageViewContainer)
profileView.pin(.top, to: .top, of: imageViewContainer)//, withInset: 15)
profileView.pin(.bottom, to: .bottom, of: imageViewContainer)//, withInset: -15)
profileView.pin(.top, to: .top, of: imageViewContainer)
profileView.pin(.bottom, to: .bottom, of: imageViewContainer)
mainStackView.pin(to: contentView)
closeButton.pin(.top, to: .top, of: contentView, withInset: 8)
@ -149,6 +195,7 @@ public class ConfirmationModal: Modal {
public func updateContent(with info: Info) {
internalOnBodyTap = nil
internalOnTextChanged = nil
internalOnConfirm = { modal in
if info.dismissOnConfirm {
modal.close()
@ -161,6 +208,8 @@ public class ConfirmationModal: Modal {
info.onCancel?(modal)
}
contentTapGestureRecognizer.isEnabled = true
imageViewTapGestureRecognizer.isEnabled = false
// Set the content based on the provided info
titleLabel.text = info.title
@ -179,6 +228,15 @@ public class ConfirmationModal: Modal {
explanationLabel.attributedText = attributedText
explanationLabel.isHidden = false
case .input(let explanation, let placeholder, let value, let clearButton, let onTextChanged):
explanationLabel.attributedText = explanation
explanationLabel.isHidden = (explanation == nil)
textField.placeholder = placeholder
textField.text = (value ?? "")
textField.clearButtonMode = (clearButton ? .always : .never)
textFieldContainer.isHidden = false
internalOnTextChanged = onTextChanged
case .image(let placeholder, let value, let icon, let style, let accessibility, let onClick):
imageViewContainer.isAccessibilityElement = (accessibility != nil)
imageViewContainer.accessibilityIdentifier = accessibility?.identifier
@ -193,6 +251,8 @@ public class ConfirmationModal: Modal {
)
)
internalOnBodyTap = onClick
contentTapGestureRecognizer.isEnabled = false
imageViewTapGestureRecognizer.isEnabled = true
}
confirmButton.accessibilityLabel = info.confirmAccessibility?.label
@ -216,8 +276,38 @@ public class ConfirmationModal: Modal {
contentView.accessibilityIdentifier = info.accessibility?.identifier
}
// MARK: - UITextFieldDelegate
public func textFieldShouldReturn(_ textField: UITextField) -> Bool {
textField.resignFirstResponder()
return true
}
public func textFieldShouldClear(_ textField: UITextField) -> Bool {
internalOnTextChanged?("")
return true
}
public func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool {
if let text: String = textField.text, let textRange: Range = Range(range, in: text) {
let updatedText = text.replacingCharacters(in: textRange, with: string)
internalOnTextChanged?(updatedText)
}
return true
}
// MARK: - Interaction
@objc private func contentViewTapped() {
if textField.isFirstResponder {
textField.resignFirstResponder()
}
internalOnBodyTap?()
}
@objc private func imageViewTapped() {
internalOnBodyTap?()
}
@ -400,8 +490,14 @@ public extension ConfirmationModal.Info {
case none
case text(String)
case attributedText(NSAttributedString)
// FIXME: Implement these
// case input(placeholder: String, value: String?)
case input(
explanation: NSAttributedString?,
placeholder: String,
initialValue: String?,
clearButton: Bool,
onChange: (String) -> ()
)
// FIXME: Implement this
// case radio(explanation: NSAttributedString?, options: [(title: String, selected: Bool)])
case image(
placeholderData: Data?,
@ -418,14 +514,15 @@ public extension ConfirmationModal.Info {
case (.text(let lhsText), .text(let rhsText)): return (lhsText == rhsText)
case (.attributedText(let lhsText), .attributedText(let rhsText)): return (lhsText == rhsText)
// FIXME: Implement these
//case (.input(let lhsPlaceholder, let lhsValue), .input(let rhsPlaceholder, let rhsValue)):
// return (
// lhsPlaceholder == rhsPlaceholder &&
// lhsValue == rhsValue &&
// )
case (.input(let lhsExplanation, let lhsPlaceholder, let lhsInitialValue, let lhsClearButton, _), .input(let rhsExplanation, let rhsPlaceholder, let rhsInitialValue, let rhsClearButton, _)):
return (
lhsExplanation == rhsExplanation &&
lhsPlaceholder == rhsPlaceholder &&
lhsInitialValue == rhsInitialValue &&
lhsClearButton == rhsClearButton
)
// FIXME: Implement these
// FIXME: Implement this
//case (.radio(let lhsExplanation, let lhsOptions), .radio(let rhsExplanation, let rhsOptions)):
// return (
// lhsExplanation == rhsExplanation &&
@ -451,6 +548,12 @@ public extension ConfirmationModal.Info {
case .text(let text): text.hash(into: &hasher)
case .attributedText(let text): text.hash(into: &hasher)
case .input(let explanation, let placeholder, let initialValue, let clearButton, _):
explanation.hash(into: &hasher)
placeholder.hash(into: &hasher)
initialValue.hash(into: &hasher)
clearButton.hash(into: &hasher)
case .image(let placeholder, let value, let icon, let style, let accessibility, _):
placeholder.hash(into: &hasher)
value.hash(into: &hasher)

View File

@ -1,6 +1,7 @@
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import Foundation
import CryptoKit
import Combine
import GRDB
import SignalCoreKit
@ -492,3 +493,38 @@ public extension ValueObservation {
.eraseToAnyPublisher()
}
}
// MARK: - Debug Convenience
#if DEBUG
public extension Storage {
func exportInfo(password: String) throws -> (dbPath: String, keyPath: String) {
var keySpec: Data = try Storage.getOrGenerateDatabaseKeySpec()
defer { keySpec.resetBytes(in: 0..<keySpec.count) } // Reset content immediately after use
guard var passwordData: Data = password.data(using: .utf8) else { throw StorageError.generic }
defer { passwordData.resetBytes(in: 0..<passwordData.count) } // Reset content immediately after use
/// Encrypt the `keySpec` value using a SHA256 of the password provided and a nonce then base64-encode the encrypted
/// data and save it to a temporary file to share alongside the database
///
/// Decrypt the key via the termincal on macOS by running the command in the project root directory
/// `swift ./Scropts/DecryptExportedKey.swift {BASE64_CIPHERTEXT} {PASSWORD}`
///
/// Where `BASE64_CIPHERTEXT` is the content of the `key.enc` file and `PASSWORD` is the password provided via the
/// prompt during export
let nonce: ChaChaPoly.Nonce = ChaChaPoly.Nonce()
let hash: SHA256.Digest = SHA256.hash(data: passwordData)
let key: SymmetricKey = SymmetricKey(data: Data(hash.makeIterator()))
let sealedBox: ChaChaPoly.SealedBox = try ChaChaPoly.seal(keySpec, using: key, nonce: nonce, authenticating: Data())
let keyInfoPath: String = "\(NSTemporaryDirectory())key.enc"
let encryptedKeyBase64: String = sealedBox.combined.base64EncodedString()
try encryptedKeyBase64.write(toFile: keyInfoPath, atomically: true, encoding: .utf8)
return (
Storage.databasePath,
keyInfoPath
)
}
}
#endif

View File

@ -16,4 +16,5 @@ FOUNDATION_EXPORT const unsigned char SessionUtilitiesKitVersionString[];
#import <SessionUtilitiesKit/UIImage+OWS.h>
#import <SessionUtilitiesKit/UIView+OWS.h>
#import <SessionUtilitiesKit/OWSBackgroundTask.h>
#import <SessionUtilitiesKit/CExceptionHelper.h>

View File

@ -0,0 +1,16 @@
// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved.
#ifndef __CExceptionHelper_h__
#define __CExceptionHelper_h__
#import <Foundation/Foundation.h>
#define noEscape __attribute__((noescape))
@interface CExceptionHelper: NSObject
+ (BOOL)performSafely:(noEscape void(^)(void))tryBlock error:(__autoreleasing NSError **)error;
@end
#endif

View File

@ -0,0 +1,36 @@
// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved.
//
// This logic is not foolproof and may result in memory-leaks, when possible we should look to remove this
// and use the native C++ <-> Swift interoperability coming with Swift 5.9
//
// This solution was sourced from the following link, for more information please refer to this thread:
// https://forums.swift.org/t/pitch-a-swift-representation-for-thrown-and-caught-exceptions/54583
#import "CExceptionHelper.h"
#include <exception>
@implementation CExceptionHelper
+ (BOOL)performSafely:(noEscape void(^)(void))tryBlock error:(__autoreleasing NSError **)error {
try {
tryBlock();
return YES;
}
catch(NSException* e) {
*error = [[NSError alloc] initWithDomain:e.name code:-1 userInfo:e.userInfo];
return NO;
}
catch (std::exception& e) {
NSString* what = [NSString stringWithUTF8String: e.what()];
NSDictionary* userInfo = @{NSLocalizedDescriptionKey : what};
*error = [[NSError alloc] initWithDomain:@"cpp_exception" code:-2 userInfo:userInfo];
return NO;
}
catch(...) {
NSDictionary* userInfo = @{NSLocalizedDescriptionKey:@"Other C++ exception"};
*error = [[NSError alloc] initWithDomain:@"cpp_exception" code:-3 userInfo:userInfo];
return NO;
}
}
@end