Fixed a number of crashes and bugs

Fixed a crash which would occur when rendering a message containing both a mention and a url
Fixed a crash which could occur during migration due to the openGroupServerMessageId essentially being the max UInt64 value which was overflowing the Int64 storage
Fixed a bug where empty read receipt updates were sending messages (even for non one-to-one conversations)
Fixed a bug where loading in large numbers of messages (via the poller) was auto scrolling to the bottom if the user was close to the bottom (now limited to <5)
Fixed a memory leak with the AllMediaViewController (strong delegate references)
Fixed an issue where non-alphanumeric characters would cause issues with global search
Fixed an issue where search result highlighting wasn't working properly
Fixed an issue where the app switcher UI blocking wasn't working
Updated the conversations to mark messages as read while scrolling (rather than all messages when entering/participating in a conversation)
Updated the modal button font weight to be closer to the designs
Added the ability to delete "unsent" messages
pull/672/head
Morgan Pretty 3 years ago
parent 5b1e19dd2e
commit 91802e4812

@ -109,6 +109,9 @@ extension ContextMenuVC {
delegate: ContextMenuActionDelegate? delegate: ContextMenuActionDelegate?
) -> [Action]? { ) -> [Action]? {
// No context items for info messages // No context items for info messages
guard cellViewModel.variant != .standardIncomingDeleted else {
return [ Action.delete(cellViewModel, delegate) ]
}
guard cellViewModel.variant == .standardOutgoing || cellViewModel.variant == .standardIncoming else { guard cellViewModel.variant == .standardOutgoing || cellViewModel.variant == .standardIncoming else {
return nil return nil
} }

@ -221,7 +221,7 @@ final class ContextMenuVC: UIViewController {
menuView.pin(.right, to: .right, of: view, withInset: -(UIScreen.main.bounds.width - targetFrame.maxX)) menuView.pin(.right, to: .right, of: view, withInset: -(UIScreen.main.bounds.width - targetFrame.maxX))
emojiBar.pin(.right, to: .right, of: view, withInset: -(UIScreen.main.bounds.width - targetFrame.maxX)) emojiBar.pin(.right, to: .right, of: view, withInset: -(UIScreen.main.bounds.width - targetFrame.maxX))
case .standardIncoming: case .standardIncoming, .standardIncomingDeleted:
menuView.pin(.left, to: .left, of: view, withInset: targetFrame.minX) menuView.pin(.left, to: .left, of: view, withInset: targetFrame.minX)
emojiBar.pin(.left, to: .left, of: view, withInset: targetFrame.minX) emojiBar.pin(.left, to: .left, of: view, withInset: targetFrame.minX)
@ -288,8 +288,8 @@ final class ContextMenuVC: UIViewController {
let ratio: CGFloat = (frame.width / frame.height) let ratio: CGFloat = (frame.width / frame.height)
// FIXME: Need to update this when an appropriate replacement is added (see https://teng.pub/technical/2021/11/9/uiapplication-key-window-replacement) // FIXME: Need to update this when an appropriate replacement is added (see https://teng.pub/technical/2021/11/9/uiapplication-key-window-replacement)
let topMargin = max(UIApplication.shared.keyWindow!.safeAreaInsets.top, Values.mediumSpacing) let topMargin = max((UIApplication.shared.keyWindow?.safeAreaInsets.top ?? 0), Values.mediumSpacing)
let bottomMargin = max(UIApplication.shared.keyWindow!.safeAreaInsets.bottom, Values.mediumSpacing) let bottomMargin = max((UIApplication.shared.keyWindow?.safeAreaInsets.bottom ?? 0), Values.mediumSpacing)
let diffY = finalFrame.height + menuHeight + Self.actionViewHeight + 2 * spacing + topMargin + bottomMargin - UIScreen.main.bounds.height let diffY = finalFrame.height + menuHeight + Self.actionViewHeight + 2 * spacing + topMargin + bottomMargin - UIScreen.main.bounds.height
if diffY > 0 { if diffY > 0 {

@ -1594,6 +1594,14 @@ extension ConversationVC:
func delete(_ cellViewModel: MessageViewModel) { func delete(_ cellViewModel: MessageViewModel) {
// Only allow deletion on incoming and outgoing messages // Only allow deletion on incoming and outgoing messages
guard cellViewModel.variant != .standardIncomingDeleted else {
Storage.shared.writeAsync { db in
_ = try Interaction
.filter(id: cellViewModel.id)
.deleteAll(db)
}
return
}
guard cellViewModel.variant == .standardIncoming || cellViewModel.variant == .standardOutgoing else { guard cellViewModel.variant == .standardIncoming || cellViewModel.variant == .standardOutgoing else {
return return
} }

@ -138,11 +138,13 @@ final class ConversationVC: BaseVC, ConversationSearchControllerDelegate, UITabl
result.showsVerticalScrollIndicator = false result.showsVerticalScrollIndicator = false
result.contentInsetAdjustmentBehavior = .never result.contentInsetAdjustmentBehavior = .never
result.keyboardDismissMode = .interactive result.keyboardDismissMode = .interactive
let bottomInset: CGFloat = viewModel.threadData.canWrite ? Values.mediumSpacing : Values.mediumSpacing + UIApplication.shared.keyWindow!.safeAreaInsets.bottom
result.contentInset = UIEdgeInsets( result.contentInset = UIEdgeInsets(
top: 0, top: 0,
leading: 0, leading: 0,
bottom: bottomInset, bottom: (viewModel.threadData.canWrite ?
Values.mediumSpacing :
(Values.mediumSpacing + (UIApplication.shared.keyWindow?.safeAreaInsets.bottom ?? 0))
),
trailing: 0 trailing: 0
) )
result.registerHeaderFooterView(view: UITableViewHeaderFooterView.self) result.registerHeaderFooterView(view: UITableViewHeaderFooterView.self)
@ -604,11 +606,8 @@ final class ConversationVC: BaseVC, ConversationSearchControllerDelegate, UITabl
snInputView.text = draft snInputView.text = draft
} }
// Now we have done all the needed diffs, update the viewModel with the latest data and mark // Now we have done all the needed diffs update the viewModel with the latest data
// all messages as read (we do it in here as the 'threadData' actually contains the last
// 'interactionId' for the thread)
self.viewModel.updateThreadData(updatedThreadData) self.viewModel.updateThreadData(updatedThreadData)
self.viewModel.markAllAsRead()
/// **Note:** This needs to happen **after** we have update the viewModel's thread data /// **Note:** This needs to happen **after** we have update the viewModel's thread data
if initialLoad || viewModel.threadData.currentUserIsClosedGroupMember != updatedThreadData.currentUserIsClosedGroupMember { if initialLoad || viewModel.threadData.currentUserIsClosedGroupMember != updatedThreadData.currentUserIsClosedGroupMember {
@ -682,7 +681,8 @@ final class ConversationVC: BaseVC, ConversationSearchControllerDelegate, UITabl
source: viewModel.interactionData, source: viewModel.interactionData,
target: updatedData target: updatedData
) )
let isInsert: Bool = (changeset.map({ $0.elementInserted.count }).reduce(0, +) > 0) let numItemsInserted: Int = changeset.map { $0.elementInserted.count }.reduce(0, +)
let isInsert: Bool = (numItemsInserted > 0)
let wasLoadingMore: Bool = self.isLoadingMore let wasLoadingMore: Bool = self.isLoadingMore
let wasOffsetCloseToBottom: Bool = self.isCloseToBottom let wasOffsetCloseToBottom: Bool = self.isCloseToBottom
let numItemsInUpdatedData: [Int] = updatedData.map { $0.elements.count } let numItemsInUpdatedData: [Int] = updatedData.map { $0.elements.count }
@ -758,10 +758,12 @@ final class ConversationVC: BaseVC, ConversationSearchControllerDelegate, UITabl
} }
} }
} }
else if wasOffsetCloseToBottom && !wasLoadingMore { else if wasOffsetCloseToBottom && !wasLoadingMore && numItemsInserted < 5 {
// Scroll to the bottom if an interaction was just inserted and we either /// Scroll to the bottom if an interaction was just inserted and we either just sent a message or are close enough to the
// just sent a message or are close enough to the bottom (wait a tiny fraction /// bottom (wait a tiny fraction to avoid buggy animation behaviour)
// to avoid buggy animation behaviour) ///
/// **Note:** We won't automatically scroll to the bottom if 5 or more messages were inserted (to avoid endlessly
/// auto-scrolling to the bottom when fetching new pages of data within open groups
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(100)) { [weak self] in DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(100)) { [weak self] in
self?.scrollToBottom(isAnimated: true) self?.scrollToBottom(isAnimated: true)
} }
@ -771,6 +773,11 @@ final class ConversationVC: BaseVC, ConversationSearchControllerDelegate, UITabl
self.isLoadingMore = false self.isLoadingMore = false
self.autoLoadNextPageIfNeeded() self.autoLoadNextPageIfNeeded()
} }
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
}
return return
} }
@ -1311,6 +1318,7 @@ final class ConversationVC: BaseVC, ConversationSearchControllerDelegate, UITabl
) )
self.handleInitialOffsetBounceBug(targetIndexPath: targetIndexPath, at: .bottom) self.handleInitialOffsetBounceBug(targetIndexPath: targetIndexPath, at: .bottom)
self.viewModel.markAsRead(beforeInclusive: nil)
} }
func scrollViewWillBeginDragging(_ scrollView: UIScrollView) { func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
@ -1322,8 +1330,41 @@ final class ConversationVC: BaseVC, ConversationSearchControllerDelegate, UITabl
} }
func scrollViewDidScroll(_ scrollView: UIScrollView) { func scrollViewDidScroll(_ scrollView: UIScrollView) {
scrollButton.alpha = getScrollButtonOpacity() self.scrollButton.alpha = self.getScrollButtonOpacity()
unreadCountView.alpha = scrollButton.alpha self.unreadCountView.alpha = self.scrollButton.alpha
// 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(beforeInclusive: newestCellViewModel.id)
}
} }
func scrollViewDidEndScrollingAnimation(_ scrollView: UIScrollView) { func scrollViewDidEndScrollingAnimation(_ scrollView: UIScrollView) {
@ -1472,6 +1513,7 @@ final class ConversationVC: BaseVC, ConversationSearchControllerDelegate, UITabl
// Store the info incase we need to load more data (call will be re-triggered) // Store the info incase we need to load more data (call will be re-triggered)
self.focusedInteractionId = interactionId self.focusedInteractionId = interactionId
self.shouldHighlightNextScrollToInteraction = highlight self.shouldHighlightNextScrollToInteraction = highlight
self.viewModel.markAsRead(beforeInclusive: interactionId)
// Ensure the target interaction has been loaded // Ensure the target interaction has been loaded
guard guard

@ -149,6 +149,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
// MARK: - Interaction Data // MARK: - Interaction Data
private var lastInteractionIdMarkedAsRead: Int64?
public private(set) var unobservedInteractionDataChanges: [SectionModel]? public private(set) var unobservedInteractionDataChanges: [SectionModel]?
public private(set) var interactionData: [SectionModel] = [] public private(set) var interactionData: [SectionModel] = []
public private(set) var reactionExpandedInteractionIds: Set<Int64> = [] public private(set) var reactionExpandedInteractionIds: Set<Int64> = []
@ -380,21 +381,30 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
} }
} }
public func markAllAsRead() { /// This method will mark all interactions as read before the specified interaction id, if no id is provided then all interactions for
// Don't bother marking anything as read if there are no unread interactions (we can rely /// the thread will be marked as read
// on the 'threadData.threadUnreadCount' to always be accurate) public func markAsRead(beforeInclusive interactionId: Int64?) {
/// Since this method now gets triggered when scrolling we want to try to optimise it and avoid busying the database
/// write queue when it isn't needed, in order to do this we:
///
/// - Don't bother marking anything as read if there are no unread interactions (we can rely on the
/// `threadData.threadUnreadCount` to always be accurate)
/// - Don't bother marking anything as read if this was called with the same `interactionId` that we
/// previously marked as read (ie. when scrolling and the last message hasn't changed)
guard guard
(self.threadData.threadUnreadCount ?? 0) > 0, (self.threadData.threadUnreadCount ?? 0) > 0,
let lastInteractionId: Int64 = self.threadData.interactionId let targetInteractionId: Int64 = (interactionId ?? self.threadData.interactionId),
self.lastInteractionIdMarkedAsRead != targetInteractionId
else { return } else { return }
let threadId: String = self.threadData.threadId let threadId: String = self.threadData.threadId
let trySendReadReceipt: Bool = (self.threadData.threadIsMessageRequest == false) let trySendReadReceipt: Bool = (self.threadData.threadIsMessageRequest == false)
self.lastInteractionIdMarkedAsRead = targetInteractionId
Storage.shared.writeAsync { db in Storage.shared.writeAsync { db in
try Interaction.markAsRead( try Interaction.markAsRead(
db, db,
interactionId: lastInteractionId, interactionId: targetInteractionId,
threadId: threadId, threadId: threadId,
includingOlder: true, includingOlder: true,
trySendReadReceipt: trySendReadReceipt trySendReadReceipt: trySendReadReceipt

@ -1049,51 +1049,52 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate {
) )
// Custom handle links // Custom handle links
let links: [String: NSRange] = { let links: [URL: NSRange] = {
guard guard let detector: NSDataDetector = try? NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue) else {
let body: String = cellViewModel.body, return [:]
let detector: NSDataDetector = try? NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue) }
else { return [:] }
var links: [String: NSRange] = [:]
let matches = detector.matches(
in: body,
options: [],
range: NSRange(location: 0, length: body.count)
)
for match in matches { return detector
guard let matchURL = match.url else { continue } .matches(
in: attributedText.string,
/// If the URL entered didn't have a scheme it will default to 'http', we want to catch this and options: [],
/// set the scheme to 'https' instead as we don't load previews for 'http' so this will result range: NSRange(location: 0, length: attributedText.string.count)
/// in more previews actually getting loaded without forcing the user to enter 'https://' before
/// every URL they enter
let urlString: String = (matchURL.absoluteString == "http://\(body)" ?
"https://\(body)" :
matchURL.absoluteString
) )
.reduce(into: [:]) { result, match in
if URL(string: urlString) != nil { guard
links[urlString] = (body as NSString).range(of: urlString) let matchUrl: URL = match.url,
let originalRange: Range = Range(match.range, in: attributedText.string)
else { return }
/// If the URL entered didn't have a scheme it will default to 'http', we want to catch this and
/// set the scheme to 'https' instead as we don't load previews for 'http' so this will result
/// in more previews actually getting loaded without forcing the user to enter 'https://' before
/// every URL they enter
let originalString: String = String(attributedText.string[originalRange])
guard matchUrl.absoluteString != "http://\(originalString)" else {
guard let httpsUrl: URL = URL(string: "https://\(originalString)") else {
return
}
result[httpsUrl] = match.range
return
}
result[matchUrl] = match.range
} }
}
return links
}() }()
for (urlString, range) in links { for (linkUrl, urlRange) in links {
guard let url: URL = URL(string: urlString) else { continue }
attributedText.addAttributes( attributedText.addAttributes(
[ [
.font: UIFont.systemFont(ofSize: getFontSize(for: cellViewModel)), .font: UIFont.systemFont(ofSize: getFontSize(for: cellViewModel)),
.foregroundColor: actualTextColor, .foregroundColor: actualTextColor,
.underlineColor: actualTextColor, .underlineColor: actualTextColor,
.underlineStyle: NSUnderlineStyle.single.rawValue, .underlineStyle: NSUnderlineStyle.single.rawValue,
.attachment: url .attachment: linkUrl
], ],
range: range range: urlRange
) )
} }
@ -1105,7 +1106,8 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate {
.map { part -> String in .map { part -> String in
guard part.hasPrefix("\"") && part.hasSuffix("\"") else { return part } guard part.hasPrefix("\"") && part.hasSuffix("\"") else { return part }
return String(part[part.index(after: part.startIndex)..<part.endIndex]) let partRange = (part.index(after: part.startIndex)..<part.index(before: part.endIndex))
return String(part[partRange])
} }
.forEach { part in .forEach { part in
// Highlight all ranges of the text (Note: The search logic only finds // Highlight all ranges of the text (Note: The search logic only finds
@ -1114,13 +1116,26 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate {
normalizedBody normalizedBody
.ranges( .ranges(
of: (CurrentAppContext().isRTL ? of: (CurrentAppContext().isRTL ?
"\(part.lowercased())(^|[ ])" : "(\(part.lowercased()))(^|[^a-zA-Z0-9])" :
"(^|[ ])\(part.lowercased())" "(^|[^a-zA-Z0-9])(\(part.lowercased()))"
), ),
options: [.regularExpression] options: [.regularExpression]
) )
.forEach { range in .forEach { range in
let legacyRange: NSRange = NSRange(range, in: normalizedBody) let targetRange: Range<String.Index> = {
let term: String = String(normalizedBody[range])
// If the matched term doesn't actually match the "part" value then it means
// we've matched a term after a non-alphanumeric character so need to shift
// the range over by 1
guard term.starts(with: part.lowercased()) else {
return (normalizedBody.index(after: range.lowerBound)..<range.upperBound)
}
return range
}()
let legacyRange: NSRange = NSRange(targetRange, in: normalizedBody)
attributedText.addThemeAttribute(.background(backgroundPrimaryColor), range: legacyRange) attributedText.addThemeAttribute(.background(backgroundPrimaryColor), range: legacyRange)
attributedText.addThemeAttribute(.foreground(textPrimaryColor), range: legacyRange) attributedText.addThemeAttribute(.foreground(textPrimaryColor), range: legacyRange)
} }

@ -331,7 +331,9 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, SeedRemi
// PagedDatabaseObserver won't have them so we need to force a re-fetch of the current // PagedDatabaseObserver won't have them so we need to force a re-fetch of the current
// data to ensure everything is up to date // data to ensure everything is up to date
if didReturnFromBackground { if didReturnFromBackground {
self.viewModel.pagedDataObserver?.reload() DispatchQueue.global(qos: .default).async { [weak self] in
self?.viewModel.pagedDataObserver?.reload()
}
} }
} }

@ -23,7 +23,7 @@ public class DocumentTileViewController: UIViewController, UITableViewDelegate,
private var isAutoLoadingNextPage: Bool = false private var isAutoLoadingNextPage: Bool = false
private var currentTargetOffset: CGPoint? private var currentTargetOffset: CGPoint?
public var delegate: DocumentTileViewControllerDelegate? public weak var delegate: DocumentTileViewControllerDelegate?
// MARK: - Initialization // MARK: - Initialization

@ -23,7 +23,7 @@ public class MediaTileViewController: UIViewController, UICollectionViewDataSour
private var isAutoLoadingNextPage: Bool = false private var isAutoLoadingNextPage: Bool = false
private var currentTargetOffset: CGPoint? private var currentTargetOffset: CGPoint?
public var delegate: MediaTileViewControllerDelegate? public weak var delegate: MediaTileViewControllerDelegate?
var isInBatchSelectMode = false { var isInBatchSelectMode = false {
didSet { didSet {

@ -545,7 +545,8 @@ public final class FullConversationCell: UITableViewCell {
.map { part -> String in .map { part -> String in
guard part.hasPrefix("\"") && part.hasSuffix("\"") else { return part } guard part.hasPrefix("\"") && part.hasSuffix("\"") else { return part }
return String(part[part.index(after: part.startIndex)..<part.endIndex]) let partRange = (part.index(after: part.startIndex)..<part.index(before: part.endIndex))
return String(part[partRange])
} }
.forEach { part in .forEach { part in
// Highlight all ranges of the text (Note: The search logic only finds results that start // Highlight all ranges of the text (Note: The search logic only finds results that start
@ -553,18 +554,31 @@ public final class FullConversationCell: UITableViewCell {
normalizedSnippet normalizedSnippet
.ranges( .ranges(
of: (CurrentAppContext().isRTL ? of: (CurrentAppContext().isRTL ?
"\(part.lowercased())(^|[ ])" : "(\(part.lowercased()))(^|[^a-zA-Z0-9])" :
"(^|[ ])\(part.lowercased())" "(^|[^a-zA-Z0-9])(\(part.lowercased()))"
), ),
options: [.regularExpression] options: [.regularExpression]
) )
.forEach { range in .forEach { range in
let targetRange: Range<String.Index> = {
let term: String = String(normalizedSnippet[range])
// If the matched term doesn't actually match the "part" value then it means
// we've matched a term after a non-alphanumeric character so need to shift
// the range over by 1
guard term.starts(with: part.lowercased()) else {
return (normalizedSnippet.index(after: range.lowerBound)..<range.upperBound)
}
return range
}()
// Store the range of the first match so we can focus it in the content displayed // Store the range of the first match so we can focus it in the content displayed
if firstMatchRange == nil { if firstMatchRange == nil {
firstMatchRange = range firstMatchRange = targetRange
} }
let legacyRange: NSRange = NSRange(range, in: normalizedSnippet) let legacyRange: NSRange = NSRange(targetRange, in: normalizedSnippet)
result.addAttribute(.foregroundColor, value: textColor, range: legacyRange) result.addAttribute(.foregroundColor, value: textColor, range: legacyRange)
result.addAttribute(.font, value: UIFont.boldSystemFont(ofSize: fontSize), range: legacyRange) result.addAttribute(.font, value: UIFont.boldSystemFont(ofSize: fontSize), range: legacyRange)
} }

@ -104,13 +104,8 @@ class ScreenLockUI {
return .none; return .none;
} }
if Storage.shared[.appSwitcherPreviewEnabled] { Logger.verbose("desiredUIState: screen protection 4.")
Logger.verbose("desiredUIState: screen protection 4.") return .protection;
return .protection;
}
Logger.verbose("desiredUIState: none 5.")
return .none
} }
// MARK: - Lifecycle // MARK: - Lifecycle

@ -49,7 +49,6 @@ public enum SMKLegacy {
internal static let preferencesCollection = "SignalPreferences" internal static let preferencesCollection = "SignalPreferences"
internal static let additionalPreferencesCollection = "SSKPreferences" internal static let additionalPreferencesCollection = "SSKPreferences"
internal static let preferencesKeyScreenSecurityDisabled = "Screen Security Key"
internal static let preferencesKeyLastRecordedPushToken = "LastRecordedPushToken" internal static let preferencesKeyLastRecordedPushToken = "LastRecordedPushToken"
internal static let preferencesKeyLastRecordedVoipToken = "LastRecordedVoipToken" internal static let preferencesKeyLastRecordedVoipToken = "LastRecordedVoipToken"
internal static let preferencesKeyAreLinkPreviewsEnabled = "areLinkPreviewsEnabled" internal static let preferencesKeyAreLinkPreviewsEnabled = "areLinkPreviewsEnabled"

@ -722,7 +722,7 @@ enum _003_YDBToGRDBMigration: Migration {
let wasRead: Bool let wasRead: Bool
let expiresInSeconds: UInt32? let expiresInSeconds: UInt32?
let expiresStartedAtMs: UInt64? let expiresStartedAtMs: UInt64?
let openGroupServerMessageId: UInt64? let openGroupServerMessageId: Int64?
let recipientStateMap: [String: SMKLegacy._DBOutgoingMessageRecipientState]? let recipientStateMap: [String: SMKLegacy._DBOutgoingMessageRecipientState]?
let mostRecentFailureText: String? let mostRecentFailureText: String?
let quotedMessage: SMKLegacy._DBQuotedMessage? let quotedMessage: SMKLegacy._DBQuotedMessage?
@ -737,9 +737,13 @@ enum _003_YDBToGRDBMigration: Migration {
// The legacy code only considered '!= 0' ids as valid so set those // The legacy code only considered '!= 0' ids as valid so set those
// values to be null to avoid the unique constraint (it's also more // values to be null to avoid the unique constraint (it's also more
// correct for the values to be null) // correct for the values to be null)
openGroupServerMessageId = (legacyMessage.openGroupServerMessageID == 0 ? //
// Note: Looks like it was also possible for this to be set to the max
// value which overflows when trying to convert to a signed version so
// we essentially discard the information in those cases)
openGroupServerMessageId = (Int64.zeroingOverflow(legacyMessage.openGroupServerMessageID) == 0 ?
nil : nil :
legacyMessage.openGroupServerMessageID Int64.zeroingOverflow(legacyMessage.openGroupServerMessageID)
) )
quotedMessage = legacyMessage.quotedMessage quotedMessage = legacyMessage.quotedMessage
@ -904,8 +908,8 @@ enum _003_YDBToGRDBMigration: Migration {
authorId: authorId, authorId: authorId,
variant: variant, variant: variant,
body: body, body: body,
timestampMs: Int64(legacyInteraction.timestamp), timestampMs: Int64.zeroingOverflow(legacyInteraction.timestamp),
receivedAtTimestampMs: Int64(legacyInteraction.receivedAtTimestamp), receivedAtTimestampMs: Int64.zeroingOverflow(legacyInteraction.receivedAtTimestamp),
wasRead: wasRead, wasRead: wasRead,
hasMention: Interaction.isUserMentioned( hasMention: Interaction.isUserMentioned(
db, db,
@ -923,7 +927,7 @@ enum _003_YDBToGRDBMigration: Migration {
nil nil
), ),
linkPreviewUrl: linkPreview?.urlString, // Only a soft link so save to set linkPreviewUrl: linkPreview?.urlString, // Only a soft link so save to set
openGroupServerMessageId: openGroupServerMessageId.map { Int64($0) }, openGroupServerMessageId: openGroupServerMessageId,
openGroupWhisperMods: false, openGroupWhisperMods: false,
openGroupWhisperTo: nil openGroupWhisperTo: nil
).inserted(db) ).inserted(db)
@ -945,7 +949,7 @@ enum _003_YDBToGRDBMigration: Migration {
try ControlMessageProcessRecord( try ControlMessageProcessRecord(
threadId: threadId, threadId: threadId,
variant: variant, variant: variant,
timestampMs: Int64(legacyInteraction.timestamp) timestampMs: Int64.zeroingOverflow(legacyInteraction.timestamp)
)?.insert(db) )?.insert(db)
// Remove timestamps we created records for (they will be protected by unique // Remove timestamps we created records for (they will be protected by unique
@ -1086,7 +1090,7 @@ enum _003_YDBToGRDBMigration: Migration {
try Quote( try Quote(
interactionId: interactionId, interactionId: interactionId,
authorId: quotedMessage.authorId, authorId: quotedMessage.authorId,
timestampMs: Int64(quotedMessage.timestamp), timestampMs: Int64.zeroingOverflow(quotedMessage.timestamp),
body: quotedMessage.body, body: quotedMessage.body,
attachmentId: attachmentId attachmentId: attachmentId
).insert(db) ).insert(db)
@ -1192,7 +1196,7 @@ enum _003_YDBToGRDBMigration: Migration {
// entries as "legacy" // entries as "legacy"
try ControlMessageProcessRecord.generateLegacyProcessRecords( try ControlMessageProcessRecord.generateLegacyProcessRecords(
db, db,
receivedMessageTimestamps: receivedMessageTimestamps.map { Int64($0) } receivedMessageTimestamps: receivedMessageTimestamps.map { Int64.zeroingOverflow($0) }
) )
// Clear out processed data (give the memory a change to be freed) // Clear out processed data (give the memory a change to be freed)
@ -1448,9 +1452,6 @@ enum _003_YDBToGRDBMigration: Migration {
db[.lastRecordedVoipToken] = lastVoipToken db[.lastRecordedVoipToken] = lastVoipToken
} }
// Note: The 'preferencesKeyScreenSecurityDisabled' value previously controlled whether the
// setting was disabled, this has been inverted to 'appSwitcherPreviewEnabled' so it can default
// to 'false' (as most Bool values do)
db[.areReadReceiptsEnabled] = (legacyPreferences[SMKLegacy.readReceiptManagerAreReadReceiptsEnabled] as? Bool == true) db[.areReadReceiptsEnabled] = (legacyPreferences[SMKLegacy.readReceiptManagerAreReadReceiptsEnabled] as? Bool == true)
db[.typingIndicatorsEnabled] = (legacyPreferences[SMKLegacy.typingIndicatorsEnabledKey] as? Bool == true) db[.typingIndicatorsEnabled] = (legacyPreferences[SMKLegacy.typingIndicatorsEnabledKey] as? Bool == true)
db[.isScreenLockEnabled] = (legacyPreferences[SMKLegacy.screenLockIsScreenLockEnabledKey] as? Bool == true) db[.isScreenLockEnabled] = (legacyPreferences[SMKLegacy.screenLockIsScreenLockEnabledKey] as? Bool == true)
@ -1461,7 +1462,6 @@ enum _003_YDBToGRDBMigration: Migration {
value: (legacyPreferences[SMKLegacy.screenLockScreenLockTimeoutSecondsKey] as? Double) value: (legacyPreferences[SMKLegacy.screenLockScreenLockTimeoutSecondsKey] as? Double)
.defaulting(to: (15 * 60)) .defaulting(to: (15 * 60))
) )
db[.appSwitcherPreviewEnabled] = (legacyPreferences[SMKLegacy.preferencesKeyScreenSecurityDisabled] as? Bool == false)
db[.areLinkPreviewsEnabled] = (legacyPreferences[SMKLegacy.preferencesKeyAreLinkPreviewsEnabled] as? Bool == true) db[.areLinkPreviewsEnabled] = (legacyPreferences[SMKLegacy.preferencesKeyAreLinkPreviewsEnabled] as? Bool == true)
db[.areCallsEnabled] = (legacyPreferences[SMKLegacy.preferencesKeyAreCallsEnabled] as? Bool == true) db[.areCallsEnabled] = (legacyPreferences[SMKLegacy.preferencesKeyAreCallsEnabled] as? Bool == true)
db[.hasHiddenMessageRequests] = CurrentAppContext().appUserDefaults() db[.hasHiddenMessageRequests] = CurrentAppContext().appUserDefaults()
@ -1854,3 +1854,9 @@ enum _003_YDBToGRDBMigration: Migration {
) )
} }
} }
fileprivate extension Int64 {
static func zeroingOverflow(_ value: UInt64) -> Int64 {
return (value > UInt64(Int64.max) ? 0 : Int64(value))
}
}

@ -109,6 +109,7 @@ public extension SendReadReceiptsJob {
.filter(interactionIds.contains(Interaction.Columns.id)) .filter(interactionIds.contains(Interaction.Columns.id))
// Only `standardIncoming` incoming interactions should have read receipts sent // Only `standardIncoming` incoming interactions should have read receipts sent
.filter(Interaction.Columns.variant == Interaction.Variant.standardIncoming) .filter(Interaction.Columns.variant == Interaction.Variant.standardIncoming)
.filter(Interaction.Columns.wasRead == false) // Only send for unread messages
.joining( .joining(
// Don't send read receipts in group threads // Don't send read receipts in group threads
required: Interaction.thread required: Interaction.thread
@ -119,7 +120,10 @@ public extension SendReadReceiptsJob {
) )
// If there are no timestamp values then do nothing // If there are no timestamp values then do nothing
guard let timestampMsValues: [Int64] = maybeTimestampMsValues else { return nil } guard
let timestampMsValues: [Int64] = maybeTimestampMsValues,
!timestampMsValues.isEmpty
else { return nil }
// Try to get an existing job (if there is one that's not running) // Try to get an existing job (if there is one that's not running)
if if

@ -940,6 +940,13 @@ public extension SessionThreadViewModel {
public extension SessionThreadViewModel { public extension SessionThreadViewModel {
static let searchResultsLimit: Int = 500 static let searchResultsLimit: Int = 500
/// FTS will fail or try to process characters outside of `[A-Za-z0-9]` are included directly in a search
/// term, in order to resolve this the term needs to be wrapped in quotation marks so the eventual SQL
/// is `MATCH '"{term}"'` or `MATCH '"{term}"*'`
static func searchSafeTerm(_ term: String) -> String {
return "\"\(term)\""
}
static func searchTermParts(_ searchTerm: String) -> [String] { static func searchTermParts(_ searchTerm: String) -> [String] {
/// Process the search term in order to extract the parts of the search pattern we want /// Process the search term in order to extract the parts of the search pattern we want
/// ///
@ -954,7 +961,7 @@ public extension SessionThreadViewModel {
guard index % 2 == 1 else { guard index % 2 == 1 else {
return String(value) return String(value)
.split(separator: " ") .split(separator: " ")
.map { String($0) } .map { "\"\(String($0))\"" }
} }
return ["\"\(value)\""] return ["\"\(value)\""]
@ -972,13 +979,14 @@ public extension SessionThreadViewModel {
let rawPattern: String = searchTermParts(searchTerm) let rawPattern: String = searchTermParts(searchTerm)
.joined(separator: " OR ") .joined(separator: " OR ")
.appending("*") .appending("*")
let fallbackTerm: String = "\(searchSafeTerm(searchTerm))*"
/// There are cases where creating a pattern can fail, we want to try and recover from those cases /// There are cases where creating a pattern can fail, we want to try and recover from those cases
/// by failling back to simpler patterns if needed /// by failling back to simpler patterns if needed
let maybePattern: FTS5Pattern? = (try? db.makeFTS5Pattern(rawPattern: rawPattern, forTable: table)) let maybePattern: FTS5Pattern? = (try? db.makeFTS5Pattern(rawPattern: rawPattern, forTable: table))
.defaulting( .defaulting(
to: (try? db.makeFTS5Pattern(rawPattern: searchTerm, forTable: table)) to: (try? db.makeFTS5Pattern(rawPattern: fallbackTerm, forTable: table))
.defaulting(to: FTS5Pattern(matchingAnyTokenIn: searchTerm)) .defaulting(to: FTS5Pattern(matchingAnyTokenIn: fallbackTerm))
) )
guard let pattern: FTS5Pattern = maybePattern else { throw StorageError.invalidSearchPattern } guard let pattern: FTS5Pattern = maybePattern else { throw StorageError.invalidSearchPattern }

@ -15,12 +15,6 @@ public extension Setting.EnumKey {
} }
public extension Setting.BoolKey { public extension Setting.BoolKey {
/// Controls whether the preview screen in the app switcher should be enabled
///
/// **Note:** In the legacy setting this flag controlled whether the preview was "disabled" (and defaulted to
/// true), by inverting this flag we can default it to false as is standard for Bool values
static let appSwitcherPreviewEnabled: Setting.BoolKey = "appSwitcherPreviewEnabled"
/// Controls whether typing indicators are enabled /// Controls whether typing indicators are enabled
/// ///
/// **Note:** Only works if both participants in a "contact" thread have this setting enabled /// **Note:** Only works if both participants in a "contact" thread have this setting enabled

@ -116,8 +116,8 @@ open class Modal: UIViewController, UIGestureRecognizerDelegate {
} }
public static func createButton(title: String, titleColor: ThemeValue) -> UIButton { public static func createButton(title: String, titleColor: ThemeValue) -> UIButton {
let result: UIButton = UIButton() // TODO: NEED to fix the font (looks bad) let result: UIButton = UIButton()
result.titleLabel?.font = .systemFont(ofSize: Values.mediumFontSize, weight: UIFont.Weight(600)) result.titleLabel?.font = .boldSystemFont(ofSize: Values.mediumFontSize)
result.setTitle(title, for: .normal) result.setTitle(title, for: .normal)
result.setThemeTitleColor(titleColor, for: .normal) result.setThemeTitleColor(titleColor, for: .normal)
result.setThemeBackgroundColor(.alert_buttonBackground, for: .normal) result.setThemeBackgroundColor(.alert_buttonBackground, for: .normal)

@ -360,14 +360,26 @@ public final class Storage {
guard isValid, let dbWriter: DatabaseWriter = dbWriter else { return } guard isValid, let dbWriter: DatabaseWriter = dbWriter else { return }
guard let observer: TransactionObserver = observer else { return } guard let observer: TransactionObserver = observer else { return }
dbWriter.add(transactionObserver: observer) // Note: This actually triggers a write to the database so can be blocked by other
// writes, since it's usually called on the main thread when creating a view controller
// this can result in the UI hanging - to avoid this we dispatch (and hope there isn't
// negative impact)
DispatchQueue.global(qos: .default).async {
dbWriter.add(transactionObserver: observer)
}
} }
public func removeObserver(_ observer: TransactionObserver?) { public func removeObserver(_ observer: TransactionObserver?) {
guard isValid, let dbWriter: DatabaseWriter = dbWriter else { return } guard isValid, let dbWriter: DatabaseWriter = dbWriter else { return }
guard let observer: TransactionObserver = observer else { return } guard let observer: TransactionObserver = observer else { return }
dbWriter.remove(transactionObserver: observer) // Note: This actually triggers a write to the database so can be blocked by other
// writes, since it's usually called on the main thread when creating a view controller
// this can result in the UI hanging - to avoid this we dispatch (and hope there isn't
// negative impact)
DispatchQueue.global(qos: .default).async {
dbWriter.remove(transactionObserver: observer)
}
} }
} }

@ -5,10 +5,11 @@
import Foundation import Foundation
import AVFoundation import AVFoundation
import MediaPlayer import MediaPlayer
import CoreServices
import PromiseKit import PromiseKit
import SessionUIKit import SessionUIKit
import CoreServices
import SessionMessagingKit import SessionMessagingKit
import SignalCoreKit
public protocol AttachmentApprovalViewControllerDelegate: AnyObject { public protocol AttachmentApprovalViewControllerDelegate: AnyObject {
func attachmentApproval( func attachmentApproval(
@ -538,7 +539,7 @@ public class AttachmentApprovalViewController: UIPageViewController, UIPageViewC
private func setCurrentItem(_ item: SignalAttachmentItem?, direction: UIPageViewController.NavigationDirection, animated isAnimated: Bool) { private func setCurrentItem(_ item: SignalAttachmentItem?, direction: UIPageViewController.NavigationDirection, animated isAnimated: Bool) {
guard let item: SignalAttachmentItem = item, let page = self.buildPage(item: item) else { guard let item: SignalAttachmentItem = item, let page = self.buildPage(item: item) else {
owsFailDebug("unexpectedly unable to build new page") Logger.error("unexpectedly unable to build new page")
return return
} }
@ -550,7 +551,7 @@ public class AttachmentApprovalViewController: UIPageViewController, UIPageViewC
func updateMediaRail() { func updateMediaRail() {
guard let currentItem = self.currentItem else { guard let currentItem = self.currentItem else {
owsFailDebug("currentItem was unexpectedly nil") Logger.error("currentItem was unexpectedly nil")
return return
} }
@ -565,7 +566,7 @@ public class AttachmentApprovalViewController: UIPageViewController, UIPageViewC
return cell return cell
default: default:
owsFailDebug("unexpted rail item type: \(railItem)") Logger.error("unexpted rail item type: \(railItem)")
return GalleryRailCellView() return GalleryRailCellView()
} }
} }

@ -105,7 +105,7 @@ public class ScreenLock {
defaultErrorDescription: defaultErrorDescription) defaultErrorDescription: defaultErrorDescription)
switch outcome { switch outcome {
case .success: case .success:
owsFailDebug("local authentication unexpected success") Logger.error("local authentication unexpected success")
completion(.failure(error: defaultErrorDescription)) completion(.failure(error: defaultErrorDescription))
case .cancel, .failure, .unexpectedFailure: case .cancel, .failure, .unexpectedFailure:
@ -129,8 +129,8 @@ public class ScreenLock {
switch outcome { switch outcome {
case .success: case .success:
owsFailDebug("local authentication unexpected success") Logger.error("local authentication unexpected success")
completion(.failure(error:defaultErrorDescription)) completion(.failure(error: defaultErrorDescription))
case .cancel, .failure, .unexpectedFailure: case .cancel, .failure, .unexpectedFailure:
completion(outcome) completion(outcome)
@ -190,11 +190,11 @@ public class ScreenLock {
return .failure(error: "SCREEN_LOCK_ERROR_LOCAL_AUTHENTICATION_LOCKOUT".localized()) return .failure(error: "SCREEN_LOCK_ERROR_LOCAL_AUTHENTICATION_LOCKOUT".localized())
case .invalidContext: case .invalidContext:
owsFailDebug("context not valid.") Logger.error("context not valid.")
return .unexpectedFailure(error: defaultErrorDescription) return .unexpectedFailure(error: defaultErrorDescription)
case .notInteractive: case .notInteractive:
owsFailDebug("context not interactive.") Logger.error("context not interactive.")
return .unexpectedFailure(error: defaultErrorDescription) return .unexpectedFailure(error: defaultErrorDescription)
@unknown default: @unknown default:

Loading…
Cancel
Save