Use LRU to cache the Avatar shown in push notifications (#7124)

* iOS switch from file cache to memory cache and use last_picture_update to update the avatar if needed

* Android switch from file cache to memory cache and use last_picture_update to update the avatar if needed, split function to multiple files and catch potential exceptions
This commit is contained in:
Elias Nahum
2023-02-15 11:19:31 +02:00
committed by GitHub
parent ab5084ce48
commit 23cbf82353
40 changed files with 1919 additions and 1042 deletions

View File

@@ -0,0 +1,13 @@
import Foundation
extension ImageCache {
func image(for userId: String, updatedAt: Double, onServer serverUrl: String) -> Data? {
lock.lock(); defer { lock.unlock() }
let key = "\(serverUrl)-\(userId)-\(updatedAt)" as NSString
if let image = imageCache.object(forKey: key) as? Data {
return image
}
return nil
}
}

View File

@@ -0,0 +1,29 @@
import Foundation
extension ImageCache {
public func removeAllImages() {
imageCache.removeAllObjects()
keysCache.removeAllObjects()
}
func insertImage(_ data: Data?, for userId: String, updatedAt: Double, onServer serverUrl: String ) {
guard let data = data else {
return removeImage(for: userId, onServer: serverUrl)
}
lock.lock(); defer { lock.unlock() }
let cacheKey = "\(serverUrl)-\(userId)" as NSString
let imageKey = "\(cacheKey)-\(updatedAt)" as NSString
imageCache.setObject(NSData(data: data), forKey: imageKey as NSString, cost: data.count)
keysCache.setObject(imageKey, forKey: cacheKey)
}
func removeImage(for userId: String, onServer serverUrl: String) {
lock.lock(); defer { lock.unlock() }
let cacheKey = "\(serverUrl)-\(userId)" as NSString
if let key = keysCache.object(forKey: cacheKey) {
keysCache.removeObject(forKey: cacheKey)
imageCache.removeObject(forKey: key)
}
}
}

View File

@@ -0,0 +1,31 @@
import Foundation
public final class ImageCache: ImageCacheType {
public static let `default` = ImageCache()
struct Config {
let countLimit: Int
let memoryLimit: Int
static let defaultConfig = Config(countLimit: 50, memoryLimit: 1024 * 1024 * 50)
}
lazy var imageCache: NSCache<NSString, NSData> = {
let cache = NSCache<NSString, NSData>()
cache.countLimit = config.countLimit
cache.totalCostLimit = config.memoryLimit
return cache
}()
lazy var keysCache: NSCache<NSString, NSString> = {
let cache = NSCache<NSString, NSString>()
cache.countLimit = config.countLimit
return cache
}()
let lock = NSLock()
let config: Config
private init(config: Config = Config.defaultConfig) {
self.config = config
}
}

View File

@@ -0,0 +1,11 @@
import Foundation
protocol ImageCacheType: class {
func image(for userId: String, updatedAt: Double, onServer serverUrl: String) -> Data?
func insertImage(_ data: Data?, for userId: String, updatedAt: Double, onServer serverUrl: String )
func removeImage(for userId: String, onServer serverUrl: String)
func removeAllImages()
}

View File

@@ -24,8 +24,8 @@ extension Network {
return request(url, withMethod: "POST", withBody: data, withHeaders: nil, withServerUrl: serverUrl, completionHandler: completionHandler)
}
public func fetchUserProfilePicture(userId: String, withServerUrl serverUrl: String, completionHandler: @escaping ResponseHandler) {
let endpoint = "/users/\(userId)/image"
public func fetchUserProfilePicture(userId: String, lastUpdateAt: Double, withServerUrl serverUrl: String, completionHandler: @escaping ResponseHandler) {
let endpoint = "/users/\(userId)/image?lastPictureUpdate=\(lastUpdateAt)"
let url = buildApiUrl(serverUrl, endpoint)
return request(url, withMethod: "GET", withServerUrl: serverUrl, completionHandler: completionHandler)

View File

@@ -98,10 +98,11 @@ extension Network {
}
public func fetchProfileImageSync(_ serverUrl: String, senderId: String, overrideIconUrl: String?, completionHandler: @escaping (_ data: Data?) -> Void) {
var updatedAt: Double = 0
func processResponse(data: Data?, response: URLResponse?, error: Error?) {
if let httpResponse = response as? HTTPURLResponse {
if (httpResponse.statusCode == 200 && error == nil) {
FileCache.default.saveProfileImage(serverUrl: serverUrl, userId: senderId, imageData: data)
ImageCache.default.insertImage(data, for: senderId, updatedAt: updatedAt, onServer: serverUrl)
completionHandler(data)
} else {
os_log(
@@ -118,12 +119,16 @@ extension Network {
let url = URL(string: overrideUrl) {
request(url, withMethod: "GET", withServerUrl: "", completionHandler: processResponse)
} else {
if let image = FileCache.default.getProfileImage(serverUrl: serverUrl, userId: senderId) {
if let lastUpdateAt = Database.default.getUserLastPictureAt(for: senderId, withServerUrl: serverUrl) {
updatedAt = lastUpdateAt
}
if let image = ImageCache.default.image(for: senderId, updatedAt: updatedAt, onServer: serverUrl) {
os_log(OSLogType.default, "Mattermost Notifications: cached image")
completionHandler(image.pngData())
completionHandler(image)
} else {
ImageCache.default.removeImage(for: senderId, onServer: serverUrl)
os_log(OSLogType.default, "Mattermost Notifications: image not cached")
fetchUserProfilePicture(userId: senderId, withServerUrl: serverUrl, completionHandler: processResponse)
fetchUserProfilePicture(userId: senderId, lastUpdateAt: updatedAt, withServerUrl: serverUrl, completionHandler: processResponse)
}
}
}

View File

@@ -12,14 +12,14 @@ import SQLite
public struct User: Codable, Hashable {
let id: String
let auth_service: String
let update_at: Int64
let delete_at: Int64
let update_at: Double
let delete_at: Double
let email: String
let first_name: String
let is_bot: Bool
let is_guest: Bool
let last_name: String
let last_picture_update: Int64
let last_picture_update: Double
let locale: String
let nickname: String
let position: String
@@ -54,36 +54,36 @@ public struct User: Codable, Hashable {
let container = try decoder.container(keyedBy: UserKeys.self)
id = try container.decode(String.self, forKey: .id)
auth_service = try container.decode(String.self, forKey: .auth_service)
update_at = try container.decode(Int64.self, forKey: .update_at)
delete_at = try container.decode(Int64.self, forKey: .delete_at)
update_at = (try? container.decodeIfPresent(Double.self, forKey: .update_at)) ?? 0
delete_at = (try? container.decodeIfPresent(Double.self, forKey: .delete_at)) ?? 0
email = try container.decode(String.self, forKey: .email)
first_name = try container.decode(String.self, forKey: .first_name)
is_bot = container.contains(.is_bot) ? try container.decode(Bool.self, forKey: .is_bot) : false
roles = try container.decode(String.self, forKey: .roles)
is_guest = roles.contains("system_guest")
last_name = try container.decode(String.self, forKey: .last_name)
last_picture_update = try container.decodeIfPresent(Int64.self, forKey: .last_picture_update) ?? 0
last_picture_update = (try? container.decodeIfPresent(Double.self, forKey: .last_picture_update)) ?? 0
locale = try container.decode(String.self, forKey: .locale)
nickname = try container.decode(String.self, forKey: .nickname)
position = try container.decode(String.self, forKey: .position)
status = "offline"
username = try container.decode(String.self, forKey: .username)
let notifyPropsData = try container.decodeIfPresent([String: String].self, forKey: .notify_props)
let notifyPropsData = try? container.decodeIfPresent([String: String].self, forKey: .notify_props)
if (notifyPropsData != nil) {
notify_props = Database.default.json(from: notifyPropsData) ?? "{}"
} else {
notify_props = "{}"
}
let propsData = try container.decodeIfPresent([String: String].self, forKey: .props)
let propsData = try? container.decodeIfPresent([String: String].self, forKey: .props)
if (propsData != nil) {
props = Database.default.json(from: propsData) ?? "{}"
} else {
props = "{}"
}
let timezoneData = try container.decodeIfPresent([String: String].self, forKey: .timezone)
let timezoneData = try? container.decodeIfPresent([String: String].self, forKey: .timezone)
if (timezoneData != nil) {
timezone = Database.default.json(from: timezoneData) ?? "{}"
} else {
@@ -119,6 +119,24 @@ extension Database {
throw DatabaseError.NoResults(query.expression.description)
}
public func getUserLastPictureAt(for userId: String, withServerUrl serverUrl: String) -> Double? {
let idCol = Expression<String>("id")
var updateAt: Double?
do {
let db = try getDatabaseForServer(serverUrl)
let stmtString = "SELECT * FROM User WHERE id='\(userId)'"
let results: [User] = try db.prepareRowIterator(stmtString).map {try $0.decode()}
updateAt = results.first?.last_picture_update
} catch {
return nil
}
return updateAt
}
public func queryUsers(byIds: Set<String>, withServerUrl: String) throws -> Set<String> {
let db = try getDatabaseForServer(withServerUrl)
@@ -180,14 +198,14 @@ extension Database {
var setter = [Setter]()
setter.append(id <- user.id)
setter.append(authService <- user.auth_service)
setter.append(updateAt <- user.update_at)
setter.append(deleteAt <- user.delete_at)
setter.append(updateAt <- Int64(user.update_at))
setter.append(deleteAt <- Int64(user.delete_at))
setter.append(email <- user.email)
setter.append(firstName <- user.first_name)
setter.append(isBot <- user.is_bot)
setter.append(isGuest <- user.is_guest)
setter.append(lastName <- user.last_name)
setter.append(lastPictureUpdate <- user.last_picture_update)
setter.append(lastPictureUpdate <- Int64(user.last_picture_update))
setter.append(locale <- user.locale)
setter.append(nickname <- user.nickname)
setter.append(position <- user.position)

View File

@@ -91,7 +91,7 @@ class ShareViewModel: ObservableObject {
return
}
Gekidou.Network.default.fetchUserProfilePicture(userId: userId, withServerUrl: serverUrl, completionHandler: {data, response, error in
Gekidou.Network.default.fetchUserProfilePicture(userId: userId, lastUpdateAt: 0, withServerUrl: serverUrl, completionHandler: {data, response, error in
guard (response as? HTTPURLResponse)?.statusCode == 200 else {
debugPrint("Error while fetching image \(String(describing: (response as? HTTPURLResponse)?.statusCode))")
return

View File

@@ -89,9 +89,11 @@ class NotificationService: UNNotificationServiceExtension {
let isCRTEnabled = notification.userInfo["is_crt_enabled"] as? Bool ?? false
let rootId = notification.userInfo["root_id"] as? String ?? ""
let senderId = notification.userInfo["sender_id"] as? String ?? ""
let channelName = notification.userInfo["channel_name"] as? String ?? ""
let message = (notification.userInfo["message"] as? String ?? "")
let overrideUsername = notification.userInfo["override_username"] as? String
let senderId = notification.userInfo["sender_id"] as? String
let senderIdentifier = overrideUsername ?? senderId
let avatar = INImage(imageData: imgData) as INImage?
var conversationId = channelId
@@ -99,7 +101,7 @@ class NotificationService: UNNotificationServiceExtension {
conversationId = rootId
}
let handle = INPersonHandle(value: senderId, type: .unknown)
let handle = INPersonHandle(value: senderIdentifier, type: .unknown)
let sender = INPerson(personHandle: handle,
nameComponents: nil,
displayName: channelName,
@@ -140,18 +142,22 @@ class NotificationService: UNNotificationServiceExtension {
func sendMessageIntent(notification: UNNotificationContent) {
if #available(iOSApplicationExtension 15.0, *) {
let overrideUsername = notification.userInfo["override_username"] as? String
let senderId = notification.userInfo["sender_id"] as? String
let sender = overrideUsername ?? senderId
guard let serverUrl = notification.userInfo["server_url"] as? String,
let senderId = notification.userInfo["sender_id"] as? String
let sender = sender
else {
os_log(OSLogType.default, "Mattermost Notifications: No intent created. will call contentHandler to present notification")
self.contentHandler?(notification)
return
}
os_log(OSLogType.default, "Mattermost Notifications: Fetching profile Image in server %{public}@ for sender %{public}@", serverUrl, senderId)
let overrideIconUrl = notification.userInfo["override_icon_url"] as? String
os_log(OSLogType.default, "Mattermost Notifications: Fetching profile Image in server %{public}@ for sender %{public}@", serverUrl, sender)
Network.default.fetchProfileImageSync(serverUrl, senderId: senderId, overrideIconUrl: overrideIconUrl) {[weak self] data in
Network.default.fetchProfileImageSync(serverUrl, senderId: sender, overrideIconUrl: overrideIconUrl) {[weak self] data in
self?.sendMessageIntentCompletion(notification, data)
}
}