diff --git a/Source/Database/Models/OfflineAsset.swift b/Source/Database/Models/OfflineAsset.swift index 50e16da..1ce7f7f 100644 --- a/Source/Database/Models/OfflineAsset.swift +++ b/Source/Database/Models/OfflineAsset.swift @@ -22,6 +22,7 @@ public struct OfflineAsset: Hashable { public var thumbnailURL: String? = nil public var folderTree: String = "" public var metadata: [String: Any]? = nil + public var contentProtectionType: ContentProtectionType? = nil public func hash(into hasher: inout Hasher) { hasher.combine(assetId) diff --git a/Source/Database/Models/OfflineAssetEntity.swift b/Source/Database/Models/OfflineAssetEntity.swift index 0a6c174..789b200 100644 --- a/Source/Database/Models/OfflineAssetEntity.swift +++ b/Source/Database/Models/OfflineAssetEntity.swift @@ -24,6 +24,7 @@ class LocalOfflineAsset: Object { @Persisted var size: Double = 0.0 @Persisted var folderTree: String = "" @Persisted var drmContentId: String? = nil + @Persisted var contentProtectionType: ContentProtectionType? = nil @Persisted var metadataMap = Map() @Persisted var licenseExpiryDate: Date? = nil @@ -55,6 +56,7 @@ extension LocalOfflineAsset { thumbnailURL: String? = nil, folderTree: String, drmContentId: String? = nil, + contentProtectionType: ContentProtectionType? = nil, metadata: [String: Any]? = nil ) -> LocalOfflineAsset { let localOfflineAsset = LocalOfflineAsset() @@ -68,6 +70,7 @@ extension LocalOfflineAsset { localOfflineAsset.thumbnailURL = thumbnailURL localOfflineAsset.folderTree = folderTree localOfflineAsset.drmContentId = drmContentId + localOfflineAsset.contentProtectionType = contentProtectionType localOfflineAsset.metadata = metadata return localOfflineAsset } @@ -86,7 +89,8 @@ extension LocalOfflineAsset { size: self.size, thumbnailURL: self.thumbnailURL, folderTree: self.folderTree, - metadata: self.metadata + metadata: self.metadata, + contentProtectionType: self.contentProtectionType ) } @@ -99,11 +103,13 @@ extension LocalOfflineAsset { let playbackURLString = downloadedFileURL.absoluteString let video = Video( + id: self.assetId, playbackURL: playbackURLString, status: self.status, drmEncrypted: isDrmEncrypted, duration: self.duration, - thumbnailURL: self.thumbnailURL + thumbnailURL: self.thumbnailURL, + contentProtectionType: isDrmEncrypted ? .drm : self.contentProtectionType ) let asset: Asset = Asset( @@ -120,7 +126,7 @@ extension LocalOfflineAsset { } var downloadedFileURL: URL? { - if !self.downloadedPath.isEmpty{ + if !self.downloadedPath.isEmpty { let baseURL = URL(fileURLWithPath: NSHomeDirectory()) return baseURL.appendingPathComponent(self.downloadedPath) } diff --git a/Source/Database/TPStreamsDownloadManager.swift b/Source/Database/TPStreamsDownloadManager.swift index 7e7a581..2ce1a7e 100644 --- a/Source/Database/TPStreamsDownloadManager.swift +++ b/Source/Database/TPStreamsDownloadManager.swift @@ -19,6 +19,7 @@ public final class TPStreamsDownloadManager { private var contentKeySession: AVContentKeySession! private var contentKeyDelegate: ContentKeyDelegate! private let contentKeyDelegateQueue = DispatchQueue(label: "com.tpstreams.iOSPlayerSDK.ContentKeyDelegateQueueOffline") + private let encryptionKeyDelegate = EncryptionKeyDelegate.shared private init() { let backgroundConfiguration = URLSessionConfiguration.background(withIdentifier: "com.tpstreams.downloadSession") @@ -195,13 +196,19 @@ public final class TPStreamsDownloadManager { throw NSError(domain: "TPStreamsSDK", code: -1, userInfo: [NSLocalizedDescriptionKey: "DRM content downloading is not supported in simulator"]) } #else - contentKeyDelegate.setAssetDetails(asset.id, accessToken, true, offlineLicenseDurationSeconds) + if asset.video?.drmEncrypted == true { + contentKeyDelegate.setAssetDetails(asset.id, accessToken, true, offlineLicenseDurationSeconds) + } #endif if LocalOfflineAsset.manager.exists(id: asset.id) { return } + if let video = asset.video, video.isAESEncrypted { + EncryptionKeyDelegate.shared.prefetchKey(for: video, identifier: asset.keyIdentifier, accessToken: accessToken) + } + let avUrlAsset = AVURLAsset(url: URL(string: asset.video!.playbackURL)!) guard let task = assetDownloadURLSession.aggregateAssetDownloadTask( @@ -227,6 +234,7 @@ public final class TPStreamsDownloadManager { thumbnailURL: asset.video!.thumbnailURL ?? "", folderTree: asset.folderTree ?? "", drmContentId: asset.drmContentId, + contentProtectionType: asset.video?.contentProtectionType, metadata: metadata ) LocalOfflineAsset.manager.add(object: localOfflineAsset) @@ -314,6 +322,7 @@ public final class TPStreamsDownloadManager { } guard let fileURL = localOfflineAsset.downloadedFileURL else { + deleteEncryptionKey(for: localOfflineAsset) LocalOfflineAsset.manager.delete(id: assetId) if hasActiveTask == nil { tpStreamsDownloadDelegate?.onCanceled(assetId: assetId) @@ -368,6 +377,8 @@ public final class TPStreamsDownloadManager { self.contentKeyDelegate.cleanupPersistentContentKey() } + self.deleteEncryptionKey(for: localOfflineAsset) + completion(true, nil) } } catch { @@ -377,6 +388,10 @@ public final class TPStreamsDownloadManager { } } } + + internal func deleteEncryptionKey(for localOfflineAsset: LocalOfflineAsset) { + encryptionKeyDelegate.delete(for: localOfflineAsset.assetId) + } public func getAllOfflineAssets() -> [OfflineAsset] { // This method retrieves all offline assets from the local storage. @@ -424,6 +439,37 @@ public final class TPStreamsDownloadManager { } } } + + internal func hardenOfflineManifests(for localOfflineAsset: LocalOfflineAsset) { + guard localOfflineAsset.contentProtectionType == .aes, + let downloadURL = localOfflineAsset.downloadedFileURL else { + return + } + + let keyIdentifier: String = localOfflineAsset.assetId + + let enumerator = FileManager.default.enumerator(at: downloadURL, includingPropertiesForKeys: nil) + + while let fileURL = enumerator?.nextObject() as? URL { + guard fileURL.pathExtension.lowercased() == "m3u8" else { continue } + hardenManifest(at: fileURL, keyIdentifier: keyIdentifier) + } + } + + private func hardenManifest(at url: URL, keyIdentifier: String) { + guard let content = try? String(contentsOf: url, encoding: .utf8), + let regex = try? NSRegularExpression(pattern: "#EXT-X-KEY:METHOD=AES-128,URI=\"([^\"]+)\""), + let match = regex.firstMatch(in: content, range: NSRange(content.startIndex..., in: content)) else { + return + } + + let fullMatchRange = Range(match.range, in: content)! + let uriRange = Range(match.range(at: 1), in: content)! + let originalURI = String(content[uriRange]) + + let hardenedContent = content.replacingCharacters(in: fullMatchRange, with: "#EXT-X-KEY:METHOD=AES-128,URI=\"tpkey://\(keyIdentifier)\"") + try? hardenedContent.write(to: url, atomically: true, encoding: .utf8) + } } internal class AssetDownloadDelegate: NSObject, AVAssetDownloadDelegate { @@ -469,14 +515,22 @@ internal class AssetDownloadDelegate: NSObject, AVAssetDownloadDelegate { return .failed } }() + + if status == .failed || status == .deleted { + TPStreamsDownloadManager.shared.deleteEncryptionKey(for: localOfflineAsset) + } + let updateValues: [String: Any] = ["status": status.rawValue, "downloadedAt": Date()] LocalOfflineAsset.manager.update(object: localOfflineAsset, with: updateValues) - if status == Status.deleted { + if status == .deleted { tpStreamsDownloadDelegate?.onCanceled(assetId: localOfflineAsset.assetId) } else if status == Status.failed { tpStreamsDownloadDelegate?.onFailed(offlineAsset: localOfflineAsset.asOfflineAsset(), error: error) tpStreamsDownloadDelegate?.onStateChange(status: status, offlineAsset: localOfflineAsset.asOfflineAsset()) } else { + if status == .finished { + TPStreamsDownloadManager.shared.hardenOfflineManifests(for: localOfflineAsset) + } tpStreamsDownloadDelegate?.onComplete(offlineAsset: localOfflineAsset.asOfflineAsset()) tpStreamsDownloadDelegate?.onStateChange(status: status, offlineAsset: localOfflineAsset.asOfflineAsset()) } diff --git a/Source/Managers/EncryptionKeyDelegate.swift b/Source/Managers/EncryptionKeyDelegate.swift new file mode 100644 index 0000000..5e30023 --- /dev/null +++ b/Source/Managers/EncryptionKeyDelegate.swift @@ -0,0 +1,133 @@ +import Foundation +import Alamofire + +final class EncryptionKeyDelegate { + static let shared = EncryptionKeyDelegate() + + private let serviceId = "com.tpstreams.iOSPlayerSDK.encryption.keys" + private let keyPrefix = "VIDEO_ENCRYPTION_KEY_" + + private init() {} + + private func keychainKey(for identifier: String) -> String { + return keyPrefix + identifier + } + + func save(encryptionKey: Data, for identifier: String) { + KeychainUtil.save(data: encryptionKey, service: serviceId, account: keychainKey(for: identifier)) + } + + func get(for identifier: String) -> Data? { + return KeychainUtil.get(service: serviceId, account: keychainKey(for: identifier)) + } + + func delete(for identifier: String) { + KeychainUtil.delete(service: serviceId, account: keychainKey(for: identifier)) + } + + func deleteAll() { + KeychainUtil.deleteAll(service: serviceId) + } + + func prefetchKey(for video: Video, identifier: String, accessToken: String?) { + fetchKey(assetId: identifier, accessToken: accessToken) { [weak self] keyData in + if let keyData = keyData { + self?.save(encryptionKey: keyData, for: identifier) + } else if let url = URL(string: video.playbackURL) { + self?.parsePlaylist(url, identifier: identifier, accessToken: accessToken) + } + } + } + + private func parsePlaylist(_ playlistURL: URL, identifier: String, accessToken: String?) { + let headers = makeAuthHeaders(accessToken: accessToken) + var finalURL = addTokenToURL(playlistURL, accessToken: accessToken) ?? playlistURL + + AF.request(finalURL, headers: headers).responseData { [weak self] response in + guard let data = response.data, + let content = String(data: data, encoding: .utf8) else { return } + + if content.contains("#EXT-X-STREAM-INF") { + self?.handleVariantPlaylist(content, baseURL: playlistURL, identifier: identifier, accessToken: accessToken) + return + } + + self?.extractAndSaveKey(from: content, identifier: identifier, baseURL: playlistURL, accessToken: accessToken) + } + } + + private func handleVariantPlaylist(_ content: String, baseURL: URL, identifier: String, accessToken: String?) { + guard let variantLine = content.components(separatedBy: .newlines) + .first(where: { $0.contains(".m3u8") && !$0.hasPrefix("#") }), + let variantURL = URL(string: variantLine.trimmingCharacters(in: .whitespaces), relativeTo: baseURL) else { + return + } + parsePlaylist(variantURL, identifier: identifier, accessToken: accessToken) + } + + private func extractAndSaveKey(from content: String, identifier: String, baseURL: URL, accessToken: String?) { + guard let keyURL = extractKeyURL(from: content, baseURL: baseURL) else { return } + + fetchKey(url: keyURL, accessToken: accessToken) { [weak self] data in + if let data = data { + self?.save(encryptionKey: data, for: identifier) + } + } + } + + private func extractKeyURL(from content: String, baseURL: URL) -> URL? { + let pattern = "#EXT-X-KEY:METHOD=AES-128,URI=\"([^\"]+)\"" + guard let regex = try? NSRegularExpression(pattern: pattern), + let match = regex.firstMatch(in: content, range: NSRange(content.startIndex..., in: content)), + let uriRange = Range(match.range(at: 1), in: content) else { + return nil + } + let uri = String(content[uriRange]) + return URL(string: uri, relativeTo: baseURL) + } + + func fetchKey(assetId: String? = nil, url: URL? = nil, accessToken: String?, completion: @escaping (Data?) -> Void) { + guard let requestURL = buildRequestURL(assetId: assetId, url: url) else { + completion(nil) + return + } + + var finalURL = addTokenToURL(requestURL, accessToken: accessToken) ?? requestURL + let headers = makeAuthHeaders(accessToken: accessToken) + + AF.request(finalURL, headers: headers).responseData { response in + completion(try? response.result.get()) + } + } + + private func buildRequestURL(assetId: String?, url: URL?) -> URL? { + if let url = url { return url } + + guard let assetId = assetId, + let org = TPStreamsSDK.orgCode else { return nil } + + let template = TPStreamsSDK.provider.API.AES_ENCRYPTION_KEY_API + return URL(string: String(format: template, org, assetId)) + } + + private func addTokenToURL(_ url: URL, accessToken: String?) -> URL? { + guard let token = accessToken, !token.isEmpty, + var components = URLComponents(url: url, resolvingAgainstBaseURL: true) else { + return url + } + + var items = components.queryItems ?? [] + if !items.contains(where: { $0.name == "access_token" }) { + items.append(URLQueryItem(name: "access_token", value: token)) + components.queryItems = items + } + return components.url + } + + private func makeAuthHeaders(accessToken: String?) -> HTTPHeaders { + if let token = TPStreamsSDK.authToken { + return ["Authorization": "JWT \(token)"] + } + return [:] + } +} diff --git a/Source/Managers/ResourceLoaderDelegate.swift b/Source/Managers/ResourceLoaderDelegate.swift index 62d65ae..9fea034 100644 --- a/Source/Managers/ResourceLoaderDelegate.swift +++ b/Source/Managers/ResourceLoaderDelegate.swift @@ -10,79 +10,94 @@ import AVFoundation class ResourceLoaderDelegate: NSObject, AVAssetResourceLoaderDelegate { let accessToken: String? + private let assetId: String? + private let isPlaybackOffline: Bool + private let offlineAssetId: String? + internal var asset: Asset? = nil - init(accessToken: String?) { + private let encryptionKeyDelegate = EncryptionKeyDelegate.shared + + init(accessToken: String?, assetId: String? = nil, isPlaybackOffline: Bool = false, offlineAssetId: String? = nil, localOfflineAsset: LocalOfflineAsset? = nil) { self.accessToken = accessToken + self.assetId = assetId + self.isPlaybackOffline = isPlaybackOffline + self.offlineAssetId = offlineAssetId super.init() } func resourceLoader(_ resourceLoader: AVAssetResourceLoader, shouldWaitForLoadingOfRequestedResource loadingRequest: AVAssetResourceLoadingRequest) -> Bool { guard let url = loadingRequest.request.url else { return false } - if isEncryptionKeyUrl(url), let modifiedURL = appendAccessToken(url) { - fetchEncryptionKey(from: modifiedURL) { [weak self] data in - self?.setEncryptionKeyResponse(for: loadingRequest, data: data) + guard let asset = asset, asset.video?.isAESEncrypted == true else { + return false + } + + if url.scheme == "tpkey" { + handleLocalKeyRequest(loadingRequest) + return true + } else if url.scheme == "https" && isEncryptionKeyUrl(url) { + if isPlaybackOffline { + handleLocalKeyRequest(loadingRequest) + } else { + handleOnlineKeyRequest(loadingRequest) } return true } + return false } - func isEncryptionKeyUrl(_ url: URL) -> Bool { - return url.path.contains("key") - } + // MARK: - Key Request Handling - func appendAccessToken(_ url: URL) -> URL? { - if var components = URLComponents(url: url, resolvingAgainstBaseURL: true){ - if TPStreamsSDK.provider == .testpress, let orgCode = TPStreamsSDK.orgCode, !orgCode.isEmpty { - components.host = "\(orgCode).testpress.in" + private func handleLocalKeyRequest(_ loadingRequest: AVAssetResourceLoadingRequest) { + guard let url = loadingRequest.request.url else { return } + + let id: String = (url.scheme == "tpkey" ? url.host : url.pathComponents.last(where: { component in + !["aes_key", "encryption_key", "api", "v1", "v2.5", "/"].contains(component) + })) ?? "" + + let fallbacks = ([id, assetId, offlineAssetId].compactMap { $0 }).filter { !$0.isEmpty } + for key in fallbacks { + if let data = encryptionKeyDelegate.get(for: key) { + setEncryptionKeyResponse(for: loadingRequest, data: data) + return } - let accessTokenQueryItem = URLQueryItem(name: "access_token", value: self.accessToken) - components.queryItems = (components.queryItems ?? []) + [accessTokenQueryItem] - return components.url } - return url + debugPrint("Key not found for \(id). Tried: \(fallbacks)") + loadingRequest.finishLoading() } - func fetchEncryptionKey(from url: URL, completion: @escaping (Data) -> Void) { - var request = URLRequest(url: url) + private func handleOnlineKeyRequest(_ loadingRequest: AVAssetResourceLoadingRequest) { + guard let url = loadingRequest.request.url else { return } - // Add Authorization header if authToken is available and non-empty - if let authToken = TPStreamsSDK.authToken, !authToken.isEmpty { - request.addValue("JWT \(authToken)", forHTTPHeaderField: "Authorization") + var requestURL = url + if TPStreamsSDK.provider == .testpress, let org = TPStreamsSDK.orgCode, var components = URLComponents(url: url, resolvingAgainstBaseURL: true) { + components.host = "\(org).testpress.in" + requestURL = components.url ?? url } - let dataTask = URLSession.shared.dataTask(with: request) { data, _, _ in + encryptionKeyDelegate.fetchKey(url: requestURL, accessToken: accessToken) { [weak self] data in if let data = data { - completion(data) + self?.setEncryptionKeyResponse(for: loadingRequest, data: data) + } else { + loadingRequest.finishLoading() } } - dataTask.resume() } - func setEncryptionKeyResponse(for loadingRequest: AVAssetResourceLoadingRequest, data: Data) { - if let contentInformationRequest = loadingRequest.contentInformationRequest { - contentInformationRequest.contentType = getContentType(contentInformationRequest: contentInformationRequest) - contentInformationRequest.isByteRangeAccessSupported = true - contentInformationRequest.contentLength = Int64(data.count) + private func setEncryptionKeyResponse(for loadingRequest: AVAssetResourceLoadingRequest, data: Data) { + if let info = loadingRequest.contentInformationRequest { + info.contentType = "application/octet-stream" + info.isByteRangeAccessSupported = true + info.contentLength = Int64(data.count) } - loadingRequest.dataRequest?.respond(with: data) loadingRequest.finishLoading() } - func getContentType(contentInformationRequest: AVAssetResourceLoadingContentInformationRequest?) -> String { - var contentType = AVStreamingKeyDeliveryPersistentContentKeyType - - if #available(iOS 11.2, *) { - if let allowedContentType = contentInformationRequest?.allowedContentTypes?.first { - if allowedContentType == AVStreamingKeyDeliveryContentKeyType { - contentType = AVStreamingKeyDeliveryContentKeyType - } - } - } - - return contentType + private func isEncryptionKeyUrl(_ url: URL) -> Bool { + let path = url.path.lowercased() + return path.contains("/aes_key") || path.contains("/encryption_key") } } diff --git a/Source/Network/BaseAPI.swift b/Source/Network/BaseAPI.swift index 532a9e8..e19d481 100644 --- a/Source/Network/BaseAPI.swift +++ b/Source/Network/BaseAPI.swift @@ -15,6 +15,9 @@ class BaseAPI { class var DRM_LICENSE_API: String { fatalError("DRM_LICENSE_API must be implemented by subclasses.") } + class var AES_ENCRYPTION_KEY_API: String { + fatalError("AES_ENCRYPTION_KEY_API must be implemented by subclasses.") + } class var parser: APIParser { fatalError("parser must be implemented by subclasses.") } diff --git a/Source/Network/Models/Asset.swift b/Source/Network/Models/Asset.swift index 69d51bc..cb293cb 100644 --- a/Source/Network/Models/Asset.swift +++ b/Source/Network/Models/Asset.swift @@ -25,4 +25,9 @@ struct Asset { return nil } } + + /// Returns the canonical identifier for encryption keys. + var keyIdentifier: String { + return id + } } diff --git a/Source/Network/Models/Video.swift b/Source/Network/Models/Video.swift index d41dffe..5c63cfb 100644 --- a/Source/Network/Models/Video.swift +++ b/Source/Network/Models/Video.swift @@ -6,11 +6,34 @@ // import Foundation +import RealmSwift -struct Video{ +public enum ContentProtectionType: Int, PersistableEnum { + case drm = 1 + case aes = 2 + + public static func fromString(_ type: String?) -> ContentProtectionType? { + guard let type = type?.lowercased() else { return nil } + if type == "drm" { return .drm } + if type == "aes" { return .aes } + return nil + } +} + +public struct Video { + let id: String? let playbackURL: String let status: String let drmEncrypted: Bool let duration: Double let thumbnailURL: String? + let contentProtectionType: ContentProtectionType? + + var isAESEncrypted: Bool { + return contentProtectionType == .aes + } + + var keyIdentifier: String? { + return id + } } diff --git a/Source/Network/Parsers/StreamsAPIParser.swift b/Source/Network/Parsers/StreamsAPIParser.swift index 3a38c0a..4805e3e 100644 --- a/Source/Network/Parsers/StreamsAPIParser.swift +++ b/Source/Network/Parsers/StreamsAPIParser.swift @@ -27,15 +27,23 @@ class StreamsAPIParser: APIParser { func parseVideo(from dictionary: [String: Any]?) -> Video? { guard let videoDict = dictionary, let playbackURL = videoDict["playback_url"] as? String, - let status = videoDict["status"] as? String, - let contentProtectionType = videoDict["content_protection_type"] as? String else { + let status = videoDict["status"] as? String else { return nil } + let id: String? = videoDict["id"] as? String let duration: Double = videoDict["duration"] as? Double ?? 0.0 let thumbnailURL: String? = videoDict["cover_thumbnail_url"] as? String + let contentProtectionType: ContentProtectionType? = { + let type = ContentProtectionType.fromString(videoDict["content_protection_type"] as? String) + // If the backend specifically sends DRM, or if we want to force priority + return type + }() - return Video(playbackURL: playbackURL, status: status, drmEncrypted: contentProtectionType == "drm", duration: duration, thumbnailURL: thumbnailURL) + let isDrm = contentProtectionType == .drm + + + return Video(id: id, playbackURL: playbackURL, status: status, drmEncrypted: isDrm, duration: duration, thumbnailURL: thumbnailURL, contentProtectionType: contentProtectionType) } func parseLiveStream(from dictionary: [String: Any]?) -> LiveStream? { diff --git a/Source/Network/Parsers/TestpressAPIParser.swift b/Source/Network/Parsers/TestpressAPIParser.swift index 7796e27..a25c7e7 100644 --- a/Source/Network/Parsers/TestpressAPIParser.swift +++ b/Source/Network/Parsers/TestpressAPIParser.swift @@ -27,13 +27,27 @@ class TestpressAPIParser: APIParser { func parseVideo(from dictionary: [String: Any]?) -> Video? { guard let videoDict = dictionary, let playbackURL = videoDict["hls_url"] as? String ?? videoDict["url"] as? String, - let status = videoDict["transcoding_status"] as? String, - let drmEncrypted = videoDict["drm_enabled"] as? Bool else { + let status = videoDict["transcoding_status"] as? String else { return nil } + + let drmEncrypted = videoDict["drm_enabled"] as? Bool ?? false + + let id: String? = { + if let stringId = videoDict["id"] as? String { + return stringId + } else if let intId = videoDict["id"] as? Int { + return String(intId) + } + return nil + }() let duration: Double = videoDict["duration"] as? Double ?? 0.0 + let thumbnailURL: String? = videoDict["thumbnail_url"] as? String + + // Get content protection type directly from API + let contentProtectionType = ContentProtectionType.fromString(videoDict["content_protection_type"] as? String) - return Video(playbackURL: playbackURL, status: status, drmEncrypted: drmEncrypted, duration: duration, thumbnailURL: nil) + return Video(id: id, playbackURL: playbackURL, status: status, drmEncrypted: drmEncrypted, duration: duration, thumbnailURL: thumbnailURL, contentProtectionType: contentProtectionType) } func parseLiveStream(from dictionary: [String: Any]?) -> LiveStream? { diff --git a/Source/Network/StreamsAPI.swift b/Source/Network/StreamsAPI.swift index 4993469..ebe0b21 100644 --- a/Source/Network/StreamsAPI.swift +++ b/Source/Network/StreamsAPI.swift @@ -16,6 +16,10 @@ class StreamsAPI: BaseAPI { return "https://app.tpstreams.com/api/v1/%@/assets/%@/drm_license/?access_token=%@&drm_type=fairplay&download=%@" } + class override var AES_ENCRYPTION_KEY_API: String { + return "https://app.tpstreams.com/api/v1/%@/assets/%@/aes_key/" + } + override class var parser: APIParser { return StreamsAPIParser() } diff --git a/Source/Network/TestpressAPI.swift b/Source/Network/TestpressAPI.swift index dbe8041..d2d166b 100644 --- a/Source/Network/TestpressAPI.swift +++ b/Source/Network/TestpressAPI.swift @@ -18,6 +18,10 @@ class TestpressAPI: BaseAPI { class override var DRM_LICENSE_API: String { return "https://%@.testpress.in/api/v2.5/drm_license_key/%@/?access_token=%@&drm_type=fairplay&download=%@" } + + class override var AES_ENCRYPTION_KEY_API: String { + return "https://%@.testpress.in/api/v2.5/encryption_key/%@/" + } override class var parser: APIParser { return TestpressAPIParser() diff --git a/Source/TPAVPlayer.swift b/Source/TPAVPlayer.swift index 50ff062..1ea0e9c 100644 --- a/Source/TPAVPlayer.swift +++ b/Source/TPAVPlayer.swift @@ -48,7 +48,10 @@ public class TPAVPlayer: AVPlayer { self.accessToken = accessToken self.assetID = assetID self.setupCompletion = completion - self.resourceLoaderDelegate = ResourceLoaderDelegate(accessToken: accessToken) + self.resourceLoaderDelegate = ResourceLoaderDelegate( + accessToken: accessToken, + assetId: assetID + ) super.init() fetchAsset() @@ -57,12 +60,26 @@ public class TPAVPlayer: AVPlayer { public init(offlineAssetId: String, completion: SetupCompletion? = nil) { self.setupCompletion = completion + self.assetID = offlineAssetId super.init() - isPlaybackOffline = true - guard let localOfflineAsset = LocalOfflineAsset.manager.get(id: offlineAssetId) else { return } - if (localOfflineAsset.status == "finished") { - self.asset = localOfflineAsset.asAsset() - self.assetID = offlineAssetId + + guard let localOfflineAsset = LocalOfflineAsset.manager.get(id: offlineAssetId) else { + self.processInitializationFailure(TPStreamPlayerError.resourceNotFound) + return + } + + self.isPlaybackOffline = true + self.resourceLoaderDelegate = ResourceLoaderDelegate( + accessToken: nil, + assetId: offlineAssetId, + isPlaybackOffline: true, + offlineAssetId: offlineAssetId, + localOfflineAsset: localOfflineAsset + ) + if localOfflineAsset.status == "finished" { + let asset = localOfflineAsset.asAsset() + self.asset = asset + self.resourceLoaderDelegate?.asset = asset self.initializePlayer() self.setupCompletion?(nil) self.initializationStatus = "ready" @@ -109,6 +126,7 @@ public class TPAVPlayer: AVPlayer { private func initializePlayerWithFetchedAsset(_ asset: Asset) { self.asset = asset + self.resourceLoaderDelegate?.asset = asset initializePlayer() setupCompletion?(nil) initializationStatus = "ready" diff --git a/Source/TPStreamsSDK.swift b/Source/TPStreamsSDK.swift index a2c084c..650b7be 100644 --- a/Source/TPStreamsSDK.swift +++ b/Source/TPStreamsSDK.swift @@ -88,9 +88,9 @@ public class TPStreamsSDK { private static func initializeDatabase() { var config = Realm.Configuration( - schemaVersion: 4, + schemaVersion: 5, migrationBlock: { migration, oldSchemaVersion in - if oldSchemaVersion < 4 { + if oldSchemaVersion < 5 { // No manual migration needed. // Realm automatically handles newly added optional properties. } diff --git a/Source/Utils/KeychainUtil.swift b/Source/Utils/KeychainUtil.swift new file mode 100644 index 0000000..a8004e4 --- /dev/null +++ b/Source/Utils/KeychainUtil.swift @@ -0,0 +1,66 @@ +import Foundation +import Security + +class KeychainUtil { + + static func save(data: Data, service: String, account: String) { + let keychainQuery: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account + ] + let keychainUpdateAttributes: [String: Any] = [ + kSecValueData as String: data + ] + + let status = SecItemUpdate(keychainQuery as CFDictionary, keychainUpdateAttributes as CFDictionary) + if status == errSecItemNotFound { + var insertQuery = keychainQuery + insertQuery[kSecValueData as String] = data + insertQuery[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly + let addStatus = SecItemAdd(insertQuery as CFDictionary, nil) + if addStatus != errSecSuccess { + debugPrint("KeychainUtil: Failed to save key for \(account). Status: \(addStatus)") + } + } else if status != errSecSuccess { + debugPrint("KeychainUtil: Failed to update key for \(account). Status: \(status)") + } + } + + static func get(service: String, account: String) -> Data? { + let keychainQuery: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + kSecMatchLimit as String: kSecMatchLimitOne, + kSecReturnData as String: true + ] + + var result: CFTypeRef? + let status = SecItemCopyMatching(keychainQuery as CFDictionary, &result) + return status == errSecSuccess ? result as? Data : nil + } + + static func delete(service: String, account: String) { + let keychainQuery: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account + ] + let status = SecItemDelete(keychainQuery as CFDictionary) + if status != errSecSuccess && status != errSecItemNotFound { + debugPrint("KeychainUtil: Failed to delete key for \(account). Status: \(status)") + } + } + + static func deleteAll(service: String) { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service + ] + let status = SecItemDelete(query as CFDictionary) + if status != errSecSuccess && status != errSecItemNotFound { + debugPrint("KeychainUtil: Failed to delete all keys. Status: \(status)") + } + } +} diff --git a/iOSPlayerSDK.xcodeproj/project.pbxproj b/iOSPlayerSDK.xcodeproj/project.pbxproj index 6ad96e6..e22cb6b 100644 --- a/iOSPlayerSDK.xcodeproj/project.pbxproj +++ b/iOSPlayerSDK.xcodeproj/project.pbxproj @@ -50,8 +50,10 @@ 03CE75022A7A337B00B84304 /* UIView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03CE75012A7A337B00B84304 /* UIView.swift */; }; 03D7F4252C21C64D00DF3597 /* LiveIndicatorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03D7F4242C21C64D00DF3597 /* LiveIndicatorView.swift */; }; 03D7F4282C22C4F900DF3597 /* PlaybackSpeed.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03D7F4272C22C4F900DF3597 /* PlaybackSpeed.swift */; }; + 2D2BA5792F5FEB2400D53996 /* KeychainUtil.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D2BA5782F5FEB2400D53996 /* KeychainUtil.swift */; }; 2D3CE81F2F5957EE0011CAB7 /* VideoQualityUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D3CE81E2F5957EE0011CAB7 /* VideoQualityUtils.swift */; }; 2D46EC6A2EE856BB008B559A /* M3U8Kit in Frameworks */ = {isa = PBXBuildFile; productRef = 2D46EC692EE856BB008B559A /* M3U8Kit */; }; + 2DC08F892F448BB900AABC60 /* EncryptionKeyDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DC08F872F448BB900AABC60 /* EncryptionKeyDelegate.swift */; }; 2DD12A212D4CF75400272433 /* RealmSwift in Embed Frameworks */ = {isa = PBXBuildFile; productRef = D92E48F22CB0226A00B1FAC3 /* RealmSwift */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; }; 70D42E0B2E6075F3002AC32C /* AnyRealmValue.swift in Sources */ = {isa = PBXBuildFile; fileRef = 70D42E0A2E6075F3002AC32C /* AnyRealmValue.swift */; }; 8E6389BC2A2724D000306FA4 /* TPStreamPlayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8E6389BB2A2724D000306FA4 /* TPStreamPlayerView.swift */; }; @@ -202,7 +204,9 @@ 03CE75012A7A337B00B84304 /* UIView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIView.swift; sourceTree = ""; }; 03D7F4242C21C64D00DF3597 /* LiveIndicatorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveIndicatorView.swift; sourceTree = ""; }; 03D7F4272C22C4F900DF3597 /* PlaybackSpeed.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaybackSpeed.swift; sourceTree = ""; }; + 2D2BA5782F5FEB2400D53996 /* KeychainUtil.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainUtil.swift; sourceTree = ""; }; 2D3CE81E2F5957EE0011CAB7 /* VideoQualityUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoQualityUtils.swift; sourceTree = ""; }; + 2DC08F872F448BB900AABC60 /* EncryptionKeyDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EncryptionKeyDelegate.swift; sourceTree = ""; }; 70D42E0A2E6075F3002AC32C /* AnyRealmValue.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnyRealmValue.swift; sourceTree = ""; }; 8E6389BB2A2724D000306FA4 /* TPStreamPlayerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TPStreamPlayerView.swift; sourceTree = ""; }; 8E6389C12A27277D00306FA4 /* Example.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Example.app; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -377,6 +381,7 @@ 03CE74FF2A7946A800B84304 /* Time.swift */, 2D3CE81E2F5957EE0011CAB7 /* VideoQualityUtils.swift */, 0367F6F42B861CFC0000922D /* Sentry.swift */, + 2D2BA5782F5FEB2400D53996 /* KeychainUtil.swift */, D960A8FA2CEDEE7C003B0B04 /* M3U8Parser.swift */, ); path = Utils; @@ -442,6 +447,7 @@ 8E6389E72A278D0400306FA4 /* Managers */ = { isa = PBXGroup; children = ( + 2DC08F872F448BB900AABC60 /* EncryptionKeyDelegate.swift */, 03B809092A2DF8A000AB3D03 /* TPStreamPlayer.swift */, 8E6389E82A278D1D00306FA4 /* ContentKeyDelegate.swift */, 8E6C5CB12A28AB94003EC948 /* ContentKeyManager.swift */, @@ -813,6 +819,7 @@ 8E6389DF2A2751C800306FA4 /* TPAVPlayer.swift in Sources */, D92E48D62CAFCD2400B1FAC3 /* OfflineAsset.swift in Sources */, D960A8FB2CEDEE7C003B0B04 /* M3U8Parser.swift in Sources */, + 2DC08F892F448BB900AABC60 /* EncryptionKeyDelegate.swift in Sources */, 03B8090A2A2DF8A000AB3D03 /* TPStreamPlayer.swift in Sources */, 70D42E0B2E6075F3002AC32C /* AnyRealmValue.swift in Sources */, 03D7F4252C21C64D00DF3597 /* LiveIndicatorView.swift in Sources */, @@ -822,6 +829,7 @@ 0374E3742C1B159A00CE9CF2 /* Video.swift in Sources */, 8E6C5CB22A28AB94003EC948 /* ContentKeyManager.swift in Sources */, 03CC784D2A6BB6E5005E8F7E /* VideoPlayerView.swift in Sources */, + 2D2BA5792F5FEB2400D53996 /* KeychainUtil.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; };