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:
parent
65bf7d7d82
commit
d2c82cb915
|
@ -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")
|
||||||
|
}
|
|
@ -588,6 +588,8 @@
|
||||||
FD2B4AFF2946C93200AB4848 /* ConfigurationSyncJob.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD2B4AFE2946C93200AB4848 /* ConfigurationSyncJob.swift */; };
|
FD2B4AFF2946C93200AB4848 /* ConfigurationSyncJob.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD2B4AFE2946C93200AB4848 /* ConfigurationSyncJob.swift */; };
|
||||||
FD2B4B042949887A00AB4848 /* QueryInterfaceRequest+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD2B4B032949887A00AB4848 /* QueryInterfaceRequest+Utilities.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 */; };
|
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 */; };
|
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 */; };
|
FD368A6A29DE9E30000DBF1E /* UIContextualAction+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD368A6929DE9E30000DBF1E /* UIContextualAction+Utilities.swift */; };
|
||||||
FD37E9C328A1C6F3003AE748 /* ThemeManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD37E9C228A1C6F3003AE748 /* ThemeManager.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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
FD37E9C228A1C6F3003AE748 /* ThemeManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThemeManager.swift; sourceTree = "<group>"; };
|
||||||
|
@ -3589,6 +3593,8 @@
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
FDA8EB0F280F8238002B68E5 /* Codable+Utilities.swift */,
|
FDA8EB0F280F8238002B68E5 /* Codable+Utilities.swift */,
|
||||||
|
FD3003692A3ADD6000B5A5FB /* CExceptionHelper.h */,
|
||||||
|
FD30036D2A3AE26000B5A5FB /* CExceptionHelper.mm */,
|
||||||
FD09796A27F6C67500936362 /* Failable.swift */,
|
FD09796A27F6C67500936362 /* Failable.swift */,
|
||||||
FD09797127FAA2F500936362 /* Optional+Utilities.swift */,
|
FD09797127FAA2F500936362 /* Optional+Utilities.swift */,
|
||||||
FD09797C27FBDB2000936362 /* Notification+Utilities.swift */,
|
FD09797C27FBDB2000936362 /* Notification+Utilities.swift */,
|
||||||
|
@ -4457,6 +4463,7 @@
|
||||||
B8856E1A256F1700001CE70E /* OWSMath.h in Headers */,
|
B8856E1A256F1700001CE70E /* OWSMath.h in Headers */,
|
||||||
C352A3772557864000338F3E /* NSTimer+Proxying.h in Headers */,
|
C352A3772557864000338F3E /* NSTimer+Proxying.h in Headers */,
|
||||||
C32C5B51256DC219003C73A2 /* NSNotificationCenter+OWS.h in Headers */,
|
C32C5B51256DC219003C73A2 /* NSNotificationCenter+OWS.h in Headers */,
|
||||||
|
FD30036A2A3ADEC100B5A5FB /* CExceptionHelper.h in Headers */,
|
||||||
C3C2A67D255388CC00C340D1 /* SessionUtilitiesKit.h in Headers */,
|
C3C2A67D255388CC00C340D1 /* SessionUtilitiesKit.h in Headers */,
|
||||||
C32C6018256E07F9003C73A2 /* NSUserDefaults+OWS.h in Headers */,
|
C32C6018256E07F9003C73A2 /* NSUserDefaults+OWS.h in Headers */,
|
||||||
B8856D8D256F1502001CE70E /* UIView+OWS.h in Headers */,
|
B8856D8D256F1502001CE70E /* UIView+OWS.h in Headers */,
|
||||||
|
@ -5639,6 +5646,7 @@
|
||||||
C3D9E4D12567777D0040E4F3 /* OWSMediaUtils.swift in Sources */,
|
C3D9E4D12567777D0040E4F3 /* OWSMediaUtils.swift in Sources */,
|
||||||
C3BBE0AA2554D4DE0050F1E3 /* Dictionary+Utilities.swift in Sources */,
|
C3BBE0AA2554D4DE0050F1E3 /* Dictionary+Utilities.swift in Sources */,
|
||||||
FD37EA1128AB34B3003AE748 /* TypedTableAlteration.swift in Sources */,
|
FD37EA1128AB34B3003AE748 /* TypedTableAlteration.swift in Sources */,
|
||||||
|
FD30036E2A3AE26000B5A5FB /* CExceptionHelper.mm in Sources */,
|
||||||
C3D9E4DA256778410040E4F3 /* UIImage+OWS.m in Sources */,
|
C3D9E4DA256778410040E4F3 /* UIImage+OWS.m in Sources */,
|
||||||
C32C600F256E07F5003C73A2 /* NSUserDefaults+OWS.m in Sources */,
|
C32C600F256E07F5003C73A2 /* NSUserDefaults+OWS.m in Sources */,
|
||||||
FDE658A329418E2F00A33BC1 /* KeyPair.swift in Sources */,
|
FDE658A329418E2F00A33BC1 /* KeyPair.swift in Sources */,
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import CryptoKit
|
||||||
import GRDB
|
import GRDB
|
||||||
import DifferenceKit
|
import DifferenceKit
|
||||||
import SessionUIKit
|
import SessionUIKit
|
||||||
|
@ -9,6 +10,10 @@ import SessionUtilitiesKit
|
||||||
import SignalCoreKit
|
import SignalCoreKit
|
||||||
|
|
||||||
class HelpViewModel: SessionTableViewModel<NoNav, HelpViewModel.Section, HelpViewModel.Section> {
|
class HelpViewModel: SessionTableViewModel<NoNav, HelpViewModel.Section, HelpViewModel.Section> {
|
||||||
|
#if DEBUG
|
||||||
|
private var databaseKeyEncryptionPassword: String = ""
|
||||||
|
#endif
|
||||||
|
|
||||||
// MARK: - Section
|
// MARK: - Section
|
||||||
|
|
||||||
public enum Section: SessionTableSection {
|
public enum Section: SessionTableSection {
|
||||||
|
@ -17,6 +22,9 @@ class HelpViewModel: SessionTableViewModel<NoNav, HelpViewModel.Section, HelpVie
|
||||||
case feedback
|
case feedback
|
||||||
case faq
|
case faq
|
||||||
case support
|
case support
|
||||||
|
#if DEBUG
|
||||||
|
case exportDatabase
|
||||||
|
#endif
|
||||||
|
|
||||||
var style: SessionTableSectionStyle { .padding }
|
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()
|
.removeDuplicates()
|
||||||
.publisher(in: Storage.shared)
|
.publisher(in: Storage.shared)
|
||||||
|
@ -197,4 +227,132 @@ class HelpViewModel: SessionTableViewModel<NoNav, HelpViewModel.Section, HelpVie
|
||||||
showShareSheet()
|
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
|
||||||
}
|
}
|
||||||
|
|
|
@ -55,11 +55,9 @@ enum _013_SessionUtilChanges: Migration {
|
||||||
// shares the same 'id' as the 'groupId') so we can cascade delete automatically
|
// shares the same 'id' as the 'groupId') so we can cascade delete automatically
|
||||||
t.column(.groupId, .text)
|
t.column(.groupId, .text)
|
||||||
.notNull()
|
.notNull()
|
||||||
.indexed() // Quicker querying
|
|
||||||
.references(SessionThread.self, onDelete: .cascade) // Delete if Thread deleted
|
.references(SessionThread.self, onDelete: .cascade) // Delete if Thread deleted
|
||||||
t.column(.profileId, .text)
|
t.column(.profileId, .text)
|
||||||
.notNull()
|
.notNull()
|
||||||
.indexed() // Quicker querying
|
|
||||||
t.column(.role, .integer).notNull()
|
t.column(.role, .integer).notNull()
|
||||||
t.column(.isHidden, .boolean)
|
t.column(.isHidden, .boolean)
|
||||||
.notNull()
|
.notNull()
|
||||||
|
@ -80,6 +78,11 @@ enum _013_SessionUtilChanges: Migration {
|
||||||
try db.drop(table: GroupMember.self)
|
try db.drop(table: GroupMember.self)
|
||||||
try db.rename(table: TmpGroupMember.databaseTableName, to: GroupMember.databaseTableName)
|
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
|
// 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
|
// 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 {
|
struct TmpClosedGroupKeyPair: Codable, TableRecord, FetchableRecord, PersistableRecord, ColumnExpressible {
|
||||||
|
@ -107,18 +110,16 @@ enum _013_SessionUtilChanges: Migration {
|
||||||
try db.create(table: TmpClosedGroupKeyPair.self) { t in
|
try db.create(table: TmpClosedGroupKeyPair.self) { t in
|
||||||
t.column(.threadId, .text)
|
t.column(.threadId, .text)
|
||||||
.notNull()
|
.notNull()
|
||||||
.indexed() // Quicker querying
|
|
||||||
.references(ClosedGroup.self, onDelete: .cascade) // Delete if ClosedGroup deleted
|
.references(ClosedGroup.self, onDelete: .cascade) // Delete if ClosedGroup deleted
|
||||||
t.column(.publicKey, .blob).notNull()
|
t.column(.publicKey, .blob).notNull()
|
||||||
t.column(.secretKey, .blob).notNull()
|
t.column(.secretKey, .blob).notNull()
|
||||||
t.column(.receivedTimestamp, .double)
|
t.column(.receivedTimestamp, .double)
|
||||||
.notNull()
|
.notNull()
|
||||||
.indexed() // Quicker querying
|
|
||||||
t.column(.threadKeyPairHash, .integer)
|
t.column(.threadKeyPairHash, .integer)
|
||||||
.notNull()
|
.notNull()
|
||||||
.unique()
|
.unique()
|
||||||
.indexed()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Insert into the new table, drop the old table and rename the new table to be the old one
|
// Insert into the new table, drop the old table and rename the new table to be the old one
|
||||||
try ClosedGroupKeyPair
|
try ClosedGroupKeyPair
|
||||||
.fetchAll(db)
|
.fetchAll(db)
|
||||||
|
@ -144,6 +145,12 @@ enum _013_SessionUtilChanges: Migration {
|
||||||
try db.rename(table: TmpClosedGroupKeyPair.databaseTableName, to: ClosedGroupKeyPair.databaseTableName)
|
try db.rename(table: TmpClosedGroupKeyPair.databaseTableName, to: ClosedGroupKeyPair.databaseTableName)
|
||||||
|
|
||||||
// Add an index for the 'ClosedGroupKeyPair' so we can lookup existing keys more easily
|
// 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(
|
try db.createIndex(
|
||||||
on: ClosedGroupKeyPair.self,
|
on: ClosedGroupKeyPair.self,
|
||||||
columns: [.threadId, .threadKeyPairHash]
|
columns: [.threadId, .threadKeyPairHash]
|
||||||
|
|
|
@ -54,12 +54,13 @@ public struct Contact: Codable, Identifiable, Equatable, FetchableRecord, Persis
|
||||||
isApproved: Bool = false,
|
isApproved: Bool = false,
|
||||||
isBlocked: Bool = false,
|
isBlocked: Bool = false,
|
||||||
didApproveMe: Bool = false,
|
didApproveMe: Bool = false,
|
||||||
hasBeenBlocked: Bool = false
|
hasBeenBlocked: Bool = false,
|
||||||
|
dependencies: Dependencies = Dependencies()
|
||||||
) {
|
) {
|
||||||
self.id = id
|
self.id = id
|
||||||
self.isTrusted = (
|
self.isTrusted = (
|
||||||
isTrusted ||
|
isTrusted ||
|
||||||
id == getUserHexEncodedPublicKey() // Always trust ourselves
|
id == getUserHexEncodedPublicKey(dependencies: dependencies) // Always trust ourselves
|
||||||
)
|
)
|
||||||
self.isApproved = isApproved
|
self.isApproved = isApproved
|
||||||
self.isBlocked = isBlocked
|
self.isBlocked = isBlocked
|
||||||
|
|
|
@ -36,7 +36,7 @@ public struct SessionThread: Codable, Identifiable, Equatable, FetchableRecord,
|
||||||
case pinnedPriority
|
case pinnedPriority
|
||||||
}
|
}
|
||||||
|
|
||||||
public enum Variant: Int, Codable, Hashable, DatabaseValueConvertible {
|
public enum Variant: Int, Codable, Hashable, DatabaseValueConvertible, CaseIterable {
|
||||||
case contact
|
case contact
|
||||||
case legacyGroup
|
case legacyGroup
|
||||||
case community
|
case community
|
||||||
|
|
|
@ -34,63 +34,16 @@ internal extension SessionUtil {
|
||||||
mergeNeedsDump: Bool,
|
mergeNeedsDump: Bool,
|
||||||
latestConfigSentTimestampMs: Int64
|
latestConfigSentTimestampMs: Int64
|
||||||
) throws {
|
) throws {
|
||||||
typealias ContactData = [
|
|
||||||
String: (
|
|
||||||
contact: Contact,
|
|
||||||
profile: Profile,
|
|
||||||
priority: Int32,
|
|
||||||
created: TimeInterval
|
|
||||||
)
|
|
||||||
]
|
|
||||||
|
|
||||||
guard mergeNeedsDump else { return }
|
guard mergeNeedsDump else { return }
|
||||||
guard conf != nil else { throw SessionUtilError.nilConfigObject }
|
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
|
// The current users contact data is handled separately so exclude it if it's present (as that's
|
||||||
// actually a bug)
|
// actually a bug)
|
||||||
let userPublicKey: String = getUserHexEncodedPublicKey(db)
|
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
|
// 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
|
// 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
|
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
|
// 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -22,71 +22,8 @@ internal extension SessionUtil {
|
||||||
guard mergeNeedsDump else { return }
|
guard mergeNeedsDump else { return }
|
||||||
guard conf != nil else { throw SessionUtilError.nilConfigObject }
|
guard conf != nil else { throw SessionUtilError.nilConfigObject }
|
||||||
|
|
||||||
var volatileThreadInfo: [VolatileThreadInfo] = []
|
// Get the volatile thread info from the conf and local conversations
|
||||||
var oneToOne: convo_info_volatile_1to1 = convo_info_volatile_1to1()
|
let volatileThreadInfo: [VolatileThreadInfo] = extractConvoVolatileInfo(from: conf)
|
||||||
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
|
|
||||||
let localVolatileThreadInfo: [String: VolatileThreadInfo] = VolatileThreadInfo.fetchAll(db)
|
let localVolatileThreadInfo: [String: VolatileThreadInfo] = VolatileThreadInfo.fetchAll(db)
|
||||||
.reduce(into: [:]) { result, next in result[next.threadId] = next }
|
.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] {
|
fileprivate extension [SessionUtil.VolatileThreadInfo.Change] {
|
||||||
|
@ -597,3 +604,4 @@ fileprivate extension [SessionUtil.VolatileThreadInfo.Change] {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import GRDB
|
||||||
import Sodium
|
import Sodium
|
||||||
import SessionUtil
|
import SessionUtil
|
||||||
import SessionUtilitiesKit
|
import SessionUtilitiesKit
|
||||||
|
@ -8,12 +9,248 @@ import SessionUtilitiesKit
|
||||||
import Quick
|
import Quick
|
||||||
import Nimble
|
import Nimble
|
||||||
|
|
||||||
|
@testable import SessionMessagingKit
|
||||||
|
|
||||||
/// This spec is designed to replicate the initial test cases for the libSession-util to ensure the behaviour matches
|
/// This spec is designed to replicate the initial test cases for the libSession-util to ensure the behaviour matches
|
||||||
class ConfigContactsSpec {
|
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
|
// MARK: - Spec
|
||||||
|
|
||||||
static func 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 createdTs: Int64 = 1680064059
|
||||||
let nowTs: Int64 = Int64(Date().timeIntervalSince1970)
|
let nowTs: Int64 = Int64(Date().timeIntervalSince1970)
|
||||||
let seed: Data = Data(hex: "0123456789abcdef0123456789abcdef")
|
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
|
||||||
|
}
|
||||||
|
|
|
@ -13,7 +13,8 @@ class ConfigConvoInfoVolatileSpec {
|
||||||
// MARK: - Spec
|
// MARK: - Spec
|
||||||
|
|
||||||
static func spec() {
|
static func spec() {
|
||||||
it("generates ConvoInfoVolatile configs correctly") {
|
context("CONVO_INFO_VOLATILE") {
|
||||||
|
it("generates config correctly") {
|
||||||
let seed: Data = Data(hex: "0123456789abcdef0123456789abcdef")
|
let seed: Data = Data(hex: "0123456789abcdef0123456789abcdef")
|
||||||
|
|
||||||
// FIXME: Would be good to move these into the libSession-util instead of using Sodium separately
|
// FIXME: Would be good to move these into the libSession-util instead of using Sodium separately
|
||||||
|
@ -263,3 +264,4 @@ class ConfigConvoInfoVolatileSpec {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -83,7 +83,8 @@ class ConfigUserGroupsSpec {
|
||||||
.to(equal("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"))
|
.to(equal("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"))
|
||||||
}
|
}
|
||||||
|
|
||||||
it("generates UserGroup configs correctly") {
|
context("USER_GROUPS") {
|
||||||
|
it("generates config correctly") {
|
||||||
let createdTs: Int64 = 1680064059
|
let createdTs: Int64 = 1680064059
|
||||||
let nowTs: Int64 = Int64(Date().timeIntervalSince1970)
|
let nowTs: Int64 = Int64(Date().timeIntervalSince1970)
|
||||||
let seed: Data = Data(hex: "0123456789abcdef0123456789abcdef")
|
let seed: Data = Data(hex: "0123456789abcdef0123456789abcdef")
|
||||||
|
@ -585,3 +586,4 @@ class ConfigUserGroupsSpec {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -14,7 +14,8 @@ class ConfigUserProfileSpec {
|
||||||
// MARK: - Spec
|
// MARK: - Spec
|
||||||
|
|
||||||
static func spec() {
|
static func spec() {
|
||||||
it("generates UserProfile configs correctly") {
|
context("USER_PROFILE") {
|
||||||
|
it("generates config correctly") {
|
||||||
let seed: Data = Data(hex: "0123456789abcdef0123456789abcdef")
|
let seed: Data = Data(hex: "0123456789abcdef0123456789abcdef")
|
||||||
|
|
||||||
// FIXME: Would be good to move these into the libSession-util instead of using Sodium separately
|
// FIXME: Would be good to move these into the libSession-util instead of using Sodium separately
|
||||||
|
@ -395,3 +396,4 @@ class ConfigUserProfileSpec {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -12,9 +12,11 @@ class LibSessionSpec: QuickSpec {
|
||||||
// MARK: - Spec
|
// MARK: - Spec
|
||||||
|
|
||||||
override func spec() {
|
override func spec() {
|
||||||
|
describe("libSession") {
|
||||||
ConfigContactsSpec.spec()
|
ConfigContactsSpec.spec()
|
||||||
ConfigUserProfileSpec.spec()
|
ConfigUserProfileSpec.spec()
|
||||||
ConfigConvoInfoVolatileSpec.spec()
|
ConfigConvoInfoVolatileSpec.spec()
|
||||||
ConfigUserGroupsSpec.spec()
|
ConfigUserGroupsSpec.spec()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -53,12 +53,16 @@ class ThreadSettingsViewModelSpec: QuickSpec {
|
||||||
|
|
||||||
try Profile(
|
try Profile(
|
||||||
id: "05\(TestConstants.publicKey)",
|
id: "05\(TestConstants.publicKey)",
|
||||||
name: "TestMe"
|
name: "TestMe",
|
||||||
|
lastNameUpdate: 0,
|
||||||
|
lastProfilePictureUpdate: 0
|
||||||
).insert(db)
|
).insert(db)
|
||||||
|
|
||||||
try Profile(
|
try Profile(
|
||||||
id: "TestId",
|
id: "TestId",
|
||||||
name: "TestUser"
|
name: "TestUser",
|
||||||
|
lastNameUpdate: 0,
|
||||||
|
lastProfilePictureUpdate: 0
|
||||||
).insert(db)
|
).insert(db)
|
||||||
}
|
}
|
||||||
viewModel = ThreadSettingsViewModel(
|
viewModel = ThreadSettingsViewModel(
|
||||||
|
|
|
@ -4,15 +4,38 @@ import UIKit
|
||||||
import SessionUtilitiesKit
|
import SessionUtilitiesKit
|
||||||
|
|
||||||
// FIXME: Refactor as part of the Groups Rebuild
|
// FIXME: Refactor as part of the Groups Rebuild
|
||||||
public class ConfirmationModal: Modal {
|
public class ConfirmationModal: Modal, UITextFieldDelegate {
|
||||||
private static let closeSize: CGFloat = 24
|
private static let closeSize: CGFloat = 24
|
||||||
|
|
||||||
private var internalOnConfirm: ((ConfirmationModal) -> ())? = nil
|
private var internalOnConfirm: ((ConfirmationModal) -> ())? = nil
|
||||||
private var internalOnCancel: ((ConfirmationModal) -> ())? = nil
|
private var internalOnCancel: ((ConfirmationModal) -> ())? = nil
|
||||||
private var internalOnBodyTap: (() -> ())? = nil
|
private var internalOnBodyTap: (() -> ())? = nil
|
||||||
|
private var internalOnTextChanged: ((String) -> ())? = nil
|
||||||
|
|
||||||
// MARK: - Components
|
// 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 = {
|
private lazy var titleLabel: UILabel = {
|
||||||
let result: UILabel = UILabel()
|
let result: UILabel = UILabel()
|
||||||
result.font = .boldSystemFont(ofSize: Values.mediumFontSize)
|
result.font = .boldSystemFont(ofSize: Values.mediumFontSize)
|
||||||
|
@ -36,16 +59,30 @@ public class ConfirmationModal: Modal {
|
||||||
return result
|
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 = {
|
private lazy var imageViewContainer: UIView = {
|
||||||
let result: UIView = UIView()
|
let result: UIView = UIView()
|
||||||
result.isHidden = true
|
result.isHidden = true
|
||||||
|
|
||||||
let gestureRecogniser: UITapGestureRecognizer = UITapGestureRecognizer(
|
|
||||||
target: self,
|
|
||||||
action: #selector(imageViewTapped)
|
|
||||||
)
|
|
||||||
result.addGestureRecognizer(gestureRecogniser)
|
|
||||||
|
|
||||||
return result
|
return result
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
@ -70,7 +107,7 @@ public class ConfirmationModal: Modal {
|
||||||
}()
|
}()
|
||||||
|
|
||||||
private lazy var contentStackView: UIStackView = {
|
private lazy var contentStackView: UIStackView = {
|
||||||
let result = UIStackView(arrangedSubviews: [ titleLabel, explanationLabel, imageViewContainer ])
|
let result = UIStackView(arrangedSubviews: [ titleLabel, explanationLabel, textFieldContainer, imageViewContainer ])
|
||||||
result.axis = .vertical
|
result.axis = .vertical
|
||||||
result.spacing = Values.smallSpacing
|
result.spacing = Values.smallSpacing
|
||||||
result.isLayoutMarginsRelativeArrangement = true
|
result.isLayoutMarginsRelativeArrangement = true
|
||||||
|
@ -132,13 +169,22 @@ public class ConfirmationModal: Modal {
|
||||||
}
|
}
|
||||||
|
|
||||||
public override func populateContentView() {
|
public override func populateContentView() {
|
||||||
|
let gestureRecogniser: UITapGestureRecognizer = UITapGestureRecognizer(
|
||||||
|
target: self,
|
||||||
|
action: #selector(contentViewTapped)
|
||||||
|
)
|
||||||
|
contentView.addGestureRecognizer(gestureRecogniser)
|
||||||
|
|
||||||
contentView.addSubview(mainStackView)
|
contentView.addSubview(mainStackView)
|
||||||
contentView.addSubview(closeButton)
|
contentView.addSubview(closeButton)
|
||||||
|
|
||||||
|
textFieldContainer.addSubview(textField)
|
||||||
|
textField.pin(to: textFieldContainer, withInset: 12)
|
||||||
|
|
||||||
imageViewContainer.addSubview(profileView)
|
imageViewContainer.addSubview(profileView)
|
||||||
profileView.center(.horizontal, in: imageViewContainer)
|
profileView.center(.horizontal, in: imageViewContainer)
|
||||||
profileView.pin(.top, to: .top, of: imageViewContainer)//, withInset: 15)
|
profileView.pin(.top, to: .top, of: imageViewContainer)
|
||||||
profileView.pin(.bottom, to: .bottom, of: imageViewContainer)//, withInset: -15)
|
profileView.pin(.bottom, to: .bottom, of: imageViewContainer)
|
||||||
|
|
||||||
mainStackView.pin(to: contentView)
|
mainStackView.pin(to: contentView)
|
||||||
closeButton.pin(.top, to: .top, of: contentView, withInset: 8)
|
closeButton.pin(.top, to: .top, of: contentView, withInset: 8)
|
||||||
|
@ -149,6 +195,7 @@ public class ConfirmationModal: Modal {
|
||||||
|
|
||||||
public func updateContent(with info: Info) {
|
public func updateContent(with info: Info) {
|
||||||
internalOnBodyTap = nil
|
internalOnBodyTap = nil
|
||||||
|
internalOnTextChanged = nil
|
||||||
internalOnConfirm = { modal in
|
internalOnConfirm = { modal in
|
||||||
if info.dismissOnConfirm {
|
if info.dismissOnConfirm {
|
||||||
modal.close()
|
modal.close()
|
||||||
|
@ -161,6 +208,8 @@ public class ConfirmationModal: Modal {
|
||||||
|
|
||||||
info.onCancel?(modal)
|
info.onCancel?(modal)
|
||||||
}
|
}
|
||||||
|
contentTapGestureRecognizer.isEnabled = true
|
||||||
|
imageViewTapGestureRecognizer.isEnabled = false
|
||||||
|
|
||||||
// Set the content based on the provided info
|
// Set the content based on the provided info
|
||||||
titleLabel.text = info.title
|
titleLabel.text = info.title
|
||||||
|
@ -179,6 +228,15 @@ public class ConfirmationModal: Modal {
|
||||||
explanationLabel.attributedText = attributedText
|
explanationLabel.attributedText = attributedText
|
||||||
explanationLabel.isHidden = false
|
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):
|
case .image(let placeholder, let value, let icon, let style, let accessibility, let onClick):
|
||||||
imageViewContainer.isAccessibilityElement = (accessibility != nil)
|
imageViewContainer.isAccessibilityElement = (accessibility != nil)
|
||||||
imageViewContainer.accessibilityIdentifier = accessibility?.identifier
|
imageViewContainer.accessibilityIdentifier = accessibility?.identifier
|
||||||
|
@ -193,6 +251,8 @@ public class ConfirmationModal: Modal {
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
internalOnBodyTap = onClick
|
internalOnBodyTap = onClick
|
||||||
|
contentTapGestureRecognizer.isEnabled = false
|
||||||
|
imageViewTapGestureRecognizer.isEnabled = true
|
||||||
}
|
}
|
||||||
|
|
||||||
confirmButton.accessibilityLabel = info.confirmAccessibility?.label
|
confirmButton.accessibilityLabel = info.confirmAccessibility?.label
|
||||||
|
@ -216,8 +276,38 @@ public class ConfirmationModal: Modal {
|
||||||
contentView.accessibilityIdentifier = info.accessibility?.identifier
|
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
|
// MARK: - Interaction
|
||||||
|
|
||||||
|
@objc private func contentViewTapped() {
|
||||||
|
if textField.isFirstResponder {
|
||||||
|
textField.resignFirstResponder()
|
||||||
|
}
|
||||||
|
|
||||||
|
internalOnBodyTap?()
|
||||||
|
}
|
||||||
|
|
||||||
@objc private func imageViewTapped() {
|
@objc private func imageViewTapped() {
|
||||||
internalOnBodyTap?()
|
internalOnBodyTap?()
|
||||||
}
|
}
|
||||||
|
@ -400,8 +490,14 @@ public extension ConfirmationModal.Info {
|
||||||
case none
|
case none
|
||||||
case text(String)
|
case text(String)
|
||||||
case attributedText(NSAttributedString)
|
case attributedText(NSAttributedString)
|
||||||
// FIXME: Implement these
|
case input(
|
||||||
// case input(placeholder: String, value: String?)
|
explanation: NSAttributedString?,
|
||||||
|
placeholder: String,
|
||||||
|
initialValue: String?,
|
||||||
|
clearButton: Bool,
|
||||||
|
onChange: (String) -> ()
|
||||||
|
)
|
||||||
|
// FIXME: Implement this
|
||||||
// case radio(explanation: NSAttributedString?, options: [(title: String, selected: Bool)])
|
// case radio(explanation: NSAttributedString?, options: [(title: String, selected: Bool)])
|
||||||
case image(
|
case image(
|
||||||
placeholderData: Data?,
|
placeholderData: Data?,
|
||||||
|
@ -418,14 +514,15 @@ public extension ConfirmationModal.Info {
|
||||||
case (.text(let lhsText), .text(let rhsText)): return (lhsText == rhsText)
|
case (.text(let lhsText), .text(let rhsText)): return (lhsText == rhsText)
|
||||||
case (.attributedText(let lhsText), .attributedText(let rhsText)): return (lhsText == rhsText)
|
case (.attributedText(let lhsText), .attributedText(let rhsText)): return (lhsText == rhsText)
|
||||||
|
|
||||||
// FIXME: Implement these
|
case (.input(let lhsExplanation, let lhsPlaceholder, let lhsInitialValue, let lhsClearButton, _), .input(let rhsExplanation, let rhsPlaceholder, let rhsInitialValue, let rhsClearButton, _)):
|
||||||
//case (.input(let lhsPlaceholder, let lhsValue), .input(let rhsPlaceholder, let rhsValue)):
|
return (
|
||||||
// return (
|
lhsExplanation == rhsExplanation &&
|
||||||
// lhsPlaceholder == rhsPlaceholder &&
|
lhsPlaceholder == rhsPlaceholder &&
|
||||||
// lhsValue == rhsValue &&
|
lhsInitialValue == rhsInitialValue &&
|
||||||
// )
|
lhsClearButton == rhsClearButton
|
||||||
|
)
|
||||||
|
|
||||||
// FIXME: Implement these
|
// FIXME: Implement this
|
||||||
//case (.radio(let lhsExplanation, let lhsOptions), .radio(let rhsExplanation, let rhsOptions)):
|
//case (.radio(let lhsExplanation, let lhsOptions), .radio(let rhsExplanation, let rhsOptions)):
|
||||||
// return (
|
// return (
|
||||||
// lhsExplanation == rhsExplanation &&
|
// lhsExplanation == rhsExplanation &&
|
||||||
|
@ -451,6 +548,12 @@ public extension ConfirmationModal.Info {
|
||||||
case .text(let text): text.hash(into: &hasher)
|
case .text(let text): text.hash(into: &hasher)
|
||||||
case .attributedText(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, _):
|
case .image(let placeholder, let value, let icon, let style, let accessibility, _):
|
||||||
placeholder.hash(into: &hasher)
|
placeholder.hash(into: &hasher)
|
||||||
value.hash(into: &hasher)
|
value.hash(into: &hasher)
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import CryptoKit
|
||||||
import Combine
|
import Combine
|
||||||
import GRDB
|
import GRDB
|
||||||
import SignalCoreKit
|
import SignalCoreKit
|
||||||
|
@ -492,3 +493,38 @@ public extension ValueObservation {
|
||||||
.eraseToAnyPublisher()
|
.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
|
||||||
|
|
|
@ -16,4 +16,5 @@ FOUNDATION_EXPORT const unsigned char SessionUtilitiesKitVersionString[];
|
||||||
#import <SessionUtilitiesKit/UIImage+OWS.h>
|
#import <SessionUtilitiesKit/UIImage+OWS.h>
|
||||||
#import <SessionUtilitiesKit/UIView+OWS.h>
|
#import <SessionUtilitiesKit/UIView+OWS.h>
|
||||||
#import <SessionUtilitiesKit/OWSBackgroundTask.h>
|
#import <SessionUtilitiesKit/OWSBackgroundTask.h>
|
||||||
|
#import <SessionUtilitiesKit/CExceptionHelper.h>
|
||||||
|
|
||||||
|
|
|
@ -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
|
|
@ -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
|
Loading…
Reference in New Issue