Compare commits

...

4 Commits

Author SHA1 Message Date
Daniel D’Aquino c6d9e0b3c9 Fix GIF uploads
This commit fixes GIF uploads and improves GIF support:
- MediaPicker will now skip location data removal processing, as it is not needed on GIF images and causes them to be converted to JPEG images
- The uploader now sets more accurate MIME types on the upload request

Issue Repro
-----------

Device: iPhone 13 Mini
iOS: 17.4.1
Damus: `ada99418f6fcdb1354bc5c1c3f3cc3b4db994ce6`
Steps:
1. Download a GIF from GIPHY to the iOS photo gallery
2. Upload that and attach into a post in Damus
3. Check if GIF is animated.
Results: GIF is not animated. Issue is reproduced.

Testing
-------

PASS

Device: iPhone 13 Mini
iOS: 17.4.1
Damus: this commit
Steps:
1. Create a new post
2. Upload the same GIF as the repro and post
3. Make sure GIF is animated. PASS
4. Create a new post
5. Upload a new GIF image (that has never been uploaded by the user on the app) and post
6. Make sure the GIF is animated on the post. PASS
7. Make sure that JPEGs can still be successfully uploaded. PASS
8. Make sure that MP4s can be uploaded.
9. Make a new post that contains 1 JPEG, 1 MP4 file, and 2 GIF files. Make sure they are all uploaded correctly and all GIF files are animated. PASS

Closes: https://github.com/damus-io/damus/issues/2157
Changelog-Fixed: Fix broken GIF uploads
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
Reviewed-by: William Casarin <jb55@jb55.com>
2024-05-01 10:27:05 -07:00
Daniel D’Aquino 6d5a152c17 Fix Ghost notifications from Damus Purple Impending expiration
This commit fixes the "ghost notifications" experienced by Purple users
whose membership has expired (or about to expire).

It does that by using a similar mechanism as other notifications to keep
track of the last event date seen on the notifications tab in a
persistent way.

Testing
--------

iOS: 17.4
Device: iPhone 15 simulator
damus-api: bfe6c4240a0b3729724162896f0024a963586f7c
Damus: This commit
Setup:
1. Local Purple server
2. Damus running on local testing mode for Purple
3. An existing but expired Purple account (on the local server)

Steps:
1. Reopen app after pointing to the new server and setting things up.
2. Check that the bell icon shows there is a new notification. PASS
3. Check that purple expiration notifications are visible. PASS
4. Restart app.
5. Check the bell icon. This time there should be no new notifications. PASS
6. Using another account, engage with the primary test account to cause a new notification to appear.
7. After a second or two, the bell icon should indicate there is a new notification from the other user. PASS
8. Switch out and into the app. Check that the bell icon does not indicate any new notifications. PASS
9. Restart the app again. The bell icon should once again NOT indicate any new notifications. PASS

Changelog-Fixed: Fix ghost notifications caused by Purple impending expiration notifications
Closes: https://github.com/damus-io/damus/issues/2158
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
Reviewed-by: William Casarin <jb55@jb55.com>
2024-04-29 17:04:06 -07:00
William Casarin 76529f69d0 Revert "Cache videos"
This reverts commit 26d2627a1c.
2024-04-25 14:56:49 -07:00
William Casarin 052ea9b727 Revert "Custom video loader caching technique"
This reverts commit ba494f94ab.
2024-04-25 14:56:29 -07:00
12 changed files with 80 additions and 435 deletions

View File

