291 lines
11 KiB
Swift
291 lines
11 KiB
Swift
//
|
|
// Copyright 2021 Signal Messenger, LLC
|
|
// SPDX-License-Identifier: AGPL-3.0-only
|
|
//
|
|
|
|
import Foundation
|
|
public import GRDB
|
|
|
|
/// Model object for a badge. Only information for the badge itself, nothing user-specific (expirations, visibility, etc.)
|
|
public class ProfileBadge: NSObject, Codable {
|
|
public let id: String
|
|
public let category: Category
|
|
public let localizedName: String
|
|
public let localizedDescriptionFormatString: String
|
|
let resourcePath: String
|
|
|
|
let badgeVariant: BadgeVariant
|
|
let localization: String
|
|
|
|
public let duration: TimeInterval?
|
|
|
|
// Nil until a badge is checked in to the BadgeStore
|
|
public fileprivate(set) var assets: BadgeAssets?
|
|
|
|
private enum CodingKeys: String, CodingKey {
|
|
// Skip encoding of `assets`
|
|
case id
|
|
case category = "rawCategory"
|
|
case localizedName
|
|
case localizedDescriptionFormatString
|
|
case resourcePath
|
|
case badgeVariant
|
|
case localization
|
|
case duration
|
|
}
|
|
|
|
public init(jsonDictionary: [String: Any]) throws {
|
|
let params = ParamParser(dictionary: jsonDictionary)
|
|
|
|
id = try params.required(key: "id")
|
|
category = Category(rawValue: try params.required(key: "category"))
|
|
localizedName = try params.required(key: "name")
|
|
localizedDescriptionFormatString = try params.required(key: "description")
|
|
|
|
let preferredVariant = BadgeVariant.devicePreferred
|
|
let spriteArray: [String] = try params.required(key: "sprites6")
|
|
guard spriteArray.count == 6 else { throw OWSAssertionError("Invalid number of sprites") }
|
|
|
|
resourcePath = spriteArray[preferredVariant.sprite6Index]
|
|
badgeVariant = preferredVariant
|
|
|
|
// TODO: Badges — Check with server to see if they'll return a Content-language
|
|
// TODO: Badges — What about reordered languages? Maybe clear if any change?
|
|
localization = Locale.preferredLanguages[0]
|
|
|
|
duration = try params.optional(key: "duration")
|
|
}
|
|
|
|
required public init(from decoder: Decoder) throws {
|
|
let values = try decoder.container(keyedBy: CodingKeys.self)
|
|
id = try values.decode(String.self, forKey: .id)
|
|
category = try values.decode(Category.self, forKey: .category)
|
|
localizedName = try values.decode(String.self, forKey: .localizedName)
|
|
localizedDescriptionFormatString = try values.decode(String.self, forKey: .localizedDescriptionFormatString)
|
|
resourcePath = try values.decode(String.self, forKey: .resourcePath)
|
|
badgeVariant = try values.decode(BadgeVariant.self, forKey: .badgeVariant)
|
|
localization = try values.decode(String.self, forKey: .localization)
|
|
duration = try values.decodeIfPresent(TimeInterval.self, forKey: .duration)
|
|
}
|
|
|
|
override public func isEqual(_ object: Any?) -> Bool {
|
|
guard
|
|
let other = object as? Self,
|
|
type(of: self) == type(of: other)
|
|
else {
|
|
return false
|
|
}
|
|
|
|
return
|
|
id == other.id &&
|
|
category == other.category &&
|
|
localizedName == other.localizedName &&
|
|
localizedDescriptionFormatString == other.localizedDescriptionFormatString &&
|
|
resourcePath == other.resourcePath &&
|
|
badgeVariant == other.badgeVariant &&
|
|
localization == other.localization &&
|
|
duration == other.duration
|
|
// Don't check assets -- it's essentially a derived property that doesn't
|
|
// need to be included in equality checks.
|
|
}
|
|
}
|
|
|
|
// MARK: - ProfileBadge assets
|
|
|
|
extension ProfileBadge {
|
|
static let remoteAssetPrefix = URL(string: "https://updates2.signal.org/static/badges/")!
|
|
static let localAssetPrefix = URL(fileURLWithPath: "ProfileBadges", isDirectory: true, relativeTo: OWSFileSystem.appSharedDataDirectoryURL())
|
|
|
|
var remoteAssetUrl: URL {
|
|
let encoded = resourcePath.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? resourcePath
|
|
return Self.remoteAssetPrefix.appendingPathComponent(encoded)
|
|
}
|
|
var localAssetDir: URL {
|
|
let extensionIndex = resourcePath.firstIndex(of: ".") ?? resourcePath.endIndex
|
|
let trimmedPath = resourcePath.prefix(upTo: extensionIndex)
|
|
return Self.localAssetPrefix.appendingPathComponent(String(trimmedPath), isDirectory: true)
|
|
}
|
|
}
|
|
|
|
// MARK: - ProfileBadge enums
|
|
|
|
extension ProfileBadge {
|
|
/// Server defined category for the badge type
|
|
public enum Category: String, Codable {
|
|
case donor
|
|
case other
|
|
|
|
/// Creates a category from a raw string.
|
|
///
|
|
/// Unrecognized strings are converted to `.other`. This includes
|
|
/// `"testing"`, which can be returned by the server in staging.
|
|
public init(rawValue: String) {
|
|
switch rawValue.lowercased() {
|
|
case "donor": self = .donor
|
|
default: self = .other
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The badge image variant that the spritSheetUrl points to
|
|
/// Currently only used for device pixel scale
|
|
enum BadgeVariant: String, Codable {
|
|
case mdpi
|
|
case xhdpi
|
|
case xxhdpi
|
|
|
|
var intendedScale: Int {
|
|
switch self {
|
|
case .mdpi: return 1
|
|
case .xhdpi: return 2
|
|
case .xxhdpi: return 3
|
|
}
|
|
}
|
|
|
|
var sprite6Index: Int {
|
|
switch self {
|
|
case .mdpi: return 1
|
|
case .xhdpi: return 3
|
|
case .xxhdpi: return 4
|
|
}
|
|
}
|
|
|
|
static var devicePreferred: BadgeVariant {
|
|
// TODO: Badges — Is this safe from an app extension? I'm pretty sure it isn't, but I'm
|
|
// not seeing anything in the docs that indicates this is this case. Should double check this.
|
|
switch UIScreen.main.scale {
|
|
case 0..<1.5:
|
|
owsAssertDebug(UIScreen.main.scale == 1.0, "Unrecognized scale: \(UIScreen.main.scale)")
|
|
return .mdpi
|
|
case 1.5..<2.5:
|
|
owsAssertDebug(UIScreen.main.scale == 2.0, "Unrecognized scale: \(UIScreen.main.scale)")
|
|
return .xhdpi
|
|
case 2.5...:
|
|
owsAssertDebug(UIScreen.main.scale == 3.0, "Unrecognized scale: \(UIScreen.main.scale)")
|
|
return .xxhdpi
|
|
default:
|
|
owsFailDebug("Unrecognized scale: \(UIScreen.main.scale)")
|
|
return .xhdpi
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - ProfileBadge fake assets
|
|
|
|
#if TESTABLE_BUILD
|
|
extension ProfileBadge {
|
|
public func _testingOnly_populateAssets() {
|
|
assets = BadgeAssets(scale: badgeVariant.intendedScale,
|
|
remoteSourceUrl: remoteAssetUrl,
|
|
localAssetDirectory: localAssetDir)
|
|
}
|
|
}
|
|
#endif
|
|
|
|
// MARK: - ProfileBadge<PersistableRecord>
|
|
|
|
extension ProfileBadge: FetchableRecord, PersistableRecord {
|
|
public static let databaseTableName = "model_ProfileBadgeTable"
|
|
}
|
|
|
|
// MARK: - BadgeStore
|
|
|
|
@objc
|
|
public class BadgeStore: NSObject {
|
|
let lock = UnfairLock()
|
|
var badgeCache = LRUCache<String, ProfileBadge>(maxSize: 5)
|
|
// BadgeAssets have two roles: fetching assets we don't currently have and vending retrieved assets as UIImages
|
|
// They're a reference type, so we're fine aliasing the assets into multiple ProfileBadges
|
|
// We don't use an LRUCache since we don't want to clear out BadgeAssets that are mid-fetch and risk having
|
|
// two instances of this class trying to fetch assets at the same time.
|
|
var assetCache = [String: BadgeAssets]()
|
|
|
|
// TODO: Badging — Memory warnings?
|
|
|
|
func createOrUpdateBadge(_ newBadge: ProfileBadge, transaction writeTx: SDSAnyWriteTransaction) throws {
|
|
try lock.withLock {
|
|
// First, we check to see if we already have a cached badge that's equal to the new version
|
|
// If so, we can just update the assets property and return
|
|
if let cachedValue = badgeCache[newBadge.id], cachedValue == newBadge {
|
|
Logger.debug("Badge already up-to-date")
|
|
newBadge.assets = cachedValue.assets
|
|
return
|
|
}
|
|
|
|
// Something changed, so we need to update our database copy
|
|
try newBadge.save(writeTx.unwrapGrdbWrite.database)
|
|
|
|
// Finally we update our cached badge and start preparing our assets
|
|
let badgeAssets = getBadgetAssets(newBadge)
|
|
Task {
|
|
do {
|
|
try await badgeAssets.prepareAssetsIfNecessary()
|
|
} catch {
|
|
owsFailDebug("Failed to populate assets on badge \(error)")
|
|
}
|
|
}
|
|
|
|
owsAssertDebug(newBadge.assets != nil)
|
|
badgeCache[newBadge.id] = newBadge
|
|
}
|
|
}
|
|
|
|
func fetchBadgeWithId(_ badgeId: String, readTx: SDSAnyReadTransaction) -> ProfileBadge? {
|
|
do {
|
|
return try lock.withLock {
|
|
if let cachedBadge = badgeCache[badgeId] {
|
|
owsAssertDebug(cachedBadge.assets != nil)
|
|
return cachedBadge
|
|
} else if let fetchedBadge = try ProfileBadge.filter(key: badgeId).fetchOne(readTx.unwrapGrdbRead.database) {
|
|
let badgeAssets = getBadgetAssets(fetchedBadge)
|
|
Task {
|
|
do {
|
|
try await badgeAssets.prepareAssetsIfNecessary()
|
|
} catch {
|
|
owsFailDebug("Failed to populate assets on badge \(error)")
|
|
}
|
|
}
|
|
|
|
owsAssertDebug(fetchedBadge.assets != nil)
|
|
badgeCache[fetchedBadge.id] = fetchedBadge
|
|
return fetchedBadge
|
|
} else {
|
|
return nil
|
|
}
|
|
}
|
|
} catch {
|
|
owsFailDebug("Failed to fetch badge: \(error)")
|
|
return nil
|
|
}
|
|
}
|
|
|
|
private func getBadgetAssets(_ badge: ProfileBadge) -> BadgeAssets {
|
|
lock.assertOwner()
|
|
|
|
let badgeAssets: BadgeAssets
|
|
|
|
// We try and reuse any existing BadgeAssets instances if we have one cached
|
|
if let cachedValue = badgeCache[badge.id], cachedValue.resourcePath == badge.resourcePath, let assets = cachedValue.assets {
|
|
badgeAssets = assets
|
|
} else if let cachedAssets = assetCache[badge.resourcePath] {
|
|
badgeAssets = cachedAssets
|
|
} else {
|
|
badgeAssets = BadgeAssets(
|
|
scale: badge.badgeVariant.intendedScale,
|
|
remoteSourceUrl: badge.remoteAssetUrl,
|
|
localAssetDirectory: badge.localAssetDir)
|
|
assetCache[badge.resourcePath] = badgeAssets
|
|
}
|
|
badge.assets = badgeAssets
|
|
|
|
return badgeAssets
|
|
}
|
|
|
|
public func populateAssetsOnBadge(_ badge: ProfileBadge) async throws {
|
|
let badgeAssets = lock.withLock {
|
|
return getBadgetAssets(badge)
|
|
}
|
|
try await badgeAssets.prepareAssetsIfNecessary()
|
|
}
|
|
}
|