Signal-iOS/SignalServiceKit/src/Contacts/TSThread.m
2019-10-11 09:31:35 -03:00

732 lines
26 KiB
Objective-C

//
// Copyright (c) 2019 Open Whisper Systems. All rights reserved.
//
#import "TSThread.h"
#import "OWSDisappearingMessagesConfiguration.h"
#import "OWSReadTracking.h"
#import "SSKEnvironment.h"
#import "TSAccountManager.h"
#import "TSIncomingMessage.h"
#import "TSInfoMessage.h"
#import "TSInteraction.h"
#import "TSInvalidIdentityKeyReceivingErrorMessage.h"
#import "TSOutgoingMessage.h"
#import <SignalCoreKit/Cryptography.h>
#import <SignalCoreKit/NSDate+OWS.h>
#import <SignalCoreKit/NSString+OWS.h>
#import <SignalServiceKit/SignalServiceKit-Swift.h>
NS_ASSUME_NONNULL_BEGIN
BOOL IsNoteToSelfEnabled(void)
{
return YES;
}
ConversationColorName const ConversationColorNameCrimson = @"red";
ConversationColorName const ConversationColorNameVermilion = @"orange";
ConversationColorName const ConversationColorNameBurlap = @"brown";
ConversationColorName const ConversationColorNameForest = @"green";
ConversationColorName const ConversationColorNameWintergreen = @"light_green";
ConversationColorName const ConversationColorNameTeal = @"teal";
ConversationColorName const ConversationColorNameBlue = @"blue";
ConversationColorName const ConversationColorNameIndigo = @"indigo";
ConversationColorName const ConversationColorNameViolet = @"purple";
ConversationColorName const ConversationColorNamePlum = @"pink";
ConversationColorName const ConversationColorNameTaupe = @"blue_grey";
ConversationColorName const ConversationColorNameSteel = @"grey";
ConversationColorName const kConversationColorName_Default = ConversationColorNameSteel;
@interface TSThread ()
@property (nonatomic, nullable) NSDate *creationDate;
@property (nonatomic) NSString *conversationColorName;
@property (nonatomic) BOOL isArchived;
@property (nonatomic, copy, nullable) NSString *messageDraft;
@property (atomic, nullable) NSDate *mutedUntilDate;
// DEPRECATED - not used since migrating to sortId
// but keeping these properties around to ease any pain in the back-forth
// migration while testing. Eventually we can safely delete these as they aren't used anywhere.
@property (nonatomic, nullable) NSDate *lastMessageDate DEPRECATED_ATTRIBUTE;
@property (nonatomic, nullable) NSDate *archivalDate DEPRECATED_ATTRIBUTE;
@property (nonatomic) int64_t lastInteractionSortId;
@end
#pragma mark -
@implementation TSThread
#pragma mark - Dependencies
- (TSAccountManager *)tsAccountManager
{
OWSAssertDebug(SSKEnvironment.shared.tsAccountManager);
return SSKEnvironment.shared.tsAccountManager;
}
#pragma mark -
+ (NSString *)collection {
return @"TSThread";
}
+ (BOOL)shouldBeIndexedForFTS
{
return YES;
}
- (instancetype)initWithUniqueId:(NSString *)uniqueId
{
self = [super initWithUniqueId:uniqueId];
if (self) {
_creationDate = [NSDate date];
_messageDraft = nil;
// This is overriden in TSContactThread to use the phone number when available
// We can't use self.colorSeed here because the subclass hasn't done its
// initializing work yet to set it up.
_conversationColorName = [self.class stableColorNameForNewConversationWithString:uniqueId];
}
return self;
}
// --- CODE GENERATION MARKER
// This snippet is generated by /Scripts/sds_codegen/sds_generate.py. Do not manually edit it, instead run `sds_codegen.sh`.
// clang-format off
- (instancetype)initWithGrdbId:(int64_t)grdbId
uniqueId:(NSString *)uniqueId
archivalDate:(nullable NSDate *)archivalDate
conversationColorName:(ConversationColorName)conversationColorName
creationDate:(nullable NSDate *)creationDate
isArchived:(BOOL)isArchived
isArchivedByLegacyTimestampForSorting:(BOOL)isArchivedByLegacyTimestampForSorting
lastInteractionSortId:(int64_t)lastInteractionSortId
lastMessageDate:(nullable NSDate *)lastMessageDate
messageDraft:(nullable NSString *)messageDraft
mutedUntilDate:(nullable NSDate *)mutedUntilDate
rowId:(int64_t)rowId
shouldThreadBeVisible:(BOOL)shouldThreadBeVisible
{
self = [super initWithGrdbId:grdbId
uniqueId:uniqueId];
if (!self) {
return self;
}
_archivalDate = archivalDate;
_conversationColorName = conversationColorName;
_creationDate = creationDate;
_isArchived = isArchived;
_isArchivedByLegacyTimestampForSorting = isArchivedByLegacyTimestampForSorting;
_lastInteractionSortId = lastInteractionSortId;
_lastMessageDate = lastMessageDate;
_messageDraft = messageDraft;
_mutedUntilDate = mutedUntilDate;
_rowId = rowId;
_shouldThreadBeVisible = shouldThreadBeVisible;
return self;
}
// clang-format on
// --- CODE GENERATION MARKER
- (nullable instancetype)initWithCoder:(NSCoder *)coder
{
self = [super initWithCoder:coder];
if (!self) {
return self;
}
// renamed `hasEverHadMessage` -> `shouldThreadBeVisible`
if (!_shouldThreadBeVisible) {
NSNumber *_Nullable legacy_hasEverHadMessage = [coder decodeObjectForKey:@"hasEverHadMessage"];
if (legacy_hasEverHadMessage != nil) {
_shouldThreadBeVisible = legacy_hasEverHadMessage.boolValue;
}
}
if (_conversationColorName.length == 0) {
ConversationColorName colorName = [self.class stableColorNameForLegacyConversationWithString:self.colorSeed];
OWSAssertDebug(colorName);
_conversationColorName = colorName;
} else if (![[[self class] conversationColorNames] containsObject:_conversationColorName]) {
// If we'd persisted a non-mapped color name
ConversationColorName _Nullable mappedColorName = self.class.legacyConversationColorMap[_conversationColorName];
if (!mappedColorName) {
// We previously used the wrong values for the new colors, it's possible we persited them.
// map them to the proper value
mappedColorName = self.class.legacyFixupConversationColorMap[_conversationColorName];
}
if (!mappedColorName) {
OWSFailDebug(@"failure: unexpected unmappable conversationColorName: %@", _conversationColorName);
mappedColorName = kConversationColorName_Default;
}
_conversationColorName = mappedColorName;
}
NSDate *_Nullable lastMessageDate = [coder decodeObjectOfClass:NSDate.class forKey:@"lastMessageDate"];
NSDate *_Nullable archivalDate = [coder decodeObjectOfClass:NSDate.class forKey:@"archivalDate"];
_isArchivedByLegacyTimestampForSorting =
[self.class legacyIsArchivedWithLastMessageDate:lastMessageDate archivalDate:archivalDate];
if ([coder decodeObjectForKey:@"archivedAsOfMessageSortId"] != nil) {
OWSAssertDebug(!_isArchived);
_isArchived = YES;
}
return self;
}
- (void)anyDidInsertWithTransaction:(SDSAnyWriteTransaction *)transaction
{
[super anyDidInsertWithTransaction:transaction];
if (self.shouldThreadBeVisible && ![SSKPreferences hasSavedThreadWithTransaction:transaction]) {
[SSKPreferences setHasSavedThread:YES transaction:transaction];
}
}
- (void)anyWillRemoveWithTransaction:(SDSAnyWriteTransaction *)transaction
{
[super anyWillRemoveWithTransaction:transaction];
[self removeAllThreadInteractionsWithTransaction:transaction];
}
- (void)removeAllThreadInteractionsWithTransaction:(SDSAnyWriteTransaction *)transaction
{
// We can't safely delete interactions while enumerating them, so
// we collect and delete separately.
//
// We don't want to instantiate the interactions when collecting them
// or when deleting them.
NSMutableArray<NSString *> *interactionIds = [NSMutableArray new];
NSError *error;
InteractionFinder *interactionFinder = [[InteractionFinder alloc] initWithThreadUniqueId:self.uniqueId];
[interactionFinder enumerateInteractionIdsWithTransaction:transaction
error:&error
block:^(NSString *key, BOOL *stop) {
[interactionIds addObject:key];
}];
if (error != nil) {
OWSFailDebug(@"Error during enumeration: %@", error);
}
for (NSString *interactionId in interactionIds) {
// We need to fetch each interaction, since [TSInteraction removeWithTransaction:] does important work.
TSInteraction *_Nullable interaction =
[TSInteraction anyFetchWithUniqueId:interactionId transaction:transaction];
if (!interaction) {
OWSFailDebug(@"couldn't load thread's interaction for deletion.");
continue;
}
[interaction anyRemoveWithTransaction:transaction];
}
}
- (BOOL)isNoteToSelf
{
return NO;
}
- (NSString *)colorSeed
{
return self.uniqueId;
}
#pragma mark - To be subclassed.
- (BOOL)isGroupThread {
OWSAbstractMethod();
return NO;
}
- (NSArray<SignalServiceAddress *> *)recipientAddresses
{
OWSAbstractMethod();
return @[];
}
- (BOOL)hasSafetyNumbers
{
return NO;
}
#pragma mark - Interactions
/**
* Iterate over this thread's interactions
*/
- (void)enumerateInteractionsWithTransaction:(SDSAnyReadTransaction *)transaction
usingBlock:(void (^)(TSInteraction *interaction))block
{
NSError *error;
InteractionFinder *interactionFinder = [[InteractionFinder alloc] initWithThreadUniqueId:self.uniqueId];
[interactionFinder enumerateInteractionsWithTransaction:transaction
error:&error
block:^(TSInteraction *interaction, BOOL *stop) {
block(interaction);
}];
if (error != nil) {
OWSFailDebug(@"Error during enumeration: %@", error);
}
}
/**
* Enumerates all the threads interactions. Note this will explode if you try to create a transaction in the block.
* If you need a transaction, use the sister method: `enumerateInteractionsWithTransaction:usingBlock`
*/
- (void)enumerateInteractionsUsingBlock:(void (^)(TSInteraction *interaction))block
{
[self.databaseStorage readWithBlock:^(SDSAnyReadTransaction *transaction) {
[self enumerateInteractionsWithTransaction:transaction
usingBlock:^(TSInteraction *interaction) {
block(interaction);
}];
}];
}
/**
* Useful for tests and debugging. In production use an enumeration method.
*/
- (NSArray<TSInteraction *> *)allInteractions
{
NSMutableArray<TSInteraction *> *interactions = [NSMutableArray new];
[self enumerateInteractionsUsingBlock:^(TSInteraction *interaction) {
[interactions addObject:interaction];
}];
return [interactions copy];
}
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Wdeprecated-declarations"
- (NSArray<TSInvalidIdentityKeyReceivingErrorMessage *> *)receivedMessagesForInvalidKey:(NSData *)key
{
NSMutableArray *errorMessages = [NSMutableArray new];
[self enumerateInteractionsUsingBlock:^(TSInteraction *interaction) {
if ([interaction isKindOfClass:[TSInvalidIdentityKeyReceivingErrorMessage class]]) {
TSInvalidIdentityKeyReceivingErrorMessage *error = (TSInvalidIdentityKeyReceivingErrorMessage *)interaction;
@try {
if ([[error throws_newIdentityKey] isEqualToData:key]) {
[errorMessages addObject:(TSInvalidIdentityKeyReceivingErrorMessage *)interaction];
}
} @catch (NSException *exception) {
OWSFailDebug(@"exception: %@", exception);
}
}
}];
return [errorMessages copy];
}
#pragma clang diagnostic pop
- (NSUInteger)numberOfInteractionsWithTransaction:(SDSAnyReadTransaction *)transaction
{
OWSAssertDebug(transaction);
return [[[InteractionFinder alloc] initWithThreadUniqueId:self.uniqueId] countWithTransaction:transaction];
}
- (NSArray<id<OWSReadTracking>> *)unseenMessagesWithTransaction:(SDSAnyReadTransaction *)transaction
{
NSMutableArray<id<OWSReadTracking>> *messages = [NSMutableArray new];
NSError *error;
InteractionFinder *interactionFinder = [[InteractionFinder alloc] initWithThreadUniqueId:self.uniqueId];
[interactionFinder
enumerateUnseenInteractionsWithTransaction:transaction
error:&error
block:^(TSInteraction *interaction, BOOL *stop) {
if (![interaction conformsToProtocol:@protocol(OWSReadTracking)]) {
OWSFailDebug(@"Unexpected object in unseen messages: %@",
interaction.class);
return;
}
[messages addObject:(id<OWSReadTracking>)interaction];
}];
if (error != nil) {
OWSFailDebug(@"Error during enumeration: %@", error);
}
return [messages copy];
}
- (void)markAllAsReadWithTransaction:(SDSAnyWriteTransaction *)transaction
{
for (id<OWSReadTracking> message in [self unseenMessagesWithTransaction:transaction]) {
[message markAsReadAtTimestamp:[NSDate ows_millisecondTimeStamp] sendReadReceipt:YES transaction:transaction];
}
// Just to be defensive, we'll also check for unread messages.
OWSAssertDebug([self unseenMessagesWithTransaction:transaction].count < 1);
}
- (nullable TSInteraction *)lastInteractionForInboxWithTransaction:(SDSAnyReadTransaction *)transaction
{
OWSAssertDebug(transaction);
return [[[InteractionFinder alloc] initWithThreadUniqueId:self.uniqueId]
mostRecentInteractionForInboxWithTransaction:transaction];
}
- (NSString *)lastMessageTextWithTransaction:(SDSAnyReadTransaction *)transaction
{
TSInteraction *interaction = [self lastInteractionForInboxWithTransaction:transaction];
if ([interaction conformsToProtocol:@protocol(OWSPreviewText)]) {
id<OWSPreviewText> previewable = (id<OWSPreviewText>)interaction;
return [previewable previewTextWithTransaction:transaction].filterStringForDisplay;
} else {
return @"";
}
}
// Returns YES IFF the interaction should show up in the inbox as the last message.
+ (BOOL)shouldInteractionAppearInInbox:(TSInteraction *)interaction
{
OWSAssertDebug(interaction);
if (!interaction.shouldBeSaved) {
OWSFailDebug(@"Unexpected interaction type: %@", interaction.class);
return NO;
}
if (interaction.isDynamicInteraction) {
OWSFailDebug(@"Unexpected interaction type: %@", interaction.class);
return NO;
}
if ([interaction isKindOfClass:[OWSOutgoingSyncMessage class]]) {
OWSFailDebug(@"Unexpected interaction type: %@", interaction.class);
return NO;
}
if ([interaction isKindOfClass:[TSErrorMessage class]]) {
TSErrorMessage *errorMessage = (TSErrorMessage *)interaction;
if (errorMessage.errorType == TSErrorMessageNonBlockingIdentityChange) {
// Otherwise all group threads with the recipient will percolate to the top of the inbox, even though
// there was no meaningful interaction.
return NO;
}
} else if ([interaction isKindOfClass:[TSInfoMessage class]]) {
TSInfoMessage *infoMessage = (TSInfoMessage *)interaction;
if (infoMessage.messageType == TSInfoMessageVerificationStateChange) {
return NO;
}
}
return YES;
}
- (void)updateWithLastMessage:(TSInteraction *)lastMessage
wasMessageInserted:(BOOL)wasMessageInserted
transaction:(SDSAnyWriteTransaction *)transaction
{
OWSAssertDebug(lastMessage);
OWSAssertDebug(transaction);
if (![self.class shouldInteractionAppearInInbox:lastMessage]) {
return;
}
int64_t messageSortId = 0;
if (transaction.transitional_yapWriteTransaction) {
messageSortId = lastMessage.sortId;
} else {
if (lastMessage.grdbId == nil) {
OWSFailDebug(@"Missing messageSortId.");
} else if (lastMessage.grdbId.unsignedLongLongValue == 0) {
OWSFailDebug(@"Invalid messageSortId.");
} else {
messageSortId = lastMessage.grdbId.longLongValue;
}
}
BOOL needsToMarkAsVisible = !self.shouldThreadBeVisible;
BOOL needsToClearArchived = self.isArchived && wasMessageInserted;
BOOL needsToUpdateLastInteractionSortId = messageSortId > self.lastInteractionSortId;
if (needsToMarkAsVisible || needsToClearArchived || needsToUpdateLastInteractionSortId) {
[self anyUpdateWithTransaction:transaction
block:^(TSThread *thread) {
thread.shouldThreadBeVisible = YES;
thread.lastInteractionSortId = MAX(thread.lastInteractionSortId, messageSortId);
if (thread.isArchived && wasMessageInserted) {
// No longer archived.
thread.isArchived = NO;
}
}];
} else {
[self.databaseStorage touchThread:self transaction:transaction];
}
}
- (void)softDeleteThreadWithTransaction:(SDSAnyWriteTransaction *)transaction
{
[self removeAllThreadInteractionsWithTransaction:transaction];
[self anyUpdateWithTransaction:transaction
block:^(TSThread *thread) {
thread.messageDraft = nil;
thread.shouldThreadBeVisible = NO;
}];
}
#pragma mark - Disappearing Messages
- (OWSDisappearingMessagesConfiguration *)disappearingMessagesConfigurationWithTransaction:
(SDSAnyReadTransaction *)transaction
{
return [OWSDisappearingMessagesConfiguration fetchOrBuildDefaultWithThread:self transaction:transaction];
}
- (uint32_t)disappearingMessagesDurationWithTransaction:(SDSAnyReadTransaction *)transaction
{
OWSDisappearingMessagesConfiguration *config = [self disappearingMessagesConfigurationWithTransaction:transaction];
if (!config.isEnabled) {
return 0;
} else {
return config.durationSeconds;
}
}
#pragma mark - Archival
+ (BOOL)legacyIsArchivedWithLastMessageDate:(nullable NSDate *)lastMessageDate
archivalDate:(nullable NSDate *)archivalDate
{
if (!archivalDate) {
return NO;
}
if (!lastMessageDate) {
return YES;
}
return [archivalDate compare:lastMessageDate] != NSOrderedAscending;
}
- (void)archiveThreadWithTransaction:(SDSAnyWriteTransaction *)transaction
{
[self anyUpdateWithTransaction:transaction
block:^(TSThread *thread) {
thread.isArchived = YES;
}];
[self markAllAsReadWithTransaction:transaction];
}
- (void)unarchiveThreadWithTransaction:(SDSAnyWriteTransaction *)transaction
{
[self anyUpdateWithTransaction:transaction
block:^(TSThread *thread) {
thread.isArchived = NO;
}];
}
#pragma mark - Drafts
- (NSString *)currentDraftWithTransaction:(SDSAnyReadTransaction *)transaction
{
TSThread *_Nullable thread = [TSThread anyFetchWithUniqueId:self.uniqueId transaction:transaction];
if (thread.messageDraft != nil) {
return thread.messageDraft;
} else {
return @"";
}
}
- (void)updateWithDraft:(NSString *)draftString transaction:(SDSAnyWriteTransaction *)transaction
{
[self anyUpdateWithTransaction:transaction
block:^(TSThread *thread) {
thread.messageDraft = draftString;
}];
}
#pragma mark - Muted
- (BOOL)isMuted
{
NSDate *mutedUntilDate = self.mutedUntilDate;
NSDate *now = [NSDate date];
return (mutedUntilDate != nil &&
[mutedUntilDate timeIntervalSinceDate:now] > 0);
}
- (void)updateWithMutedUntilDate:(NSDate *)mutedUntilDate transaction:(SDSAnyWriteTransaction *)transaction
{
[self anyUpdateWithTransaction:transaction
block:^(TSThread *thread) {
[thread setMutedUntilDate:mutedUntilDate];
}];
}
#pragma mark - Conversation Color
- (ConversationColorName)conversationColorName
{
OWSAssertDebug([self.class.conversationColorNames containsObject:_conversationColorName]);
return _conversationColorName;
}
+ (NSArray<ConversationColorName> *)colorNamesForNewConversation
{
// all conversation colors except "steel"
return @[
ConversationColorNameCrimson,
ConversationColorNameVermilion,
ConversationColorNameBurlap,
ConversationColorNameForest,
ConversationColorNameWintergreen,
ConversationColorNameTeal,
ConversationColorNameBlue,
ConversationColorNameIndigo,
ConversationColorNameViolet,
ConversationColorNamePlum,
ConversationColorNameTaupe,
];
}
+ (NSArray<ConversationColorName> *)conversationColorNames
{
return [self.colorNamesForNewConversation arrayByAddingObject:kConversationColorName_Default];
}
+ (ConversationColorName)stableConversationColorNameForString:(NSString *)colorSeed
colorNames:(NSArray<ConversationColorName> *)colorNames
{
NSData *contactData = [colorSeed dataUsingEncoding:NSUTF8StringEncoding];
unsigned long long hash = 0;
NSUInteger hashingLength = sizeof(hash);
NSData *_Nullable hashData = [Cryptography computeSHA256Digest:contactData truncatedToBytes:hashingLength];
if (hashData) {
[hashData getBytes:&hash length:hashingLength];
} else {
OWSFailDebug(@"could not compute hash for color seed.");
}
NSUInteger index = (hash % colorNames.count);
return [colorNames objectAtIndex:index];
}
+ (ConversationColorName)stableColorNameForNewConversationWithString:(NSString *)colorSeed
{
return [self stableConversationColorNameForString:colorSeed colorNames:self.colorNamesForNewConversation];
}
// After introducing new conversation colors, we want to try to maintain as close as possible to the old color for an
// existing thread.
+ (ConversationColorName)stableColorNameForLegacyConversationWithString:(NSString *)colorSeed
{
NSString *legacyColorName =
[self stableConversationColorNameForString:colorSeed colorNames:self.legacyConversationColorNames];
ConversationColorName _Nullable mappedColorName = self.class.legacyConversationColorMap[legacyColorName];
if (!mappedColorName) {
OWSFailDebug(@"failure: unexpected unmappable legacyColorName: %@", legacyColorName);
return kConversationColorName_Default;
}
return mappedColorName;
}
+ (NSArray<NSString *> *)legacyConversationColorNames
{
return @[
@"red",
@"pink",
@"purple",
@"indigo",
@"blue",
@"cyan",
@"teal",
@"green",
@"deep_orange",
@"grey"
];
}
+ (NSDictionary<NSString *, ConversationColorName> *)legacyConversationColorMap
{
static NSDictionary<NSString *, ConversationColorName> *colorMap;
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
colorMap = @{
@"red" : ConversationColorNameCrimson,
@"deep_orange" : ConversationColorNameCrimson,
@"orange" : ConversationColorNameVermilion,
@"amber" : ConversationColorNameVermilion,
@"brown" : ConversationColorNameBurlap,
@"yellow" : ConversationColorNameBurlap,
@"pink" : ConversationColorNamePlum,
@"purple" : ConversationColorNameViolet,
@"deep_purple" : ConversationColorNameViolet,
@"indigo" : ConversationColorNameIndigo,
@"blue" : ConversationColorNameBlue,
@"light_blue" : ConversationColorNameBlue,
@"cyan" : ConversationColorNameTeal,
@"teal" : ConversationColorNameTeal,
@"green" : ConversationColorNameForest,
@"light_green" : ConversationColorNameWintergreen,
@"lime" : ConversationColorNameWintergreen,
@"blue_grey" : ConversationColorNameTaupe,
@"grey" : ConversationColorNameSteel,
};
});
return colorMap;
}
// we temporarily used the wrong value for the new color names.
+ (NSDictionary<NSString *, ConversationColorName> *)legacyFixupConversationColorMap
{
static NSDictionary<NSString *, ConversationColorName> *colorMap;
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
colorMap = @{
@"crimson" : ConversationColorNameCrimson,
@"vermilion" : ConversationColorNameVermilion,
@"burlap" : ConversationColorNameBurlap,
@"forest" : ConversationColorNameForest,
@"wintergreen" : ConversationColorNameWintergreen,
@"teal" : ConversationColorNameTeal,
@"blue" : ConversationColorNameBlue,
@"indigo" : ConversationColorNameIndigo,
@"violet" : ConversationColorNameViolet,
@"plum" : ConversationColorNamePlum,
@"taupe" : ConversationColorNameTaupe,
@"steel" : ConversationColorNameSteel,
};
});
return colorMap;
}
- (void)updateConversationColorName:(ConversationColorName)colorName transaction:(SDSAnyWriteTransaction *)transaction
{
[self anyUpdateWithTransaction:transaction
block:^(TSThread *thread) {
thread.conversationColorName = colorName;
}];
}
@end
NS_ASSUME_NONNULL_END