@ -487,7 +487,6 @@
D76556D62B1E6C08001B0CCC /* DamusPurpleWelcomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D76556D52B1E6C08001B0CCC /* DamusPurpleWelcomeView.swift */; };
D76874F32AE3632B00FB0F68 /* ProfileZapLinkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D76874F22AE3632B00FB0F68 /* ProfileZapLinkView.swift */; };
D77BFA0B2AE3051200621634 /* ProfileActionSheetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D77BFA0A2AE3051200621634 /* ProfileActionSheetView.swift */; };
D7831AF82BBE11E2005DA780 /* VideoCacheTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7831AF72BBE11E2005DA780 /* VideoCacheTests.swift */; };
D783A63F2AD4E53D00658DDA /* SuggestedHashtagsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D783A63E2AD4E53D00658DDA /* SuggestedHashtagsView.swift */; };
D78525252A7B2EA4002FA637 /* NoteContentViewTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D78525242A7B2EA4002FA637 /* NoteContentViewTests.swift */; };
D7870BC12AC4750B0080BA88 /* MentionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7870BC02AC4750B0080BA88 /* MentionView.swift */; };
@ -518,7 +517,6 @@
D7ADD3DE2B53854300F104C4 /* DamusPurpleURL.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7ADD3DD2B53854300F104C4 /* DamusPurpleURL.swift */; };
D7ADD3E02B538D4200F104C4 /* DamusPurpleURLSheetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7ADD3DF2B538D4200F104C4 /* DamusPurpleURLSheetView.swift */; };
D7ADD3E22B538E3500F104C4 /* DamusPurpleVerifyNpubView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7ADD3E12B538E3500F104C4 /* DamusPurpleVerifyNpubView.swift */; };
D7C28E3B2BBB4D0000EE459F /* VideoCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7C28E3A2BBB4D0000EE459F /* VideoCache.swift */; };
D7C6787E2B2D34CC00BCEAFB /* NIP98AuthenticatedRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7C6787D2B2D34CC00BCEAFB /* NIP98AuthenticatedRequest.swift */; };
D7CB5D3B2B112FBB00AD4105 /* NotificationFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = D70A3B162B02DCE5008BD568 /* NotificationFormatter.swift */; };
D7CB5D3C2B1130C600AD4105 /* LocalNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CDA128B29EB19C40006FA5A /* LocalNotification.swift */; };
@ -1401,7 +1399,6 @@
D76556D52B1E6C08001B0CCC /* DamusPurpleWelcomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DamusPurpleWelcomeView.swift; sourceTree = "<group>"; };
D76874F22AE3632B00FB0F68 /* ProfileZapLinkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileZapLinkView.swift; sourceTree = "<group>"; };
D77BFA0A2AE3051200621634 /* ProfileActionSheetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileActionSheetView.swift; sourceTree = "<group>"; };
D7831AF72BBE11E2005DA780 /* VideoCacheTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoCacheTests.swift; sourceTree = "<group>"; };
D783A63E2AD4E53D00658DDA /* SuggestedHashtagsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestedHashtagsView.swift; sourceTree = "<group>"; };
D78525242A7B2EA4002FA637 /* NoteContentViewTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoteContentViewTests.swift; sourceTree = "<group>"; };
D7870BC02AC4750B0080BA88 /* MentionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MentionView.swift; sourceTree = "<group>"; };
@ -1417,7 +1414,6 @@
D7ADD3DD2B53854300F104C4 /* DamusPurpleURL.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DamusPurpleURL.swift; sourceTree = "<group>"; };
D7ADD3DF2B538D4200F104C4 /* DamusPurpleURLSheetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DamusPurpleURLSheetView.swift; sourceTree = "<group>"; };
D7ADD3E12B538E3500F104C4 /* DamusPurpleVerifyNpubView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DamusPurpleVerifyNpubView.swift; sourceTree = "<group>"; };
D7C28E3A2BBB4D0000EE459F /* VideoCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoCache.swift; sourceTree = "<group>"; };
D7C6787D2B2D34CC00BCEAFB /* NIP98AuthenticatedRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NIP98AuthenticatedRequest.swift; sourceTree = "<group>"; };
D7CB5D3D2B116DAD00AD4105 /* NotificationsManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationsManager.swift; sourceTree = "<group>"; };
D7CB5D442B116FE800AD4105 /* Contacts+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Contacts+.swift"; sourceTree = "<group>"; };
@ -1653,7 +1649,6 @@
D74AAFC12B153395006CF0F4 /* HeadlessDamusState.swift */,
B5C60C1F2B530D5100C5ECA7 /* MuteItem.swift */,
B533694D2B66D791008A805E /* MutelistManager.swift */,
D7C28E3A2BBB4D0000EE459F /* VideoCache.swift */,
);
path = Models;
sourceTree = "<group>";
@ -3290,7 +3285,6 @@
4C1253522A76C6130004F4B8 /* ComposeNotify.swift in Sources */,
4C7D09662A0AE62100943473 /* AlbyButton.swift in Sources */,
D7100C582B76FC8400C59298 /* MarketingContentView.swift in Sources */,
D7C28E3B2BBB4D0000EE459F /* VideoCache.swift in Sources */,
4CAAD8AD298851D000060CEA /* AccountDeletion.swift in Sources */,
4CFF8F6329CC9AD7008DB934 /* ImageContextMenuModifier.swift in Sources */,
4C54AA0A29A55429003E4487 /* EventGroup.swift in Sources */,
@ -3547,7 +3541,6 @@
75AD872B2AA23A460085EF2C /* Block+Tests.swift in Sources */,
E0E024112B7C19C20075735D /* TranslationTests.swift in Sources */,
F944F56E29EA9CCC0067B3BF /* DamusParseContentTests.swift in Sources */,
D7831AF82BBE11E2005DA780 /* VideoCacheTests.swift in Sources */,
3A5E47C72A4A76C800C0D090 /* TrieTests.swift in Sources */,
B501062D2B363036003874F5 /* AuthIntegrationTests.swift in Sources */,
4CB883AE2976FA9300DC99E7 /* FormatTests.swift in Sources */,

