mirror of https://github.com/oxen-io/session-ios
You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
454 lines
18 KiB
Objective-C
454 lines
18 KiB
Objective-C
//
|
|
// Copyright (c) 2018 Open Whisper Systems. All rights reserved.
|
|
//
|
|
|
|
#import "NSString+OWS.h"
|
|
#import "iOSVersions.h"
|
|
#import <objc/runtime.h>
|
|
#import <SessionProtocolKit/OWSAsserts.h>
|
|
|
|
NS_ASSUME_NONNULL_BEGIN
|
|
|
|
@interface UnicodeCodeRange : NSObject
|
|
|
|
@property (nonatomic) unichar first;
|
|
@property (nonatomic) unichar last;
|
|
|
|
@end
|
|
|
|
#pragma mark -
|
|
|
|
@implementation UnicodeCodeRange
|
|
|
|
+ (UnicodeCodeRange *)rangeWithStart:(unichar)first last:(unichar)last
|
|
{
|
|
OWSAssertDebug(first <= last);
|
|
|
|
UnicodeCodeRange *range = [UnicodeCodeRange new];
|
|
range.first = first;
|
|
range.last = last;
|
|
return range;
|
|
}
|
|
|
|
- (NSComparisonResult)compare:(UnicodeCodeRange *)other
|
|
{
|
|
|
|
return self.first > other.first;
|
|
}
|
|
|
|
@end
|
|
|
|
#pragma mark -
|
|
|
|
static void *kNSString_SSK_hasExcessiveDiacriticals = &kNSString_SSK_hasExcessiveDiacriticals;
|
|
|
|
@implementation NSString (OWS)
|
|
|
|
- (NSString *)ows_stripped
|
|
{
|
|
return [self stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]];
|
|
}
|
|
|
|
+ (BOOL)shouldFilterIndic
|
|
{
|
|
static BOOL result = NO;
|
|
static dispatch_once_t onceToken;
|
|
dispatch_once(&onceToken, ^{
|
|
result = (SYSTEM_VERSION_GREATER_THAN_OR_EQUAL_TO(11, 0) && !SYSTEM_VERSION_GREATER_THAN_OR_EQUAL_TO(11, 3));
|
|
});
|
|
return result;
|
|
}
|
|
|
|
+ (BOOL)isIndicVowel:(unichar)c
|
|
{
|
|
static NSArray<UnicodeCodeRange *> *ranges;
|
|
static dispatch_once_t onceToken;
|
|
dispatch_once(&onceToken, ^{
|
|
// From:
|
|
// https://unicode.org/charts/PDF/U0C00.pdf
|
|
// https://unicode.org/charts/PDF/U0980.pdf
|
|
// https://unicode.org/charts/PDF/U0900.pdf
|
|
ranges = [@[
|
|
// Telugu:
|
|
[UnicodeCodeRange rangeWithStart:0xC05 last:0xC14],
|
|
[UnicodeCodeRange rangeWithStart:0xC3E last:0xC4C],
|
|
[UnicodeCodeRange rangeWithStart:0xC60 last:0xC63],
|
|
// Bengali
|
|
[UnicodeCodeRange rangeWithStart:0x985 last:0x994],
|
|
[UnicodeCodeRange rangeWithStart:0x9BE last:0x9C8],
|
|
[UnicodeCodeRange rangeWithStart:0x9CB last:0x9CC],
|
|
[UnicodeCodeRange rangeWithStart:0x9E0 last:0x9E3],
|
|
// Devanagari
|
|
[UnicodeCodeRange rangeWithStart:0x904 last:0x914],
|
|
[UnicodeCodeRange rangeWithStart:0x93A last:0x93B],
|
|
[UnicodeCodeRange rangeWithStart:0x93E last:0x94C],
|
|
[UnicodeCodeRange rangeWithStart:0x94E last:0x94F],
|
|
[UnicodeCodeRange rangeWithStart:0x955 last:0x957],
|
|
[UnicodeCodeRange rangeWithStart:0x960 last:0x963],
|
|
[UnicodeCodeRange rangeWithStart:0x972 last:0x977],
|
|
] sortedArrayUsingSelector:@selector(compare:)];
|
|
});
|
|
|
|
for (UnicodeCodeRange *range in ranges) {
|
|
if (c < range.first) {
|
|
// For perf, we can take advantage of the fact that the
|
|
// ranges are sorted to exit early if the character lies
|
|
// before the current range.
|
|
return NO;
|
|
}
|
|
if (range.first <= c && c <= range.last) {
|
|
return YES;
|
|
}
|
|
}
|
|
return NO;
|
|
}
|
|
|
|
+ (NSCharacterSet *)problematicCharacterSetForIndicScript
|
|
{
|
|
static NSCharacterSet *characterSet;
|
|
static dispatch_once_t onceToken;
|
|
dispatch_once(&onceToken, ^{
|
|
characterSet = [NSCharacterSet characterSetWithCharactersInString:@"\u200C"];
|
|
});
|
|
|
|
return characterSet;
|
|
}
|
|
|
|
// See: https://manishearth.github.io/blog/2018/02/15/picking-apart-the-crashing-ios-string/
|
|
- (NSString *)filterForIndicScripts
|
|
{
|
|
if (!NSString.shouldFilterIndic) {
|
|
return self;
|
|
}
|
|
|
|
if ([self rangeOfCharacterFromSet:[[self class] problematicCharacterSetForIndicScript]].location == NSNotFound) {
|
|
return self;
|
|
}
|
|
|
|
NSMutableString *filteredForIndic = [NSMutableString new];
|
|
for (NSUInteger index = 0; index < self.length; index++) {
|
|
unichar c = [self characterAtIndex:index];
|
|
if (c == 0x200C) {
|
|
NSUInteger nextIndex = index + 1;
|
|
if (nextIndex < self.length) {
|
|
unichar next = [self characterAtIndex:nextIndex];
|
|
if ([NSString isIndicVowel:next]) {
|
|
// Discard ZWNJ (zero-width non-joiner) whenever we find a ZWNJ
|
|
// followed by an Indic (Telugu, Bengali, Devanagari) vowel
|
|
// and replace it with 0xFFFD, the Unicode "replacement character."
|
|
[filteredForIndic appendFormat:@"\uFFFD"];
|
|
OWSLogError(@"Filtered unsafe Indic script.");
|
|
// Then discard the vowel too.
|
|
index++;
|
|
continue;
|
|
}
|
|
}
|
|
}
|
|
[filteredForIndic appendFormat:@"%C", c];
|
|
}
|
|
return [filteredForIndic copy];
|
|
}
|
|
|
|
+ (NSCharacterSet *)unsafeFilenameCharacterSet
|
|
{
|
|
static NSCharacterSet *characterSet;
|
|
static dispatch_once_t onceToken;
|
|
dispatch_once(&onceToken, ^{
|
|
// 0x202D and 0x202E are the unicode ordering letters
|
|
// and can be used to control the rendering of text.
|
|
// They could be used to construct misleading attachment
|
|
// filenames that appear to have a different file extension,
|
|
// for example.
|
|
characterSet = [NSCharacterSet characterSetWithCharactersInString:@"\u202D\u202E"];
|
|
});
|
|
|
|
return characterSet;
|
|
}
|
|
|
|
- (NSString *)filterUnsafeFilenameCharacters
|
|
{
|
|
NSCharacterSet *unsafeCharacterSet = [[self class] unsafeFilenameCharacterSet];
|
|
NSRange range = [self rangeOfCharacterFromSet:unsafeCharacterSet];
|
|
if (range.location == NSNotFound) {
|
|
return self;
|
|
}
|
|
NSMutableString *filtered = [NSMutableString new];
|
|
NSString *remainder = [self copy];
|
|
while (range.location != NSNotFound) {
|
|
if (range.location > 0) {
|
|
[filtered appendString:[remainder substringToIndex:range.location]];
|
|
}
|
|
// The "replacement" code point.
|
|
[filtered appendString:@"\uFFFD"];
|
|
remainder = [remainder substringFromIndex:range.location + range.length];
|
|
range = [remainder rangeOfCharacterFromSet:unsafeCharacterSet];
|
|
}
|
|
[filtered appendString:remainder];
|
|
return filtered;
|
|
}
|
|
|
|
- (NSString *)filterStringForDisplay
|
|
{
|
|
return self.ows_stripped.filterForIndicScripts.filterForExcessiveDiacriticals;
|
|
}
|
|
|
|
- (NSString *)filterFilename
|
|
{
|
|
return self.ows_stripped.filterForIndicScripts.filterForExcessiveDiacriticals.filterUnsafeFilenameCharacters;
|
|
}
|
|
|
|
- (NSString *)filterForExcessiveDiacriticals
|
|
{
|
|
if (!self.hasExcessiveDiacriticals) {
|
|
return self;
|
|
}
|
|
return [self stringByFoldingWithOptions:NSDiacriticInsensitiveSearch locale:[NSLocale currentLocale]];
|
|
}
|
|
|
|
- (BOOL)hasExcessiveDiacriticals
|
|
{
|
|
NSNumber *cachedValue = objc_getAssociatedObject(self, kNSString_SSK_hasExcessiveDiacriticals);
|
|
if (!cachedValue) {
|
|
cachedValue = @([self computeHasExcessiveDiacriticals]);
|
|
objc_setAssociatedObject(self, kNSString_SSK_hasExcessiveDiacriticals, cachedValue, OBJC_ASSOCIATION_COPY);
|
|
}
|
|
|
|
return cachedValue.boolValue;
|
|
}
|
|
|
|
- (BOOL)computeHasExcessiveDiacriticals
|
|
{
|
|
// discard any zalgo style text, by detecting maximum number of glyphs per character
|
|
NSUInteger index = 0;
|
|
|
|
// store in local var, it's a hot code path.
|
|
NSUInteger length = self.length;
|
|
while (index < length) {
|
|
// Walk the grapheme clusters in the string.
|
|
NSRange range = [self rangeOfComposedCharacterSequenceAtIndex:index];
|
|
if (range.length > 8) {
|
|
// There are too many characters in this grapheme cluster.
|
|
return YES;
|
|
} else if (range.location != index || range.length < 1) {
|
|
// This should never happen.
|
|
OWSFailDebug(
|
|
@"unexpected composed character sequence: %lu, %@", (unsigned long)index, NSStringFromRange(range));
|
|
return YES;
|
|
}
|
|
index = range.location + range.length;
|
|
}
|
|
return NO;
|
|
}
|
|
|
|
+ (NSRegularExpression *)anyASCIIRegex
|
|
{
|
|
static dispatch_once_t onceToken;
|
|
static NSRegularExpression *regex;
|
|
dispatch_once(&onceToken, ^{
|
|
NSError *error;
|
|
regex = [NSRegularExpression regularExpressionWithPattern:@"[\x00-\x7F]+"
|
|
options:0
|
|
error:&error];
|
|
if (error || !regex) {
|
|
// crash! it's not clear how to proceed safely, and this regex should never fail.
|
|
OWSFail(@"could not compile regex: %@", error);
|
|
}
|
|
});
|
|
|
|
return regex;
|
|
}
|
|
|
|
+ (NSRegularExpression *)onlyASCIIRegex
|
|
{
|
|
static dispatch_once_t onceToken;
|
|
static NSRegularExpression *regex;
|
|
dispatch_once(&onceToken, ^{
|
|
NSError *error;
|
|
regex = [NSRegularExpression regularExpressionWithPattern:@"^[\x00-\x7F]*$"
|
|
options:0
|
|
error:&error];
|
|
if (error || !regex) {
|
|
// crash! it's not clear how to proceed safely, and this regex should never fail.
|
|
OWSFail(@"could not compile regex: %@", error);
|
|
}
|
|
});
|
|
|
|
return regex;
|
|
}
|
|
|
|
|
|
- (BOOL)isOnlyASCII;
|
|
{
|
|
return [self.class.onlyASCIIRegex rangeOfFirstMatchInString:self
|
|
options:0
|
|
range:NSMakeRange(0, self.length)].location != NSNotFound;
|
|
}
|
|
|
|
- (BOOL)hasAnyASCII
|
|
{
|
|
return [self.class.anyASCIIRegex rangeOfFirstMatchInString:self
|
|
options:0
|
|
range:NSMakeRange(0, self.length)].location != NSNotFound;
|
|
}
|
|
|
|
- (BOOL)isValidE164
|
|
{
|
|
NSError *error = nil;
|
|
NSRegularExpression *regex = [NSRegularExpression regularExpressionWithPattern:@"^\\+\\d+$"
|
|
options:NSRegularExpressionCaseInsensitive
|
|
error:&error];
|
|
if (error || !regex) {
|
|
OWSFailDebug(@"could not compile regex: %@", error);
|
|
return NO;
|
|
}
|
|
return [regex rangeOfFirstMatchInString:self options:0 range:NSMakeRange(0, self.length)].location != NSNotFound;
|
|
}
|
|
|
|
+ (NSString *)formatDurationSeconds:(uint32_t)durationSeconds useShortFormat:(BOOL)useShortFormat
|
|
{
|
|
NSString *amountFormat;
|
|
uint32_t duration;
|
|
|
|
uint32_t secondsPerMinute = 60;
|
|
uint32_t secondsPerHour = secondsPerMinute * 60;
|
|
uint32_t secondsPerDay = secondsPerHour * 24;
|
|
uint32_t secondsPerWeek = secondsPerDay * 7;
|
|
|
|
if (durationSeconds < secondsPerMinute) { // XX Seconds
|
|
if (useShortFormat) {
|
|
amountFormat = NSLocalizedString(@"TIME_AMOUNT_SECONDS_SHORT_FORMAT",
|
|
@"Label text below navbar button, embeds {{number of seconds}}. Must be very short, like 1 or 2 "
|
|
@"characters, The space is intentionally omitted between the text and the embedded duration so that "
|
|
@"we get, e.g. '5s' not '5 s'. See other *_TIME_AMOUNT strings");
|
|
} else {
|
|
amountFormat = NSLocalizedString(@"TIME_AMOUNT_SECONDS",
|
|
@"{{number of seconds}} embedded in strings, e.g. 'Alice updated disappearing messages "
|
|
@"expiration to {{5 seconds}}'. See other *_TIME_AMOUNT strings");
|
|
}
|
|
|
|
duration = durationSeconds;
|
|
} else if (durationSeconds < secondsPerMinute * 1.5) { // 1 Minute
|
|
if (useShortFormat) {
|
|
amountFormat = NSLocalizedString(@"TIME_AMOUNT_MINUTES_SHORT_FORMAT",
|
|
@"Label text below navbar button, embeds {{number of minutes}}. Must be very short, like 1 or 2 "
|
|
@"characters, The space is intentionally omitted between the text and the embedded duration so that "
|
|
@"we get, e.g. '5m' not '5 m'. See other *_TIME_AMOUNT strings");
|
|
} else {
|
|
amountFormat = NSLocalizedString(@"TIME_AMOUNT_SINGLE_MINUTE",
|
|
@"{{1 minute}} embedded in strings, e.g. 'Alice updated disappearing messages "
|
|
@"expiration to {{1 minute}}'. See other *_TIME_AMOUNT strings");
|
|
}
|
|
duration = durationSeconds / secondsPerMinute;
|
|
} else if (durationSeconds < secondsPerHour) { // Multiple Minutes
|
|
if (useShortFormat) {
|
|
amountFormat = NSLocalizedString(@"TIME_AMOUNT_MINUTES_SHORT_FORMAT",
|
|
@"Label text below navbar button, embeds {{number of minutes}}. Must be very short, like 1 or 2 "
|
|
@"characters, The space is intentionally omitted between the text and the embedded duration so that "
|
|
@"we get, e.g. '5m' not '5 m'. See other *_TIME_AMOUNT strings");
|
|
} else {
|
|
amountFormat = NSLocalizedString(@"TIME_AMOUNT_MINUTES",
|
|
@"{{number of minutes}} embedded in strings, e.g. 'Alice updated disappearing messages "
|
|
@"expiration to {{5 minutes}}'. See other *_TIME_AMOUNT strings");
|
|
}
|
|
|
|
duration = durationSeconds / secondsPerMinute;
|
|
} else if (durationSeconds < secondsPerHour * 1.5) { // 1 Hour
|
|
if (useShortFormat) {
|
|
amountFormat = NSLocalizedString(@"TIME_AMOUNT_HOURS_SHORT_FORMAT",
|
|
@"Label text below navbar button, embeds {{number of hours}}. Must be very short, like 1 or 2 "
|
|
@"characters, The space is intentionally omitted between the text and the embedded duration so that "
|
|
@"we get, e.g. '5h' not '5 h'. See other *_TIME_AMOUNT strings");
|
|
} else {
|
|
amountFormat = NSLocalizedString(@"TIME_AMOUNT_SINGLE_HOUR",
|
|
@"{{1 hour}} embedded in strings, e.g. 'Alice updated disappearing messages "
|
|
@"expiration to {{1 hour}}'. See other *_TIME_AMOUNT strings");
|
|
}
|
|
|
|
duration = durationSeconds / secondsPerHour;
|
|
} else if (durationSeconds < secondsPerDay) { // Multiple Hours
|
|
if (useShortFormat) {
|
|
amountFormat = NSLocalizedString(@"TIME_AMOUNT_HOURS_SHORT_FORMAT",
|
|
@"Label text below navbar button, embeds {{number of hours}}. Must be very short, like 1 or 2 "
|
|
@"characters, The space is intentionally omitted between the text and the embedded duration so that "
|
|
@"we get, e.g. '5h' not '5 h'. See other *_TIME_AMOUNT strings");
|
|
} else {
|
|
amountFormat = NSLocalizedString(@"TIME_AMOUNT_HOURS",
|
|
@"{{number of hours}} embedded in strings, e.g. 'Alice updated disappearing messages "
|
|
@"expiration to {{5 hours}}'. See other *_TIME_AMOUNT strings");
|
|
}
|
|
|
|
duration = durationSeconds / secondsPerHour;
|
|
} else if (durationSeconds < secondsPerDay * 1.5) { // 1 Day
|
|
if (useShortFormat) {
|
|
amountFormat = NSLocalizedString(@"TIME_AMOUNT_DAYS_SHORT_FORMAT",
|
|
@"Label text below navbar button, embeds {{number of days}}. Must be very short, like 1 or 2 "
|
|
@"characters, The space is intentionally omitted between the text and the embedded duration so that "
|
|
@"we get, e.g. '5d' not '5 d'. See other *_TIME_AMOUNT strings");
|
|
} else {
|
|
amountFormat = NSLocalizedString(@"TIME_AMOUNT_SINGLE_DAY",
|
|
@"{{1 day}} embedded in strings, e.g. 'Alice updated disappearing messages "
|
|
@"expiration to {{1 day}}'. See other *_TIME_AMOUNT strings");
|
|
}
|
|
|
|
duration = durationSeconds / secondsPerDay;
|
|
} else if (durationSeconds < secondsPerWeek) { // Multiple Days
|
|
if (useShortFormat) {
|
|
amountFormat = NSLocalizedString(@"TIME_AMOUNT_DAYS_SHORT_FORMAT",
|
|
@"Label text below navbar button, embeds {{number of days}}. Must be very short, like 1 or 2 "
|
|
@"characters, The space is intentionally omitted between the text and the embedded duration so that "
|
|
@"we get, e.g. '5d' not '5 d'. See other *_TIME_AMOUNT strings");
|
|
} else {
|
|
amountFormat = NSLocalizedString(@"TIME_AMOUNT_DAYS",
|
|
@"{{number of days}} embedded in strings, e.g. 'Alice updated disappearing messages "
|
|
@"expiration to {{5 days}}'. See other *_TIME_AMOUNT strings");
|
|
}
|
|
|
|
duration = durationSeconds / secondsPerDay;
|
|
} else if (durationSeconds < secondsPerWeek * 1.5) { // 1 Week
|
|
if (useShortFormat) {
|
|
amountFormat = NSLocalizedString(@"TIME_AMOUNT_WEEKS_SHORT_FORMAT",
|
|
@"Label text below navbar button, embeds {{number of weeks}}. Must be very short, like 1 or 2 "
|
|
@"characters, The space is intentionally omitted between the text and the embedded duration so that "
|
|
@"we get, e.g. '5w' not '5 w'. See other *_TIME_AMOUNT strings");
|
|
} else {
|
|
amountFormat = NSLocalizedString(@"TIME_AMOUNT_SINGLE_WEEK",
|
|
@"{{1 week}} embedded in strings, e.g. 'Alice updated disappearing messages "
|
|
@"expiration to {{1 week}}'. See other *_TIME_AMOUNT strings");
|
|
}
|
|
|
|
duration = durationSeconds / secondsPerWeek;
|
|
} else { // Multiple weeks
|
|
if (useShortFormat) {
|
|
amountFormat = NSLocalizedString(@"TIME_AMOUNT_WEEKS_SHORT_FORMAT",
|
|
@"Label text below navbar button, embeds {{number of weeks}}. Must be very short, like 1 or 2 "
|
|
@"characters, The space is intentionally omitted between the text and the embedded duration so that "
|
|
@"we get, e.g. '5w' not '5 w'. See other *_TIME_AMOUNT strings");
|
|
} else {
|
|
amountFormat = NSLocalizedString(@"TIME_AMOUNT_WEEKS",
|
|
@"{{number of weeks}}, embedded in strings, e.g. 'Alice updated disappearing messages "
|
|
@"expiration to {{5 weeks}}'. See other *_TIME_AMOUNT strings");
|
|
}
|
|
|
|
duration = durationSeconds / secondsPerWeek;
|
|
}
|
|
|
|
return [NSString stringWithFormat:amountFormat,
|
|
[NSNumberFormatter localizedStringFromNumber:@(duration) numberStyle:NSNumberFormatterNoStyle]];
|
|
}
|
|
|
|
- (NSString *)removeAllCharactersIn:(NSCharacterSet *)characterSet
|
|
{
|
|
OWSAssertDebug(characterSet);
|
|
|
|
return [[self componentsSeparatedByCharactersInSet:characterSet] componentsJoinedByString:@""];
|
|
}
|
|
|
|
- (NSString *)digitsOnly
|
|
{
|
|
return [self removeAllCharactersIn:[NSCharacterSet.decimalDigitCharacterSet invertedSet]];
|
|
}
|
|
|
|
@end
|
|
|
|
NS_ASSUME_NONNULL_END
|