Signal-iOS/SignalServiceKit/src/Util/String+SSK.swift
2020-08-17 22:58:39 -07:00

629 lines
24 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// Copyright (c) 2020 Open Whisper Systems. All rights reserved.
//
import Foundation
import NaturalLanguage
public extension String {
var digitsOnly: String {
return (self as NSString).digitsOnly()
}
func substring(from index: Int) -> String {
return String(self[self.index(self.startIndex, offsetBy: index)...])
}
func substring(to index: Int) -> String {
return String(prefix(index))
}
enum StringError: Error {
case invalidCharacterShift
}
/// Converts all non arabic numerals within a string to arabic numerals
///
/// For example: "Hello ١٢٣" would become "Hello 123"
var ensureArabicNumerals: String {
return String(map { character in
// Check if this character is a number between 0-9, if it's not just return it and carry on
//
// Some languages (like Chinese) have characters that represent larger numbers ( = 10^4)
// These are not easily translatable into arabic numerals at a character by character level,
// so we ignore them.
guard let number = character.wholeNumberValue, number <= 9, number >= 0 else { return character }
return Character("\(number)")
})
}
init?(sysctlKey key: String) {
var size: Int = 0
sysctlbyname(key, nil, &size, nil, 0)
guard size > 0 else { return nil }
var value = [CChar](repeating: 0, count: size)
sysctlbyname(key, &value, &size, nil, 0)
self.init(cString: value)
}
}
// MARK: -
@objc
public extension NSString {
var ensureArabicNumerals: String {
return (self as String).ensureArabicNumerals
}
class func stringFromSysctlKey(_ key: String) -> String? {
return String(sysctlKey: key)
}
var asAttributedString: NSAttributedString {
return NSAttributedString(string: self as String)
}
}
// MARK: - Attributed String Concatentation
public extension NSAttributedString {
@objc
func stringByAppendingString(_ string: String, attributes: [NSAttributedString.Key: Any] = [:]) -> NSAttributedString {
return stringByAppendingString(NSAttributedString(string: string, attributes: attributes))
}
@objc
func stringByAppendingString(_ string: NSAttributedString) -> NSAttributedString {
let copy = mutableCopy() as! NSMutableAttributedString
copy.append(string)
return copy.copy() as! NSAttributedString
}
static func + (lhs: NSAttributedString, rhs: NSAttributedString) -> NSAttributedString {
return lhs.stringByAppendingString(rhs)
}
static func + (lhs: NSAttributedString, rhs: String) -> NSAttributedString {
return lhs.stringByAppendingString(rhs)
}
@objc
func ows_stripped() -> NSAttributedString {
guard length > 0 else { return self }
guard !string.ows_stripped().isEmpty else { return NSAttributedString() }
let mutableString = NSMutableAttributedString(attributedString: self)
mutableString.ows_strip()
return NSAttributedString(attributedString: mutableString)
}
}
// MARK: -
@objc
public enum ImageAttachmentHeightReference: Int {
case pointSize
case lineHeight
func height(for font: UIFont) -> CGFloat {
switch self {
case .pointSize: return ceil(font.pointSize)
case .lineHeight: return ceil(font.lineHeight)
}
}
}
public extension NSMutableAttributedString {
@objc
func append(_ string: String, attributes: [NSAttributedString.Key: Any] = [:]) {
append(NSAttributedString(string: string, attributes: attributes))
}
@objc(appendTemplatedImageNamed:font:)
func appendTemplatedImage(named imageName: String, font: UIFont) {
appendTemplatedImage(named: imageName, font: font, attributes: nil)
}
@objc(appendTemplatedImageNamed:font:heightReference:)
func appendTemplatedImage(named imageName: String, font: UIFont, heightReference: ImageAttachmentHeightReference) {
appendTemplatedImage(named: imageName, font: font, attributes: nil, heightReference: heightReference)
}
@objc(appendTemplatedImageNamed:font:attributes:)
func appendTemplatedImage(named imageName: String, font: UIFont, attributes: [NSAttributedString.Key: Any]?) {
appendTemplatedImage(named: imageName, font: font, attributes: nil, heightReference: .pointSize)
}
@objc(appendTemplatedImageNamed:font:attributes:heightReference:)
func appendTemplatedImage(named imageName: String, font: UIFont, attributes: [NSAttributedString.Key: Any]?, heightReference: ImageAttachmentHeightReference) {
guard let image = UIImage(named: imageName) else {
return owsFailDebug("missing image named \(imageName)")
}
appendImage(image.withRenderingMode(.alwaysTemplate), font: font, attributes: attributes, heightReference: heightReference)
}
@objc(appendImageNamed:font:)
func appendImage(named imageName: String, font: UIFont) {
appendImage(named: imageName, font: font, attributes: nil)
}
@objc(appendImageNamed:font:heightReference:)
func appendImage(named imageName: String, font: UIFont, heightReference: ImageAttachmentHeightReference) {
appendImage(named: imageName, font: font, attributes: nil, heightReference: heightReference)
}
@objc(appendImageNamed:font:attributes:)
func appendImage(named imageName: String, font: UIFont, attributes: [NSAttributedString.Key: Any]?) {
appendImage(named: imageName, font: font, attributes: attributes, heightReference: .pointSize)
}
@objc(appendImageNamed:font:attributes:heightReference:)
func appendImage(named imageName: String, font: UIFont, attributes: [NSAttributedString.Key: Any]?, heightReference: ImageAttachmentHeightReference) {
guard let image = UIImage(named: imageName) else {
return owsFailDebug("missing image named \(imageName)")
}
appendImage(image, font: font, attributes: attributes, heightReference: heightReference)
}
@objc(appendImage:font:)
func appendImage(_ image: UIImage, font: UIFont) {
appendImage(image, font: font, attributes: nil)
}
@objc(appendImage:font:heightReference:)
func appendImage(_ image: UIImage, font: UIFont, heightReference: ImageAttachmentHeightReference) {
appendImage(image, font: font, attributes: nil, heightReference: heightReference)
}
@objc(appendImage:font:attributes:)
func appendImage(_ image: UIImage, font: UIFont, attributes: [NSAttributedString.Key: Any]?) {
appendImage(image, font: font, attributes: attributes, heightReference: .pointSize)
}
@objc(appendImage:font:attributes:heightReference:)
func appendImage(_ image: UIImage, font: UIFont, attributes: [NSAttributedString.Key: Any]?, heightReference: ImageAttachmentHeightReference) {
let attachment = NSTextAttachment()
attachment.image = image
// Match the image's height to the font's height while preserving
// the image's aspect ratio, and vertically center.
let imageHeight = heightReference.height(for: font)
let imageWidth = (imageHeight / image.size.height) * image.size.width
attachment.bounds = CGRect(x: 0, y: (font.capHeight - imageHeight) / 2, width: imageWidth, height: imageHeight)
let attachmentString = NSAttributedString(attachment: attachment)
if let attributes = attributes {
let mutableString = NSMutableAttributedString(attributedString: attachmentString)
mutableString.addAttributes(attributes, range: NSRange(location: 0, length: mutableString.length))
append(mutableString)
} else {
append(attachmentString)
}
}
@objc
func ows_strip() {
guard length > 0 else { return }
let nsString = string as NSString
let strippedString = string.ows_stripped()
func replaceWithEmptyString() {
replaceCharacters(in: NSRange(location: 0, length: length), with: "")
}
guard !strippedString.isEmpty else { return replaceWithEmptyString() }
let remainingRange = nsString.range(of: strippedString)
guard remainingRange.location != NSNotFound else {
owsFailDebug("Unexpectedly missing substring after strip")
return replaceWithEmptyString()
}
let newEndOfString = remainingRange.location + remainingRange.length
if newEndOfString < mutableString.length {
mutableString.replaceCharacters(
in: NSRange(location: newEndOfString, length: mutableString.length - newEndOfString),
with: ""
)
}
let newStartOfString = remainingRange.location
if newStartOfString > 0 {
mutableString.replaceCharacters(
in: NSRange(location: 0, length: newStartOfString),
with: ""
)
}
}
}
// MARK: - Natural Text Alignment
public extension String {
private var dominantLanguage: String? {
if #available(iOS 12, *) {
return NLLanguageRecognizer.dominantLanguage(for: self)?.rawValue
} else {
return NSLinguisticTagger.dominantLanguage(for: self)
}
}
/// The natural text alignment of a given string. This may be different
/// than the natural alignment of the current system locale depending on
/// the language of the string, especially for user entered text.
var naturalTextAlignment: NSTextAlignment {
guard let dominantLanguage = dominantLanguage else {
// If we can't identify the strings language, use the system language's natural alignment
return .natural
}
switch NSParagraphStyle.defaultWritingDirection(forLanguage: dominantLanguage) {
case .leftToRight:
return .left
case .rightToLeft:
return .right
case .natural:
return .natural
@unknown default:
return .natural
}
}
}
public extension NSString {
/// The natural text alignment of a given string. This may be different
/// than the natural alignment of the current system locale depending on
/// the language of the string, especially for user entered text.
@objc
var naturalTextAlignment: NSTextAlignment {
return (self as String).naturalTextAlignment
}
}
// MARK: - Selector Encoding
private let selectorOffset: UInt32 = 17
public extension String {
func caesar(shift: UInt32) throws -> String {
let shiftedScalars: [UnicodeScalar] = try unicodeScalars.map { c in
guard let shiftedScalar = UnicodeScalar((c.value + shift) % 127) else {
owsFailDebug("invalidCharacterShift")
throw StringError.invalidCharacterShift
}
return shiftedScalar
}
return String(String.UnicodeScalarView(shiftedScalars))
}
var encodedForSelector: String? {
guard let shifted = try? self.caesar(shift: selectorOffset) else {
owsFailDebug("shifted was unexpectedly nil")
return nil
}
guard let data = shifted.data(using: .utf8) else {
owsFailDebug("data was unexpectedly nil")
return nil
}
return data.base64EncodedString()
}
var decodedForSelector: String? {
guard let data = Data(base64Encoded: self) else {
owsFailDebug("data was unexpectedly nil")
return nil
}
guard let shifted = String(data: data, encoding: .utf8) else {
owsFailDebug("shifted was unexpectedly nil")
return nil
}
return try? shifted.caesar(shift: 127 - selectorOffset)
}
}
public extension NSString {
@objc
var encodedForSelector: String? {
return (self as String).encodedForSelector
}
@objc
var decodedForSelector: String? {
return (self as String).decodedForSelector
}
}
// MARK: - Emoji
extension UnicodeScalar {
class EmojiRange {
// rangeStart and rangeEnd are inclusive.
let rangeStart: UInt32
let rangeEnd: UInt32
// MARK: Initializers
init(rangeStart: UInt32, rangeEnd: UInt32) {
self.rangeStart = rangeStart
self.rangeEnd = rangeEnd
}
}
// From:
// https://www.unicode.org/Public/emoji/
// Current Version:
// https://www.unicode.org/Public/emoji/6.0/emoji-data.txt
//
// These ranges can be code-generated using:
//
// * Scripts/emoji-data.txt
// * Scripts/emoji_ranges.py
static let kEmojiRanges = [
// NOTE: Don't treat Pound Sign # as Jumbomoji.
// EmojiRange(rangeStart:0x23, rangeEnd:0x23),
// NOTE: Don't treat Asterisk * as Jumbomoji.
// EmojiRange(rangeStart:0x2A, rangeEnd:0x2A),
// NOTE: Don't treat Digits 0..9 as Jumbomoji.
// EmojiRange(rangeStart:0x30, rangeEnd:0x39),
// NOTE: Don't treat Copyright Symbol © as Jumbomoji.
// EmojiRange(rangeStart:0xA9, rangeEnd:0xA9),
// NOTE: Don't treat Trademark Sign ® as Jumbomoji.
// EmojiRange(rangeStart:0xAE, rangeEnd:0xAE),
EmojiRange(rangeStart: 0x200D, rangeEnd: 0x200D),
EmojiRange(rangeStart: 0x203C, rangeEnd: 0x203C),
EmojiRange(rangeStart: 0x2049, rangeEnd: 0x2049),
EmojiRange(rangeStart: 0x20D0, rangeEnd: 0x20FF),
EmojiRange(rangeStart: 0x2122, rangeEnd: 0x2122),
EmojiRange(rangeStart: 0x2139, rangeEnd: 0x2139),
EmojiRange(rangeStart: 0x2194, rangeEnd: 0x2199),
EmojiRange(rangeStart: 0x21A9, rangeEnd: 0x21AA),
EmojiRange(rangeStart: 0x231A, rangeEnd: 0x231B),
EmojiRange(rangeStart: 0x2328, rangeEnd: 0x2328),
EmojiRange(rangeStart: 0x2388, rangeEnd: 0x2388),
EmojiRange(rangeStart: 0x23CF, rangeEnd: 0x23CF),
EmojiRange(rangeStart: 0x23E9, rangeEnd: 0x23F3),
EmojiRange(rangeStart: 0x23F8, rangeEnd: 0x23FA),
EmojiRange(rangeStart: 0x24C2, rangeEnd: 0x24C2),
EmojiRange(rangeStart: 0x25AA, rangeEnd: 0x25AB),
EmojiRange(rangeStart: 0x25B6, rangeEnd: 0x25B6),
EmojiRange(rangeStart: 0x25C0, rangeEnd: 0x25C0),
EmojiRange(rangeStart: 0x25FB, rangeEnd: 0x25FE),
EmojiRange(rangeStart: 0x2600, rangeEnd: 0x27BF),
EmojiRange(rangeStart: 0x2934, rangeEnd: 0x2935),
EmojiRange(rangeStart: 0x2B05, rangeEnd: 0x2B07),
EmojiRange(rangeStart: 0x2B1B, rangeEnd: 0x2B1C),
EmojiRange(rangeStart: 0x2B50, rangeEnd: 0x2B50),
EmojiRange(rangeStart: 0x2B55, rangeEnd: 0x2B55),
EmojiRange(rangeStart: 0x3030, rangeEnd: 0x3030),
EmojiRange(rangeStart: 0x303D, rangeEnd: 0x303D),
EmojiRange(rangeStart: 0x3297, rangeEnd: 0x3297),
EmojiRange(rangeStart: 0x3299, rangeEnd: 0x3299),
EmojiRange(rangeStart: 0xFE00, rangeEnd: 0xFE0F),
EmojiRange(rangeStart: 0x1F000, rangeEnd: 0x1F0FF),
EmojiRange(rangeStart: 0x1F10D, rangeEnd: 0x1F10F),
EmojiRange(rangeStart: 0x1F12F, rangeEnd: 0x1F12F),
EmojiRange(rangeStart: 0x1F16C, rangeEnd: 0x1F171),
EmojiRange(rangeStart: 0x1F17E, rangeEnd: 0x1F17F),
EmojiRange(rangeStart: 0x1F18E, rangeEnd: 0x1F18E),
EmojiRange(rangeStart: 0x1F191, rangeEnd: 0x1F19A),
EmojiRange(rangeStart: 0x1F1AD, rangeEnd: 0x1F1FF),
EmojiRange(rangeStart: 0x1F201, rangeEnd: 0x1F20F),
EmojiRange(rangeStart: 0x1F21A, rangeEnd: 0x1F21A),
EmojiRange(rangeStart: 0x1F22F, rangeEnd: 0x1F22F),
EmojiRange(rangeStart: 0x1F232, rangeEnd: 0x1F23A),
EmojiRange(rangeStart: 0x1F23C, rangeEnd: 0x1F23F),
EmojiRange(rangeStart: 0x1F249, rangeEnd: 0x1F64F),
EmojiRange(rangeStart: 0x1F680, rangeEnd: 0x1F6FF),
EmojiRange(rangeStart: 0x1F774, rangeEnd: 0x1F77F),
EmojiRange(rangeStart: 0x1F7D5, rangeEnd: 0x1F7FF),
EmojiRange(rangeStart: 0x1F80C, rangeEnd: 0x1F80F),
EmojiRange(rangeStart: 0x1F848, rangeEnd: 0x1F84F),
EmojiRange(rangeStart: 0x1F85A, rangeEnd: 0x1F85F),
EmojiRange(rangeStart: 0x1F888, rangeEnd: 0x1F88F),
EmojiRange(rangeStart: 0x1F8AE, rangeEnd: 0x1FFFD),
EmojiRange(rangeStart: 0xE0020, rangeEnd: 0xE007F)
]
var isEmoji: Bool {
// Binary search.
var left: Int = 0
var right = Int(UnicodeScalar.kEmojiRanges.count - 1)
while true {
let mid = (left + right) / 2
let midRange = UnicodeScalar.kEmojiRanges[mid]
if value < midRange.rangeStart {
if mid == left {
return false
}
right = mid - 1
} else if value > midRange.rangeEnd {
if mid == right {
return false
}
left = mid + 1
} else {
return true
}
}
}
var isZeroWidthJoiner: Bool {
return value == 8205
}
}
public extension String {
var glyphCount: Int {
let richText = NSAttributedString(string: self)
let line = CTLineCreateWithAttributedString(richText)
return CTLineGetGlyphCount(line)
}
var isSingleEmoji: Bool {
return glyphCount == 1 && containsEmoji
}
var containsEmoji: Bool {
return unicodeScalars.contains { $0.isEmoji }
}
var containsOnlyEmoji: Bool {
return !isEmpty
&& !unicodeScalars.contains(where: {
!$0.isEmoji
&& !$0.isZeroWidthJoiner
})
}
}
@objc
public extension NSString {
var glyphCount: Int {
return (self as String).glyphCount
}
var isSingleEmoji: Bool {
return (self as String).isSingleEmoji
}
var containsEmoji: Bool {
return (self as String).containsEmoji
}
var containsOnlyEmoji: Bool {
return (self as String).containsOnlyEmoji
}
}
// MARK: - encodeURIComponent
public extension String {
var encodeURIComponent: String? {
return (self as NSString).encodeURIComponent
}
}
@objc
public extension NSString {
var encodeURIComponent: String? {
// Match behavior of encodeURIComponent used by desktop.
//
// Removes any "/" in the base64. All other base64 chars are URL safe.
// Apple's built-in `stringByAddingPercentEncodingWithAllowedCharacters:[NSCharacterSet URL*]]`
// doesn't offer a flavor for encoding "/".
var characterSet = CharacterSet.alphanumerics
characterSet.insert(charactersIn: "-_.!~*'()")
return addingPercentEncoding(withAllowedCharacters: characterSet)
}
}
// MARK: -
public extension String {
static func formatDurationLossless(durationSeconds: UInt32) -> String {
return NSString.formatDurationLossless(durationSeconds: durationSeconds)
}
}
// MARK: -
@objc
public extension NSString {
static func formatDurationLossless(durationSeconds: UInt32) -> String {
let secondsPerMinute: UInt32 = 60
let secondsPerHour: UInt32 = secondsPerMinute * 60
let secondsPerDay: UInt32 = secondsPerHour * 24
let secondsPerWeek: UInt32 = secondsPerDay * 7
let secondsPerYear: UInt32 = secondsPerDay * 365
struct Unit {
let secondsPerUnit: UInt32
let singleUnitFormat: String
let multipleUnitsFormat: String
}
// Listed in descending order.
let units: [Unit] = [
// Years
Unit(secondsPerUnit: secondsPerYear,
singleUnitFormat: NSLocalizedString("TIME_AMOUNT_SINGLE_YEAR",
comment: "{{1 year}} embedded in strings, e.g. 'Alice updated disappearing messages expiration to {{1 year}}'. See other *_TIME_AMOUNT strings"),
multipleUnitsFormat: NSLocalizedString("TIME_AMOUNT_YEARS_FORMAT",
comment: "{{N years}} embedded in strings, e.g. 'Alice updated disappearing messages expiration to {{5 years}}'. Embeds: {{ the number of years }}. See other *_TIME_AMOUNT strings")),
// Weeks
Unit(secondsPerUnit: secondsPerWeek,
singleUnitFormat: NSLocalizedString("TIME_AMOUNT_SINGLE_WEEK",
comment:
"{{1 week}} embedded in strings, e.g. 'Alice updated disappearing messages expiration to {{1 week}}'. See other *_TIME_AMOUNT strings"),
multipleUnitsFormat: NSLocalizedString("TIME_AMOUNT_WEEKS",
comment: "{{number of weeks}}, embedded in strings, e.g. 'Alice updated disappearing messages expiration to {{5 weeks}}'. See other *_TIME_AMOUNT strings")),
// Days
Unit(secondsPerUnit: secondsPerDay,
singleUnitFormat: NSLocalizedString("TIME_AMOUNT_SINGLE_DAY",
comment:
"{{1 day}} embedded in strings, e.g. 'Alice updated disappearing messages expiration to {{1 day}}'. See other *_TIME_AMOUNT strings"),
multipleUnitsFormat: NSLocalizedString("TIME_AMOUNT_DAYS",
comment: "{{number of days}} embedded in strings, e.g. 'Alice updated disappearing messages expiration to {{5 days}}'. See other *_TIME_AMOUNT strings")),
// Hours
Unit(secondsPerUnit: secondsPerHour,
singleUnitFormat: NSLocalizedString("TIME_AMOUNT_SINGLE_HOUR",
comment:
"{{1 hour}} embedded in strings, e.g. 'Alice updated disappearing messages expiration to {{1 hour}}'. See other *_TIME_AMOUNT strings"),
multipleUnitsFormat: NSLocalizedString("TIME_AMOUNT_HOURS",
comment: "{{number of hours}} embedded in strings, e.g. 'Alice updated disappearing messages expiration to {{5 hours}}'. See other *_TIME_AMOUNT strings")),
// Minutes
Unit(secondsPerUnit: secondsPerMinute,
singleUnitFormat: NSLocalizedString("TIME_AMOUNT_SINGLE_MINUTE",
comment: "{{1 minute}} embedded in strings, e.g. 'Alice updated disappearing messages expiration to {{1 minute}}'. See other *_TIME_AMOUNT strings"),
multipleUnitsFormat: NSLocalizedString("TIME_AMOUNT_MINUTES",
comment:
"{{number of minutes}} embedded in strings, e.g. 'Alice updated disappearing messages expiration to {{5 minutes}}'. See other *_TIME_AMOUNT strings")),
// Seconds
Unit(secondsPerUnit: 1,
singleUnitFormat: NSLocalizedString("TIME_AMOUNT_SINGLE_SECOND",
comment:
"{{1 second}} embedded in strings, e.g. 'Alice updated disappearing messages expiration to {{1 second}}'. See other *_TIME_AMOUNT strings"),
multipleUnitsFormat: NSLocalizedString("TIME_AMOUNT_SECONDS",
comment: "{{number of seconds}} embedded in strings, e.g. 'Alice updated disappearing messages expiration to {{5 seconds}}'. See other *_TIME_AMOUNT strings"))
]
var components = [String]()
var remainder = durationSeconds
for unit in units {
let secondsPerUnit = unit.secondsPerUnit
if remainder >= secondsPerUnit {
let units = remainder / secondsPerUnit
remainder -= units * secondsPerUnit
assert(units > 0)
let formattedUnits = String(format: "%ld", Int(units))
let format = (units > 1
? unit.multipleUnitsFormat
: unit.singleUnitFormat)
components.append(String(format: format, formattedUnits))
}
}
if components.isEmpty {
return NSLocalizedString("TIME_AMOUNT_ZERO_SECONDS",
comment: "Indicates that a duration of time is zero seconds. See other *_TIME_AMOUNT strings")
} else {
// TODO: Can we localize this join?
return components.joined(separator: ", ")
}
}
}