View File

@ -516,7 +516,6 @@ struct ContentView: View {
print("txn: 📙 DAMUS BACKGROUNDED")
Task { @MainActor in
damus_state.ndb.close()
VideoCache.standard?.periodic_purge()
}
break
case .inactive:
@ -833,6 +832,12 @@ func save_last_event(_ ev: NostrEvent, timeline: Timeline) {
UserDefaults.standard.set(String(ev.created_at), forKey: "last_\(str)_time")
}
func save_last_event(_ ev_id: NoteId, created_at: UInt32, timeline: Timeline) {
let str = timeline.rawValue
UserDefaults.standard.set(ev_id.hex(), forKey: "last_\(str)")
UserDefaults.standard.set(String(created_at), forKey: "last_\(str)_time")
}
func update_filters_with_since(last_of_kind: [UInt32: NostrEvent], filters: [NostrFilter]) -> [NostrFilter] {
return filters.map { filter in

View File

@ -309,9 +309,14 @@ class HomeModel: ContactsDelegate {
@MainActor
func handle_damus_app_notification(_ notification: DamusAppNotification) async {
if self.notifications.insert_app_notification(notification: notification) {
// If we successfully inserted a new Damus App notification, switch ON the Damus App notification bit on our NewsEventsBits
// This will cause the bell icon on the tab bar to display the purple dot indicating there is an unread notification
self.notification_status.new_events = NewEventsBits(rawValue: self.notification_status.new_events.rawValue | NewEventsBits.damus_app_notifications.rawValue)
let last_notification = get_last_event(.notifications)
if last_notification == nil || last_notification!.created_at < notification.last_event_at {
save_last_event(NoteId.empty, created_at: notification.last_event_at, timeline: .notifications)
// If we successfully inserted a new Damus App notification, switch ON the Damus App notification bit on our NewsEventsBits
// This will cause the bell icon on the tab bar to display the purple dot indicating there is an unread notification
self.notification_status.new_events = NewEventsBits(rawValue: self.notification_status.new_events.rawValue | NewEventsBits.damus_app_notifications.rawValue)
}
return
}
}

View File

@ -49,6 +49,32 @@ enum MediaUpload {
return false
}
var mime_type: String {
switch self.file_extension {
case "jpg", "jpeg":
return "image/jpg"
case "png":
return "image/png"
case "gif":
return "image/gif"
case "tiff", "tif":
return "image/tiff"
case "mp4":
return "video/mp4"
case "ogg":
return "video/ogg"
case "webm":
return "video/webm"
default:
switch self {
case .image:
return "image/jpg"
case .video:
return "video/mp4"
}
}
}
}
class ImageUploadModel: NSObject, URLSessionTaskDelegate, ObservableObject {

View File

@ -6,7 +6,6 @@
//
import Foundation
import CryptoKit
import AVKit
// Default expiry time of only 1 day to prevent using too much storage
fileprivate let DEFAULT_EXPIRY_TIME: TimeInterval = 60*60*24
@ -17,18 +16,12 @@ fileprivate let DEFAULT_CACHE_DIRECTORY_PATH: URL? = FileManager.default.urls(fo
struct VideoCache {
private let cache_url: URL
private let expiry_time: TimeInterval
private var loader_queue: DispatchQueue
static var standard: VideoCache? = try? VideoCache()
static let standard: VideoCache? = try? VideoCache()
init?(cache_url: URL? = nil, expiry_time: TimeInterval = DEFAULT_EXPIRY_TIME) throws {
guard let cache_url_to_apply = cache_url ?? DEFAULT_CACHE_DIRECTORY_PATH else { return nil }
self.cache_url = cache_url_to_apply
self.expiry_time = expiry_time
self.loader_queue = DispatchQueue.init(
label: "com.damus.video_loader",
qos: .utility,
attributes: []
)
// Create the cache directory if it doesn't exist
do {
@ -50,9 +43,18 @@ struct VideoCache {
// Video is not expired
return cached_url
} else {
Task {
// Video is expired, delete and re-download on the background
try FileManager.default.removeItem(at: cached_url)
return try await download_and_cache_video(from: video_url)
}
return video_url
}
} else {
Task {
// Video is not cached, download and cache on the background
return try await download_and_cache_video(from: video_url)
}
return video_url
}
}
@ -71,25 +73,6 @@ struct VideoCache {
try data.write(to: destination_url)
return destination_url
}
/// Returns an asset that may be cached (or not)
/// - Parameter video_url: The video URL to load
/// - Returns: An AVAsset + loader delegate wrapped together. The AVAsset can be used with AVPlayer. The loader delegate does not need to be used. Just keep it around to avoid it from being garbage collected
mutating func maybe_cached_asset_for(video_url: URL) throws -> MaybeCachedAVAsset? {
let maybe_cached_url = try self.maybe_cached_url_for(video_url: video_url)
if maybe_cached_url.isFileURL {
// We have this video cached. Return the cached asset
return MaybeCachedAVAsset(av_asset: AVAsset(url: maybe_cached_url), loader: nil)
}
// If we get here, we do not have the video cached yet.
// Load the video asset using our custom loader delegate, which will give us control over how video data is loaded, and allows us to cache it
guard let loader_delegate = LoaderDelegate(url: video_url, video_cache: self) else { return nil }
let video_asset = AVURLAsset(url: loader_delegate.streaming_url) // Get the modified URL that forces the AVAsset to use our loader delegate
video_asset.resourceLoader.setDelegate(loader_delegate, queue: self.loader_queue)
// Return the video asset to the player who is requesting this. Loading and caching will take place as AVPlayer makes loading requests
return MaybeCachedAVAsset(av_asset: video_asset, loader: loader_delegate)
}
func url_to_cached_url(url: URL) -> URL {
let hashed_url = hash_url(url)
@ -122,275 +105,10 @@ struct VideoCache {
}
}
/// Caches a video to storage with a given data
func save(data video_data: Data, for video_url: URL) throws {
if video_url.isFileURL {
return
}
Log.info("Caching video for: %s", for: .storage, video_url.absoluteString)
let cache_destination_url: URL = self.url_to_cached_url(url: video_url)
if FileManager.default.fileExists(atPath: cache_destination_url.path) {
try FileManager.default.removeItem(at: cache_destination_url)
}
try video_data.write(to: cache_destination_url)
}
/// Hashes the URL using SHA-256
private func hash_url(_ url: URL) -> String {
let data = Data(url.absoluteString.utf8)
let hashed_data = SHA256.hash(data: data)
return hashed_data.compactMap { String(format: "%02x", $0) }.joined()
}
struct MaybeCachedAVAsset {
let av_asset: AVAsset
let loader: LoaderDelegate?
}
// MARK: - Resource loader delegate
/// This handles the nitty gritty of loading data for a particular video for the AVPlayer, and saves up that data to the cache.
class LoaderDelegate: NSObject, AVAssetResourceLoaderDelegate, URLSessionDataDelegate, URLSessionTaskDelegate {
// MARK: Constants
static let protocol_suffix = "cache"
// MARK: Stored properties
/// The video cache to use when saving data
let cache: VideoCache
/// Video URL to be loaded
let url: URL
/// The URL to be used as a parameter to AVURLAsset, which forces it to use our delegate for data loading
let streaming_url: URL
/// The data loading requests we must fulfill
private var loading_requests = [AVAssetResourceLoadingRequest]()
/// The URL session we will use for handling video data loading
var url_session: URLSession? = nil
/// The video download task
var loading_task: URLSessionDataTask? = nil
/// The latest information response we received whilst downloading the video
var latest_info_response: URLResponse?
/// All of the video data we got so far from the download
var downloaded_video_data = Data()
/// Whether the download is successfully completed
var download_completed: Bool = false
/// Semaphore to avoid race conditions
let semaphore = DispatchSemaphore(value: 1)
// MARK: Initializer
init?(url: URL, video_cache: VideoCache) {
self.cache = video_cache
self.url = url
guard let streaming_url = Self.streaming_url(from: url) else { return nil }
self.streaming_url = streaming_url
}
// MARK: AVAssetResourceLoaderDelegate protocol implementation
// This allows us to handle the data loading for the AVPlayer
// This is called when our AVPlayer wants to load some video data. Here we need to do two things:
// - just respond whether or not we can handle the request
// - Queue up the load request so that we can work on it on the background
func resourceLoader(_ resourceLoader: AVAssetResourceLoader, shouldWaitForLoadingOfRequestedResource loadingRequest: AVAssetResourceLoadingRequest) -> Bool {
Log.debug("Receiving load request for: %s", for: .storage, self.url.absoluteString)
// Use semaphore to avoid race condition
semaphore.wait()
defer { semaphore.signal() } // Use defer to avoid forgetting to signal and causing deadlocks
self.start_downloading_video_if_not_already() // Start downloading data if we have not started
self.loading_requests.append(loadingRequest) // Add this loading request to our queue
return true // Yes Mr. AVPlayer, we can handle this loading request for you.
}
// This is called when our AVPlayer wants to cancel a loading request.
func resourceLoader(_ resourceLoader: AVAssetResourceLoader, didCancel loadingRequest: AVAssetResourceLoadingRequest) {
Log.debug("Receiving load request cancellation for: %s", for: .storage, self.url.absoluteString)
// Use semaphore to avoid race condition
semaphore.wait()
defer { semaphore.signal() } // Use defer to avoid forgetting to signal and causing deadlocks
self.remove(loading_request: loadingRequest)
// Pause downloading if we have no loading requests from our AVPlayer
if loading_requests.isEmpty {
loading_task?.suspend()
}
}
// MARK: URLSessionDataDelegate
// This helps us receive updates from our URL download session as we download the video
// This enables us to progressively serve AV loading requests we have on our queue
// Our URLSession (which is downloading the video) will call this function when we receive a URL response
func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive response: URLResponse, completionHandler: @escaping (URLSession.ResponseDisposition) -> Void) {
Log.debug("Receiving URL response for: %s", for: .storage, self.url.absoluteString)
// Use semaphore to avoid race condition
semaphore.wait()
defer { semaphore.signal() } // Use defer to avoid forgetting to signal and causing deadlocks
self.latest_info_response = response
self.process_loading_requests()
completionHandler(.allow)
}
// Our URLSession (which is downloading the video) will call this function when we receive some video data
func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) {
Log.debug("Receiving data (%d bytes) for: %s", for: .storage, data.count, self.url.absoluteString)
// Use semaphore to avoid race condition
semaphore.wait()
defer { semaphore.signal() } // Use defer to avoid forgetting to signal and causing deadlocks
self.downloaded_video_data.append(data)
self.process_loading_requests()
}
// MARK: Internal methods
// Were we do some heavy lifting
/// Goes through the loading requests we received from the AVPlayer and respond to them if we can. This is called when we get updates from our download operation.
private func process_loading_requests() {
Log.debug("Processing loading requests for: %s", for: .storage, self.url.absoluteString)
var served_loading_requests = 0
for loading_request in loading_requests {
if loading_request.isCancelled {
self.remove(loading_request: loading_request)
}
if let content_info_request = loading_request.contentInformationRequest,
let latest_info_response {
self.respond(to: content_info_request, with: latest_info_response)
}
if let data_request = loading_request.dataRequest, self.respond_if_possible(to: data_request) == true {
served_loading_requests += 1
loading_request.finishLoading()
self.remove(loading_request: loading_request)
}
}
Log.debug("Served %d loading requests for: %s", for: .storage, served_loading_requests, self.url.absoluteString)
}
private func respond(to info_request: AVAssetResourceLoadingContentInformationRequest, with response: URLResponse) {
info_request.isByteRangeAccessSupported = true
info_request.contentType = response.mimeType
info_request.contentLength = response.expectedContentLength
}
private func respond_if_possible(to data_request: AVAssetResourceLoadingDataRequest) -> Bool {
let bytes_downloaded = Int64(self.downloaded_video_data.count)
let bytes_requested = Int64(data_request.requestedLength)
if bytes_downloaded < data_request.currentOffset {
return false // We do not have enough bytes to respond to this request
}
let bytes_downloaded_but_unread = bytes_downloaded - data_request.currentOffset
let bytes_requested_and_unread = data_request.requestedOffset + bytes_requested - data_request.currentOffset
let bytes_to_respond = min(bytes_requested_and_unread, bytes_downloaded_but_unread)
guard let byte_range = Range(NSMakeRange(Int(data_request.currentOffset), Int(bytes_to_respond))) else { return false }
data_request.respond(with: self.downloaded_video_data.subdata(in: byte_range))
let request_end_offset = data_request.requestedOffset + bytes_requested
return data_request.currentOffset >= request_end_offset
}
private func start_downloading_video_if_not_already() {
if self.download_completed {
Log.info("Already downloaded video data for: %s. Won't start downloading again", for: .storage, self.url.absoluteString)
return
}
if self.url_session == nil {
self.downloaded_video_data = Data() // We are starting from scratch, so make sure we don't add corrupt data to the mix
let new_url_session = self.create_url_session()
let loading_task = new_url_session.dataTask(with: self.url)
loading_task.resume()
Log.info("Started downloading video data for: %s", for: .storage, self.url.absoluteString)
self.url_session = new_url_session
self.loading_task = loading_task
}
}
// MARK: URLSessionTaskDelegate
// Called when we are finished downloading the video
func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
// Use semaphore to avoid race condition
semaphore.wait()
defer { semaphore.signal() } // Use defer to avoid forgetting to signal and causing deadlocks
if let error {
Log.info("Error on downloading '%s'. Error: %s", for: .storage, self.url.absoluteString, error.localizedDescription)
self.download_completed = false
self.url_session?.invalidateAndCancel()
self.url_session = nil
self.loading_task = nil
self.start_downloading_video_if_not_already()
return
}
Log.info("Finished downloading data for '%s' without errors", for: .storage, self.url.absoluteString)
self.download_completed = true
do {
try self.cache.save(data: self.downloaded_video_data, for: self.url)
Log.info("Saved cache video data for: %s", for: .storage, self.url.absoluteString)
self.url_session?.invalidateAndCancel()
self.url_session = nil
self.loading_task = nil
}
catch {
Log.error("Failed to save cache video data for: %s", for: .storage, self.url.absoluteString)
}
}
// MARK: Utility functions
/// Modifies the url to change its protocol and force AV loaders to use our delegate for data loading.
/// - Parameter url: The URL to be modified
/// - Returns: The modified URL with custom scheme
private static func streaming_url(from url: URL) -> URL? {
guard var components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { return nil }
components.scheme = (components.scheme ?? "") + protocol_suffix
return components.url
}
private func create_url_session() -> URLSession {
let config = URLSessionConfiguration.default
let operationQueue = OperationQueue()
operationQueue.maxConcurrentOperationCount = 1
return URLSession(
configuration: config,
delegate: self, // Set ourselves as the delegate, so that we can receive updates and use them to serve our AV Loading requests.
delegateQueue: operationQueue
)
}
/// Removes a loading request from our queue
/// - Parameter loading_request: The loading request object to be removed
private func remove(loading_request: AVAssetResourceLoadingRequest) {
self.loading_requests.removeAll(where: { $0 == loading_request })
}
}
}

View File

@ -30,7 +30,7 @@ func processImage(image: UIImage) -> URL? {
}
fileprivate func processImage(source: CGImageSource, fileExtension: String) -> URL? {
let destinationURL = createMediaURL(fileExtension: fileExtension)
let destinationURL = generateUniqueTemporaryMediaURL(fileExtension: fileExtension)
guard let destination = removeGPSDataFromImage(source: source, url: destinationURL) else { return nil }
@ -45,7 +45,7 @@ func processVideo(videoURL: URL) -> URL? {
}
fileprivate func saveVideoToTemporaryFolder(videoURL: URL) -> URL? {
let destinationURL = createMediaURL(fileExtension: videoURL.pathExtension)
let destinationURL = generateUniqueTemporaryMediaURL(fileExtension: videoURL.pathExtension)
do {
try FileManager.default.copyItem(at: videoURL, to: destinationURL)
@ -57,7 +57,7 @@ fileprivate func saveVideoToTemporaryFolder(videoURL: URL) -> URL? {
}
/// Generate a temporary URL with a unique filename
fileprivate func createMediaURL(fileExtension: String) -> URL {
func generateUniqueTemporaryMediaURL(fileExtension: String) -> URL {
let temporaryDirectoryURL = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true)
let uniqueMediaName = "\(UUID().uuidString).\(fileExtension)"
let temporaryMediaURL = temporaryDirectoryURL.appendingPathComponent(uniqueMediaName)

View File

@ -15,6 +15,7 @@ enum LogCategory: String {
case storage
case push_notifications
case damus_purple
case image_uploading
}
/// Damus structured logger

View File

@ -17,7 +17,7 @@ enum ImageUploadResult {
fileprivate func create_upload_body(mediaData: Data, boundary: String, mediaUploader: MediaUploader, mediaToUpload: MediaUpload) -> Data {
let body = NSMutableData();
let contentType = mediaToUpload.is_image ? "image/jpg" : "video/mp4"
let contentType = mediaToUpload.mime_type
body.appendString(string: "Content-Type: multipart/form-data; boundary=\(boundary)\r\n\r\n")
body.appendString(string: "--\(boundary)\r\n")
body.appendString(string: "Content-Disposition: form-data; name=\(mediaUploader.nameParam); filename=\(mediaToUpload.genericFileName)\r\n")

View File

@ -36,7 +36,29 @@ struct MediaPicker: UIViewControllerRepresentable {
result.itemProvider.loadItem(forTypeIdentifier: UTType.image.identifier, options: nil) { (item, error) in
guard let url = item as? URL else { return }
if canGetSourceTypeFromUrl(url: url) {
if(url.pathExtension == "gif") {
// GIFs do not natively support location metadata (See https://superuser.com/a/556320 and https://www.w3.org/Graphics/GIF/spec-gif89a.txt)
// It is better to avoid any GPS data processing at all, as it can cause the image to be converted to JPEG.
// Therefore, we should load the file directtly and deliver it as "already processed".
// Load the data for the GIF image
// - Don't load it as an UIImage since that can only get exported into JPEG/PNG
// - Don't load it as a file representation because it gets deleted before the upload can occur
_ = result.itemProvider.loadDataRepresentation(for: .gif, completionHandler: { imageData, error in
guard let imageData else { return }
let destinationURL = generateUniqueTemporaryMediaURL(fileExtension: "gif")
do {
try imageData.write(to: destinationURL)
Task {
await self.chooseMedia(.processed_image(destinationURL))
}
}
catch {
Log.error("Failed to write GIF image data from Photo picker into a local copy", for: .image_uploading)
}
})
}
else if canGetSourceTypeFromUrl(url: url) {
// Media was not taken from camera
self.attemptAcquireResourceAndChooseMedia(
url: url,

View File

@ -33,7 +33,7 @@ struct DamusVideoPlayer: View {
else {
mute = nil
}
_model = StateObject(wrappedValue: DamusVideoPlayerViewModel.cached_video_model(url: url, video_size: video_size, controller: controller, mute: mute))
_model = StateObject(wrappedValue: DamusVideoPlayerViewModel(url: url, video_size: video_size, controller: controller, mute: mute))
self.visibility_tracking_method = visibility_tracking_method
self.style = style
}

View File

@ -26,7 +26,6 @@ func video_has_audio(player: AVPlayer) async -> Bool {
final class DamusVideoPlayerViewModel: ObservableObject {
private let url: URL
private let maybe_cached_av_asset: VideoCache.MaybeCachedAVAsset?
private let player_item: AVPlayerItem
let player: AVPlayer
fileprivate let controller: VideoController
@ -58,22 +57,10 @@ final class DamusVideoPlayerViewModel: ObservableObject {
}
}
static func cached_video_model(url: URL, video_size: Binding<CGSize?>, controller: VideoController, mute: Bool? = nil) -> Self {
let maybe_cached_url = (try? VideoCache.standard?.maybe_cached_url_for(video_url: url)) ?? url
Log.info("Loading video with URL: %s",for: .render, maybe_cached_url.absoluteString)
return Self.init(url: maybe_cached_url, video_size: video_size, controller: controller, mute: mute)
}
init(url: URL, video_size: Binding<CGSize?>, controller: VideoController, mute: Bool? = nil) {
self.url = url
let maybe_cached_av_asset = try? VideoCache.standard?.maybe_cached_asset_for(video_url: url)
if maybe_cached_av_asset == nil {
Log.info("Something went wrong when trying to load the video with the video cache. Gracefully downgrading to non-cache video loading", for: .storage)
}
self.maybe_cached_av_asset = maybe_cached_av_asset // Save this wrapped asset to avoid having the loader delegate garbage collected while we still need it.
player_item = AVPlayerItem(asset: self.maybe_cached_av_asset?.av_asset ?? AVURLAsset(url: url))
player_item = AVPlayerItem(url: url)
player = AVPlayer(playerItem: player_item)
player.automaticallyWaitsToMinimizeStalling = true
self.controller = controller
_video_size = video_size

View File

@ -1,112 +0,0 @@
//
// VideoCacheTests.swift
// damusTests
//
// Created by Daniel DAquino on 2024-04-03.
//
import Foundation
import XCTest
@testable import damus
// TODO: Reduce test dependency on external factors such as external URLs.
let TEST_VIDEO_URL = "http://cdn.jb55.com/s/zaps-build.mp4"
let LONG_TEST_EXPIRY_TIME: TimeInterval = 60 * 60 * 24 // A long expiry time for a video (in seconds).
let SHORT_TEST_EXPIRY_TIME: TimeInterval = 15 // A short expiry time for a video (in seconds). Must be as short as possible but large enough to allow some test operations to occur
let CACHE_SAVE_TIME_TIMEOUT: TimeInterval = 8 // How long the test will wait for the cache to save a file (in seconds)
let EXPIRY_TIME_MARGIN: TimeInterval = 3 // The extra time we will wait after expected expiry, to avoid test timing issues. (in seconds)
final class VideoCacheTests: XCTestCase {
func testCachedURLForExistingVideo() throws {
// Create a temporary directory for the cache
let test_cache_directory = FileManager.default.temporaryDirectory.appendingPathComponent("test_video_cache")
// Create a test video file
let original_video_url = URL(string: TEST_VIDEO_URL)!
FileManager.default.createFile(atPath: original_video_url.path, contents: Data(), attributes: nil)
// Create a VideoCache instance with the temporary cache directory
let test_expiry_time: TimeInterval = 10
let video_cache = try VideoCache(cache_url: test_cache_directory, expiry_time: test_expiry_time)!
// Call the maybe_cached_url_for method with the test video URL
let expected_cache_url = video_cache.url_to_cached_url(url: original_video_url)
let maybe_cached_url = try video_cache.maybe_cached_url_for(video_url: original_video_url)
// Assert that the returned URL is the same as the original
XCTAssertEqual(maybe_cached_url, original_video_url, "Returned URL should be the same as the original video URL on the first time we download it")
// Check that next time we get this video, we get the cached URL.
let cached_url_expectation = XCTestExpectation(description: "On second time we get a video, the cached URL should be returned")
let start_time = Date()
while Date().timeIntervalSince(start_time) < CACHE_SAVE_TIME_TIMEOUT {
let maybe_cached_url = try video_cache.maybe_cached_url_for(video_url: original_video_url)
if maybe_cached_url == expected_cache_url {
cached_url_expectation.fulfill()
break
}
sleep(1)
}
wait(for: [cached_url_expectation], timeout: CACHE_SAVE_TIME_TIMEOUT)
// Now wait for the remaining time until the expiry time + a margin
let remaining_time = test_expiry_time + EXPIRY_TIME_MARGIN - Date().timeIntervalSince(start_time)
// Wait for the expiry time to pass
sleep(UInt32(max(remaining_time, 0)))
// Call the periodic_purge method to purge expired video items
video_cache.periodic_purge()
// Call the maybe_cached_url_for method again
let maybe_cached_url_after_expiry = try video_cache.maybe_cached_url_for(video_url: original_video_url)
// Assert that the returned URL is the same as the original video URL, since the cache should have expired.
XCTAssertEqual(maybe_cached_url_after_expiry, original_video_url, "Video cache should expire after expiry time")
// Clean up the temporary files and directory
try FileManager.default.removeItem(at: test_cache_directory)
}
func testClearCache() throws {
// Create a temporary directory for the cache
let test_cache_directory = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first!.appendingPathComponent("test_video_cache")
try FileManager.default.createDirectory(at: test_cache_directory, withIntermediateDirectories: true, attributes: nil)
// Create a test video file
let original_video_url = URL(string: TEST_VIDEO_URL)!
FileManager.default.createFile(atPath: original_video_url.path, contents: Data(), attributes: nil)
// Create a VideoCache instance with the temporary cache directory and a longer expiry time
let expiry_time: TimeInterval = LONG_TEST_EXPIRY_TIME
let video_cache = try VideoCache(cache_url: test_cache_directory, expiry_time: expiry_time)!
// Request the cached URL for the test video to create the cached file
let expected_cache_url = video_cache.url_to_cached_url(url: original_video_url)
let _ = try video_cache.maybe_cached_url_for(video_url: original_video_url)
// Check that next time we get this video, we get the cached URL.
let cached_url_expectation = XCTestExpectation(description: "On second time we get a video, the cached URL should be returned")
let start_time = Date()
while Date().timeIntervalSince(start_time) < CACHE_SAVE_TIME_TIMEOUT {
let maybe_cached_url = try video_cache.maybe_cached_url_for(video_url: original_video_url)
if maybe_cached_url == expected_cache_url {
cached_url_expectation.fulfill()
break
}
sleep(1)
}
wait(for: [cached_url_expectation], timeout: CACHE_SAVE_TIME_TIMEOUT)
// Call the periodic_purge method
DamusCacheManager.shared.clear_cache(damus_state: test_damus_state, completion: {
// Assert that fetching the cached URL after clearing cache will
let maybe_cached_url_after_purge = try? video_cache.maybe_cached_url_for(video_url: original_video_url)
XCTAssertEqual(maybe_cached_url_after_purge, original_video_url)
// Clean up the temporary directory
try? FileManager.default.removeItem(at: test_cache_directory)
})
}
}