Merge branch 'updated-user-config-handling' into disappearing-message-redesign

pull/941/head
Ryan Zhao 1 year ago
commit f7c7c75527

@ -1 +1 @@
Subproject commit 9777b37e8545febcc082578341352dba7433db21
Subproject commit e0b994201a016cc5bf9065526a0ceb4291f60d5a

@ -235,7 +235,7 @@
B88FA7FB26114EA70049422F /* Hex.swift in Sources */ = {isa = PBXBuildFile; fileRef = B88FA7FA26114EA70049422F /* Hex.swift */; };
B893063F2383961A005EAA8E /* ScanQRCodeWrapperVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = B893063E2383961A005EAA8E /* ScanQRCodeWrapperVC.swift */; };
B894D0752339EDCF00B4D94D /* NukeDataModal.swift in Sources */ = {isa = PBXBuildFile; fileRef = B894D0742339EDCF00B4D94D /* NukeDataModal.swift */; };
B897621C25D201F7004F83B2 /* ScrollToBottomButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = B897621B25D201F7004F83B2 /* ScrollToBottomButton.swift */; };
B897621C25D201F7004F83B2 /* RoundIconButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = B897621B25D201F7004F83B2 /* RoundIconButton.swift */; };
B8B320B7258C30D70020074B /* HTMLMetadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8B320B6258C30D70020074B /* HTMLMetadata.swift */; };
B8B558F126C4BB0600693325 /* CameraManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8B558F026C4BB0600693325 /* CameraManager.swift */; };
B8B558FF26C4E05E00693325 /* WebRTCSession+MessageHandling.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8B558FE26C4E05E00693325 /* WebRTCSession+MessageHandling.swift */; };
@ -752,6 +752,8 @@
FD9004142818AD0B00ABAAF6 /* _002_SetupStandardJobs.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD9004132818AD0B00ABAAF6 /* _002_SetupStandardJobs.swift */; };
FD9004152818B46300ABAAF6 /* JobRunner.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF0B7432804EF1B004C14C5 /* JobRunner.swift */; };
FD9004162818B46700ABAAF6 /* JobRunnerError.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE77F68280F9EDA002CFC5D /* JobRunnerError.swift */; };
FD97B2402A3FEB050027DD57 /* ARC4RandomNumberGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD97B23F2A3FEB050027DD57 /* ARC4RandomNumberGenerator.swift */; };
FD97B2422A3FEBF30027DD57 /* UnreadMarkerCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD97B2412A3FEBF30027DD57 /* UnreadMarkerCell.swift */; };
FD9B30F3293EA0BF008DEE3E /* BatchResponseSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD9B30F2293EA0BF008DEE3E /* BatchResponseSpec.swift */; };
FDA1E83629A5748F00C5C3BD /* ConfigUserGroupsSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDA1E83529A5748F00C5C3BD /* ConfigUserGroupsSpec.swift */; };
FDA1E83929A5771A00C5C3BD /* LibSessionSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDA1E83829A5771A00C5C3BD /* LibSessionSpec.swift */; };
@ -1377,7 +1379,7 @@
B88FA7FA26114EA70049422F /* Hex.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Hex.swift; sourceTree = "<group>"; };
B893063E2383961A005EAA8E /* ScanQRCodeWrapperVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScanQRCodeWrapperVC.swift; sourceTree = "<group>"; };
B894D0742339EDCF00B4D94D /* NukeDataModal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NukeDataModal.swift; sourceTree = "<group>"; };
B897621B25D201F7004F83B2 /* ScrollToBottomButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScrollToBottomButton.swift; sourceTree = "<group>"; };
B897621B25D201F7004F83B2 /* RoundIconButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoundIconButton.swift; sourceTree = "<group>"; };
B8B320B6258C30D70020074B /* HTMLMetadata.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = HTMLMetadata.swift; sourceTree = "<group>"; };
B8B558F026C4BB0600693325 /* CameraManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CameraManager.swift; sourceTree = "<group>"; };
B8B558FE26C4E05E00693325 /* WebRTCSession+MessageHandling.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WebRTCSession+MessageHandling.swift"; sourceTree = "<group>"; };
@ -1811,6 +1813,7 @@
FD5C7304284F0FF30029977D /* MessageReceiver+VisibleMessages.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MessageReceiver+VisibleMessages.swift"; sourceTree = "<group>"; };
FD5C7306284F103B0029977D /* MessageReceiver+MessageRequests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MessageReceiver+MessageRequests.swift"; sourceTree = "<group>"; };
FD5C7308285007920029977D /* BlindedIdLookup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlindedIdLookup.swift; sourceTree = "<group>"; };
FD5CE3442A3C5D96001A6DE3 /* DecryptExportedKey.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DecryptExportedKey.swift; sourceTree = "<group>"; };
FD5D201D27B0D87C00FEA984 /* SessionId.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionId.swift; sourceTree = "<group>"; };
FD6A7A682818BE7300035AC1 /* RetrieveDefaultOpenGroupRoomsJob.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RetrieveDefaultOpenGroupRoomsJob.swift; sourceTree = "<group>"; };
FD6A7A6A2818C17C00035AC1 /* UpdateProfilePictureJob.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdateProfilePictureJob.swift; sourceTree = "<group>"; };
@ -1897,6 +1900,8 @@
FD8ECF912938552800C0D1BB /* Threading.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Threading.swift; sourceTree = "<group>"; };
FD8ECF93293856AF00C0D1BB /* Randomness.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Randomness.swift; sourceTree = "<group>"; };
FD9004132818AD0B00ABAAF6 /* _002_SetupStandardJobs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _002_SetupStandardJobs.swift; sourceTree = "<group>"; };
FD97B23F2A3FEB050027DD57 /* ARC4RandomNumberGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ARC4RandomNumberGenerator.swift; sourceTree = "<group>"; };
FD97B2412A3FEBF30027DD57 /* UnreadMarkerCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UnreadMarkerCell.swift; sourceTree = "<group>"; };
FD9B30F2293EA0BF008DEE3E /* BatchResponseSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BatchResponseSpec.swift; sourceTree = "<group>"; };
FDA1E83529A5748F00C5C3BD /* ConfigUserGroupsSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfigUserGroupsSpec.swift; sourceTree = "<group>"; };
FDA1E83829A5771A00C5C3BD /* LibSessionSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibSessionSpec.swift; sourceTree = "<group>"; };
@ -2508,7 +2513,7 @@
B821493625D4D6A7009C0F2A /* Views & Modals */ = {
isa = PBXGroup;
children = (
B897621B25D201F7004F83B2 /* ScrollToBottomButton.swift */,
B897621B25D201F7004F83B2 /* RoundIconButton.swift */,
B82149C025D605C6009C0F2A /* InfoBanner.swift */,
C374EEEA25DA3CA70073A857 /* ConversationTitleView.swift */,
FD4B200D283492210034334B /* InsetLockableTableView.swift */,
@ -2545,6 +2550,7 @@
7B0EFDEF275084AA00FFAAE7 /* CallMessageCell.swift */,
B8041AA625C90927003C2166 /* TypingIndicatorCell.swift */,
FDB7400C28EBEC240094D718 /* DateHeaderCell.swift */,
FD97B2412A3FEBF30027DD57 /* UnreadMarkerCell.swift */,
);
path = "Message Cells";
sourceTree = "<group>";
@ -2987,7 +2993,6 @@
children = (
C33FD9B7255A54A300E217F9 /* Meta */,
C36096ED25AD20FD008B62B2 /* Media Viewing & Editing */,
FD16AB5D2A1DD8E70083D849 /* Profile Pictures */,
C36096EE25AD21BC008B62B2 /* Screen Lock */,
C3851CD225624B060061EEB0 /* Shared Views */,
C360970125AD22D3008B62B2 /* Shared View Controllers */,
@ -3621,6 +3626,7 @@
FD09796527F6B0A800936362 /* Utilities */ = {
isa = PBXGroup;
children = (
FD97B23F2A3FEB050027DD57 /* ARC4RandomNumberGenerator.swift */,
FDA8EB0F280F8238002B68E5 /* Codable+Utilities.swift */,
FD3003692A3ADD6000B5A5FB /* CExceptionHelper.h */,
FD30036D2A3AE26000B5A5FB /* CExceptionHelper.mm */,
@ -3664,13 +3670,6 @@
path = Models;
sourceTree = "<group>";
};
FD16AB5D2A1DD8E70083D849 /* Profile Pictures */ = {
isa = PBXGroup;
children = (
);
path = "Profile Pictures";
sourceTree = "<group>";
};
FD17D79427F3E03300122BE0 /* Migrations */ = {
isa = PBXGroup;
children = (
@ -4324,6 +4323,7 @@
7B02DF432A16F47B00ADCFD2 /* EmojiGenerator.swift */,
FDE7214F287E50D50093DF33 /* ProtoWrappers.py */,
FDE72150287E50D50093DF33 /* LintLocalizableStrings.swift */,
FD5CE3442A3C5D96001A6DE3 /* DecryptExportedKey.swift */,
);
path = Scripts;
sourceTree = "<group>";
@ -5684,6 +5684,7 @@
FD17D7CA27F546D900122BE0 /* _001_InitialSetupMigration.swift in Sources */,
C3D9E4D12567777D0040E4F3 /* OWSMediaUtils.swift in Sources */,
C3BBE0AA2554D4DE0050F1E3 /* Dictionary+Utilities.swift in Sources */,
FD97B2402A3FEB050027DD57 /* ARC4RandomNumberGenerator.swift in Sources */,
FD37EA1128AB34B3003AE748 /* TypedTableAlteration.swift in Sources */,
FD30036E2A3AE26000B5A5FB /* CExceptionHelper.mm in Sources */,
C3D9E4DA256778410040E4F3 /* UIImage+OWS.m in Sources */,
@ -5962,6 +5963,7 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
FD97B2422A3FEBF30027DD57 /* UnreadMarkerCell.swift in Sources */,
FD52090928B59411006098F6 /* ScreenLockUI.swift in Sources */,
7B2561C429874851005C086C /* SessionCarouselView+Info.swift in Sources */,
FDF2220B2818F38D000A4995 /* SessionApp.swift in Sources */,
@ -6148,7 +6150,7 @@
7B4C75CD26BB92060000AC89 /* DeletedMessageView.swift in Sources */,
FDD250722837234B00198BDA /* MediaGalleryNavigationController.swift in Sources */,
FDD2506E283711D600198BDA /* DifferenceKit+Utilities.swift in Sources */,
B897621C25D201F7004F83B2 /* ScrollToBottomButton.swift in Sources */,
B897621C25D201F7004F83B2 /* RoundIconButton.swift in Sources */,
346B66311F4E29B200E5122F /* CropScaleImageViewController.swift in Sources */,
FD1C98E4282E3C5B00B76F9E /* UINavigationBar+Utilities.swift in Sources */,
C302093E25DCBF08001F572D /* MentionSelectionView.swift in Sources */,
@ -6439,7 +6441,7 @@
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
CODE_SIGN_STYLE = Automatic;
COPY_PHASE_STRIP = NO;
CURRENT_PROJECT_VERSION = 407;
CURRENT_PROJECT_VERSION = 408;
DEBUG_INFORMATION_FORMAT = dwarf;
DEVELOPMENT_TEAM = SUQ8J2PCT7;
FRAMEWORK_SEARCH_PATHS = "$(inherited)";
@ -6511,7 +6513,7 @@
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
CODE_SIGN_STYLE = Automatic;
COPY_PHASE_STRIP = NO;
CURRENT_PROJECT_VERSION = 407;
CURRENT_PROJECT_VERSION = 408;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEVELOPMENT_TEAM = SUQ8J2PCT7;
ENABLE_NS_ASSERTIONS = NO;
@ -6576,7 +6578,7 @@
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
CODE_SIGN_STYLE = Automatic;
COPY_PHASE_STRIP = NO;
CURRENT_PROJECT_VERSION = 407;
CURRENT_PROJECT_VERSION = 408;
DEBUG_INFORMATION_FORMAT = dwarf;
DEVELOPMENT_TEAM = SUQ8J2PCT7;
FRAMEWORK_SEARCH_PATHS = "$(inherited)";
@ -6650,7 +6652,7 @@
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
CODE_SIGN_STYLE = Automatic;
COPY_PHASE_STRIP = NO;
CURRENT_PROJECT_VERSION = 407;
CURRENT_PROJECT_VERSION = 408;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEVELOPMENT_TEAM = SUQ8J2PCT7;
ENABLE_NS_ASSERTIONS = NO;
@ -7558,7 +7560,7 @@
CODE_SIGN_ENTITLEMENTS = Session/Meta/Signal.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer";
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
CURRENT_PROJECT_VERSION = 407;
CURRENT_PROJECT_VERSION = 408;
DEVELOPMENT_TEAM = SUQ8J2PCT7;
FRAMEWORK_SEARCH_PATHS = (
"$(inherited)",
@ -7629,7 +7631,7 @@
CODE_SIGN_ENTITLEMENTS = Session/Meta/Signal.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer";
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
CURRENT_PROJECT_VERSION = 407;
CURRENT_PROJECT_VERSION = 408;
DEVELOPMENT_TEAM = SUQ8J2PCT7;
FRAMEWORK_SEARCH_PATHS = (
"$(inherited)",

@ -16,7 +16,6 @@ extension ConversationVC:
InputViewDelegate,
MessageCellDelegate,
ContextMenuActionDelegate,
ScrollToBottomButtonDelegate,
SendMediaNavDelegate,
UIDocumentPickerDelegate,
AttachmentApprovalViewControllerDelegate,
@ -72,15 +71,6 @@ extension ConversationVC:
navigationController?.pushViewController(viewController, animated: true)
}
// MARK: - ScrollToBottomButtonDelegate
func handleScrollToBottomButtonTapped() {
// The table view's content size is calculated by the estimated height of cells,
// so the result may be inaccurate before all the cells are loaded. Use this
// to scroll to the last row instead.
scrollToBottom(isAnimated: true)
}
// MARK: - Call
@objc func startCall(_ sender: Any?) {
@ -889,10 +879,7 @@ extension ConversationVC:
UIView.animate(
withDuration: 0.25,
animations: {
self?.scrollButton.alpha = (self?.getScrollButtonOpacity() ?? 0)
self?.unreadCountView.alpha = (self?.scrollButton.alpha ?? 0)
},
animations: { self?.updateScrollToBottom() },
completion: { _ in
guard let contentOffset: CGPoint = self?.tableView.contentOffset else { return }
@ -1083,7 +1070,7 @@ extension ConversationVC:
return
}
self.scrollToInteractionIfNeeded(with: interactionInfo, highlight: true)
self.scrollToInteractionIfNeeded(with: interactionInfo, focusBehaviour: .highlight)
}
else if let linkPreview: LinkPreview = cellViewModel.linkPreview {
switch linkPreview.variant {
@ -1756,7 +1743,7 @@ extension ConversationVC:
func copy(_ cellViewModel: MessageViewModel) {
switch cellViewModel.cellType {
case .typingIndicator, .dateHeader: break
case .typingIndicator, .dateHeader, .unreadMarker: break
case .textOnlyMessage:
if cellViewModel.body == nil, let linkPreview: LinkPreview = cellViewModel.linkPreview {

@ -26,6 +26,7 @@ final class ConversationVC: BaseVC, SessionUtilRespondingViewController, Convers
private var hasReloadedThreadDataAfterDisappearance: Bool = true
var focusedInteractionInfo: Interaction.TimestampInfo?
var focusBehaviour: ConversationViewModel.FocusBehaviour = .none
var shouldHighlightNextScrollToInteraction: Bool = false
// Search
@ -157,6 +158,7 @@ final class ConversationVC: BaseVC, SessionUtilRespondingViewController, Convers
)
result.registerHeaderFooterView(view: UITableViewHeaderFooterView.self)
result.register(view: DateHeaderCell.self)
result.register(view: UnreadMarkerCell.self)
result.register(view: VisibleMessageCell.self)
result.register(view: InfoMessageCell.self)
result.register(view: TypingIndicatorCell.self)
@ -182,6 +184,7 @@ final class ConversationVC: BaseVC, SessionUtilRespondingViewController, Convers
result.set(.width, greaterThanOrEqualTo: ConversationVC.unreadCountViewSize)
result.set(.height, to: ConversationVC.unreadCountViewSize)
result.isHidden = true
result.alpha = 0
return result
}()
@ -273,7 +276,20 @@ final class ConversationVC: BaseVC, SessionUtilRespondingViewController, Convers
return result
}()
lazy var scrollButton: ScrollToBottomButton = ScrollToBottomButton(delegate: self)
lazy var scrollButton: RoundIconButton = {
let result: RoundIconButton = RoundIconButton(
image: UIImage(named: "ic_chevron_down")?
.withRenderingMode(.alwaysTemplate)
) { [weak self] in
// The table view's content size is calculated by the estimated height of cells,
// so the result may be inaccurate before all the cells are loaded. Use this
// to scroll to the last row instead.
self?.scrollToBottom(isAnimated: true)
}
result.alpha = 0
return result
}()
lazy var messageRequestBackgroundView: UIView = {
let result: UIView = UIView()
@ -964,7 +980,6 @@ final class ConversationVC: BaseVC, SessionUtilRespondingViewController, Convers
.firstIndex(where: { item -> Bool in
// Since the first item is probably a `DateHeaderCell` (which would likely
// be removed when inserting items above it) we check if the id matches
// either the first or second item
let messages: [MessageViewModel] = self.viewModel
.interactionData[oldSectionIndex]
.elements
@ -1020,8 +1035,8 @@ final class ConversationVC: BaseVC, SessionUtilRespondingViewController, Convers
self?.searchController.resultsBar.stopLoading()
self?.scrollToInteractionIfNeeded(
with: focusedInteractionInfo,
isAnimated: true,
highlight: (self?.shouldHighlightNextScrollToInteraction == true)
focusBehaviour: (self?.shouldHighlightNextScrollToInteraction == true ? .highlight : .none),
isAnimated: true
)
if wasLoadingMore {
@ -1048,8 +1063,7 @@ final class ConversationVC: BaseVC, SessionUtilRespondingViewController, Convers
}
else {
// Need to update the scroll button alpha in case new messages were added but we didn't scroll
self.scrollButton.alpha = self.getScrollButtonOpacity()
self.unreadCountView.alpha = self.scrollButton.alpha
self.updateScrollToBottom()
}
return
}
@ -1098,8 +1112,8 @@ final class ConversationVC: BaseVC, SessionUtilRespondingViewController, Convers
self?.searchController.resultsBar.stopLoading()
self?.scrollToInteractionIfNeeded(
with: focusedInteractionInfo,
isAnimated: true,
highlight: (self?.shouldHighlightNextScrollToInteraction == true)
focusBehaviour: (self?.shouldHighlightNextScrollToInteraction == true ? .highlight : .none),
isAnimated: true
)
}
}
@ -1118,8 +1132,8 @@ final class ConversationVC: BaseVC, SessionUtilRespondingViewController, Convers
self?.searchController.resultsBar.stopLoading()
self?.scrollToInteractionIfNeeded(
with: focusedInteractionInfo,
isAnimated: true,
highlight: (self?.shouldHighlightNextScrollToInteraction == true)
focusBehaviour: (self?.shouldHighlightNextScrollToInteraction == true ? .highlight : .none),
isAnimated: true
)
// Complete page loading
@ -1160,14 +1174,16 @@ final class ConversationVC: BaseVC, SessionUtilRespondingViewController, Convers
// When the unread message count is more than the number of view items of a page,
// the screen will scroll to the bottom instead of the first unread message
if let focusedInteractionInfo: Interaction.TimestampInfo = self.viewModel.focusedInteractionInfo {
self.scrollToInteractionIfNeeded(with: focusedInteractionInfo, isAnimated: false, highlight: true)
self.scrollToInteractionIfNeeded(
with: focusedInteractionInfo,
focusBehaviour: self.viewModel.focusBehaviour,
isAnimated: false
)
}
else {
self.scrollToBottom(isAnimated: false)
}
self.scrollButton.alpha = self.getScrollButtonOpacity()
self.unreadCountView.alpha = self.scrollButton.alpha
self.updateScrollToBottom()
self.hasPerformedInitialScroll = true
// Now that the data has loaded we need to check if either of the "load more" sections are
@ -1355,10 +1371,7 @@ final class ConversationVC: BaseVC, SessionUtilRespondingViewController, Convers
self?.messageRequestsViewBotomConstraint?.constant = -(keyboardTop + 12)
self?.tableView.contentInset = newContentInset
self?.tableView.contentOffset.y = newContentOffsetY
let scrollButtonOpacity: CGFloat = (self?.getScrollButtonOpacity() ?? 0)
self?.scrollButton.alpha = scrollButtonOpacity
self?.unreadCountView.alpha = scrollButtonOpacity
self?.updateScrollToBottom()
self?.view.setNeedsLayout()
self?.view.layoutIfNeeded()
@ -1400,10 +1413,7 @@ final class ConversationVC: BaseVC, SessionUtilRespondingViewController, Convers
animations: { [weak self] in
self?.scrollButtonBottomConstraint?.constant = -(keyboardTop + 12)
self?.messageRequestsViewBotomConstraint?.constant = -(keyboardTop + 12)
let scrollButtonOpacity: CGFloat = (self?.getScrollButtonOpacity() ?? 0)
self?.scrollButton.alpha = scrollButtonOpacity
self?.unreadCountView.alpha = scrollButtonOpacity
self?.updateScrollToBottom()
self?.view.setNeedsLayout()
self?.view.layoutIfNeeded()
@ -1634,48 +1644,13 @@ final class ConversationVC: BaseVC, SessionUtilRespondingViewController, Convers
}
func scrollViewDidScroll(_ scrollView: UIScrollView) {
self.scrollButton.alpha = self.getScrollButtonOpacity()
self.unreadCountView.alpha = self.scrollButton.alpha
self.updateScrollToBottom()
// The initial scroll can trigger this logic but we already mark the initially focused message
// as read so don't run the below until the user actually scrolls after the initial layout
guard self.didFinishInitialLayout else { return }
// We want to mark messages as read while we scroll, so grab the newest message and mark
// everything older as read
//
// Note: For the 'tableVisualBottom' we remove the 'Values.mediumSpacing' as that is the distance
// the table content appears above the input view
let tableVisualBottom: CGFloat = (tableView.frame.maxY - (tableView.contentInset.bottom - Values.mediumSpacing))
if
let visibleIndexPaths: [IndexPath] = self.tableView.indexPathsForVisibleRows,
let messagesSection: Int = visibleIndexPaths
.first(where: { self.viewModel.interactionData[$0.section].model == .messages })?
.section,
let newestCellViewModel: MessageViewModel = visibleIndexPaths
.sorted()
.filter({ $0.section == messagesSection })
.compactMap({ indexPath -> (frame: CGRect, cellViewModel: MessageViewModel)? in
guard let frame: CGRect = tableView.cellForRow(at: indexPath)?.frame else {
return nil
}
return (
view.convert(frame, from: tableView),
self.viewModel.interactionData[indexPath.section].elements[indexPath.row]
)
})
// Exclude messages that are partially off the bottom of the screen
.filter({ $0.frame.maxY <= tableVisualBottom })
.last?
.cellViewModel
{
self.viewModel.markAsRead(
target: .threadAndInteractions(interactionsBeforeInclusive: newestCellViewModel.id),
timestampMs: newestCellViewModel.timestampMs
)
}
self.markFullyVisibleAndOlderCellsAsRead(interactionInfo: nil)
}
func scrollViewDidEndScrollingAnimation(_ scrollView: UIScrollView) {
@ -1684,12 +1659,16 @@ final class ConversationVC: BaseVC, SessionUtilRespondingViewController, Convers
self.shouldHighlightNextScrollToInteraction
else {
self.focusedInteractionInfo = nil
self.focusBehaviour = .none
self.shouldHighlightNextScrollToInteraction = false
return
}
let behaviour: ConversationViewModel.FocusBehaviour = self.focusBehaviour
DispatchQueue.main.async { [weak self] in
self?.highlightCellIfNeeded(interactionId: focusedInteractionInfo.id)
self?.markFullyVisibleAndOlderCellsAsRead(interactionInfo: focusedInteractionInfo)
self?.highlightCellIfNeeded(interactionId: focusedInteractionInfo.id, behaviour: behaviour)
}
}
@ -1700,12 +1679,20 @@ final class ConversationVC: BaseVC, SessionUtilRespondingViewController, Convers
unreadCountLabel.font = .boldSystemFont(ofSize: fontSize)
unreadCountView.isHidden = (unreadCount == 0)
}
func getScrollButtonOpacity() -> CGFloat {
let contentOffsetY = tableView.contentOffset.y
public func updateScrollToBottom() {
// The initial scroll can trigger this logic but we already mark the initially focused message
// as read so don't run the below until the user actually scrolls after the initial layout
guard self.didFinishInitialLayout else { return }
// Calculate the target opacity for the scroll button
let contentOffsetY: CGFloat = tableView.contentOffset.y
let x = (lastPageTop - ConversationVC.bottomInset - contentOffsetY).clamp(0, .greatestFiniteMagnitude)
let a = 1 / (ConversationVC.scrollButtonFullVisibilityThreshold - ConversationVC.scrollButtonNoVisibilityThreshold)
return max(0, min(1, a * x))
let targetOpacity: CGFloat = max(0, min(1, a * x))
self.scrollButton.alpha = targetOpacity
self.unreadCountView.alpha = targetOpacity
}
// MARK: - Search
@ -1819,24 +1806,19 @@ final class ConversationVC: BaseVC, SessionUtilRespondingViewController, Convers
}
func conversationSearchController(_ conversationSearchController: ConversationSearchController, didSelectInteractionInfo interactionInfo: Interaction.TimestampInfo) {
scrollToInteractionIfNeeded(with: interactionInfo, highlight: true)
scrollToInteractionIfNeeded(with: interactionInfo, focusBehaviour: .highlight)
}
func scrollToInteractionIfNeeded(
with interactionInfo: Interaction.TimestampInfo,
focusBehaviour: ConversationViewModel.FocusBehaviour = .none,
position: UITableView.ScrollPosition = .middle,
isJumpingToLastInteraction: Bool = false,
isAnimated: Bool = true,
highlight: Bool = false,
isInitialScroll: Bool = false
isAnimated: Bool = true
) {
// Store the info incase we need to load more data (call will be re-triggered)
self.focusedInteractionInfo = interactionInfo
self.shouldHighlightNextScrollToInteraction = highlight
self.viewModel.markAsRead(
target: .threadAndInteractions(interactionsBeforeInclusive: interactionInfo.id),
timestampMs: interactionInfo.timestampMs
)
self.shouldHighlightNextScrollToInteraction = (focusBehaviour == .highlight)
// Ensure the target interaction has been loaded
guard
@ -1870,16 +1852,47 @@ final class ConversationVC: BaseVC, SessionUtilRespondingViewController, Convers
return
}
let targetIndexPath: IndexPath = IndexPath(
row: targetMessageIndex,
section: messageSectionIndex
)
// If it's before the initial layout and the index before the target is an 'UnreadMarker' then
// we should scroll to that instead (will be better UX)
let targetIndexPath: IndexPath = {
guard
!self.didFinishInitialLayout &&
targetMessageIndex > 0 &&
self.viewModel.interactionData[messageSectionIndex]
.elements[targetMessageIndex - 1]
.cellType == .unreadMarker
else {
return IndexPath(
row: targetMessageIndex,
section: messageSectionIndex
)
}
return IndexPath(
row: (targetMessageIndex - 1),
section: messageSectionIndex
)
}()
let targetPosition: UITableView.ScrollPosition = {
guard position == .middle else { return position }
// Make sure the target cell isn't too large for the screen (if it is then we want to scroll
// it to the top rather than the middle
let cellSize: CGSize = self.tableView(
tableView,
cellForRowAt: targetIndexPath
).systemLayoutSizeFitting(view.bounds.size)
guard cellSize.height > tableView.frame.size.height else { return position }
return .top
}()
// If we aren't animating or aren't highlighting then everything can be run immediately
guard isAnimated && highlight else {
guard isAnimated else {
self.tableView.scrollToRow(
at: targetIndexPath,
at: position,
at: targetPosition,
animated: (self.didFinishInitialLayout && isAnimated)
)
@ -1888,16 +1901,17 @@ final class ConversationVC: BaseVC, SessionUtilRespondingViewController, Convers
// of messages)
self.scrollViewDidScroll(self.tableView)
// If we haven't finished the initial layout then we want to delay the highlight slightly
// so it doesn't look buggy with the push transition
if highlight {
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(self.didFinishInitialLayout ? 0 : 150)) { [weak self] in
self?.highlightCellIfNeeded(interactionId: interactionInfo.id)
}
// If we haven't finished the initial layout then we want to delay the highlight/markRead slightly
// so it doesn't look buggy with the push transition and we know for sure the correct visible cells
// have been loaded
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(self.didFinishInitialLayout ? 0 : 150)) { [weak self] in
self?.markFullyVisibleAndOlderCellsAsRead(interactionInfo: interactionInfo)
self?.highlightCellIfNeeded(interactionId: interactionInfo.id, behaviour: focusBehaviour)
}
self.shouldHighlightNextScrollToInteraction = false
self.focusedInteractionInfo = nil
self.focusBehaviour = .none
return
}
@ -1908,16 +1922,70 @@ final class ConversationVC: BaseVC, SessionUtilRespondingViewController, Convers
let targetRect: CGRect = self.tableView.rectForRow(at: targetIndexPath)
guard !self.tableView.bounds.contains(targetRect) else {
self.highlightCellIfNeeded(interactionId: interactionInfo.id)
self.markFullyVisibleAndOlderCellsAsRead(interactionInfo: interactionInfo)
self.highlightCellIfNeeded(interactionId: interactionInfo.id, behaviour: focusBehaviour)
return
}
self.tableView.scrollToRow(at: targetIndexPath, at: position, animated: true)
self.tableView.scrollToRow(at: targetIndexPath, at: targetPosition, animated: true)
}
func highlightCellIfNeeded(interactionId: Int64) {
func markFullyVisibleAndOlderCellsAsRead(interactionInfo: Interaction.TimestampInfo?) {
// We want to mark messages as read on load and while we scroll, so grab the newest message and mark
// everything older as read
//
// Note: For the 'tableVisualBottom' we remove the 'Values.mediumSpacing' as that is the distance
// the table content appears above the input view
let tableVisualBottom: CGFloat = (tableView.frame.maxY - (tableView.contentInset.bottom - Values.mediumSpacing))
guard
let visibleIndexPaths: [IndexPath] = self.tableView.indexPathsForVisibleRows,
let messagesSection: Int = visibleIndexPaths
.first(where: { self.viewModel.interactionData[$0.section].model == .messages })?
.section,
let newestCellViewModel: MessageViewModel = visibleIndexPaths
.sorted()
.filter({ $0.section == messagesSection })
.compactMap({ indexPath -> (frame: CGRect, cellViewModel: MessageViewModel)? in
guard let cell: VisibleMessageCell = tableView.cellForRow(at: indexPath) as? VisibleMessageCell else {
return nil
}
return (
view.convert(cell.frame, from: tableView),
self.viewModel.interactionData[indexPath.section].elements[indexPath.row]
)
})
// Exclude messages that are partially off the bottom of the screen
.filter({ $0.frame.maxY <= tableVisualBottom })
.last?
.cellViewModel
else {
// If we weren't able to get any visible cells for some reason then we should fall back to
// marking the provided interactionInfo as read just in case
if let interactionInfo: Interaction.TimestampInfo = interactionInfo {
self.viewModel.markAsRead(
target: .threadAndInteractions(interactionsBeforeInclusive: interactionInfo.id),
timestampMs: interactionInfo.timestampMs
)
}
return
}
// Mark all interactions before the newest entirely-visible one as read
self.viewModel.markAsRead(
target: .threadAndInteractions(interactionsBeforeInclusive: newestCellViewModel.id),
timestampMs: newestCellViewModel.timestampMs
)
}
func highlightCellIfNeeded(interactionId: Int64, behaviour: ConversationViewModel.FocusBehaviour) {
self.shouldHighlightNextScrollToInteraction = false
self.focusedInteractionInfo = nil
self.focusBehaviour = .none
// Only trigger the highlight if that's the desired behaviour
guard behaviour == .highlight else { return }
// Trigger on the next run loop incase we are still finishing some other animation
DispatchQueue.main.async {

@ -9,6 +9,13 @@ import SessionUtilitiesKit
public class ConversationViewModel: OWSAudioPlayerDelegate {
public typealias SectionModel = ArraySection<Section, MessageViewModel>
// MARK: - FocusBehaviour
public enum FocusBehaviour {
case none
case highlight
}
// MARK: - Action
public enum Action {
@ -35,6 +42,8 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
public var sentMessageBeforeUpdate: Bool = false
public var lastSearchedText: String?
public let focusedInteractionInfo: Interaction.TimestampInfo? // Note: This is used for global search
public let focusBehaviour: FocusBehaviour
private let initialUnreadInteractionId: Int64?
public lazy var blockedBannerMessage: String = {
switch self.threadData.threadVariant {
@ -54,7 +63,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
init(threadId: String, threadVariant: SessionThread.Variant, focusedInteractionInfo: Interaction.TimestampInfo?) {
typealias InitialData = (
targetInteractionInfo: Interaction.TimestampInfo?,
initialUnreadInteractionInfo: Interaction.TimestampInfo?,
threadIsBlocked: Bool,
currentUserIsClosedGroupMember: Bool?,
openGroupPermissions: OpenGroup.Permissions?,
@ -67,15 +76,13 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
// If we have a specified 'focusedInteractionInfo' then use that, otherwise retrieve the oldest
// unread interaction and start focused around that one
let targetInteractionInfo: Interaction.TimestampInfo? = (focusedInteractionInfo != nil ? focusedInteractionInfo :
try Interaction
.select(.id, .timestampMs)
.filter(interaction[.wasRead] == false)
.filter(interaction[.threadId] == threadId)
.order(interaction[.timestampMs].asc)
.asRequest(of: Interaction.TimestampInfo.self)
.fetchOne(db)
)
let initialUnreadInteractionInfo: Interaction.TimestampInfo? = try Interaction
.select(.id, .timestampMs)
.filter(interaction[.wasRead] == false)
.filter(interaction[.threadId] == threadId)
.order(interaction[.timestampMs].asc)
.asRequest(of: Interaction.TimestampInfo.self)
.fetchOne(db)
let threadIsBlocked: Bool = (threadVariant != .contact ? false :
try Contact
.filter(id: threadId)
@ -105,7 +112,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
)
return (
targetInteractionInfo,
initialUnreadInteractionInfo,
threadIsBlocked,
currentUserIsClosedGroupMember,
openGroupPermissions,
@ -115,7 +122,9 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
self.threadId = threadId
self.initialThreadVariant = threadVariant
self.focusedInteractionInfo = initialData?.targetInteractionInfo
self.focusedInteractionInfo = (focusedInteractionInfo ?? initialData?.initialUnreadInteractionInfo)
self.focusBehaviour = (focusedInteractionInfo == nil ? .none : .highlight)
self.initialUnreadInteractionId = initialData?.initialUnreadInteractionInfo?.id
self.threadData = SessionThreadViewModel(
threadId: threadId,
threadVariant: threadVariant,
@ -143,7 +152,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
// If we don't have a `initialFocusedInfo` then default to `.pageBefore` (it'll query
// from a `0` offset)
guard let initialFocusedInfo: Interaction.TimestampInfo = initialData?.targetInteractionInfo else {
guard let initialFocusedInfo: Interaction.TimestampInfo = (focusedInteractionInfo ?? initialData?.initialUnreadInteractionInfo) else {
self?.pagedDataObserver?.load(.pageBefore)
return
}
@ -321,6 +330,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
}
private func process(data: [MessageViewModel], for pageInfo: PagedData.PageInfo) -> [SectionModel] {
let initialUnreadInteractionId: Int64? = self.initialUnreadInteractionId
let typingIndicator: MessageViewModel? = data.first(where: { $0.isTypingIndicator == true })
let sortedData: [MessageViewModel] = data
.filter { $0.isTypingIndicator != true }
@ -362,11 +372,20 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
)
}
.reduce([]) { result, message in
let updatedResult: [MessageViewModel] = result
.appending(initialUnreadInteractionId == nil || message.id != initialUnreadInteractionId ?
nil :
MessageViewModel(
timestampMs: message.timestampMs,
cellType: .unreadMarker
)
)
guard message.shouldShowDateHeader else {
return result.appending(message)
return updatedResult.appending(message)
}
return result
return updatedResult
.appending(
MessageViewModel(
timestampMs: message.timestampMs,
@ -657,6 +676,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
// Then setup the state for the new audio
currentPlayingInteraction.mutate { $0 = viewModel.id }
let currentPlaybackTime: TimeInterval? = playbackInfo.wrappedValue[viewModel.id]?.progress
audioPlayer.mutate { [weak self] player in
// Note: We clear the delegate and explicitly set to nil here as when the OWSAudioPlayer
// gets deallocated it triggers state changes which cause UI bugs when auto-playing
@ -669,7 +689,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
delegate: self
)
audioPlayer.play()
audioPlayer.setCurrentTime(playbackInfo.wrappedValue[viewModel.id]?.progress ?? 0)
audioPlayer.setCurrentTime(currentPlaybackTime ?? 0)
player = audioPlayer
}
}

@ -65,6 +65,7 @@ public class MessageCell: UITableViewCell {
static func cellType(for viewModel: MessageViewModel) -> MessageCell.Type {
guard viewModel.cellType != .typingIndicator else { return TypingIndicatorCell.self }
guard viewModel.cellType != .dateHeader else { return DateHeaderCell.self }
guard viewModel.cellType != .unreadMarker else { return UnreadMarkerCell.self }
switch viewModel.variant {
case .standardOutgoing, .standardIncoming, .standardIncomingDeleted:

@ -0,0 +1,73 @@
// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved.
import UIKit
import SignalUtilitiesKit
import SessionUtilitiesKit
import SessionMessagingKit
final class UnreadMarkerCell: MessageCell {
public static let height: CGFloat = 32
// MARK: - UI
private let leftLine: UIView = {
let result: UIView = UIView()
result.themeBackgroundColor = .unreadMarker
result.set(.height, to: 1) // Intentionally 1 instead of 'separatorThickness'
return result
}()
private lazy var titleLabel: UILabel = {
let result = UILabel()
result.font = .boldSystemFont(ofSize: Values.smallFontSize)
result.text = "UNREAD_MESSAGES".localized()
result.themeTextColor = .unreadMarker
result.textAlignment = .center
return result
}()
private let rightLine: UIView = {
let result: UIView = UIView()
result.themeBackgroundColor = .unreadMarker
result.set(.height, to: 1) // Intentionally 1 instead of 'separatorThickness'
return result
}()
// MARK: - Initialization
override func setUpViewHierarchy() {
super.setUpViewHierarchy()
addSubview(leftLine)
addSubview(titleLabel)
addSubview(rightLine)
leftLine.pin(.leading, to: .leading, of: self, withInset: Values.mediumSpacing)
leftLine.pin(.trailing, to: .leading, of: titleLabel, withInset: -Values.smallSpacing)
leftLine.center(.vertical, in: self)
titleLabel.center(.horizontal, in: self)
titleLabel.center(.vertical, in: self)
titleLabel.pin(.top, to: .top, of: self, withInset: Values.smallSpacing)
titleLabel.pin(.bottom, to: .bottom, of: self, withInset: -Values.smallSpacing)
rightLine.pin(.leading, to: .trailing, of: titleLabel, withInset: Values.smallSpacing)
rightLine.pin(.trailing, to: .trailing, of: self, withInset: -Values.mediumSpacing)
rightLine.center(.vertical, in: self)
}
// MARK: - Updating
override func update(
with cellViewModel: MessageViewModel,
mediaCache: NSCache<NSString, AnyObject>,
playbackInfo: ConversationViewModel.PlaybackInfo?,
showExpandedReactions: Bool,
lastSearchText: String?
) {
guard cellViewModel.cellType == .unreadMarker else { return }
}
override func dynamicUpdate(with cellViewModel: MessageViewModel, playbackInfo: ConversationViewModel.PlaybackInfo?) {}
}

@ -489,7 +489,7 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate {
}
switch cellViewModel.cellType {
case .typingIndicator, .dateHeader: break
case .typingIndicator, .dateHeader, .unreadMarker: break
case .textOnlyMessage:
let inset: CGFloat = 12

@ -3,8 +3,8 @@
import UIKit
import SessionUIKit
final class ScrollToBottomButton: UIView {
private weak var delegate: ScrollToBottomButtonDelegate?
final class RoundIconButton: UIView {
private let onTap: () -> ()
// MARK: - Settings
@ -13,12 +13,12 @@ final class ScrollToBottomButton: UIView {
// MARK: - Lifecycle
init(delegate: ScrollToBottomButtonDelegate) {
self.delegate = delegate
init(image: UIImage?, onTap: @escaping () -> ()) {
self.onTap = onTap
super.init(frame: CGRect.zero)
setUpViewHierarchy()
setUpViewHierarchy(image: image)
}
override init(frame: CGRect) {
@ -29,7 +29,7 @@ final class ScrollToBottomButton: UIView {
preconditionFailure("Use init(delegate:) instead.")
}
private func setUpViewHierarchy() {
private func setUpViewHierarchy(image: UIImage?) {
// Background & blur
let backgroundView = UIView()
backgroundView.themeBackgroundColor = .backgroundSecondary
@ -49,9 +49,9 @@ final class ScrollToBottomButton: UIView {
}
// Size & shape
set(.width, to: ScrollToBottomButton.size)
set(.height, to: ScrollToBottomButton.size)
layer.cornerRadius = (ScrollToBottomButton.size / 2)
set(.width, to: RoundIconButton.size)
set(.height, to: RoundIconButton.size)
layer.cornerRadius = (RoundIconButton.size / 2)
layer.masksToBounds = true
// Border
@ -59,16 +59,13 @@ final class ScrollToBottomButton: UIView {
layer.borderWidth = Values.separatorThickness
// Icon
let iconImageView = UIImageView(
image: UIImage(named: "ic_chevron_down")?
.withRenderingMode(.alwaysTemplate)
)
let iconImageView = UIImageView(image: image)
iconImageView.themeTintColor = .textPrimary
iconImageView.contentMode = .scaleAspectFit
addSubview(iconImageView)
iconImageView.center(in: self)
iconImageView.set(.width, to: ScrollToBottomButton.iconSize)
iconImageView.set(.height, to: ScrollToBottomButton.iconSize)
iconImageView.set(.width, to: RoundIconButton.iconSize)
iconImageView.set(.height, to: RoundIconButton.iconSize)
// Gesture recognizer
let tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(handleTap))
@ -78,12 +75,6 @@ final class ScrollToBottomButton: UIView {
// MARK: - Interaction
@objc private func handleTap() {
delegate?.handleScrollToBottomButtonTapped()
onTap()
}
}
// MARK: - ScrollToBottomButtonDelegate
protocol ScrollToBottomButtonDelegate: AnyObject {
func handleScrollToBottomButtonTapped()
}

@ -651,9 +651,10 @@ final class HomeVC: BaseVC, SessionUtilRespondingViewController, UITableViewData
switch section.model {
case .threads:
// Cannot properly sync outgoing blinded message requests so don't provide the option
guard SessionId(from: section.elements[indexPath.row].threadId)?.prefix == .standard else {
return nil
}
guard
threadViewModel.threadVariant != .contact ||
SessionId(from: section.elements[indexPath.row].threadId)?.prefix == .standard
else { return nil }
return UIContextualAction.configuration(
for: UIContextualAction.generateSwipeActions(

@ -47,9 +47,6 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD
let mainWindow: UIWindow = TraitObservingWindow(frame: UIScreen.main.bounds)
self.loadingViewController = LoadingViewController()
// Store a weak reference in the ThemeManager so it can properly apply themes as needed
ThemeManager.mainWindow = mainWindow
AppSetup.setupEnvironment(
appSpecificBlock: {
// Create AppEnvironment
@ -78,6 +75,11 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD
return
}
/// Store a weak reference in the ThemeManager so it can properly apply themes as needed
///
/// **Note:** Need to do this after the db migrations because theme preferences are stored in the database and
/// we don't want to access it until after the migrations run
ThemeManager.mainWindow = mainWindow
self?.completePostMigrationSetup(calledFrom: .finishLaunching, needsConfigSync: needsConfigSync)
}
)
@ -333,7 +335,12 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD
private func showFailedMigrationAlert(calledFrom lifecycleMethod: LifecycleMethod, error: Error?) {
let alert = UIAlertController(
title: "Session",
message: "DATABASE_MIGRATION_FAILED".localized(),
message: {
switch (error ?? StorageError.generic) {
case StorageError.startupFailed: return "DATABASE_STARTUP_FAILED".localized()
default: return "DATABASE_MIGRATION_FAILED".localized()
}
}(),
preferredStyle: .alert
)
alert.addAction(UIAlertAction(title: "HELP_REPORT_BUG_ACTION_TITLE".localized(), style: .default) { _ in

@ -414,6 +414,7 @@
"DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred";
"DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later";
"LOADING_CONVERSATIONS" = "Loading Conversations...";
"DATABASE_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
"DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
"RECOVERY_PHASE_ERROR_GENERIC" = "Something went wrong. Please check your recovery phrase and try again.";
"RECOVERY_PHASE_ERROR_LENGTH" = "Looks like you didn't enter enough words. Please check your recovery phrase and try again.";
@ -645,6 +646,7 @@
"CONTACT_NICKNAME_PLACEHOLDER" = "Enter a name";
"MARK_AS_READ" = "Mark Read";
"MARK_AS_UNREAD" = "Mark Unread";
"UNREAD_MESSAGES" = "Unread Messages";
"CONVERSATION_EMPTY_STATE" = "You have no messages from %@. Send a message to start the conversation!";
"CONVERSATION_EMPTY_STATE_READ_ONLY" = "There are no messages in %@.";
"CONVERSATION_EMPTY_STATE_NOTE_TO_SELF" = "You have no messages in %@.";

@ -414,6 +414,7 @@
"DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred";
"DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later";
"LOADING_CONVERSATIONS" = "Loading Conversations...";
"DATABASE_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
"DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
"RECOVERY_PHASE_ERROR_GENERIC" = "Something went wrong. Please check your recovery phrase and try again.";
"RECOVERY_PHASE_ERROR_LENGTH" = "Looks like you didn't enter enough words. Please check your recovery phrase and try again.";
@ -645,6 +646,7 @@
"CONTACT_NICKNAME_PLACEHOLDER" = "Enter a name";
"MARK_AS_READ" = "Mark Read";
"MARK_AS_UNREAD" = "Mark Unread";
"UNREAD_MESSAGES" = "Unread Messages";
"CONVERSATION_EMPTY_STATE" = "You have no messages from %@. Send a message to start the conversation!";
"CONVERSATION_EMPTY_STATE_READ_ONLY" = "There are no messages in %@.";
"CONVERSATION_EMPTY_STATE_NOTE_TO_SELF" = "You have no messages in %@.";

@ -414,6 +414,7 @@
"DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred";
"DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later";
"LOADING_CONVERSATIONS" = "Loading Conversations...";
"DATABASE_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
"DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
"RECOVERY_PHASE_ERROR_GENERIC" = "Something went wrong. Please check your recovery phrase and try again.";
"RECOVERY_PHASE_ERROR_LENGTH" = "Looks like you didn't enter enough words. Please check your recovery phrase and try again.";
@ -645,6 +646,7 @@
"CONTACT_NICKNAME_PLACEHOLDER" = "Enter a name";
"MARK_AS_READ" = "Mark Read";
"MARK_AS_UNREAD" = "Mark Unread";
"UNREAD_MESSAGES" = "Unread Messages";
"CONVERSATION_EMPTY_STATE" = "You have no messages from %@. Send a message to start the conversation!";
"CONVERSATION_EMPTY_STATE_READ_ONLY" = "There are no messages in %@.";
"CONVERSATION_EMPTY_STATE_NOTE_TO_SELF" = "You have no messages in %@.";

@ -414,6 +414,7 @@
"DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "متاسفانه خطایی رخ داده است";
"DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "لطفا بعدا دوباره تلاش کنید";
"LOADING_CONVERSATIONS" = "درحال بارگزاری پیام ها...";
"DATABASE_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
"DATABASE_MIGRATION_FAILED" = "هنگام بهینه‌سازی پایگاه داده خطایی روی داد\n\nشما می‌توانید گزارش‌های برنامه خود را صادر کنید تا بتوانید برای عیب‌یابی به اشتراک بگذارید یا می‌توانید دستگاه خود را بازیابی کنید\n\nهشدار: بازیابی دستگاه شما منجر به از دست رفتن داده‌های قدیمی‌تر از دو هفته می‌شود.";
"RECOVERY_PHASE_ERROR_GENERIC" = "مشکلی پیش آمد. لطفاً عبارت بازیابی خود را بررسی کنید و دوباره امتحان کنید.";
"RECOVERY_PHASE_ERROR_LENGTH" = "به نظر می رسد کلمات کافی وارد نکرده اید. لطفاً عبارت بازیابی خود را بررسی کنید و دوباره امتحان کنید.";
@ -645,6 +646,7 @@
"CONTACT_NICKNAME_PLACEHOLDER" = "Enter a name";
"MARK_AS_READ" = "Mark Read";
"MARK_AS_UNREAD" = "Mark Unread";
"UNREAD_MESSAGES" = "Unread Messages";
"CONVERSATION_EMPTY_STATE" = "You have no messages from %@. Send a message to start the conversation!";
"CONVERSATION_EMPTY_STATE_READ_ONLY" = "There are no messages in %@.";
"CONVERSATION_EMPTY_STATE_NOTE_TO_SELF" = "You have no messages in %@.";

@ -414,6 +414,7 @@
"DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred";
"DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later";
"LOADING_CONVERSATIONS" = "Loading Conversations...";
"DATABASE_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
"DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
"RECOVERY_PHASE_ERROR_GENERIC" = "Something went wrong. Please check your recovery phrase and try again.";
"RECOVERY_PHASE_ERROR_LENGTH" = "Looks like you didn't enter enough words. Please check your recovery phrase and try again.";
@ -645,6 +646,7 @@
"CONTACT_NICKNAME_PLACEHOLDER" = "Enter a name";
"MARK_AS_READ" = "Mark Read";
"MARK_AS_UNREAD" = "Mark Unread";
"UNREAD_MESSAGES" = "Unread Messages";
"CONVERSATION_EMPTY_STATE" = "You have no messages from %@. Send a message to start the conversation!";
"CONVERSATION_EMPTY_STATE_READ_ONLY" = "There are no messages in %@.";
"CONVERSATION_EMPTY_STATE_NOTE_TO_SELF" = "You have no messages in %@.";

@ -414,6 +414,7 @@
"DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oups, une erreur est survenue";
"DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Veuillez réessayer plus tard";
"LOADING_CONVERSATIONS" = "Chargement des conversations...";
"DATABASE_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
"DATABASE_MIGRATION_FAILED" = "Une erreur est survenue pendant l'optimisation de la base de données\n\nVous pouvez exporter votre journal d'application pour le partager et aider à régler le problème ou vous pouvez restaurer votre appareil\n\nAttention : restaurer votre appareil résultera en une perte des données des deux dernières semaines";
"RECOVERY_PHASE_ERROR_GENERIC" = "Quelque chose s'est mal passé. Vérifiez votre phrase de récupération et réessayez s'il vous plaît.";
"RECOVERY_PHASE_ERROR_LENGTH" = "Il semble que vous n'avez pas saisi tous les mots. Vérifiez votre phrase de récupération et réessayez s'il vous plaît.";
@ -645,6 +646,7 @@
"CONTACT_NICKNAME_PLACEHOLDER" = "Enter a name";
"MARK_AS_READ" = "Mark Read";
"MARK_AS_UNREAD" = "Mark Unread";
"UNREAD_MESSAGES" = "Unread Messages";
"CONVERSATION_EMPTY_STATE" = "You have no messages from %@. Send a message to start the conversation!";
"CONVERSATION_EMPTY_STATE_READ_ONLY" = "There are no messages in %@.";
"CONVERSATION_EMPTY_STATE_NOTE_TO_SELF" = "You have no messages in %@.";

@ -414,6 +414,7 @@
"DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred";
"DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later";
"LOADING_CONVERSATIONS" = "Loading Conversations...";
"DATABASE_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
"DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
"RECOVERY_PHASE_ERROR_GENERIC" = "Something went wrong. Please check your recovery phrase and try again.";
"RECOVERY_PHASE_ERROR_LENGTH" = "Looks like you didn't enter enough words. Please check your recovery phrase and try again.";
@ -645,6 +646,7 @@
"CONTACT_NICKNAME_PLACEHOLDER" = "Enter a name";
"MARK_AS_READ" = "Mark Read";
"MARK_AS_UNREAD" = "Mark Unread";
"UNREAD_MESSAGES" = "Unread Messages";
"CONVERSATION_EMPTY_STATE" = "You have no messages from %@. Send a message to start the conversation!";
"CONVERSATION_EMPTY_STATE_READ_ONLY" = "There are no messages in %@.";
"CONVERSATION_EMPTY_STATE_NOTE_TO_SELF" = "You have no messages in %@.";

@ -414,6 +414,7 @@
"DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred";
"DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later";
"LOADING_CONVERSATIONS" = "Loading Conversations...";
"DATABASE_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
"DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
"RECOVERY_PHASE_ERROR_GENERIC" = "Something went wrong. Please check your recovery phrase and try again.";
"RECOVERY_PHASE_ERROR_LENGTH" = "Looks like you didn't enter enough words. Please check your recovery phrase and try again.";
@ -645,6 +646,7 @@
"CONTACT_NICKNAME_PLACEHOLDER" = "Enter a name";
"MARK_AS_READ" = "Mark Read";
"MARK_AS_UNREAD" = "Mark Unread";
"UNREAD_MESSAGES" = "Unread Messages";
"CONVERSATION_EMPTY_STATE" = "You have no messages from %@. Send a message to start the conversation!";
"CONVERSATION_EMPTY_STATE_READ_ONLY" = "There are no messages in %@.";
"CONVERSATION_EMPTY_STATE_NOTE_TO_SELF" = "You have no messages in %@.";

@ -414,6 +414,7 @@
"DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred";
"DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later";
"LOADING_CONVERSATIONS" = "Loading Conversations...";
"DATABASE_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
"DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
"RECOVERY_PHASE_ERROR_GENERIC" = "Something went wrong. Please check your recovery phrase and try again.";
"RECOVERY_PHASE_ERROR_LENGTH" = "Looks like you didn't enter enough words. Please check your recovery phrase and try again.";
@ -645,6 +646,7 @@
"CONTACT_NICKNAME_PLACEHOLDER" = "Enter a name";
"MARK_AS_READ" = "Mark Read";
"MARK_AS_UNREAD" = "Mark Unread";
"UNREAD_MESSAGES" = "Unread Messages";
"CONVERSATION_EMPTY_STATE" = "You have no messages from %@. Send a message to start the conversation!";
"CONVERSATION_EMPTY_STATE_READ_ONLY" = "There are no messages in %@.";
"CONVERSATION_EMPTY_STATE_NOTE_TO_SELF" = "You have no messages in %@.";

@ -414,6 +414,7 @@
"DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred";
"DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later";
"LOADING_CONVERSATIONS" = "Loading Conversations...";
"DATABASE_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
"DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
"RECOVERY_PHASE_ERROR_GENERIC" = "Something went wrong. Please check your recovery phrase and try again.";
"RECOVERY_PHASE_ERROR_LENGTH" = "Looks like you didn't enter enough words. Please check your recovery phrase and try again.";
@ -645,6 +646,7 @@
"CONTACT_NICKNAME_PLACEHOLDER" = "Enter a name";
"MARK_AS_READ" = "Mark Read";
"MARK_AS_UNREAD" = "Mark Unread";
"UNREAD_MESSAGES" = "Unread Messages";
"CONVERSATION_EMPTY_STATE" = "You have no messages from %@. Send a message to start the conversation!";
"CONVERSATION_EMPTY_STATE_READ_ONLY" = "There are no messages in %@.";
"CONVERSATION_EMPTY_STATE_NOTE_TO_SELF" = "You have no messages in %@.";

@ -414,6 +414,7 @@
"DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred";
"DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later";
"LOADING_CONVERSATIONS" = "Loading Conversations...";
"DATABASE_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
"DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
"RECOVERY_PHASE_ERROR_GENERIC" = "Something went wrong. Please check your recovery phrase and try again.";
"RECOVERY_PHASE_ERROR_LENGTH" = "Looks like you didn't enter enough words. Please check your recovery phrase and try again.";
@ -645,6 +646,7 @@
"CONTACT_NICKNAME_PLACEHOLDER" = "Enter a name";
"MARK_AS_READ" = "Mark Read";
"MARK_AS_UNREAD" = "Mark Unread";
"UNREAD_MESSAGES" = "Unread Messages";
"CONVERSATION_EMPTY_STATE" = "You have no messages from %@. Send a message to start the conversation!";
"CONVERSATION_EMPTY_STATE_READ_ONLY" = "There are no messages in %@.";
"CONVERSATION_EMPTY_STATE_NOTE_TO_SELF" = "You have no messages in %@.";

@ -414,6 +414,7 @@
"DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred";
"DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later";
"LOADING_CONVERSATIONS" = "Loading Conversations...";
"DATABASE_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
"DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
"RECOVERY_PHASE_ERROR_GENERIC" = "Something went wrong. Please check your recovery phrase and try again.";
"RECOVERY_PHASE_ERROR_LENGTH" = "Looks like you didn't enter enough words. Please check your recovery phrase and try again.";
@ -645,6 +646,7 @@
"CONTACT_NICKNAME_PLACEHOLDER" = "Enter a name";
"MARK_AS_READ" = "Mark Read";
"MARK_AS_UNREAD" = "Mark Unread";
"UNREAD_MESSAGES" = "Unread Messages";
"CONVERSATION_EMPTY_STATE" = "You have no messages from %@. Send a message to start the conversation!";
"CONVERSATION_EMPTY_STATE_READ_ONLY" = "There are no messages in %@.";
"CONVERSATION_EMPTY_STATE_NOTE_TO_SELF" = "You have no messages in %@.";

@ -414,6 +414,7 @@
"DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred";
"DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later";
"LOADING_CONVERSATIONS" = "Loading Conversations...";
"DATABASE_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
"DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
"RECOVERY_PHASE_ERROR_GENERIC" = "Something went wrong. Please check your recovery phrase and try again.";
"RECOVERY_PHASE_ERROR_LENGTH" = "Looks like you didn't enter enough words. Please check your recovery phrase and try again.";
@ -645,6 +646,7 @@
"CONTACT_NICKNAME_PLACEHOLDER" = "Enter a name";
"MARK_AS_READ" = "Mark Read";
"MARK_AS_UNREAD" = "Mark Unread";
"UNREAD_MESSAGES" = "Unread Messages";
"CONVERSATION_EMPTY_STATE" = "You have no messages from %@. Send a message to start the conversation!";
"CONVERSATION_EMPTY_STATE_READ_ONLY" = "There are no messages in %@.";
"CONVERSATION_EMPTY_STATE_NOTE_TO_SELF" = "You have no messages in %@.";

@ -414,6 +414,7 @@
"DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred";
"DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later";
"LOADING_CONVERSATIONS" = "Loading Conversations...";
"DATABASE_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
"DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
"RECOVERY_PHASE_ERROR_GENERIC" = "Something went wrong. Please check your recovery phrase and try again.";
"RECOVERY_PHASE_ERROR_LENGTH" = "Looks like you didn't enter enough words. Please check your recovery phrase and try again.";
@ -645,6 +646,7 @@
"CONTACT_NICKNAME_PLACEHOLDER" = "Enter a name";
"MARK_AS_READ" = "Mark Read";
"MARK_AS_UNREAD" = "Mark Unread";
"UNREAD_MESSAGES" = "Unread Messages";
"CONVERSATION_EMPTY_STATE" = "You have no messages from %@. Send a message to start the conversation!";
"CONVERSATION_EMPTY_STATE_READ_ONLY" = "There are no messages in %@.";
"CONVERSATION_EMPTY_STATE_NOTE_TO_SELF" = "You have no messages in %@.";

@ -414,6 +414,7 @@
"DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred";
"DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later";
"LOADING_CONVERSATIONS" = "Loading Conversations...";
"DATABASE_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
"DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
"RECOVERY_PHASE_ERROR_GENERIC" = "Something went wrong. Please check your recovery phrase and try again.";
"RECOVERY_PHASE_ERROR_LENGTH" = "Looks like you didn't enter enough words. Please check your recovery phrase and try again.";
@ -645,6 +646,7 @@
"CONTACT_NICKNAME_PLACEHOLDER" = "Enter a name";
"MARK_AS_READ" = "Mark Read";
"MARK_AS_UNREAD" = "Mark Unread";
"UNREAD_MESSAGES" = "Unread Messages";
"CONVERSATION_EMPTY_STATE" = "You have no messages from %@. Send a message to start the conversation!";
"CONVERSATION_EMPTY_STATE_READ_ONLY" = "There are no messages in %@.";
"CONVERSATION_EMPTY_STATE_NOTE_TO_SELF" = "You have no messages in %@.";

@ -414,6 +414,7 @@
"DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred";
"DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later";
"LOADING_CONVERSATIONS" = "Loading Conversations...";
"DATABASE_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
"DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
"RECOVERY_PHASE_ERROR_GENERIC" = "Something went wrong. Please check your recovery phrase and try again.";
"RECOVERY_PHASE_ERROR_LENGTH" = "Looks like you didn't enter enough words. Please check your recovery phrase and try again.";
@ -645,6 +646,7 @@
"CONTACT_NICKNAME_PLACEHOLDER" = "Enter a name";
"MARK_AS_READ" = "Mark Read";
"MARK_AS_UNREAD" = "Mark Unread";
"UNREAD_MESSAGES" = "Unread Messages";
"CONVERSATION_EMPTY_STATE" = "You have no messages from %@. Send a message to start the conversation!";
"CONVERSATION_EMPTY_STATE_READ_ONLY" = "There are no messages in %@.";
"CONVERSATION_EMPTY_STATE_NOTE_TO_SELF" = "You have no messages in %@.";

@ -414,6 +414,7 @@
"DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred";
"DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later";
"LOADING_CONVERSATIONS" = "Loading Conversations...";
"DATABASE_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
"DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
"RECOVERY_PHASE_ERROR_GENERIC" = "Something went wrong. Please check your recovery phrase and try again.";
"RECOVERY_PHASE_ERROR_LENGTH" = "Looks like you didn't enter enough words. Please check your recovery phrase and try again.";
@ -645,6 +646,7 @@
"CONTACT_NICKNAME_PLACEHOLDER" = "Enter a name";
"MARK_AS_READ" = "Mark Read";
"MARK_AS_UNREAD" = "Mark Unread";
"UNREAD_MESSAGES" = "Unread Messages";
"CONVERSATION_EMPTY_STATE" = "You have no messages from %@. Send a message to start the conversation!";
"CONVERSATION_EMPTY_STATE_READ_ONLY" = "There are no messages in %@.";
"CONVERSATION_EMPTY_STATE_NOTE_TO_SELF" = "You have no messages in %@.";

@ -414,6 +414,7 @@
"DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred";
"DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later";
"LOADING_CONVERSATIONS" = "Loading Conversations...";
"DATABASE_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
"DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
"RECOVERY_PHASE_ERROR_GENERIC" = "Something went wrong. Please check your recovery phrase and try again.";
"RECOVERY_PHASE_ERROR_LENGTH" = "Looks like you didn't enter enough words. Please check your recovery phrase and try again.";
@ -645,6 +646,7 @@
"CONTACT_NICKNAME_PLACEHOLDER" = "Enter a name";
"MARK_AS_READ" = "Mark Read";
"MARK_AS_UNREAD" = "Mark Unread";
"UNREAD_MESSAGES" = "Unread Messages";
"CONVERSATION_EMPTY_STATE" = "You have no messages from %@. Send a message to start the conversation!";
"CONVERSATION_EMPTY_STATE_READ_ONLY" = "There are no messages in %@.";
"CONVERSATION_EMPTY_STATE_NOTE_TO_SELF" = "You have no messages in %@.";

@ -414,6 +414,7 @@
"DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred";
"DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later";
"LOADING_CONVERSATIONS" = "Loading Conversations...";
"DATABASE_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
"DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
"RECOVERY_PHASE_ERROR_GENERIC" = "Something went wrong. Please check your recovery phrase and try again.";
"RECOVERY_PHASE_ERROR_LENGTH" = "Looks like you didn't enter enough words. Please check your recovery phrase and try again.";
@ -645,6 +646,7 @@
"CONTACT_NICKNAME_PLACEHOLDER" = "Enter a name";
"MARK_AS_READ" = "Mark Read";
"MARK_AS_UNREAD" = "Mark Unread";
"UNREAD_MESSAGES" = "Unread Messages";
"CONVERSATION_EMPTY_STATE" = "You have no messages from %@. Send a message to start the conversation!";
"CONVERSATION_EMPTY_STATE_READ_ONLY" = "There are no messages in %@.";
"CONVERSATION_EMPTY_STATE_NOTE_TO_SELF" = "You have no messages in %@.";

@ -414,6 +414,7 @@
"DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred";
"DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later";
"LOADING_CONVERSATIONS" = "Loading Conversations...";
"DATABASE_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
"DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
"RECOVERY_PHASE_ERROR_GENERIC" = "Something went wrong. Please check your recovery phrase and try again.";
"RECOVERY_PHASE_ERROR_LENGTH" = "Looks like you didn't enter enough words. Please check your recovery phrase and try again.";
@ -645,6 +646,7 @@
"CONTACT_NICKNAME_PLACEHOLDER" = "Enter a name";
"MARK_AS_READ" = "Mark Read";
"MARK_AS_UNREAD" = "Mark Unread";
"UNREAD_MESSAGES" = "Unread Messages";
"CONVERSATION_EMPTY_STATE" = "You have no messages from %@. Send a message to start the conversation!";
"CONVERSATION_EMPTY_STATE_READ_ONLY" = "There are no messages in %@.";
"CONVERSATION_EMPTY_STATE_NOTE_TO_SELF" = "You have no messages in %@.";

@ -414,6 +414,7 @@
"DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred";
"DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later";
"LOADING_CONVERSATIONS" = "Loading Conversations...";
"DATABASE_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
"DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
"RECOVERY_PHASE_ERROR_GENERIC" = "Something went wrong. Please check your recovery phrase and try again.";
"RECOVERY_PHASE_ERROR_LENGTH" = "Looks like you didn't enter enough words. Please check your recovery phrase and try again.";
@ -645,6 +646,7 @@
"CONTACT_NICKNAME_PLACEHOLDER" = "Enter a name";
"MARK_AS_READ" = "Mark Read";
"MARK_AS_UNREAD" = "Mark Unread";
"UNREAD_MESSAGES" = "Unread Messages";
"CONVERSATION_EMPTY_STATE" = "You have no messages from %@. Send a message to start the conversation!";
"CONVERSATION_EMPTY_STATE_READ_ONLY" = "There are no messages in %@.";
"CONVERSATION_EMPTY_STATE_NOTE_TO_SELF" = "You have no messages in %@.";

@ -414,6 +414,7 @@
"DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred";
"DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later";
"LOADING_CONVERSATIONS" = "Loading Conversations...";
"DATABASE_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
"DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
"RECOVERY_PHASE_ERROR_GENERIC" = "Something went wrong. Please check your recovery phrase and try again.";
"RECOVERY_PHASE_ERROR_LENGTH" = "Looks like you didn't enter enough words. Please check your recovery phrase and try again.";
@ -645,6 +646,7 @@
"CONTACT_NICKNAME_PLACEHOLDER" = "Enter a name";
"MARK_AS_READ" = "Mark Read";
"MARK_AS_UNREAD" = "Mark Unread";
"UNREAD_MESSAGES" = "Unread Messages";
"CONVERSATION_EMPTY_STATE" = "You have no messages from %@. Send a message to start the conversation!";
"CONVERSATION_EMPTY_STATE_READ_ONLY" = "There are no messages in %@.";
"CONVERSATION_EMPTY_STATE_NOTE_TO_SELF" = "You have no messages in %@.";

@ -15,7 +15,7 @@ class ScreenLockUI {
result.isHidden = false
result.windowLevel = ._Background
result.isOpaque = true
result.themeBackgroundColor = .backgroundPrimary
result.themeBackgroundColorForced = .theme(.classicDark, color: .backgroundPrimary)
result.rootViewController = self.screenBlockingViewController
return result
@ -291,7 +291,7 @@ class ScreenLockUI {
window.isHidden = false
window.windowLevel = ._Background
window.isOpaque = true
window.themeBackgroundColor = .backgroundPrimary
window.themeBackgroundColorForced = .theme(.classicDark, color: .backgroundPrimary)
let viewController: ScreenLockViewController = ScreenLockViewController { [weak self] in
guard self?.appIsInactiveOrBackground == false else {

@ -6,67 +6,6 @@ import Curve25519Kit
import SessionMessagingKit
enum MockDataGenerator {
// Note: This was taken from TensorFlow's Random (https://github.com/apple/swift/blob/bc8f9e61d333b8f7a625f74d48ef0b554726e349/stdlib/public/TensorFlow/Random.swift)
// the complex approach is needed due to an issue with Swift's randomElement(using:)
// generation (see https://stackoverflow.com/a/64897775 for more info)
struct ARC4RandomNumberGenerator: RandomNumberGenerator {
var state: [UInt8] = Array(0...255)
var iPos: UInt8 = 0
var jPos: UInt8 = 0
init<T: BinaryInteger>(seed: T) {
self.init(
seed: (0..<(UInt64.bitWidth / UInt64.bitWidth)).map { index in
UInt8(truncatingIfNeeded: seed >> (UInt8.bitWidth * index))
}
)
}
init(seed: [UInt8]) {
precondition(seed.count > 0, "Length of seed must be positive")
precondition(seed.count <= 256, "Length of seed must be at most 256")
// Note: Have to use a for loop instead of a 'forEach' otherwise
// it doesn't work properly (not sure why...)
var j: UInt8 = 0
for i: UInt8 in 0...255 {
j &+= S(i) &+ seed[Int(i) % seed.count]
swapAt(i, j)
}
}
/// Produce the next random UInt64 from the stream, and advance the internal state
mutating func next() -> UInt64 {
// Note: Have to use a for loop instead of a 'forEach' otherwise
// it doesn't work properly (not sure why...)
var result: UInt64 = 0
for _ in 0..<UInt64.bitWidth / UInt8.bitWidth {
result <<= UInt8.bitWidth
result += UInt64(nextByte())
}
return result
}
/// Helper to access the state
private func S(_ index: UInt8) -> UInt8 {
return state[Int(index)]
}
/// Helper to swap elements of the state
private mutating func swapAt(_ i: UInt8, _ j: UInt8) {
state.swapAt(Int(i), Int(j))
}
/// Generates the next byte in the keystream.
private mutating func nextByte() -> UInt8 {
iPos &+= 1
jPos &+= S(iPos)
swapAt(iPos, jPos)
return S(S(iPos) &+ S(jPos))
}
}
// MARK: - Generation
static var printProgress: Bool = true
@ -125,7 +64,7 @@ enum MockDataGenerator {
logProgress("DM Thread \(threadIndex)", "Start")
let data = Data((0..<16).map { _ in UInt8.random(in: (UInt8.min...UInt8.max), using: &dmThreadRandomGenerator) })
let data: Data = Data(dmThreadRandomGenerator.nextBytes(count: 16))
let randomSessionId: String = try! Identity.generate(from: data).x25519KeyPair.hexEncodedPublicKey
let isMessageRequest: Bool = Bool.random(using: &dmThreadRandomGenerator)
let contactNameLength: Int = ((5..<20).randomElement(using: &dmThreadRandomGenerator) ?? 0)
@ -207,7 +146,7 @@ enum MockDataGenerator {
logProgress("Closed Group Thread \(threadIndex)", "Start")
let data = Data((0..<16).map { _ in UInt8.random(in: (UInt8.min...UInt8.max), using: &cgThreadRandomGenerator) })
let data: Data = Data(cgThreadRandomGenerator.nextBytes(count: 16))
let randomGroupPublicKey: String = try! Identity.generate(from: data).x25519KeyPair.hexEncodedPublicKey
let groupNameLength: Int = ((5..<20).randomElement(using: &cgThreadRandomGenerator) ?? 0)
let groupName: String = (0..<groupNameLength)
@ -222,7 +161,7 @@ enum MockDataGenerator {
logProgress("Closed Group Thread \(threadIndex)", "Generate \(numGroupMembers) Contacts")
(0..<numGroupMembers).forEach { _ in
let contactData = Data((0..<16).map { _ in UInt8.random(in: (UInt8.min...UInt8.max), using: &cgThreadRandomGenerator) })
let contactData: Data = Data(cgThreadRandomGenerator.nextBytes(count: 16))
let randomSessionId: String = try! Identity.generate(from: contactData).x25519KeyPair.hexEncodedPublicKey
let contactNameLength: Int = ((5..<20).randomElement(using: &cgThreadRandomGenerator) ?? 0)
@ -353,7 +292,7 @@ enum MockDataGenerator {
logProgress("Open Group Thread \(threadIndex)", "Generate \(numGroupMembers) Contacts")
(0..<numGroupMembers).forEach { _ in
let contactData = Data((0..<16).map { _ in UInt8.random(in: (UInt8.min...UInt8.max), using: &ogThreadRandomGenerator) })
let contactData: Data = Data(ogThreadRandomGenerator.nextBytes(count: 16))
let randomSessionId: String = try! Identity.generate(from: contactData).x25519KeyPair.hexEncodedPublicKey
let contactNameLength: Int = ((5..<20).randomElement(using: &ogThreadRandomGenerator) ?? 0)
_ = try! Contact(

@ -479,121 +479,6 @@ 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

@ -83,7 +83,7 @@ internal extension SessionUtil {
}
}
catch {
SNLog("[libSession] Failed to update/dump updated \(variant) config data")
SNLog("[libSession] Failed to update/dump updated \(variant) config data due to error: \(error)")
throw error
}

@ -270,7 +270,9 @@ public enum SessionUtil {
var dumpResult: UnsafeMutablePointer<UInt8>? = nil
var dumpResultLen: Int = 0
config_dump(conf, &dumpResult, &dumpResultLen)
try CExceptionHelper.performSafely {
config_dump(conf, &dumpResult, &dumpResultLen)
}
guard let dumpResult: UnsafeMutablePointer<UInt8> = dumpResult else { return nil }
@ -308,15 +310,40 @@ public enum SessionUtil {
// Ensure we always check the required user config types for changes even if there is no dump
// data yet (to deal with first launch cases)
return existingDumpVariants
return try existingDumpVariants
.compactMap { variant -> OutgoingConfResult? in
SessionUtil
try SessionUtil
.config(for: variant, publicKey: publicKey)
.mutate { conf in
// Check if the config needs to be pushed
guard conf != nil && config_needs_push(conf) else { return nil }
let cPushData: UnsafeMutablePointer<config_push_data> = config_push(conf)
var cPushData: UnsafeMutablePointer<config_push_data>!
let configCountInfo: String = {
var result: String = "Invalid"
try? CExceptionHelper.performSafely {
switch variant {
case .userProfile: result = "1 profile"
case .contacts: result = "\(contacts_size(conf)) contacts"
case .userGroups: result = "\(user_groups_size(conf)) group conversations"
case .convoInfoVolatile: result = "\(convo_info_volatile_size(conf)) volatile conversations"
}
}
return result
}()
do {
try CExceptionHelper.performSafely {
cPushData = config_push(conf)
}
}
catch {
SNLog("[libSession] Failed to generate push data for \(variant) config data, size: \(configCountInfo), error: \(error)")
throw error
}
let pushData: Data = Data(
bytes: cPushData.pointee.config,
count: cPushData.pointee.config_len
@ -328,6 +355,7 @@ public enum SessionUtil {
)
let seqNo: Int64 = cPushData.pointee.seqno
cPushData.deallocate()
SNLog("[libSession - DEBUG] Push data for \(variant) config data, size: \(configCountInfo), bytes: \(pushData.count)")
return OutgoingConfResult(
message: SharedConfigMessage(

@ -55,6 +55,7 @@ public struct MessageViewModel: FetchableRecordWithRowId, Decodable, Equatable,
case genericAttachment
case typingIndicator
case dateHeader
case unreadMarker
}
public var differenceIdentifier: Int64 { id }

@ -53,25 +53,15 @@ class ConfigContactsSpec {
// 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())
var randomGenerator: ARC4RandomNumberGenerator = ARC4RandomNumberGenerator(seed: 1000)
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)
var contact: contacts_contact = try createContact(
for: index,
in: conf,
rand: &randomGenerator,
maxing: .allProperties
)
contacts_set(conf, &contact)
}
@ -80,12 +70,7 @@ class ConfigContactsSpec {
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()
}
try CExceptionHelper.performSafely { config_push(conf).deallocate() }
}
.to(throwError(NSError(domain: "cpp_exception", code: -2, userInfo: ["NSLocalizedDescription": "Config data is too large"])))
}
@ -117,8 +102,14 @@ class ConfigContactsSpec {
// 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)
var randomGenerator: ARC4RandomNumberGenerator = ARC4RandomNumberGenerator(seed: 1000)
for index in (0..<100000) {
var contact: contacts_contact = try createContact(
for: index,
in: conf,
rand: &randomGenerator
)
contacts_set(conf, &contact)
do { try CExceptionHelper.performSafely { config_push(conf).deallocate() } }
@ -129,13 +120,20 @@ class ConfigContactsSpec {
}
// Check that the record count matches the maximum when we last checked
expect(numRecords).to(equal(1775))
expect(numRecords).to(equal(2370))
}
// 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])
var randomGenerator: ARC4RandomNumberGenerator = ARC4RandomNumberGenerator(seed: 1000)
for index in (0..<100000) {
var contact: contacts_contact = try createContact(
for: index,
in: conf,
rand: &randomGenerator,
maxing: [.name]
)
contacts_set(conf, &contact)
do { try CExceptionHelper.performSafely { config_push(conf).deallocate() } }
@ -146,13 +144,20 @@ class ConfigContactsSpec {
}
// Check that the record count matches the maximum when we last checked
expect(numRecords).to(equal(526))
expect(numRecords).to(equal(796))
}
// 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])
var randomGenerator: ARC4RandomNumberGenerator = ARC4RandomNumberGenerator(seed: 1000)
for index in (0..<100000) {
var contact: contacts_contact = try createContact(
for: index,
in: conf,
rand: &randomGenerator,
maxing: [.name, .profile_pic]
)
contacts_set(conf, &contact)
do { try CExceptionHelper.performSafely { config_push(conf).deallocate() } }
@ -163,13 +168,20 @@ class ConfigContactsSpec {
}
// Check that the record count matches the maximum when we last checked
expect(numRecords).to(equal(184))
expect(numRecords).to(equal(290))
}
// 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)
var randomGenerator: ARC4RandomNumberGenerator = ARC4RandomNumberGenerator(seed: 1000)
for index in (0..<100000) {
var contact: contacts_contact = try createContact(
for: index,
in: conf,
rand: &randomGenerator,
maxing: .allProperties
)
contacts_set(conf, &contact)
do { try CExceptionHelper.performSafely { config_push(conf).deallocate() } }
@ -180,71 +192,7 @@ class ConfigContactsSpec {
}
// 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))
}
expect(numRecords).to(equal(236))
}
}
@ -547,9 +495,10 @@ class ConfigContactsSpec {
private static func createContact(
for index: Int,
in conf: UnsafeMutablePointer<config_object>?,
rand: inout ARC4RandomNumberGenerator,
maxing properties: [ContactProperty] = []
) throws -> contacts_contact {
let postPrefixId: String = "050000000000000000000000000000000000000000000000000000000000000000"
let postPrefixId: String = "05\(rand.nextBytes(count: 32).toHexString())"
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()
@ -569,33 +518,22 @@ class ConfigContactsSpec {
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()
contact.name = rand.nextBytes(count: SessionUtil.libSessionMaxNameByteLength)
.toHexString()
.toLibSession()
case .nickname:
contact.nickname = String(
data: Data(
repeating: "A".data(using: .utf8)![0],
count: SessionUtil.libSessionMaxNameByteLength
),
encoding: .utf8
).toLibSession()
contact.nickname = rand.nextBytes(count: SessionUtil.libSessionMaxNameByteLength)
.toHexString()
.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()
url: rand.nextBytes(count: SessionUtil.libSessionMaxProfileUrlByteLength)
.toHexString()
.toLibSession(),
key: Data(rand.nextBytes(count: 32))
.toLibSession()
)
}
}

@ -407,8 +407,10 @@ internal class ThemeApplier {
.compactMap { $0?.clearingOtherAppliers() }
.filter { $0.info != info }
// Automatically apply the theme immediately
self.apply(theme: ThemeManager.currentTheme, isInitialApplication: true)
// Automatically apply the theme immediately (if the database has been setup)
if Storage.hasCreatedValidInstance {
self.apply(theme: ThemeManager.currentTheme, isInitialApplication: true)
}
}
// MARK: - Functions

@ -115,6 +115,9 @@ internal enum Theme_ClassicDark: ThemeColors {
// Profile
.profileIcon: .primary,
.profileIcon_greenPrimaryColor: .black,
.profileIcon_background: .white
.profileIcon_background: .white,
// Unread Marker
.unreadMarker: .primary
]
}

@ -115,6 +115,9 @@ internal enum Theme_ClassicLight: ThemeColors {
// Profile
.profileIcon: .primary,
.profileIcon_greenPrimaryColor: .primary,
.profileIcon_background: .black
.profileIcon_background: .black,
// Unread Marker
.unreadMarker: .black
]
}

@ -115,6 +115,9 @@ internal enum Theme_OceanDark: ThemeColors {
// Profile
.profileIcon: .primary,
.profileIcon_greenPrimaryColor: .black,
.profileIcon_background: .white
.profileIcon_background: .white,
// Unread Marker
.unreadMarker: .primary
]
}

@ -115,6 +115,9 @@ internal enum Theme_OceanLight: ThemeColors {
// Profile
.profileIcon: .primary,
.profileIcon_greenPrimaryColor: .primary,
.profileIcon_background: .oceanLight1
.profileIcon_background: .oceanLight1,
// Unread Marker
.unreadMarker: .black
]
}

@ -204,6 +204,9 @@ public indirect enum ThemeValue: Hashable {
case profileIcon
case profileIcon_greenPrimaryColor
case profileIcon_background
// Unread Marker
case unreadMarker
}
// MARK: - ForcedThemeValue

@ -17,13 +17,16 @@ open class Storage {
private static var databasePathShm: String { "\(Storage.sharedDatabaseDirectoryPath)/\(Storage.dbFileName)-shm" }
private static var databasePathWal: String { "\(Storage.sharedDatabaseDirectoryPath)/\(Storage.dbFileName)-wal" }
public static var hasCreatedValidInstance: Bool { internalHasCreatedValidInstance.wrappedValue }
public static var isDatabasePasswordAccessible: Bool {
guard (try? getDatabaseCipherKeySpec()) != nil else { return false }
return true
}
private var startupError: Error?
private let migrationsCompleted: Atomic<Bool> = Atomic(false)
private static let internalHasCreatedValidInstance: Atomic<Bool> = Atomic(false)
internal let internalCurrentlyRunningMigration: Atomic<(identifier: TargetMigrations.Identifier, migration: Migration.Type)?> = Atomic(nil)
public static let shared: Storage = Storage()
@ -53,6 +56,7 @@ open class Storage {
guard customWriter == nil else {
dbWriter = customWriter
isValid = true
Storage.internalHasCreatedValidInstance.mutate { $0 = true }
perform(migrations: (customMigrations ?? []), async: false, onProgressUpdate: nil, onComplete: { _, _ in })
return
}
@ -99,8 +103,9 @@ open class Storage {
configuration: config
)
isValid = true
Storage.internalHasCreatedValidInstance.mutate { $0 = true }
}
catch {}
catch { startupError = error }
}
// MARK: - Migrations
@ -118,7 +123,12 @@ open class Storage {
onProgressUpdate: ((CGFloat, TimeInterval) -> ())?,
onComplete: @escaping (Swift.Result<Void, Error>, Bool) -> ()
) {
guard isValid, let dbWriter: DatabaseWriter = dbWriter else { return }
guard isValid, let dbWriter: DatabaseWriter = dbWriter else {
let error: Error = (startupError ?? StorageError.startupFailed)
SNLog("[Database Error] Statup failed with error: \(error)")
onComplete(.failure(StorageError.startupFailed), false)
return
}
typealias MigrationInfo = (identifier: TargetMigrations.Identifier, migrations: TargetMigrations.MigrationSet)
let sortedMigrationInfo: [MigrationInfo] = migrations
@ -135,11 +145,11 @@ open class Storage {
.reduce(into: []) { result, next in result.append(contentsOf: next) }
// Setup and run any required migrations
migrator = {
migrator = { [weak self] in
var migrator: DatabaseMigrator = DatabaseMigrator()
sortedMigrationInfo.forEach { migrationInfo in
migrationInfo.migrations.forEach { migration in
migrator.registerMigration(migrationInfo.identifier, migration: migration)
migrator.registerMigration(self, targetIdentifier: migrationInfo.identifier, migration: migration)
}
}
@ -316,6 +326,7 @@ open class Storage {
try? SUKLegacy.deleteLegacyDatabaseFilesAndKey()
Storage.shared.isValid = false
Storage.internalHasCreatedValidInstance.mutate { $0 = false }
Storage.shared.migrationsCompleted.mutate { $0 = false }
Storage.shared.dbWriter = nil

@ -5,6 +5,7 @@ import Foundation
public enum StorageError: Error {
case generic
case databaseInvalid
case startupFailed
case migrationFailed
case invalidKeySpec
case decodingFailed

@ -13,15 +13,16 @@ public protocol Migration {
}
public extension Migration {
static func loggedMigrate(_ targetIdentifier: TargetMigrations.Identifier) -> ((_ db: Database) throws -> ()) {
static func loggedMigrate(
_ storage: Storage?,
targetIdentifier: TargetMigrations.Identifier
) -> ((_ db: Database) throws -> ()) {
return { (db: Database) in
SNLogNotTests("[Migration Info] Starting \(targetIdentifier.key(with: self))")
Storage.shared.internalCurrentlyRunningMigration.mutate { $0 = (targetIdentifier, self) }
do { try migrate(db) }
catch {
Storage.shared.internalCurrentlyRunningMigration.mutate { $0 = nil }
throw error
}
storage?.internalCurrentlyRunningMigration.mutate { $0 = (targetIdentifier, self) }
defer { storage?.internalCurrentlyRunningMigration.mutate { $0 = nil } }
try migrate(db)
SNLogNotTests("[Migration Info] Completed \(targetIdentifier.key(with: self))")
}
}

@ -4,10 +4,15 @@ import Foundation
import GRDB
public extension DatabaseMigrator {
mutating func registerMigration(_ targetIdentifier: TargetMigrations.Identifier, migration: Migration.Type, foreignKeyChecks: ForeignKeyChecks = .deferred) {
mutating func registerMigration(
_ storage: Storage?,
targetIdentifier: TargetMigrations.Identifier,
migration: Migration.Type,
foreignKeyChecks: ForeignKeyChecks = .deferred
) {
self.registerMigration(
targetIdentifier.key(with: migration),
migrate: migration.loggedMigrate(targetIdentifier)
migrate: migration.loggedMigrate(storage, targetIdentifier: targetIdentifier)
)
}
}

@ -0,0 +1,73 @@
// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved.
//
// Note: This was taken from TensorFlow's Random:
// https://github.com/apple/swift/blob/bc8f9e61d333b8f7a625f74d48ef0b554726e349/stdlib/public/TensorFlow/Random.swift
//
// the complex approach is needed due to an issue with Swift's randomElement(using:)
// generation (see https://stackoverflow.com/a/64897775 for more info)
import Foundation
public struct ARC4RandomNumberGenerator: RandomNumberGenerator {
var state: [UInt8] = Array(0...255)
var iPos: UInt8 = 0
var jPos: UInt8 = 0
public init<T: BinaryInteger>(seed: T) {
self.init(
seed: (0..<(UInt64.bitWidth / UInt64.bitWidth)).map { index in
UInt8(truncatingIfNeeded: seed >> (UInt8.bitWidth * index))
}
)
}
public init(seed: [UInt8]) {
precondition(seed.count > 0, "Length of seed must be positive")
precondition(seed.count <= 256, "Length of seed must be at most 256")
// Note: Have to use a for loop instead of a 'forEach' otherwise
// it doesn't work properly (not sure why...)
var j: UInt8 = 0
for i: UInt8 in 0...255 {
j &+= S(i) &+ seed[Int(i) % seed.count]
swapAt(i, j)
}
}
/// Produce the next random UInt64 from the stream, and advance the internal state
public mutating func next() -> UInt64 {
// Note: Have to use a for loop instead of a 'forEach' otherwise
// it doesn't work properly (not sure why...)
var result: UInt64 = 0
for _ in 0..<UInt64.bitWidth / UInt8.bitWidth {
result <<= UInt8.bitWidth
result += UInt64(nextByte())
}
return result
}
/// Helper to access the state
private func S(_ index: UInt8) -> UInt8 {
return state[Int(index)]
}
/// Helper to swap elements of the state
private mutating func swapAt(_ i: UInt8, _ j: UInt8) {
state.swapAt(Int(i), Int(j))
}
/// Generates the next byte in the keystream.
private mutating func nextByte() -> UInt8 {
iPos &+= 1
jPos &+= S(iPos)
swapAt(iPos, jPos)
return S(S(iPos) &+ S(jPos))
}
}
public extension ARC4RandomNumberGenerator {
mutating func nextBytes(count: Int) -> [UInt8] {
(0..<count).map { _ in nextByte() }
}
}

@ -340,7 +340,8 @@ class PersistableRecordUtilitiesSpec: QuickSpec {
beforeEach {
var migrator: DatabaseMigrator = DatabaseMigrator()
migrator.registerMigration(
TestAddColumnMigration.target,
mockStorage,
targetIdentifier: TestAddColumnMigration.target,
migration: TestAddColumnMigration.self
)

@ -63,7 +63,7 @@ open class ScreenLockViewController: UIViewController {
open override func loadView() {
super.loadView()
view.themeBackgroundColor = .black // Need to match the Launch screen
view.themeBackgroundColorForced = .theme(.classicDark, color: .black) // Need to match the Launch screen
let edgesView: UIView = UIView.container()
self.view.addSubview(edgesView)

Loading…
Cancel
Save