This commit is contained in:
Robert Patchett
2025-07-02 10:58:37 +02:00
parent 1e68c079fd
commit aae3eb8f63
1024 changed files with 198661 additions and 17825 deletions
+92
View File
@@ -0,0 +1,92 @@
Created by https://www.gitignore.io/api/xcode,osx
### OSX ###
*.DS_Store
.AppleDouble
.LSOverride
# Thumbnails
._*
# Files that might appear in the root of a volume
.DocumentRevisions-V100
.fseventsd
.Spotlight-V100
.TemporaryItems
.Trashes
.VolumeIcon.icns
.com.apple.timemachine.donotpresent
# Directories potentially created on remote AFP share
.AppleDB
.AppleDesktop
Network Trash Folder
Temporary Items
.apdisk
### Xcode ###
# Xcode
#
# gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore
## Build generated
build/
.build/
DerivedData/
## Fastlane
outputs
*.p12
*.provisionprofile
## Various settings
*.pbxuser
!default.pbxuser
*.mode1v3
!default.mode1v3
*.mode2v3
!default.mode2v3
*.perspectivev3
!default.perspectivev3
xcuserdata/
## Other
*.moved-aside
*.xccheckout
*.xcscmblueprint
Carthage/
.vscode
### Xcode Patch ###
*.xcodeproj/*
!*.xcodeproj/project.pbxproj
!*.xcodeproj/xcshareddata/
!*.xcworkspace/contents.xcworkspacedata
/*.gcno
# End of https://www.gitignore.io/api/xcode,osx
# Fastlane
**/fastlane/report.xml
**/fastlane/Preview.html
**/fastlane/screenshots
**/fastlane/test_output
# Obfuscated constants
**/ObfuscatedConstants.swift
# DDK
PDDesktopDevKit/Libraries
PDDesktopDevKit/Package.resolved
PDFileProviderOperations/Package.resolved
# Packagemodifier
Scripts/opensource/packagemodifier/.build
Scripts/opensource/packagemodifier/Packages
Scripts/opensource/packagemodifier/.swiftpm/configuration/registries.json
Scripts/opensource/packagemodifier/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
# XcodeGen
ProtonDrive-iOS/ProtonDrive-iOS.xcodeproj
# Dynamic domain
.dynamic_domain
@@ -1,14 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>SchemeUserState</key>
<dict>
<key>PDClient-Package.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>12</integer>
</dict>
</dict>
</dict>
</plist>
+21
View File
@@ -0,0 +1,21 @@
// Copyright (c) 2025 Proton AG
//
// This file is part of Proton Drive.
//
// Proton Drive is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Proton Drive is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Proton Drive. If not, see https://www.gnu.org/licenses/.
public enum APIErrorCodes: Int, Equatable {
case itemOrItsParentDeletedErrorCode = 2501 // NOT_EXISTS
case protonDocumentCannotBeCreatedFromMacOSAppErrorCode = 200701 // FILE_CREATION_NOT_ENABLED_FOR_DOCUMENTS
}
@@ -0,0 +1,40 @@
// Copyright (c) 2024 Proton AG
//
// This file is part of Proton Drive.
//
// Proton Drive is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Proton Drive is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Proton Drive. If not, see https://www.gnu.org/licenses/.
import Foundation
public struct DeleteBookmarkEndpoint: Endpoint {
public struct Response: Codable {
public var code: Int
}
public var request: URLRequest
public init(token: String, service: APIService, credential: ClientCredential) {
let url = service.url(of: "/v2/urls/\(token)/bookmark")
var headers = service.baseHeaders
headers.merge(service.authHeaders(credential), uniquingKeysWith: { $1 })
var request = URLRequest(url: url)
request.httpMethod = "DELETE"
headers.forEach { request.setValue($1, forHTTPHeaderField: $0) }
self.request = request
}
}
@@ -0,0 +1,74 @@
// Copyright (c) 2024 Proton AG
//
// This file is part of Proton Drive.
//
// Proton Drive is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Proton Drive is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Proton Drive. If not, see https://www.gnu.org/licenses/.
import Foundation
// MARK: - Response Structure
public struct ListBookmarksResponse: Codable {
public let bookmarks: [BookmarkResponse]
public let code: Int
public struct BookmarkResponse: Codable {
public let encryptedUrlPassword: String
public let createTime: Int
public let token: TokenResponse
public struct TokenResponse: Codable {
public let token: String
public let linkType: Int
public let linkID: String
public let sharePasswordSalt: String
public let sharePassphrase: String
public let shareKey: String
public let nodePassphrase: String
public let nodeKey: String
public let name: String
public let contentKeyPacket: String?
public let MIMEType: String?
public let permissions: Int
public let size: Int?
public let thumbnailURLInfo: ThumbnailURLInfoResponse?
public let nodeHashKey: String?
public struct ThumbnailURLInfoResponse: Codable {
public let url: String?
public let bareURL: String?
public let token: String?
}
}
}
}
// MARK: - Endpoint
/// Fetch the list of bookmarks
/// - GET: /drive/v2/bookmarks
public struct ListBookmarksEndpoint: Endpoint {
public typealias Response = ListBookmarksResponse
public let request: URLRequest
public init(service: APIService, credential: ClientCredential) {
let url = service.url(of: "/v2/shared-bookmarks")
var headers = service.baseHeaders
headers.merge(service.authHeaders(credential), uniquingKeysWith: { $1 })
var request = URLRequest(url: url)
request.httpMethod = "GET"
headers.forEach { request.setValue($1, forHTTPHeaderField: $0) }
self.request = request
}
}
+182 -33
View File
@@ -49,31 +49,6 @@ extension Client {
return response.shareURLs
}
public func listVolumeTrash(volumeID: VolumeID, page: Int, pageSize: Int) async throws -> ListVolumeTrashEndpoint.Response {
let credential = try credential()
let endpoint = ListVolumeTrashEndpoint(
parameters: .init(
volumeId: volumeID,
page: page,
pageSize: pageSize
),
service: service,
credential: credential
)
return try await request(endpoint)
}
@discardableResult
public func trashNodes(parameters: TrashLinksParameters, breadcrumbs: Breadcrumbs) async throws -> MultipleLinkResponse {
guard let credential = self.credentialProvider.clientCredential() else {
throw Errors.couldNotObtainCredential
}
let endpoint = try TrashLinkEndpoint(parameters: parameters, service: service, credential: credential, breadcrumbs: breadcrumbs.collect())
return try await request(endpoint)
}
public func getFolderChildren(_ shareID: ShareID, folderID: FolderID, parameters: [FolderChildrenEndpointParameters]? = nil) async throws -> [Link] {
let endpoint = FolderChildrenEndpoint(shareID: shareID, folderID: folderID, parameters: parameters, service: service, credential: try credential())
return try await request(endpoint, completionExecutor: .asyncExecutor(dispatchQueue: backgroundQueue)).links
@@ -139,12 +114,6 @@ extension Client {
_ = try await request(endpoint)
}
public func moveEntry(shareID: Share.ShareID, nodeID: Link.LinkID, parameters: MoveEntryEndpoint.Parameters) async throws {
let credential = try credential()
let endpoint = MoveEntryEndpoint(shareID: shareID, nodeID: nodeID, parameters: parameters, service: service, credential: credential)
_ = try await request(endpoint)
}
public func createShare(volumeID: Volume.VolumeID, parameters: NewShareParameters) async throws -> NewShareShort {
let credential = try credential()
let endpoint = NewShareEndpoint(volumeID: volumeID, parameters: parameters, service: self.service, credential: credential)
@@ -152,21 +121,55 @@ extension Client {
}
}
public protocol MoveNodeClient {
func moveEntry(shareID: Share.ShareID, nodeID: Link.LinkID, parameters: MoveEntryEndpoint.Parameters) async throws
func moveMultiple(volumeID: Volume.VolumeID, parameters: MoveMultipleEndpoint.Parameters) async throws
func transferMultiple(volumeID: Volume.VolumeID, parameters: TransferMultipleEndpoint.Parameters) async throws
}
extension Client: MoveNodeClient {
public func moveEntry(shareID: Share.ShareID, nodeID: Link.LinkID, parameters: MoveEntryEndpoint.Parameters) async throws {
let credential = try credential()
let endpoint = MoveEntryEndpoint(shareID: shareID, nodeID: nodeID, parameters: parameters, service: service, credential: credential)
_ = try await request(endpoint)
}
public func moveMultiple(volumeID: VolumeID, parameters: MoveMultipleEndpoint.Parameters) async throws {
let credential = try credential()
let endpoint = MoveMultipleEndpoint(volumeID: volumeID, parameters: parameters, service: service, credential: credential)
_ = try await request(endpoint)
}
public func transferMultiple(volumeID: VolumeID, parameters: TransferMultipleEndpoint.Parameters) async throws {
let credential = try credential()
let endpoint = TransferMultipleEndpoint(volumeID: volumeID, parameters: parameters, service: service, credential: credential)
_ = try await request(endpoint)
}
}
public protocol SharesListing {
func listShares() async throws -> [ListSharesEndpoint.Response.Share]
func listShares(parameters: ListSharesEndpoint.Parameters) async throws -> [ListSharesEndpoint.Response.Share]
}
extension Client: SharesListing {
public func listShares() async throws -> [ListSharesEndpoint.Response.Share] {
let parameters = ListSharesEndpoint.Parameters(shareType: nil, showAll: .default)
return try await listShares(parameters: parameters)
}
public func listShares(parameters: ListSharesEndpoint.Parameters) async throws -> [ListSharesEndpoint.Response.Share] {
let endpoint = ListSharesEndpoint(parameters: parameters, service: service, credential: try credential())
let response = try await request(endpoint)
return response.shares
}
}
extension Client {
public protocol BootstrapRootClient {
func bootstrapRoot(shareID: String, rootLinkID: String) async throws -> Root
}
extension Client: BootstrapRootClient {
public func bootstrapRoot(shareID: String, rootLinkID: String) async throws -> Root {
async let share = try bootstrapShare(id: shareID)
async let root = try getLinkMetadata(parameters: .init(shareId: shareID, linkIds: [rootLinkID]))
@@ -190,6 +193,18 @@ extension Client {
}
}
public protocol UserSettingAPIClient {
func getDriveEntitlements() async throws -> DriveEntitlementsEndpoint.DriveEntitlements
}
extension Client: UserSettingAPIClient {
public func getDriveEntitlements() async throws -> DriveEntitlementsEndpoint.DriveEntitlements {
let credential = try credential()
let endpoint = DriveEntitlementsEndpoint(service: service, credential: credential)
return try await request(endpoint).entitlements
}
}
extension Client {
public func postVolume(parameters: NewVolumeParameters) async throws -> NewVolume {
let credential = try credential()
@@ -420,3 +435,137 @@ extension Client: LinksMetadataRepository {
return try await request(endpoint, completionExecutor: .asyncExecutor(dispatchQueue: backgroundQueue))
}
}
// MARK: - RemoteLinksMetadataByVolumeDataSource
public protocol RemoteLinksMetadataByVolumeDataSource {
func getMetadata(forLinks links: [String], inVolume volume: String) async throws -> LinksResponseByVolume
}
extension Client: RemoteLinksMetadataByVolumeDataSource {
public func getMetadata(forLinks links: [String], inVolume volume: String) async throws -> LinksResponseByVolume {
let parameters = LinksMetadataByVolumeParameters(volumeId: volume, linkIds: links)
let endpoint = LinksMetadataByVolumeEndpoint(service: service, credential: try credential(), parameters: parameters)
return try await request(endpoint, completionExecutor: .asyncExecutor(dispatchQueue: backgroundQueue))
}
}
// MARK: - RemoteShareMetadataDataSource
public protocol RemoteShareMetadataDataSource {
func getMetadata(forShare share: String) async throws -> ShareMetadata
}
extension Client: RemoteShareMetadataDataSource {
public func getMetadata(forShare share: String) async throws -> ShareMetadata {
let credential = try credential()
let endpoint = GetShareBootstrapEndpoint(shareID: share, service: service, credential: credential)
return try await request(endpoint, completionExecutor: .asyncExecutor(dispatchQueue: backgroundQueue))
}
}
// MARK: - TrashRepository
public protocol TrashRepository {
func emptyVolumeTrash(volumeId: Volume.VolumeID) async throws
func deleteTrashed(volumeId: Volume.VolumeID, linkIds: [Link.LinkID]) async throws -> [PartialFailure]
func trashVolumeNodes(parameters: TrashVolumeLinksParameters, breadcrumbs: Breadcrumbs) async throws -> MultipleLinkResponse
func restoreVolumeTrashedNodes(volumeID: String, linkIDs: [String]) async throws -> [PartialFailure]
func retoreTrashNode(shareID: Client.ShareID, linkIDs: [Client.LinkID]) async throws -> [PartialFailure]
}
extension Client: TrashRepository {
public func emptyVolumeTrash(volumeId: Volume.VolumeID) async throws {
let endpoint = EmptyVolumeTrashEndpoint(volumeId: volumeId, service: service, credential: try credential())
do {
_ = try await request(endpoint)
} catch {
if error.httpCode == 202 {
// ProtonCore throws when `statusCode != 200`. This is a hack around the limitation since this API
// actually returns 202 in success case.
return
} else {
throw error
}
}
}
public func deleteTrashed(volumeId: Volume.VolumeID, linkIds: [Link.LinkID]) async throws -> [PartialFailure] {
let parameters = DeleteMultipleParameters(volumeId: volumeId, linkIds: linkIds)
let endpoint = DeleteMultipleEndpoint(parameters: parameters, service: service, credential: try credential())
let response = try await request(endpoint)
return response.responses.compactMap(PartialFailure.init)
}
public func listVolumeTrash(volumeID: VolumeID, page: Int, pageSize: Int) async throws -> ListVolumeTrashEndpoint.Response {
let credential = try credential()
let endpoint = ListVolumeTrashEndpoint(
parameters: .init(
volumeId: volumeID,
page: page,
pageSize: pageSize
),
service: service,
credential: credential
)
return try await request(endpoint)
}
@discardableResult
public func trashVolumeNodes(parameters: TrashVolumeLinksParameters, breadcrumbs: Breadcrumbs) async throws -> MultipleLinkResponse {
guard let credential = self.credentialProvider.clientCredential() else {
throw Errors.couldNotObtainCredential
}
let endpoint = try TrashVolumeLinkEndpoint(parameters: parameters, service: service, credential: credential, breadcrumbs: breadcrumbs)
return try await request(endpoint)
}
public func restoreVolumeTrashedNodes(volumeID: String, linkIDs: [String]) async throws -> [PartialFailure] {
let parameters: RestoreVolumeLinkEndpoint.Parameters = .init(volumeID: volumeID, linkIDs: linkIDs)
let endpoint = RestoreVolumeLinkEndpoint(parameters: parameters, service: service, credential: try credential())
let response = try await request(endpoint)
return response.responses.compactMap(PartialFailure.init)
}
// Legacy, todo when album / computer is released, this can be removed
@discardableResult
public func trashNodes(parameters: TrashLinksParameters, breadcrumbs: Breadcrumbs) async throws -> MultipleLinkResponse {
guard let credential = self.credentialProvider.clientCredential() else {
throw Errors.couldNotObtainCredential
}
let endpoint = try TrashLinkEndpoint(parameters: parameters, service: service, credential: credential, breadcrumbs: breadcrumbs.collect())
return try await request(endpoint)
}
// Legacy, todo when album / computer is released, this can be removed
public func retoreTrashNode(shareID: ShareID, linkIDs: [LinkID]) async throws -> [PartialFailure] {
let parameters = RestoreLinkEndpoint.Parameters(shareID: shareID, linkIDs: linkIDs)
let endpoint = RestoreLinkEndpoint(parameters: parameters, service: service, credential: try credential())
let response = try await request(endpoint)
return response.responses.compactMap(PartialFailure.init)
}
}
public protocol CopyRepository {
func copyLinkToVolume(parameters: CopyLinkToVolumeEndpoint.Parameters) async throws -> Link.LinkID
}
extension Client: CopyRepository {
public func copyLinkToVolume(parameters: CopyLinkToVolumeEndpoint.Parameters) async throws -> Link.LinkID {
let endpoint = CopyLinkToVolumeEndpoint(parameters: parameters, service: service, credential: try credential())
let response = try await request(endpoint)
return response.linkID
}
}
public protocol DriveChecklistDataSource {
func getDriveChecklist() async throws -> DriveChecklistStatusResponse
}
extension Client: DriveChecklistDataSource {
public func getDriveChecklist() async throws -> DriveChecklistStatusResponse {
let endpoint = DriveChecklistEndpoint(service: service, credential: try credential())
let response = try await request(endpoint)
return DriveChecklistStatusResponse(from: response)
}
}
+7 -37
View File
@@ -16,7 +16,6 @@
// along with Proton Drive. If not, see https://www.gnu.org/licenses/.
import Foundation
import PDLoadTesting
import ProtonCoreUtilities
extension Client {
@@ -85,7 +84,7 @@ extension Client {
return completion(.failure(Errors.couldNotObtainCredential))
}
let endpoint = FolderChildrenEndpoint(shareID: shareID, folderID: folderID, parameters: parameters, service: self.service, credential: credential)
request(endpoint) {
request(endpoint, completionExecutor: .asyncExecutor(dispatchQueue: backgroundQueue)) {
completion( $0.flatMap { .success($0.links) })
}
}
@@ -181,34 +180,11 @@ extension Client {
let endpoint = NewBlocksEndpoint(parameters: parameters, service: service, credential: credential)
request(endpoint) { result in
completion(result.flatMap {
if LoadTesting.isEnabled {
return .success((
blocks: Self.fixUploadLinks(requestURL: endpoint.request.url, links: $0.uploadLinks),
thumbnails: Self.fixUploadLinks(requestURL: endpoint.request.url, links: $0.thumbnailLinks)
))
} else {
return .success((blocks: $0.uploadLinks, thumbnails: $0.thumbnailLinks ?? []))
}
.success((blocks: $0.uploadLinks, thumbnails: $0.thumbnailLinks ?? []))
})
}
}
private static func fixUploadLinks(requestURL: URL?, links: [ContentUploadLink]?) -> [ContentUploadLink] {
guard LoadTesting.isEnabled else {
assertionFailure("This method should only be called when load testing is enabled")
return links ?? []
}
guard let apiURL = requestURL?.absoluteString, let links else { return links ?? [] }
return links.map { link in
guard apiURL.hasPrefix("http://") && link.URL.hasPrefix("https://") else {
return link
}
let host = apiURL.replacingOccurrences(of: "drive/blocks", with: "")
let fixedURL = host + link.URL.replacingOccurrences(of: "https:\\/\\/\\w+\\/", with: "", options: .regularExpression)
return ContentUploadLink(token: link.token, URL: fixedURL)
}
}
public func postVolume(parameters: NewVolumeParameters, completion: @escaping (Result<NewVolume, Error>) -> Void) {
guard let credential = self.credentialProvider.clientCredential() else {
return completion(.failure(Errors.couldNotObtainCredential))
@@ -412,6 +388,7 @@ extension Client {
}
extension Client {
@discardableResult
public func trash(shareID: ShareID, parentID: LinkID, linkIDs: [LinkID]) async throws -> [PartialFailure] {
let parameters = TrashLinksParameters(shareId: shareID, parentLinkId: parentID, linkIds: linkIDs)
let endpoint = try TrashLinkEndpoint(parameters: parameters, service: service, credential: try credential(), breadcrumbs: .startCollecting())
@@ -430,13 +407,6 @@ extension Client {
_ = try await request(endpoint)
}
public func retoreTrashNode(shareID: ShareID, linkIDs: [LinkID]) async throws -> [PartialFailure] {
let parameters = RestoreLinkEndpoint.Parameters(shareID: shareID, linkIDs: linkIDs)
let endpoint = RestoreLinkEndpoint(parameters: parameters, service: service, credential: try credential())
let response = try await request(endpoint)
return response.responses.compactMap(PartialFailure.init)
}
public func deleteTrashed(shareID: ShareID, linkIDs: [LinkID]) async throws -> [PartialFailure] {
let parameters = DeleteLinkEndpoint.Parameters(shareID: shareID, linkIDs: linkIDs)
let endpoint = DeleteLinkEndpoint(parameters: parameters, service: service, credential: try credential())
@@ -472,10 +442,10 @@ public struct PartialFailure {
self.error = NSError(domain: error, code: code, localizedDescription: description)
}
init?(_ linkReponse: MultipleLinkResponse.LinkResponse) {
guard let error = linkReponse.response.error else { return nil }
self.id = linkReponse.linkID
self.error = NSError(domain: error, code: linkReponse.response.code)
public init?(_ linkResponse: MultipleLinkResponse.LinkResponse) {
guard let error = linkResponse.response.error else { return nil }
self.id = linkResponse.linkID
self.error = NSError(domain: error, code: linkResponse.response.code)
}
}
+10 -1
View File
@@ -17,6 +17,7 @@
import Foundation
import ProtonCoreUtilities
import ProtonCoreServices
public protocol CredentialProvider: AnyObject {
/// Obtaining credential optionally
@@ -43,7 +44,15 @@ public class Client {
}
public func credential() throws -> ClientCredential {
return try credentialProvider.getCredential()
do {
let credential = try credentialProvider.getCredential()
return credential
} catch {
#if os(iOS)
networking.authDelegate?.onAuthenticatedSessionInvalidated(sessionUID: "")
#endif
throw error
}
}
func request<E: Endpoint, Response>(_ endpoint: E, completionExecutor: CompletionBlockExecutor = .asyncMainExecutor, completion: @escaping (Result<Response, Error>) -> Void) where Response == E.Response {
+22
View File
@@ -0,0 +1,22 @@
// Copyright (c) 2025 Proton AG
//
// This file is part of Proton Drive.
//
// Proton Drive is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Proton Drive is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Proton Drive. If not, see https://www.gnu.org/licenses/.
import Foundation
public struct CodeResponse: Codable {
public let code: Int
}
@@ -0,0 +1,26 @@
// Copyright (c) 2024 Proton AG
//
// This file is part of Proton Drive.
//
// Proton Drive is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Proton Drive is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Proton Drive. If not, see https://www.gnu.org/licenses/.
import Foundation
public protocol LinksMetadataDataSource {
func getLinksMetadata(with parameters: LinksMetadataParameters) async throws -> LinksResponse
}
public protocol PhotosListingDataSource {
func getPhotosList(with parameters: PhotosListRequestParameters) async throws -> PhotosListResponse
}
@@ -0,0 +1,25 @@
// Copyright (c) 2025 Proton AG
//
// This file is part of Proton Drive.
//
// Proton Drive is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Proton Drive is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Proton Drive. If not, see https://www.gnu.org/licenses/.
public enum ShareTargetType: Int, CaseIterable {
case root = 0
case folder = 1
case file = 2
case album = 3
case photo = 4
case document = 5
}
@@ -0,0 +1,22 @@
// Copyright (c) 2024 Proton AG
//
// This file is part of Proton Drive.
//
// Proton Drive is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Proton Drive is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Proton Drive. If not, see https://www.gnu.org/licenses/.
import Foundation
public protocol SharedByMeLinkIdsDataSource {
func getSharedByMeLinks(parameters: SharedByMeListParameters) async throws -> SharedByMeListResponse
}
@@ -0,0 +1,63 @@
// Copyright (c) 2024 Proton AG
//
// This file is part of Proton Drive.
//
// Proton Drive is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Proton Drive is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Proton Drive. If not, see https://www.gnu.org/licenses/.
import Foundation
public struct AcceptInvitationParameters {
let invitationID: String
let bodyParameters: BodyParameters
public init(invitationID: String, sessionKeySignature: String) {
self.invitationID = invitationID
self.bodyParameters = BodyParameters(sessionKeySignature: sessionKeySignature)
}
struct BodyParameters: Encodable {
let sessionKeySignature: String
}
}
public struct AcceptInvitationResponse: Codable {
public let code: Int
}
/// Accept invitation
/// - POST: /drive/v2/shares/invitations/{invitationID}/accept
public struct AcceptInvitationEndpoint: Endpoint {
public typealias Response = AcceptInvitationResponse
public var request: URLRequest
public init(parameters: AcceptInvitationParameters, service: APIService, credential: ClientCredential) throws {
// url
let url = service.url(of: "/v2/shares/invitations/\(parameters.invitationID)/accept")
// request
var request = URLRequest(url: url)
request.httpMethod = "POST"
// headers
var headers = service.baseHeaders
headers.merge(service.authHeaders(credential), uniquingKeysWith: { $1 })
headers.forEach { request.setValue($1, forHTTPHeaderField: $0) }
request.httpBody = try JSONEncoder(strategy: .capitalizeFirstLetter).encode(parameters.bodyParameters)
self.request = request
}
}
@@ -0,0 +1,26 @@
// Copyright (c) 2024 Proton AG
//
// This file is part of Proton Drive.
//
// Proton Drive is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Proton Drive is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Proton Drive. If not, see https://www.gnu.org/licenses/.
import Foundation
extension Client: SharedByMeLinkIdsDataSource, LinksMetadataDataSource {
public func getSharedByMeLinks(parameters: SharedByMeListParameters) async throws -> SharedByMeListResponse {
let endpoint = SharedByMeEndpoint(service: service, credential: try credential(), parameters: parameters)
let response = try await request(endpoint, completionExecutor: .immediateExecutor)
return response
}
}
@@ -0,0 +1,76 @@
// Copyright (c) 2024 Proton AG
//
// This file is part of Proton Drive.
//
// Proton Drive is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Proton Drive is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Proton Drive. If not, see https://www.gnu.org/licenses/.
import Foundation
public struct ListUserInvitationsParameters {
let anchorID: String?
let pageSize: Int?
let shareTypes: Set<ShareTargetType>
public init(anchorID: String?, pageSize: Int? = nil, shareTypes: Set<ShareTargetType>) {
self.anchorID = anchorID
self.pageSize = pageSize
self.shareTypes = shareTypes
}
}
public struct ListUserInvitationsResponse: Codable {
public let invitations: [InvitationResponse]
public let anchorID: String?
public let more: Bool
public let code: Int
public struct InvitationResponse: Codable {
public let volumeID: String
public let shareID: String
public let invitationID: String
}
}
/// Get List invitations of a user
/// - GET: /drive/v2/shares/invitations
public struct ListUserInvitationsEndpoint: Endpoint {
public typealias Response = ListUserInvitationsResponse
public let request: URLRequest
public init(service: APIService, credential: ClientCredential, parameters: ListUserInvitationsParameters) {
var queries: [URLQueryItem] = []
if let anchorID = parameters.anchorID {
queries.append(URLQueryItem(name: "AnchorID", value: anchorID))
}
if let pageSize = parameters.pageSize {
queries.append(URLQueryItem(name: "PageSize", value: String(pageSize)))
}
let shareTypes = parameters.shareTypes.map(\.rawValue).map(String.init)
shareTypes.forEach { shareType in
// Query key contains `[]` to convey array type
queries.append(URLQueryItem(name: "ShareTargetTypes[]", value: shareType))
}
let url = service.url(of: "/v2/shares/invitations", queries: queries)
var headers = service.baseHeaders
headers.merge(service.authHeaders(credential), uniquingKeysWith: { $1 })
var request = URLRequest(url: url)
request.httpMethod = "GET"
headers.forEach { request.setValue($1, forHTTPHeaderField: $0) }
self.request = request
}
}
@@ -0,0 +1,55 @@
// Copyright (c) 2024 Proton AG
//
// This file is part of Proton Drive.
//
// Proton Drive is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Proton Drive is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Proton Drive. If not, see https://www.gnu.org/licenses/.
import Foundation
public struct RejectInvitationParameters {
let invitationID: String
public init(invitationID: String) {
self.invitationID = invitationID
}
}
public struct RejectInvitationResponse: Codable {
public let code: Int
}
/// Reject invitation
/// - POST: /drive/v2/shares/invitations/{invitationID}/reject
public struct RejectInvitationEndpoint: Endpoint {
public typealias Response = RejectInvitationResponse
public var request: URLRequest
public init(parameters: RejectInvitationParameters, service: APIService, credential: ClientCredential) {
// url
let url = service.url(of: "/v2/shares/invitations/\(parameters.invitationID)/reject")
// request
var request = URLRequest(url: url)
request.httpMethod = "POST"
// headers
var headers = service.baseHeaders
headers.merge(service.authHeaders(credential), uniquingKeysWith: { $1 })
headers.forEach { request.setValue($1, forHTTPHeaderField: $0) }
self.request = request
}
}
@@ -0,0 +1,102 @@
// Copyright (c) 2024 Proton AG
//
// This file is part of Proton Drive.
//
// Proton Drive is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Proton Drive is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Proton Drive. If not, see https://www.gnu.org/licenses/.
import Foundation
public struct ReturnInvitationInformationParameters {
let invitationID: String
public init(invitationID: String) {
self.invitationID = invitationID
}
}
public struct ReturnInvitationInformationResponse: Codable {
public let invitation: InvitationResponse
public let share: ShareResponse
public let link: LinkResponse
public let code: Int
public struct InvitationResponse: Codable {
public let invitationID: String
public let inviterEmail: String
public let inviteeEmail: String
public let permissions: Int
public let keyPacket: String
public let keyPacketSignature: String
public let createTime: Int
init(invitationID: String, inviterEmail: String, inviteeEmail: String, permissions: Int, keyPacket: String, keyPacketSignature: String, createTime: Int) {
self.invitationID = invitationID
self.inviterEmail = inviterEmail
self.inviteeEmail = inviteeEmail
self.permissions = permissions
self.keyPacket = keyPacket
self.keyPacketSignature = keyPacketSignature
self.createTime = createTime
}
}
public struct ShareResponse: Codable {
public let shareID: String
public let volumeID: String
public let passphrase: String
public let shareKey: String
public let creatorEmail: String
init(shareID: String, volumeID: String, passphrase: String, shareKey: String, creatorEmail: String) {
self.shareID = shareID
self.volumeID = volumeID
self.passphrase = passphrase
self.shareKey = shareKey
self.creatorEmail = creatorEmail
}
}
public struct LinkResponse: Codable {
public let type: Int
public let linkID: String
public let name: String
public let MIMEType: String?
init(type: Int, linkID: String, name: String, MIMEType: String?) {
self.type = type
self.linkID = linkID
self.name = name
self.MIMEType = MIMEType
}
}
}
/// Return Invitation Information
/// - GET: /drive/v2/shares/invitations/{InvitationID}
public struct ReturnInvitationInformationEndpoint: Endpoint {
public typealias Response = ReturnInvitationInformationResponse
public let request: URLRequest
public init(service: APIService, credential: ClientCredential, parameters: ReturnInvitationInformationParameters) {
let url = service.url(of: "/v2/shares/invitations/\(parameters.invitationID)")
var headers = service.baseHeaders
headers.merge(service.authHeaders(credential), uniquingKeysWith: { $1 })
var request = URLRequest(url: url)
request.httpMethod = "GET"
headers.forEach { request.setValue($1, forHTTPHeaderField: $0) }
self.request = request
}
}
@@ -0,0 +1,65 @@
// Copyright (c) 2024 Proton AG
//
// This file is part of Proton Drive.
//
// Proton Drive is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Proton Drive is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Proton Drive. If not, see https://www.gnu.org/licenses/.
import Foundation
public struct SharedByMeListParameters {
let volumeId: String
let anchorID: String?
public init(volumeId: String, anchorID: String?) {
self.volumeId = volumeId
self.anchorID = anchorID
}
}
public struct SharedByMeListResponse: Codable {
public let links: [Link]
public let anchorID: String?
public let more: Bool
public let code: Int
public struct Link: Codable {
public let contextShareID: String
public let shareID: String
public let linkID: String
}
}
/// Get Revision
/// - GET: /drive/v2/volumes/{volumeID}/shares
public struct SharedByMeEndpoint: Endpoint {
public typealias Response = SharedByMeListResponse
public let request: URLRequest
public init(service: APIService, credential: ClientCredential, parameters: SharedByMeListParameters) {
var queries: [URLQueryItem] = []
if let anchorID = parameters.anchorID {
queries.append(URLQueryItem(name: "AnchorID", value: anchorID))
}
let url = service.url(of: "/v2/volumes/\(parameters.volumeId)/shares", queries: queries)
var headers = service.baseHeaders
headers.merge(service.authHeaders(credential), uniquingKeysWith: { $1 })
var request = URLRequest(url: url)
request.httpMethod = "GET"
headers.forEach { request.setValue($1, forHTTPHeaderField: $0) }
self.request = request
}
}
@@ -39,7 +39,11 @@ public struct SharedWithMeEndpoint: Endpoint {
public let request: URLRequest
public init(service: APIService, credential: ClientCredential, parameters: Parameters) {
let url = service.url(of: "/v2/sharedwithme")
var queries: [URLQueryItem] = []
if let anchorID = parameters.anchorID {
queries.append(URLQueryItem(name: "AnchorID", value: anchorID))
}
let url = service.url(of: "/v2/sharedwithme", queries: queries)
var headers = service.baseHeaders
headers.merge(service.authHeaders(credential), uniquingKeysWith: { $1 })
+2
View File
@@ -17,8 +17,10 @@
import Foundation
import ProtonCoreNetworking
import ProtonCoreUtilities
public protocol CoreAPIService {
func perform(request route: Request, completion: @escaping (_ task: URLSessionDataTask?, _ result: Result<JSONDictionary, ResponseError>) -> Void)
func perform(request route: Request, callCompletionBlockUsing executor: CompletionBlockExecutor, completion: @escaping (_ task: URLSessionDataTask?, _ result: Result<JSONDictionary, ResponseError>) -> Void)
func perform(request route: Request, dataTaskBlock: @escaping (URLSessionDataTask) -> Void, completion: @escaping (_ task: URLSessionDataTask?, _ result: Result<JSONDictionary, ResponseError>) -> Void)
}
@@ -17,14 +17,14 @@
import Foundation
struct DeleteDeviceEndpoint: Endpoint {
public struct DeleteDeviceEndpoint: Endpoint {
public struct Response: Codable {
var code: Int
}
var request: URLRequest
public var request: URLRequest
init(deviceID: String, service: APIService, credential: ClientCredential) {
public init(deviceID: String, service: APIService, credential: ClientCredential) {
// url
var url = service.url(of: "/devices")
url.appendPathComponent(deviceID)
@@ -0,0 +1,41 @@
// Copyright (c) 2025 Proton AG
//
// This file is part of Proton Drive.
//
// Proton Drive is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Proton Drive is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Proton Drive. If not, see https://www.gnu.org/licenses/.
import Foundation
// Empty volume trash
// POST: /volumes/{volumeID}/trash
struct EmptyVolumeTrashEndpoint: Endpoint {
typealias Response = CodeResponse
var request: URLRequest
init(volumeId: Volume.VolumeID, service: APIService, credential: ClientCredential) {
var url = service.url(of: "/volumes")
url.appendPathComponent(volumeId)
url.appendPathComponent("/trash")
var request = URLRequest(url: url)
request.httpMethod = "DELETE"
var headers = service.baseHeaders
headers.merge(service.authHeaders(credential), uniquingKeysWith: { $1 })
headers.forEach { request.setValue($1, forHTTPHeaderField: $0) }
self.request = request
}
}
@@ -25,7 +25,7 @@ struct RemoveShareMemberEndpoint: Endpoint {
var request: URLRequest
init(shareID: String, memberID: String, service: APIService, credential: ClientCredential) {
var url = service.url(of: "/v2/shares/\(shareID)/members/\(memberID)")
let url = service.url(of: "/v2/shares/\(shareID)/members/\(memberID)")
var request = URLRequest(url: url)
request.httpMethod = "DELETE"
+3
View File
@@ -16,9 +16,12 @@
// along with Proton Drive. If not, see https://www.gnu.org/licenses/.
import ProtonCoreUtilities
import ProtonCoreServices
/// DriveAPIService:
/// Protocol that will allow us to decouple from Core's statically defined APIService and implement polymorphisim in a clean way
public protocol DriveAPIService {
var authDelegate: AuthDelegate? { get }
func request<E: Endpoint, R>(from endpoint: E, completionExecutor: CompletionBlockExecutor, completion: @escaping (Result<R, Error>) -> Void) where R == E.Response
}
@@ -24,21 +24,52 @@ public enum ExternalFeatureFlag: CaseIterable {
case pushNotificationIsEnabled
case logCollectionEnabled
case logCollectionDisabled
case driveiOSDebugMode
case oneDollarPlanUpsellEnabled
case driveDisablePhotosForB2B
case parallelEncryptionAndVerification
case driveDDKEnabled
case driveMacSyncRecoveryDisabled
case driveMacKeepDownloaded
// MARK: - Sharing
// Sharing
case driveSharingMigration
case driveiOSSharing
case driveSharingDevelopment
case driveSharingInvitations
case driveSharingExternalInvitations
case driveSharingDisabled
case driveSharingExternalInvitationsDisabled
case driveSharingEditingDisabled
case drivePublicShareEditMode
case drivePublicShareEditModeDisabled
case acceptRejectInvitation
case driveShareURLBookmarking
case driveShareURLBookmarksDisabled
// ProtonDoc
case driveDocsWebView
case driveDocsDisabled
// Rating booster
// Legacy feature flags we used before migrating to Unleash
case ratingIOSDrive
case driveRatingBooster
// Entitlement
case driveDynamicEntitlementConfiguration
// Refactor
case driveiOSRefreshableBlockDownloadLink
// Computers
case driveiOSComputers
case driveiOSComputersDisabled
// Albums
case driveAlbumsDisabled
case driveCopyDisabled
case drivePhotosTagsMigration
case drivePhotosTagsMigrationDisabled
// Sheets
case docsSheetsEnabled
case docsSheetsDisabled
case docsCreateNewSheetOnMobileEnabled
}
@@ -20,8 +20,6 @@ import Foundation
import UnleashProxyClientSwift
public final class UnleashFeatureFlagsResource: ExternalFeatureFlagsResource {
public typealias LogErrorHandler = (Error) -> Void
public typealias LogMessageHandler = (String) -> Void
private let session: UnleashPollerSession
private let configurationResolver: ExternalFeatureFlagConfigurationResolver
@@ -29,8 +27,6 @@ public final class UnleashFeatureFlagsResource: ExternalFeatureFlagsResource {
private var client: UnleashClient?
private let updateSubject = PassthroughSubject<Void, Never>()
private let refreshInterval: Int
private let logMessageHandler: LogMessageHandler
private let logErrorHandler: LogErrorHandler
private var cancellables = Set<AnyCancellable>()
public var updatePublisher: AnyPublisher<Void, Never> {
@@ -42,15 +38,11 @@ public final class UnleashFeatureFlagsResource: ExternalFeatureFlagsResource {
public init(
refreshInterval: Int,
session: UnleashPollerSession,
configurationResolver: ExternalFeatureFlagConfigurationResolver,
logMessageHandler: @escaping LogMessageHandler,
logErrorHandler: @escaping LogErrorHandler
configurationResolver: ExternalFeatureFlagConfigurationResolver
) {
self.refreshInterval = refreshInterval
self.session = session
self.configurationResolver = configurationResolver
self.logMessageHandler = logMessageHandler
self.logErrorHandler = logErrorHandler
}
public func start(completionHandler: @escaping (Error?) -> Void) {
@@ -60,12 +52,12 @@ public final class UnleashFeatureFlagsResource: ExternalFeatureFlagsResource {
}
guard let configuration = try? configurationResolver.makeConfiguration(refreshInterval: refreshInterval) else {
logErrorHandler(Errors.unableToFetchConfiguration)
logError?(Errors.unableToFetchConfiguration.localizedDescription)
completionHandler(Errors.unableToFetchConfiguration)
return
}
poller = Poller(refreshInterval: configuration.refreshInterval, unleashUrl: configuration.url, apiKey: configuration.apiKey, session: session)
poller = Poller(refreshInterval: configuration.refreshInterval, unleashUrl: configuration.url, apiKey: configuration.apiKey, session: session, appName: "Proton Drive", connectionId: UUID())
client = UnleashClient(unleashUrl: configuration.url.absoluteString, clientKey: configuration.apiKey, refreshInterval: configuration.refreshInterval, disableMetrics: true, environment: configuration.environment, poller: poller)
startClient(completionHandler: completionHandler)
@@ -87,9 +79,9 @@ public final class UnleashFeatureFlagsResource: ExternalFeatureFlagsResource {
self?.client?.start { error in
completionHandler(error)
if let error {
self?.logErrorHandler(error)
logError?(error.localizedDescription)
} else {
self?.logMessageHandler("Unleash started")
logInfo?("Unleash started")
}
}
}
@@ -97,11 +89,11 @@ public final class UnleashFeatureFlagsResource: ExternalFeatureFlagsResource {
private func subscribeToUpdates() {
client?.subscribe(name: "update") { [weak self] in
self?.logMessageHandler("Unleash feature flags: updated")
logInfo?("Unleash feature flags: updated")
self?.updateSubject.send()
}
client?.subscribe(name: "ready") { [weak self] in
self?.logMessageHandler("Unleash feature flags: ready")
logInfo?("Unleash feature flags: ready")
self?.updateSubject.send()
}
}
@@ -125,6 +117,7 @@ public final class UnleashFeatureFlagsResource: ExternalFeatureFlagsResource {
return client?.isEnabled(name: name) ?? false
}
// swiftlint:disable:next cyclomatic_complexity
private func makeName(from flag: ExternalFeatureFlag) -> String {
switch flag {
case .photosUploadDisabled:
@@ -145,12 +138,17 @@ public final class UnleashFeatureFlagsResource: ExternalFeatureFlagsResource {
return "DriveiOSLogCollectionDisabled"
case .oneDollarPlanUpsellEnabled:
return "DriveOneDollarPlanUpsell"
case .driveDisablePhotosForB2B:
return "DriveDisablePhotosForB2B"
case .driveDDKEnabled:
return "DriveDDKEnabled"
case .driveMacSyncRecoveryDisabled:
return "DriveMacSyncRecoveryDisabled"
case .driveMacKeepDownloaded:
return "DriveMacKeepDownloaded"
// Sharing
case .driveSharingMigration:
return "DriveSharingMigration"
case .driveiOSSharing:
return "DriveiOSSharing"
case .driveSharingDevelopment:
return "DriveSharingDevelopment"
case .driveSharingInvitations:
return "DriveSharingInvitations"
case .driveSharingExternalInvitations:
@@ -161,14 +159,51 @@ public final class UnleashFeatureFlagsResource: ExternalFeatureFlagsResource {
return "DriveSharingExternalInvitationsDisabled"
case .driveSharingEditingDisabled:
return "DriveSharingEditingDisabled"
case .driveDisablePhotosForB2B:
return "DriveDisablePhotosForB2B"
case .driveDocsWebView:
return "DriveDocsWebView"
case .parallelEncryptionAndVerification:
return "DriveMacParallelEncryptionAndVerification"
case .drivePublicShareEditMode:
return "DrivePublicShareEditMode"
case .drivePublicShareEditModeDisabled:
return "DrivePublicShareEditModeDisabled"
case .acceptRejectInvitation:
return "DriveMobileSharingInvitationsAcceptReject"
case .driveDynamicEntitlementConfiguration:
return "DriveDynamicEntitlementConfiguration"
// ProtonDoc
case .driveDocsDisabled:
return "DriveDocsDisabled"
// Rating booster
// Legacy feature flags we used before migrating to Unleash
case .ratingIOSDrive:
return "RatingIOSDrive"
case .driveRatingBooster:
return "DriveRatingBooster"
case .driveShareURLBookmarking:
return "DriveShareURLBookmarking"
case .driveShareURLBookmarksDisabled:
return "DriveShareURLBookmarksDisabled"
// Refactor
case .driveiOSRefreshableBlockDownloadLink:
return "DriveiOSRefreshableBlockDownloadLink"
case .driveiOSComputers:
return "DriveiOSComputers"
case .driveiOSComputersDisabled:
return "DriveiOSComputersDisabled"
// Album
case .driveAlbumsDisabled:
return "DriveAlbumsDisabled"
case .driveCopyDisabled:
return "DriveCopyDisabled"
case .drivePhotosTagsMigration:
return "DrivePhotosTagsMigration"
case .drivePhotosTagsMigrationDisabled:
return "DrivePhotosTagsMigrationDisabled"
case .docsSheetsEnabled:
return "DocsSheetsEnabled"
case .docsSheetsDisabled:
return "DocsSheetsDisabled"
case .docsCreateNewSheetOnMobileEnabled:
return "DocsCreateNewSheetOnMobileEnabled"
case .driveiOSDebugMode:
return "DriveiOSDebugMode"
}
}
@@ -26,27 +26,23 @@ public final class UnleashPollerSession: PollerSession {
}
public func perform(_ request: URLRequest, completionHandler: @escaping (Data?, URLResponse?, Error?) -> Void) {
do {
let endpoint = UnleashEndpoint(request: request)
var dataTask: URLSessionDataTask?
networking.perform(request: endpoint, dataTaskBlock: {
dataTask = $0
}, completion: { task, result in
let response = dataTask?.response ?? task?.response
switch result {
case let .success(responseDictionary):
do {
let data = try JSONSerialization.data(withJSONObject: responseDictionary, options: .prettyPrinted)
completionHandler(data, response, nil)
} catch {
completionHandler(nil, response, error)
}
case let .failure(error):
let endpoint = UnleashEndpoint(request: request)
var dataTask: URLSessionDataTask?
networking.perform(request: endpoint, dataTaskBlock: {
dataTask = $0
}, completion: { task, result in
let response = dataTask?.response ?? task?.response
switch result {
case let .success(responseDictionary):
do {
let data = try JSONSerialization.data(withJSONObject: responseDictionary, options: .prettyPrinted)
completionHandler(data, response, nil)
} catch {
completionHandler(nil, response, error)
}
})
} catch {
completionHandler(nil, nil, error)
}
case let .failure(error):
completionHandler(nil, response, error)
}
})
}
}
@@ -0,0 +1,90 @@
// Copyright (c) 2025 Proton AG
//
// This file is part of Proton Drive.
//
// Proton Drive is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Proton Drive is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Proton Drive. If not, see https://www.gnu.org/licenses/.
import Foundation
/// /checklist/get-started
/// Fetch the current storage bonus status
public struct DriveChecklistEndpoint: Endpoint {
public struct Response: Codable {
public let items: [String]
public let createdAt: TimeInterval?
public let expiresAt: TimeInterval?
public let userWasRewarded: Bool
public let seen: Bool
public let completed: Bool
public let rewardInGB: Int
public let visible: Bool
public let code: Int
}
public var request: URLRequest
public init(service: APIService, credential: ClientCredential) {
let url = service.url(of: "/v2/checklist/get-started")
var request = URLRequest(url: url)
request.httpMethod = "GET"
var headers = service.baseHeaders
headers.merge(service.authHeaders(credential), uniquingKeysWith: { $1 })
headers.forEach { request.setValue($1, forHTTPHeaderField: $0) }
self.request = request
}
}
public struct DriveChecklistStatusResponse: Codable, Equatable {
public let items: [String]
public let createdAt: Date
public let expiresAt: Date
public let userWasRewarded: Bool
public let seen: Bool
public let completed: Bool
public let rewardInGB: Int
public let visible: Bool
public init(
items: [String],
createdAt: Date,
expiresAt: Date,
userWasRewarded: Bool,
seen: Bool,
completed: Bool,
rewardInGB: Int,
visible: Bool
) {
self.items = items
self.createdAt = createdAt
self.expiresAt = expiresAt
self.userWasRewarded = userWasRewarded
self.seen = seen
self.completed = completed
self.rewardInGB = rewardInGB
self.visible = visible
}
public init(from response: DriveChecklistEndpoint.Response) {
self.items = response.items
self.createdAt = Date(timeIntervalSince1970: response.createdAt ?? .zero)
self.expiresAt = Date(timeIntervalSince1970: response.expiresAt ?? .zero)
self.userWasRewarded = response.userWasRewarded
self.seen = response.seen
self.completed = response.completed
self.rewardInGB = response.rewardInGB
self.visible = response.visible
}
}
@@ -0,0 +1,55 @@
// Copyright (c) 2024 Proton AG
//
// This file is part of Proton Drive.
//
// Proton Drive is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Proton Drive is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Proton Drive. If not, see https://www.gnu.org/licenses/.
import Foundation
/// Expose Drive specific features available to the user, along with their specific limits.
/// - GET: /drive/entitlements
public struct DriveEntitlementsEndpoint: Endpoint {
public let request: URLRequest
public init(service: APIService, credential: ClientCredential) {
// url
let url = service.url(of: "/entitlements")
// request
var request = URLRequest(url: url)
request.httpMethod = "GET"
// headers
var headers = service.baseHeaders
headers.merge(service.authHeaders(credential), uniquingKeysWith: { $1 })
headers.forEach { request.setValue($1, forHTTPHeaderField: $0) }
self.request = request
}
}
extension DriveEntitlementsEndpoint {
public struct Response: Codable {
public let code: Int
public let entitlements: DriveEntitlements
}
public struct DriveEntitlements: Codable {
public let publicCollaboration: Bool
public init(publicCollaboration: Bool) {
self.publicCollaboration = publicCollaboration
}
}
}
@@ -42,6 +42,20 @@ public struct Event: Encodable {
private struct MinimalLink: Codable {
public var linkID: String
}
public init(
contextShareID: Share.ShareID,
eventID: EventID,
eventType: EventType,
createTime: TimeInterval,
link: Link
) {
self.contextShareID = contextShareID
self.eventID = eventID
self.eventType = eventType
self.createTime = createTime
self.link = link
}
}
extension Event: Decodable {
@@ -59,6 +59,27 @@ public struct GetShareBootstrapEndpoint: Endpoint {
public let memberships: [Membership]
public let rootLinkRecoveryPassphrase: String?
public init(code: Int, shareID: String, volumeID: String, type: Int, state: Int, creator: String, locked: Bool?, createTime: Int?, modifyTime: Int?, linkID: String, linkType: LinkType, key: String, passphrase: String, passphraseSignature: String, addressID: String, addressKeyID: String, memberships: [Membership], rootLinkRecoveryPassphrase: String?) {
self.code = code
self.shareID = shareID
self.volumeID = volumeID
self.type = type
self.state = state
self.creator = creator
self.locked = locked
self.createTime = createTime
self.modifyTime = modifyTime
self.linkID = linkID
self.linkType = linkType
self.key = key
self.passphrase = passphrase
self.passphraseSignature = passphraseSignature
self.addressID = addressID
self.addressKeyID = addressKeyID
self.memberships = memberships
self.rootLinkRecoveryPassphrase = rootLinkRecoveryPassphrase
}
public struct Membership: Codable {
public let memberID: String
public let shareID: String
@@ -0,0 +1,59 @@
// Copyright (c) 2025 Proton AG
//
// This file is part of Proton Drive.
//
// Proton Drive is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Proton Drive is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Proton Drive. If not, see https://www.gnu.org/licenses/.
import Foundation
public struct GetTagsMigrationStateEndpoint: Endpoint {
public typealias Response = TagsMigrationStateResponse
public var request: URLRequest
public init(volumeID: String, service: APIService, credential: ClientCredential) {
var url = service.url(of: "/photos/volumes")
url.appendPathComponent(volumeID)
url.appendPathComponent("tags-migration")
var request = URLRequest(url: url)
request.httpMethod = "GET"
var headers = service.baseHeaders
headers.merge(service.authHeaders(credential), uniquingKeysWith: { $1 })
headers.forEach { request.setValue($1, forHTTPHeaderField: $0) }
self.request = request
}
public struct TagsMigrationStateResponse: Codable {
public let code: Int
public let finished: Bool
public let anchor: TagsMigrationAnchorResponse?
public struct TagsMigrationAnchorResponse: Codable {
public let lastProcessedLinkID: String
public let lastProcessedCaptureTime: Int
public let lastMigrationTimestamp: Int
public let lastClientUID: String?
public init(lastProcessedLinkID: String, lastProcessedCaptureTime: Int, lastMigrationTimestamp: Int, lastClientUID: String?) {
self.lastProcessedLinkID = lastProcessedLinkID
self.lastProcessedCaptureTime = lastProcessedCaptureTime
self.lastMigrationTimestamp = lastMigrationTimestamp
self.lastClientUID = lastClientUID
}
}
}
}
@@ -50,9 +50,9 @@ extension ListDevicesEndpoint {
public struct Device: Codable {
public let deviceID: String
public let volumeID: String
public let creationTime: TimeInterval
public let modifyTime: TimeInterval?
public let lastSyncTime: TimeInterval?
public let creationTime: Date
public let modifyTime: Date?
public let lastSyncTime: Date?
public let type: Int
public let syncState: Int
}
+30 -27
View File
@@ -52,7 +52,7 @@ public struct ListSharesEndpoint: Endpoint {
let shareType: ShareType?
let showAll: ShowAll?
init(addressId: String? = nil, shareType: ShareType? = nil, showAll: Parameters.ShowAll? = nil) {
public init(addressId: String? = nil, shareType: ShareType? = nil, showAll: Parameters.ShowAll? = nil) {
self.addressId = addressId
self.shareType = shareType
self.showAll = showAll
@@ -74,33 +74,36 @@ public struct ListSharesEndpoint: Endpoint {
extension ListSharesEndpoint {
public struct Response: Codable {
public typealias Share = ShareListing
public let shares: [Share]
public let code: Int
// MARK: - Share
public struct Share: Codable {
public let shareID: String
public let volumeID: String
public let type: ´Type´
public let state: State
public let creator: String
public let locked: Bool?
public let createTime: Int?
public let modifyTime: Int?
public let linkID: String
public enum ´Type´: Int, Codable {
case main = 1
case standard = 2
case device = 3
case photos = 4
}
public enum State: Int, Codable {
case active = 1
case deleted = 2
case restored = 3
}
}
}
}
// MARK: - Share Listing
public struct ShareListing: Codable {
public let shareID: String
public let volumeID: String
public let type: ´Type´
public let state: State
public let creator: String
public let locked: Bool?
public let createTime: Int?
public let modifyTime: Int?
public let linkID: String
public enum ´Type´: Int, Codable {
case main = 1
case standard = 2
case device = 3
case photos = 4
}
public enum State: Int, Codable {
case active = 1
case deleted = 2
case restored = 3
}
}
@@ -27,6 +27,10 @@ public struct PhotosListEndpoint: Endpoint {
let item = URLQueryItem(name: "PreviousPageLastLinkID", value: lastId)
items.append(item)
}
if let tag = parameters.tag {
let item = URLQueryItem(name: "Tag", value: "\(tag)")
items.append(item)
}
let url = service.url(of: "/volumes/\(parameters.volumeId)/photos", parameters: items)
var headers = service.baseHeaders
@@ -33,6 +33,10 @@ public struct RevisionEndpoint: Endpoint {
url.appendPathComponent(fileID)
url.appendPathComponent("/revisions")
url.appendPathComponent(revisionID)
// A random query parameter to call request rather than get cache
url.append(queryItems: [
.init(name: "random", value: "\(Int.random(in: 0...100))")
])
// request
var request = URLRequest(url: url)
@@ -16,6 +16,7 @@
// along with Proton Drive. If not, see https://www.gnu.org/licenses/.
import Foundation
import ProtonCoreNetworking
// MARK: - Request
extension Endpoint {
@@ -65,13 +66,39 @@ extension Endpoint {
}
internal func networkingError(_ error: Error) -> String {
"""
\(responseHeader)
\(line)
\(printableUrl)
"|Networking Error ❌: \(error.localizedDescription)"
\(footer)
"""
var messages = [
responseHeader,
line,
printableUrl,
"|Networking Error : \(error.localizedDescription)"
]
if let responseError = error as? ResponseError {
if let underlyingError = responseError.underlyingError {
messages.append("|-UNDERLYING ERROR:")
messages.append(contentsOf: readMessage(from: underlyingError))
}
} else {
let nsError = error as NSError
let underlyingErrors = nsError.underlyingErrors.map { $0 as NSError }
if !underlyingErrors.isEmpty {
messages.append("|-UNDERLYING ERROR:")
for underlyingError in underlyingErrors {
messages.append(contentsOf: readMessage(from: underlyingError))
}
}
}
messages.append(footer)
return messages.joined(separator: "\n")
}
private func readMessage(from underlyingError: NSError) -> [String] {
var messages = [underlyingError.localizedDescription]
for error in underlyingError.underlyingErrors {
let nsError = error as NSError
messages.append(contentsOf: readMessage(from: nsError))
}
return messages
}
internal func unknownError() -> String {
@@ -17,6 +17,13 @@
import Foundation
public extension JSONEncoder {
convenience init(strategy: JSONEncoder.KeyEncodingStrategy) {
self.init()
keyEncodingStrategy = strategy
}
}
extension JSONEncoder.KeyEncodingStrategy {
private struct Key: CodingKey {
var stringValue: String
@@ -33,7 +40,7 @@ extension JSONEncoder.KeyEncodingStrategy {
}
}
static var capitalizeFirstLetter: Self {
public static var capitalizeFirstLetter: Self {
.custom { codingPath in
guard let lastKey = codingPath.last else {
fatalError("Coding path no key")
@@ -18,12 +18,21 @@
import Foundation
import ProtonCoreServices
import ProtonCoreNetworking
import ProtonCoreUtilities
extension PMAPIService: CoreAPIService {
public func perform(request route: Request, completion: @escaping (URLSessionDataTask?, Result<JSONDictionary, ResponseError>) -> Void) {
self.perform(request: route, jsonDictionaryCompletion: completion)
}
public func perform(
request route: Request,
callCompletionBlockUsing executor: CompletionBlockExecutor,
completion: @escaping (URLSessionDataTask?, Result<JSONDictionary, ResponseError>) -> Void
) {
self.perform(request: route, callCompletionBlockUsing: executor, jsonDictionaryCompletion: completion)
}
public func perform(request route: Request, dataTaskBlock: @escaping (URLSessionDataTask) -> Void, completion: @escaping (_ task: URLSessionDataTask?, _ result: Result<JSONDictionary, ResponseError>) -> Void) {
self.perform(request: route, onDataTaskCreated: dataTaskBlock, jsonDictionaryCompletion: completion)
@@ -20,7 +20,8 @@ import ProtonCoreServices
import ProtonCoreUtilities
// MARK: - Log System for PDClient
public var log: ((String) -> Void)?
public var logInfo: ((String) -> Void)?
public var logError: ((String) -> Void)?
extension PMAPIService: DriveAPIService {
public func request<E, Response>(from endpoint: E, completionExecutor: CompletionBlockExecutor, completion: @escaping (Result<Response, Error>) -> Void) where E: Endpoint, Response == E.Response {
@@ -36,33 +37,33 @@ extension DriveAPIService {
completionExecutor: CompletionBlockExecutor,
completion: @escaping (Result<Response, Error>) -> Void
) where E: Endpoint, Response == E.Response {
log?(endpoint.prettyDescription)
logInfo?(endpoint.prettyDescription)
apiService.perform(request: endpoint, callCompletionBlockUsing: completionExecutor) { task, result in
switch result {
case .failure(let responseError):
log?(endpoint.networkingError(responseError))
logError?(endpoint.networkingError(responseError))
return completion(.failure(responseError))
case .success(let responseDict):
guard let responseData = try? JSONSerialization.data(withJSONObject: responseDict, options: .prettyPrinted) else {
log?(endpoint.unknownError())
logError?(endpoint.unknownError())
return completion(.failure(URLError(.unknown)))
}
let decoder = endpoint.decoder
if let serverError = try? decoder.decode(PDClient.ErrorResponse.self, from: responseData) {
let error = NSError(serverError)
log?(endpoint.serverError(error))
logError?(endpoint.serverError(error))
return completion(.failure(error))
}
do {
let response = try decoder.decode(E.Response.self, from: responseData)
log?(endpoint.prettyResponse(responseData))
logInfo?(endpoint.prettyResponse(responseData))
return completion(.success(response))
} catch {
log?(endpoint.deserializingError(error))
logError?(endpoint.deserializingError(error))
return completion(.failure(error))
}
}
+86 -49
View File
@@ -17,43 +17,45 @@
import Foundation
public struct Link: Codable {
public struct Link: Codable, Equatable {
public typealias LinkID = String
#if os(iOS)
public let volumeID: String
public var volumeID: String
#else
// this must be removed once macOS implements the migration to volumeID-based DB
public var volumeID: String { "" }
#endif
// node
public let linkID: LinkID
public let parentLinkID: LinkID?
public let type: LinkType
public let name: String
public let nameSignatureEmail: String?
public let hash: String
public let state: NodeState
public let expirationTime: TimeInterval?
public let size: Int
public let MIMEType: String
public let attributes: AttriburesMask
public let permissions: PermissionMask
public let nodeKey: String
public let nodePassphrase: String
public let nodePassphraseSignature: String
public let signatureEmail: String
public let createTime: TimeInterval
public let modifyTime: TimeInterval
public let trashed: TimeInterval?
public let sharingDetails: SharingDetails?
public let nbUrls: Int
public let activeUrls: Int
public let urlsExpired: Int
public let XAttr: String?
public let fileProperties: FileProperties?
public let folderProperties: FolderProperties?
public let documentProperties: DocumentProperties?
public var linkID: LinkID
public var parentLinkID: LinkID?
public var type: LinkType
public var name: String
public var nameSignatureEmail: String?
public var hash: String
public var state: NodeState
public var expirationTime: TimeInterval?
public var size: Int
public var MIMEType: String
public var attributes: AttriburesMask
public var permissions: PermissionMask
public var nodeKey: String
public var nodePassphrase: String
public var nodePassphraseSignature: String
public var signatureEmail: String
public var createTime: TimeInterval
public var modifyTime: TimeInterval
public var trashed: TimeInterval?
public var sharingDetails: SharingDetails?
public var nbUrls: Int
public var activeUrls: Int
public var urlsExpired: Int
public var XAttr: String?
public var fileProperties: FileProperties?
public var folderProperties: FolderProperties?
public var documentProperties: DocumentProperties?
public var photoProperties: PhotoProperties?
public var albumProperties: AlbumProperties?
public init(linkID: LinkID, parentLinkID: LinkID?, volumeID: String, type: LinkType, name: String,
nameSignatureEmail: String?, hash: String, state: NodeState, expirationTime: TimeInterval?,
@@ -61,7 +63,9 @@ public struct Link: Codable {
nodeKey: String, nodePassphrase: String, nodePassphraseSignature: String,
signatureEmail: String, createTime: TimeInterval, modifyTime: TimeInterval,
trashed: TimeInterval?, sharingDetails: SharingDetails?, nbUrls: Int, activeUrls: Int,
urlsExpired: Int, XAttr: String?, fileProperties: FileProperties?, folderProperties: FolderProperties?, documentProperties: DocumentProperties? = nil) {
urlsExpired: Int, XAttr: String?, fileProperties: FileProperties?, folderProperties: FolderProperties?,
documentProperties: DocumentProperties? = nil, photoProperties: PhotoProperties? = nil,
albumProperties: AlbumProperties? = nil) {
self.linkID = linkID
self.parentLinkID = parentLinkID
#if os(iOS)
@@ -92,6 +96,8 @@ public struct Link: Codable {
self.fileProperties = fileProperties
self.folderProperties = folderProperties
self.documentProperties = documentProperties
self.photoProperties = photoProperties
self.albumProperties = albumProperties
}
// Convenience initializer to allow migration to volume based APIs
@@ -126,6 +132,8 @@ public struct Link: Codable {
self.fileProperties = link.fileProperties
self.folderProperties = link.folderProperties
self.documentProperties = link.documentProperties
self.photoProperties = link.photoProperties
self.albumProperties = link.albumProperties
}
}
@@ -164,12 +172,13 @@ public extension Link {
}
}
public enum LinkType: Int, Codable {
public enum LinkType: Int, Codable, CaseIterable, Equatable {
case folder = 1
case file = 2
case album = 3
}
public enum NodeState: Int, Codable {
public enum NodeState: Int, Codable, Equatable {
case draft = 0
case active = 1
case deleted = 2
@@ -189,37 +198,65 @@ public enum NodeState: Int, Codable {
}
}
public struct FileProperties: Codable {
public let contentKeyPacket: String
public let contentKeyPacketSignature: String?
public let activeRevision: RevisionShort?
public struct FileProperties: Codable, Equatable {
public var contentKeyPacket: String
public var contentKeyPacketSignature: String?
public var activeRevision: RevisionShort?
public init(contentKeyPacket: String, contentKeyPacketSignature: String?, activeRevision: RevisionShort?) {
self.contentKeyPacket = contentKeyPacket
self.contentKeyPacketSignature = contentKeyPacketSignature
self.activeRevision = activeRevision
}
}
public struct FolderProperties: Codable {
public struct FolderProperties: Codable, Equatable {
public var nodeHashKey: String
public init(nodeHashKey: String) {
self.nodeHashKey = nodeHashKey
}
}
public struct DocumentProperties: Codable {
public struct DocumentProperties: Codable, Equatable {
public var size: Int
}
public struct SharingDetails: Codable {
public let shareID: String
public let shareUrl: ShareURL? // can be null if no link is available
public struct PhotoProperties: Codable, Equatable {
public var albums: [PhotoAlbum]
// Could become nonoptional, but migration of `PersistedEvent`, relying on `Link`'s structure would be needed.
public var tags: [Int]?
}
public struct PhotoAlbum: Codable, Equatable {
public var albumLinkID: String
}
public struct AlbumProperties: Codable, Equatable {
public var locked: Bool
public var coverLinkID: String? // Nullable
public var lastActivityTime: TimeInterval // last time a Photo was added to the Album
public var nodeHashKey: String
public var photoCount: Int
}
public struct SharingDetails: Codable, Equatable {
public var shareID: String
public var shareUrl: ShareURL? // can be null if no link is available
public init(shareID: String, shareUrl: ShareURL?) {
self.shareID = shareID
self.shareUrl = shareUrl
}
}
public struct ShareURL: Codable {
public let shareUrlID: String
public let token: String? // not always provided, according to docs
public let expireTime: Date?
public let createTime: Date
public let numAccesses: Int
public let shareID: String
public struct ShareURL: Codable, Equatable {
public var shareUrlID: String
public var token: String? // not always provided, according to docs
public var expireTime: Date?
public var createTime: Date
public var numAccesses: Int
public var shareID: String
}
public typealias ShareURLShortMeta = ShareURL
+37 -2
View File
@@ -19,12 +19,47 @@ import Foundation
public struct LinksResponse: Codable {
public var code: Int
public let links: [Link]
public let parents: [Link]
public var links: [Link]
public var parents: [Link]
public init(code: Int, links: [Link], parents: [Link]) {
self.code = code
self.links = links
self.parents = parents
}
public var sortedLinks: [Link] {
let sorter = LinkHierarchySorter()
return sorter.sort(links: parents + links)
}
}
final class LinkHierarchySorter {
func sort(links: [Link]) -> [Link] {
var sorted: [Link] = []
for link in links {
guard let parentLinkID = link.parentLinkID else {
// Parent id doesn't exist, root?
sorted.insert(link, at: 0)
continue
}
if let parentIndex = sorted.firstIndex(where: { $0.linkID == parentLinkID }) {
// Parent link is in the sorted array, insert behind the parent
sorted.insert(link, at: parentIndex + 1)
continue
}
if let childrenIndex = sorted.firstIndex(where: { $0.parentLinkID == link.linkID }) {
// Children link is in the sorted array, insert ahead the children
sorted.insert(link, at: childrenIndex)
} else {
// There is no parent link yet
sorted.append(link)
}
}
return sorted
}
}
+72 -38
View File
@@ -17,17 +17,17 @@
import Foundation
public struct RevisionShort: Codable {
public let ID: Revision.RevisionID
public let createTime: TimeInterval
public let size: Int
public let manifestSignature: String? // can be nil if revision is a draft
public let signatureAddress: String
public let state: NodeState
public let thumbnailDownloadUrl: URL?
private let thumbnail: Int
public let thumbnails: [Thumbnail]?
public let photo: Photo?
public struct RevisionShort: Codable, Equatable {
public var ID: Revision.RevisionID
public var createTime: TimeInterval
public var size: Int
public var manifestSignature: String? // can be nil if revision is a draft
public var signatureAddress: String
public var state: NodeState
public var thumbnailDownloadUrl: URL?
private var thumbnail: Int
public var thumbnails: [Thumbnail]?
public var photo: Photo?
public var hasThumbnail: Bool {
NSNumber.init(value: thumbnail).boolValue
@@ -51,38 +51,72 @@ public struct RevisionShort: Codable {
public struct Revision: Codable {
public typealias RevisionID = String
public let ID: RevisionID
public let createTime: TimeInterval
public let size: Int
public let manifestSignature: String
public let signatureAddress: String
public let state: NodeState
public let blocks: [Block]
public let thumbnail: Int
public let thumbnailHash: String?
public let thumbnailDownloadUrl: URL?
public let XAttr: String?
public var ID: RevisionID
public var createTime: TimeInterval
public var size: Int
public var manifestSignature: String
public var signatureAddress: String
public var state: NodeState
public var blocks: [Block]
public var thumbnail: Int
public var thumbnailHash: String?
public var thumbnailDownloadUrl: URL?
public var XAttr: String?
public init(ID: RevisionID,
createTime: TimeInterval,
size: Int,
manifestSignature: String,
signatureAddress: String,
state: NodeState,
blocks: [Block],
thumbnail: Int,
thumbnailHash: String?,
thumbnailDownloadUrl: URL?,
XAttr: String?) {
self.ID = ID
self.createTime = createTime
self.size = size
self.manifestSignature = manifestSignature
self.signatureAddress = signatureAddress
self.state = state
self.blocks = blocks
self.thumbnail = thumbnail
self.thumbnailHash = thumbnailHash
self.thumbnailDownloadUrl = thumbnailDownloadUrl
self.XAttr = XAttr
}
}
public struct Block: Codable {
public let index: Int
public let hash: String
public let URL: URL
public let encSignature: String
public let signatureEmail: String?
public var index: Int
public var hash: String
public var URL: URL
public var encSignature: String?
public var signatureEmail: String?
}
public struct Thumbnail: Codable {
public let thumbnailID: String
public let type: Int
public let hash: String
public let size: Int
public struct Thumbnail: Codable, Equatable {
public var thumbnailID: String
public var type: Int
public var hash: String
public var size: Int
public init(thumbnailID: String, type: Int, hash: String, size: Int) {
self.thumbnailID = thumbnailID
self.type = type
self.hash = hash
self.size = size
}
}
public struct Photo: Codable {
public let linkID: String
public let captureTime: TimeInterval
public let mainPhotoLinkID: String?
public let hash: String
public let exif: String?
public struct Photo: Codable, Equatable {
public var linkID: String
public var captureTime: TimeInterval
public var addedTime: Date?
public var mainPhotoLinkID: String?
public var relatedPhotosLinkIDs: [String]?
public var hash: String // name hash
public var contentHash: String? // optional due to backward compatibility of events
public var exif: String?
}
+1
View File
@@ -58,6 +58,7 @@ public struct Share: Codable {
public let type: ´Type´
public enum ´Type´: Int, Codable {
case undefined = 0
case main = 1
case standard = 2
case device = 3
@@ -30,6 +30,10 @@ public struct AccessPermission: OptionSet, Codable {
public var isEditor: Bool {
self.contains([.read, .write])
}
public func toRequestPermission() -> ShareURLMeta.Permissions {
isEditor ? [.read, .write] : [.read]
}
}
public enum ExternalInviteState: Int, Codable {
+9 -2
View File
@@ -46,6 +46,11 @@ public struct Volume: Codable {
}
}
public enum VolumeType: Int, Codable {
case main = 1
case photo = 2
}
public var volumeID: VolumeID
public var createTime: TimeInterval?
public var modifyTime: TimeInterval?
@@ -55,10 +60,11 @@ public struct Volume: Codable {
public var state: State?
public var share: Share
public var restoreStatus: RestoreStatus?
public let type: VolumeType
public init(volumeID: VolumeID, createTime: TimeInterval? = nil, modifyTime: TimeInterval? = nil,
uploadedBytes: Int, maxSpace: Int? = nil, usedSpace: Int? = nil, state: State? = nil,
share: Share, restoreStatus: RestoreStatus? = nil) {
share: Share, restoreStatus: RestoreStatus? = nil, type: VolumeType = .main) {
self.volumeID = volumeID
self.createTime = createTime
self.modifyTime = modifyTime
@@ -68,5 +74,6 @@ public struct Volume: Codable {
self.state = state
self.share = share
self.restoreStatus = restoreStatus
self.type = type
}
}
@@ -0,0 +1,126 @@
// Copyright (c) 2025 Proton AG
//
// This file is part of Proton Drive.
//
// Proton Drive is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Proton Drive is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Proton Drive. If not, see https://www.gnu.org/licenses/.
import Foundation
/// /drive/volumes/{volumeID}/links/{linkID}/copy
/// Copy a single file to a volume, providing the new parent link ID.
public struct CopyLinkToVolumeEndpoint: Endpoint {
public struct Response: Codable {
var code: Int
var linkID: String
}
public var request: URLRequest
public init(parameters: Parameters, service: APIService, credential: ClientCredential) {
var url = service.url(of: "/volumes")
url.appendPathComponent(parameters.volumeID)
url.appendPathComponent("/links")
url.appendPathComponent(parameters.linkID)
url.appendPathComponent("/copy")
var request = URLRequest(url: url)
request.httpMethod = "POST"
var headers = service.baseHeaders
headers.merge(service.authHeaders(credential), uniquingKeysWith: { $1 })
headers.forEach { request.setValue($1, forHTTPHeaderField: $0) }
// body
let encoder = JSONEncoder()
encoder.keyEncodingStrategy = .capitalizeFirstLetter
request.httpBody = try? encoder.encode(parameters.body)
self.request = request
}
}
extension CopyLinkToVolumeEndpoint {
public struct Parameters {
public let volumeID: Volume.VolumeID
public let linkID: Link.LinkID
public let body: Body
public init(volumeID: Volume.VolumeID, linkID: Link.LinkID, body: Body) {
self.volumeID = volumeID
self.linkID = linkID
self.body = body
}
}
public struct Body: Codable {
public let name: String
public let nodePassphrase: String
public let hash: String
public let targetVolumeID: Volume.VolumeID
public let targetParentLinkID: Link.LinkID
public let nameSignatureEmail: String
/// Required when moving an anonymous Link.
public let nodePassphraseSignature: String?
public let signatureEmail: String?
/// Optional, except when moving a Photo-Link.
public let photos: Photos?
public init(
name: String,
nodePassphrase: String,
hash: String,
targetVolumeID: Volume.VolumeID,
targetParentLinkID: Link.LinkID,
nameSignatureEmail: String,
nodePassphraseSignature: String?,
signatureEmail: String?,
photos: Photos?
) {
self.name = name
self.nodePassphrase = nodePassphrase
self.hash = hash
self.targetVolumeID = targetVolumeID
self.targetParentLinkID = targetParentLinkID
self.nameSignatureEmail = nameSignatureEmail
self.nodePassphraseSignature = nodePassphraseSignature
self.signatureEmail = signatureEmail
self.photos = photos
}
}
public struct Photos: Codable {
public let contentHash: String
public let relatedPhotos: [RelatedPhoto]
public init(contentHash: String, relatedPhotos: [RelatedPhoto]) {
self.contentHash = contentHash
self.relatedPhotos = relatedPhotos
}
}
public struct RelatedPhoto: Codable {
public let linkID: String
public let name: String
public let nodePassphrase: String
public let hash: String
public let contentHash: String
public init(linkID: String, name: String, nodePassphrase: String, hash: String, contentHash: String) {
self.linkID = linkID
self.name = name
self.nodePassphrase = nodePassphrase
self.hash = hash
self.contentHash = contentHash
}
}
}
@@ -0,0 +1,70 @@
// Copyright (c) 2025 Proton AG
//
// This file is part of Proton Drive.
//
// Proton Drive is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Proton Drive is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Proton Drive. If not, see https://www.gnu.org/licenses/.
import Foundation
public struct DeleteMultipleResponse: Codable {
let code: Int
let responses: LinkCodesResponseElement
}
struct DeleteMultipleParameters {
let volumeId: Volume.VolumeID
let body: Body
init(volumeId: Volume.VolumeID, linkIds: [Link.LinkID]) {
self.volumeId = volumeId
body = Body(linkIds: linkIds)
}
struct Body: Encodable {
let linkIds: [Link.LinkID]
private enum CodingKeys: String, CodingKey {
case linkIds = "LinkIDs"
}
}
}
// To delete multiple links
// POST: /v2/volumes/{volumeID}/trash/delete_multiple
struct DeleteMultipleEndpoint: Endpoint {
typealias Response = DeleteMultipleResponse
var request: URLRequest
init(parameters: DeleteMultipleParameters, service: APIService, credential: ClientCredential) {
var url = service.url(of: "/v2/volumes")
url.appendPathComponent(parameters.volumeId)
url.appendPathComponent("/trash")
url.appendPathComponent("/delete_multiple")
var request = URLRequest(url: url)
request.httpMethod = "POST"
var headers = service.baseHeaders
headers.merge(service.authHeaders(credential), uniquingKeysWith: { $1 })
headers.forEach { request.setValue($1, forHTTPHeaderField: $0) }
let body = try? JSONEncoder().encode(parameters.body)
assert(body != nil, "Failed body encoding")
request.httpBody = body
self.request = request
}
}
@@ -0,0 +1,63 @@
// Copyright (c) 2024 Proton AG
//
// This file is part of Proton Drive.
//
// Proton Drive is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Proton Drive is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Proton Drive. If not, see https://www.gnu.org/licenses/.
import Foundation
/// List Links
/// - POST: /drive/volumes/{volumeID}/links/fetch_metadata
public struct LinksMetadataByVolumeEndpoint: Endpoint {
public let request: URLRequest
public typealias Response = LinksResponseByVolume
public init(service: APIService, credential: ClientCredential, parameters: LinksMetadataByVolumeParameters) {
let url = service.url(of: "/volumes/\(parameters.volumeId)/links/fetch_metadata")
var headers = service.baseHeaders
headers.merge(service.authHeaders(credential), uniquingKeysWith: { $1 })
var request = URLRequest(url: url)
request.httpMethod = "POST"
headers.forEach { request.setValue($1, forHTTPHeaderField: $0) }
request.httpBody = try? JSONEncoder().encode(["LinkIDs": parameters.linkIds])
self.request = request
}
}
public struct LinksMetadataByVolumeParameters {
public let volumeId: String
public let linkIds: [String]
public init(volumeId: String, linkIds: [String]) {
self.volumeId = volumeId
self.linkIds = linkIds
}
}
public struct LinksResponseByVolume: Codable {
public var code: Int
public let links: [Link]
public init(code: Int, links: [Link]) {
self.code = code
self.links = links
}
public var sortedLinks: [Link] {
let sorter = LinkHierarchySorter()
return sorter.sort(links: links)
}
}
@@ -17,14 +17,20 @@
import Foundation
struct NewBlocksEndpoint: Endpoint {
public struct NewBlocksEndpoint: Endpoint {
public struct Response: Codable {
let code: Int
let uploadLinks: [ContentUploadLink]
let thumbnailLinks: [ContentUploadLink]?
public init(code: Int, uploadLinks: [ContentUploadLink], thumbnailLinks: [ContentUploadLink]?) {
self.code = code
self.uploadLinks = uploadLinks
self.thumbnailLinks = thumbnailLinks
}
}
var request: URLRequest
public var request: URLRequest
init(parameters: NewPhotoBlocksParameters, service: APIService, credential: ClientCredential) {
// url
+24 -14
View File
@@ -20,6 +20,11 @@ import Foundation
public struct NewFile: Codable {
public var ID: String
public var revisionID: String
public init(ID: String, revisionID: String) {
self.ID = ID
self.revisionID = revisionID
}
}
public struct NewFileParameters: Codable {
@@ -48,26 +53,31 @@ public struct NewFileParameters: Codable {
self.ClientUID = clientUID
}
var Name: String
var Hash: String
var ParentLinkID: String
var NodeKey: String
var NodePassphrase: String
var NodePassphraseSignature: String
var SignatureAddress: String
var ContentKeyPacket: String
var ContentKeyPacketSignature: String
var MIMEType: String
var ClientUID: String
public private(set) var Name: String
public private(set) var Hash: String
public private(set) var ParentLinkID: String
public private(set) var NodeKey: String
public private(set) var NodePassphrase: String
public private(set) var NodePassphraseSignature: String
public private(set) var SignatureAddress: String
public private(set) var ContentKeyPacket: String
public private(set) var ContentKeyPacketSignature: String
public private(set) var MIMEType: String
public private(set) var ClientUID: String
}
struct NewFileEndpoint: Endpoint {
public struct NewFileEndpoint: Endpoint {
public struct Response: Codable {
var code: Int
var file: NewFile
public var file: NewFile
public init(code: Int, file: NewFile) {
self.code = code
self.file = file
}
}
var request: URLRequest
public private(set) var request: URLRequest
init(shareID: Share.ShareID, parameters: NewFileParameters, service: APIService, credential: ClientCredential) {
// url
+11 -2
View File
@@ -19,6 +19,10 @@ import Foundation
public struct NewFolder: Codable {
public var ID: String
public init(ID: String) {
self.ID = ID
}
}
public class NewFolderParameters: Codable {
@@ -52,13 +56,18 @@ public class NewFolderParameters: Codable {
var SignatureAddress: String
}
struct NewFolderEndpoint: Endpoint {
public struct NewFolderEndpoint: Endpoint {
public struct Response: Codable {
var code: Int
var folder: NewFolder
public init(code: Int, folder: NewFolder) {
self.code = code
self.folder = folder
}
}
var request: URLRequest
public var request: URLRequest
init(shareID: Share.ShareID, parameters: NewFolderParameters, service: APIService, credential: ClientCredential) {
// url
@@ -17,19 +17,19 @@
import Foundation
struct CreateProtonDocumentResponse: Codable {
struct CreateProtonFileResponse: Codable {
let code: Int
let document: DocumentIdentifier
}
/// Create document
/// POST: /drive/shares/{shareID}/documents
struct NewDocumentEndpoint: Endpoint {
typealias Response = CreateProtonDocumentResponse
struct NewProtonFileEndpoint: Endpoint {
typealias Response = CreateProtonFileResponse
var request: URLRequest
init(parameters: NewDocumentParameters, service: APIService, credential: ClientCredential) {
init(parameters: NewProtonFileParameters, service: APIService, credential: ClientCredential) {
// url
let url = service.url(of: "/shares/\(parameters.shareId)/documents")
@@ -19,16 +19,20 @@ import Foundation
public struct NewRevision: Codable {
public var ID: Revision.RevisionID
public init(ID: Revision.RevisionID) {
self.ID = ID
}
}
struct NewRevisionEndpoint: Endpoint {
public struct NewRevisionEndpoint: Endpoint {
public struct Response: Codable {
var code: Int
var revision: NewRevision
public var revision: NewRevision
}
var request: URLRequest
public var request: URLRequest
init(fileID: Link.LinkID, shareID: Share.ShareID, service: APIService, credential: ClientCredential) {
// url
var url = service.url(of: "/shares")
@@ -0,0 +1,79 @@
// Copyright (c) 2024 Proton AG
//
// This file is part of Proton Drive.
//
// Proton Drive is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Proton Drive is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Proton Drive. If not, see https://www.gnu.org/licenses/.
import ProtonCoreNetworking
import Foundation
public struct BugReport {
public let os: String
public let osVersion: String
public let client: String
public let clientType: Int
public let clientVersion: String
public let title: String
public let description: String
public let username: String
public let email: String
public let files: [URL]
public init(os: String, osVersion: String, client: String, clientType: Int, clientVersion: String, title: String, description: String, username: String, email: String, files: [URL]) {
self.os = os
self.osVersion = osVersion
self.client = client
self.clientType = clientType
self.clientVersion = clientVersion
self.title = title
self.description = description
self.username = username
self.email = email
self.files = files
}
}
public final class ReportsBugsEndpoint: Request {
public let report: BugReport
public init( _ report: BugReport) {
self.report = report
}
public var path: String {
return "/core/v4/reports/bug"
}
public var method: HTTPMethod {
return .post
}
public var parameters: [String: Any]? {
return [
"OS": report.os,
"OSVersion": report.osVersion,
"Client": report.client,
"ClientVersion": report.clientVersion,
"ClientType": String(report.clientType),
"Title": report.title,
"Description": report.description,
"Username": report.username,
"Email": report.email,
]
}
public var retryPolicy: ProtonRetryPolicy.RetryMode {
.userInitiated
}
}
@@ -0,0 +1,67 @@
// Copyright (c) 2025 Proton AG
//
// This file is part of Proton Drive.
//
// Proton Drive is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Proton Drive is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Proton Drive. If not, see https://www.gnu.org/licenses/.
import Foundation
public struct SetTagsMigrationStateEndpoint: Endpoint {
public struct Response: Codable {
let code: Int
}
public var request: URLRequest
public init(volumeID: String, requestBody: TagsMigrationStateRequest, service: APIService, credential: ClientCredential) {
var url = service.url(of: "/photos/volumes")
url.appendPathComponent(volumeID)
url.appendPathComponent("tags-migration")
var request = URLRequest(url: url)
request.httpMethod = "POST"
var headers = service.baseHeaders
headers.merge(service.authHeaders(credential), uniquingKeysWith: { $1 })
headers.forEach { request.setValue($1, forHTTPHeaderField: $0) }
request.httpBody = try? JSONEncoder(strategy: .capitalizeFirstLetter).encode(requestBody)
self.request = request
}
}
public struct TagsMigrationStateRequest: Codable {
public let finished: Bool
public let anchor: TagsMigrationStateAnchorRequest?
public init(finished: Bool, anchor: TagsMigrationStateAnchorRequest?) {
self.finished = finished
self.anchor = anchor
}
public struct TagsMigrationStateAnchorRequest: Codable {
public let lastProcessedLinkID: String
public let lastProcessedCaptureTime: Int
public let currentTimestamp: Int
public let clientUID: String
public init(lastProcessedLinkID: String, lastProcessedCaptureTime: Int, currentTimestamp: Int, clientUID: String) {
self.lastProcessedLinkID = lastProcessedLinkID
self.lastProcessedCaptureTime = lastProcessedCaptureTime
self.currentTimestamp = currentTimestamp
self.clientUID = clientUID
}
}
}
+18 -3
View File
@@ -31,8 +31,8 @@ public struct TrashLinksParameters {
/// Trash Children
/// /shares/{enc_shareID}/folders/{enc_linkID}/trash_multiple
struct TrashLinkEndpoint: Endpoint {
typealias Response = MultipleLinkResponse
public struct TrashLinkEndpoint: Endpoint {
public typealias Response = MultipleLinkResponse
struct Body: Encodable {
let linkIDs: [Link.LinkID]
@@ -42,7 +42,7 @@ struct TrashLinkEndpoint: Endpoint {
}
}
var request: URLRequest
public var request: URLRequest
init(parameters: TrashLinksParameters, service: APIService, credential: ClientCredential, breadcrumbs: Breadcrumbs) throws {
var url = service.url(of: "/shares")
@@ -79,14 +79,29 @@ struct TrashLinkEndpoint: Endpoint {
public struct MultipleLinkResponse: Codable {
public let code: Int
public let responses: [LinkResponse]
public init(code: Int, responses: [LinkResponse]) {
self.code = code
self.responses = responses
}
public struct LinkResponse: Codable {
public let linkID: String
public let response: ErrorResponse
public init(linkID: String, response: ErrorResponse) {
self.linkID = linkID
self.response = response
}
}
public struct ErrorResponse: Codable {
public let code: Int
public let error: String?
public init(code: Int, error: String?) {
self.code = code
self.error = error
}
}
}
@@ -0,0 +1,73 @@
// Copyright (c) 2025 Proton AG
//
// This file is part of Proton Drive.
//
// Proton Drive is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Proton Drive is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Proton Drive. If not, see https://www.gnu.org/licenses/.
import Foundation
public struct TrashVolumeLinksParameters {
public let volumeID: String
public let linkIds: [String]
public init(volumeID: String, linkIds: [String]) {
self.volumeID = volumeID
self.linkIds = linkIds
}
}
/// Trash links
/// /v2/volumes/{volumeID}/trash_multiple
public struct TrashVolumeLinkEndpoint: Endpoint {
public typealias Response = MultipleLinkResponse
struct Body: Encodable {
let linkIDs: [Link.LinkID]
private enum CodingKeys: String, CodingKey {
case linkIDs = "LinkIDs"
}
}
public var request: URLRequest
init(parameters: TrashVolumeLinksParameters, service: APIService, credential: ClientCredential, breadcrumbs: Breadcrumbs) throws {
var url = service.url(of: "/v2/volumes")
url.appendPathComponent(parameters.volumeID)
url.appendPathComponent("/trash_multiple")
var request = URLRequest(url: url)
request.httpMethod = "POST"
var headers = service.baseHeaders
headers.merge(service.authHeaders(credential), uniquingKeysWith: { $1 })
headers.forEach { request.setValue($1, forHTTPHeaderField: $0) }
let validLinkIDs = parameters.linkIds.filter { UUID(uuidString: $0) == nil }
guard !validLinkIDs.isEmpty else {
// if there are no valid linkIDs at all, there's no need to make the trash request at all
let invalidLinkIDs = parameters.linkIds.compactMap { UUID(uuidString: $0) }
let message = "Tried to trash a folder with invalid linkID(s) \(invalidLinkIDs), breadcrumbs: \(breadcrumbs.collect().reduceIntoErrorMessage())"
assertionFailure(message)
throw InvalidLinkIdError(detailedMessage: message)
}
let body = try? JSONEncoder().encode(Body(linkIDs: validLinkIDs))
assert(body != nil, "Failed body encoding")
request.httpBody = body
self.request = request
}
}
@@ -21,43 +21,6 @@ public protocol EditShareURLParameters: Codable {
var parameters: [String: Any]? { get }
}
public struct EditShareURLPasswordAndDuration: EditShareURLParameters {
let ExpirationDuration: Int?
let UrlPasswordSalt: String
let SharePasswordSalt: String
let SRPVerifier: String
let SRPModulusID: String
let Flags: ShareURLMeta.Flags
let SharePassphraseKeyPacket: String
let Password: String
public private(set) var parameters: [String: Any]?
public init(_ expirationParameters: EditShareURLExpiration, _ passwordParameters: EditShareURLPassword) {
self.ExpirationDuration = expirationParameters.ExpirationDuration
self.UrlPasswordSalt = passwordParameters.UrlPasswordSalt
self.SharePasswordSalt = passwordParameters.SharePasswordSalt
self.SRPVerifier = passwordParameters.SRPVerifier
self.SRPModulusID = passwordParameters.SRPModulusID
self.Flags = passwordParameters.Flags
self.SharePassphraseKeyPacket = passwordParameters.SharePassphraseKeyPacket
self.Password = passwordParameters.Password
parameters = expirationParameters.parameters?.merging(passwordParameters.parameters ?? [:], uniquingKeysWith: { $1 })
}
public enum CodingKeys: String, CodingKey {
case ExpirationDuration
case UrlPasswordSalt
case SharePasswordSalt
case SRPVerifier
case SRPModulusID
case Flags
case SharePassphraseKeyPacket
case Password
}
}
public struct EditShareURLPassword: EditShareURLParameters {
let UrlPasswordSalt: String
let SharePasswordSalt: String
@@ -87,6 +50,18 @@ public struct EditShareURLPassword: EditShareURLParameters {
}
}
public struct EditShareURLPermissions: EditShareURLParameters {
let Permissions: ShareURLMeta.Permissions
public init(permissions: ShareURLMeta.Permissions) {
self.Permissions = permissions
}
public var parameters: [String: Any]? {
["Permissions": Permissions.rawValue]
}
}
public struct EditShareURLExpiration: EditShareURLParameters {
let ExpirationDuration: Int?
@@ -101,6 +76,31 @@ public struct EditShareURLExpiration: EditShareURLParameters {
}
}
public struct EditShareURLUpdateParameters: EditShareURLParameters {
let expirationParameters: EditShareURLExpiration?
let passwordParameters: EditShareURLPassword?
let permissionParameters: EditShareURLPermissions?
public init(
expirationParameters: EditShareURLExpiration?,
passwordParameters: EditShareURLPassword?,
permissionParameters: EditShareURLPermissions?
) {
self.expirationParameters = expirationParameters
self.passwordParameters = passwordParameters
self.permissionParameters = permissionParameters
}
public var parameters: [String: Any]? {
let expiration = expirationParameters?.parameters ?? [:]
let password = passwordParameters?.parameters ?? [:]
let permission = permissionParameters?.parameters ?? [:]
return expiration
.merging(password) { current, _ in current }
.merging(permission) { current, _ in current }
}
}
struct EditShareURLEndpoint<Parameters: EditShareURLParameters>: Endpoint {
public struct Response: Codable {
var code: Int
+22 -8
View File
@@ -19,13 +19,17 @@ import Foundation
public struct MoveEntryEndpoint: Endpoint {
public struct Parameters: Codable {
let Name: String
let NodePassphrase: String
let Hash: String
let ParentLinkID: String
let NameSignatureEmail: String
let OriginalHash: String
let NewShareID: String?
public let Name: String
public let NodePassphrase: String
public let Hash: String
public let ParentLinkID: String
public let NameSignatureEmail: String
public let OriginalHash: String
public let NewShareID: String?
public let NodePassphraseSignature: String?
public let SignatureEmail: String?
/// Optional, except when moving a Photo-Link.
public let ContentHash: String?
public init(
name: String,
@@ -34,20 +38,30 @@ public struct MoveEntryEndpoint: Endpoint {
parentLinkID: String,
nameSignatureEmail: String,
originalHash: String,
newShareID: String?
newShareID: String?,
nodePassphraseSignature: String? = nil,
signatureEmail: String? = nil,
contentHash: String? = nil
) {
self.Name = name
self.NodePassphrase = nodePassphrase
self.NodePassphraseSignature = nodePassphraseSignature
self.Hash = hash
self.ParentLinkID = parentLinkID
self.NameSignatureEmail = nameSignatureEmail
self.OriginalHash = originalHash
self.NewShareID = newShareID
self.SignatureEmail = signatureEmail
self.ContentHash = contentHash
}
}
public struct Response: Codable {
var code: Int
public init(code: Int) {
self.code = code
}
}
public var request: URLRequest
@@ -0,0 +1,104 @@
// Copyright (c) 2025 Proton AG
//
// This file is part of Proton Drive.
//
// Proton Drive is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Proton Drive is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Proton Drive. If not, see https://www.gnu.org/licenses/.
import Foundation
public struct MoveMultipleEndpoint: Endpoint {
public struct Parameters: Codable {
public let ParentLinkID: String
public let Links: [Link]
public let NameSignatureEmail: String
public let SignatureEmail: String
public let NewShareID: String?
public init(
parentLinkID: String,
links: [Link],
nameSignatureEmail: String,
signatureEmail: String,
newShareID: String?
) {
self.ParentLinkID = parentLinkID
self.Links = links
self.NameSignatureEmail = nameSignatureEmail
self.SignatureEmail = signatureEmail
self.NewShareID = newShareID
}
}
public struct Link: Codable {
public let LinkID: String
public let Name: String
public let NodePassphrase: String
public let Hash: String
/// Current name hash before move operation.
public let OriginalHash: String
/// except when moving a Photo-Link. Photo content hash
public let ContentHash: String?
/// Required when moving an anonymous Link. It must be signed by the SignatureEmail address.
public let NodePassphraseSignature: String?
public init(
linkID: String,
name: String,
nodePassphrase: String,
hash: String,
originalHash: String,
contentHash: String?,
nodePassphraseSignature: String?
) {
self.LinkID = linkID
self.Name = name
self.NodePassphrase = nodePassphrase
self.Hash = hash
self.OriginalHash = originalHash
self.ContentHash = contentHash
self.NodePassphraseSignature = nodePassphraseSignature
}
}
public struct Response: Codable {
var code: Int
public init(code: Int) {
self.code = code
}
}
public var request: URLRequest
public init(volumeID: Volume.VolumeID, parameters: Parameters, service: APIService, credential: ClientCredential) {
// url
var url = service.url(of: "/volumes")
url.appendPathComponent(volumeID)
url.appendPathComponent("/links")
url.appendPathComponent("/move-multiple")
// request
var request = URLRequest(url: url)
request.httpMethod = "PUT"
// headers
var headers = service.baseHeaders
headers.merge(service.authHeaders(credential), uniquingKeysWith: { $1 })
headers.forEach { request.setValue($1, forHTTPHeaderField: $0) }
request.httpBody = try? JSONEncoder().encode(parameters)
self.request = request
}
}
@@ -18,7 +18,7 @@
import Foundation
public struct RenameNodeParameters: Codable {
public init(name: String, hash: String, MIMEType: String?, signatureAddress: String) {
public init(name: String, hash: String?, MIMEType: String?, signatureAddress: String) {
self.Name = name
self.Hash = hash
self.MIMEType = MIMEType
@@ -26,17 +26,21 @@ public struct RenameNodeParameters: Codable {
}
var Name: String
var Hash: String
var Hash: String?
var MIMEType: String?
var SignatureAddress: String
}
struct RenameNodeEndpoint: Endpoint {
public struct RenameNodeEndpoint: Endpoint {
public struct Response: Codable {
var code: Int
public init(code: Int) {
self.code = code
}
}
var request: URLRequest
public var request: URLRequest
init(shareID: Share.ShareID, nodeID: Link.LinkID, parameters: RenameNodeParameters, service: APIService, credential: ClientCredential) {
// url
@@ -17,11 +17,16 @@
import Foundation
struct RestoreLinkEndpoint: Endpoint {
public struct RestoreLinkEndpoint: Endpoint {
public struct Response: Codable {
let responses: [ResponseElement]
let code: Int
public init(responses: [ResponseElement], code: Int) {
self.responses = responses
self.code = code
}
}
struct Parameters {
@@ -42,7 +47,7 @@ struct RestoreLinkEndpoint: Endpoint {
}
}
var request: URLRequest
public var request: URLRequest
init(parameters: Parameters, service: APIService, credential: ClientCredential) {
var url = service.url(of: "/shares")
@@ -66,12 +71,20 @@ struct RestoreLinkEndpoint: Endpoint {
}
}
struct ResponseElement: Codable {
public typealias LinkCodesResponseElement = [LinkCodeResponseElement]
public typealias LinkCodeResponseElement = ResponseElement
public struct ResponseElement: Codable {
let linkID: String?
let response: ResponseResponse?
public init(linkID: String?, response: ResponseResponse?) {
self.linkID = linkID
self.response = response
}
}
struct ResponseResponse: Codable {
public struct ResponseResponse: Codable {
let error, errorDescription: String?
let code: Int
}
@@ -0,0 +1,72 @@
// Copyright (c) 2025 Proton AG
//
// This file is part of Proton Drive.
//
// Proton Drive is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Proton Drive is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Proton Drive. If not, see https://www.gnu.org/licenses/.
import Foundation
public struct RestoreVolumeLinkEndpoint: Endpoint {
public struct Response: Codable {
let responses: [ResponseElement]
let code: Int
public init(responses: [ResponseElement], code: Int) {
self.responses = responses
self.code = code
}
}
struct Parameters {
let volumeID: Volume.VolumeID
let body: Body
init(volumeID: Volume.VolumeID, linkIDs: [Link.LinkID]) {
self.volumeID = volumeID
body = Body(linkIDs: linkIDs)
}
struct Body: Encodable {
let linkIDs: [Link.LinkID]
private enum CodingKeys: String, CodingKey {
case linkIDs = "LinkIDs"
}
}
}
public var request: URLRequest
init(parameters: Parameters, service: APIService, credential: ClientCredential) {
var url = service.url(of: "/v2/volumes")
url.appendPathComponent(parameters.volumeID)
url.appendPathComponent("/trash")
url.appendPathComponent("/restore_multiple")
var request = URLRequest(url: url)
request.httpMethod = "PUT"
var headers = service.baseHeaders
headers.merge(service.authHeaders(credential), uniquingKeysWith: { $1 })
headers.forEach { request.setValue($1, forHTTPHeaderField: $0) }
let body = try? JSONEncoder().encode(parameters.body)
assert(body != nil, "Failed body encoding")
request.httpBody = body
self.request = request
}
}
@@ -0,0 +1,72 @@
// Copyright (c) 2025 Proton AG
//
// This file is part of Proton Drive.
//
// Proton Drive is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Proton Drive is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Proton Drive. If not, see https://www.gnu.org/licenses/.
import Foundation
/// Transfer photos from and to albums
/// photos/volumes/{volumeID}/links/transfer-multiple
public struct TransferMultipleEndpoint: Endpoint {
public struct Parameters: Codable {
public let parentLinkID: String
public let links: [MoveMultipleEndpoint.Link]
public let nameSignatureEmail: String
public let signatureEmail: String?
public init(
parentLinkID: String,
links: [MoveMultipleEndpoint.Link],
nameSignatureEmail: String,
signatureEmail: String?
) {
self.parentLinkID = parentLinkID
self.links = links
self.nameSignatureEmail = nameSignatureEmail
self.signatureEmail = signatureEmail
}
}
public struct Response: Codable {
var code: Int
public init(code: Int) {
self.code = code
}
}
public var request: URLRequest
public init(volumeID: Volume.VolumeID, parameters: Parameters, service: APIService, credential: ClientCredential) {
// url
var url = service.url(of: "photos/volumes")
url.appendPathComponent(volumeID)
url.appendPathComponent("/links")
url.appendPathComponent("/transfer-multiple")
// request
var request = URLRequest(url: url)
request.httpMethod = "PUT"
// headers
var headers = service.baseHeaders
headers.merge(service.authHeaders(credential), uniquingKeysWith: { $1 })
headers.forEach { request.setValue($1, forHTTPHeaderField: $0) }
request.httpBody = try? JSONEncoder(strategy: .capitalizeFirstLetter).encode(parameters)
self.request = request
}
}
@@ -28,10 +28,10 @@ public struct UpdateRevisionBlocks: Codable {
}
public struct UpdateRevisionParameters: Codable {
let ManifestSignature: String
let SignatureAddress: String
let XAttr: String?
let Photo: Photo?
public let ManifestSignature: String
public let SignatureAddress: String
public let XAttr: String?
public let Photo: Photo?
public init(
manifestSignature: String,
@@ -50,22 +50,28 @@ public struct UpdateRevisionParameters: Codable {
public let MainPhotoLinkID: String?
public let Exif: String?
public let ContentHash: String
public let Tags: [Int]
public init(captureTime: Int, mainPhotoLinkID: String?, exif: String?, contentHash: String) {
public init(captureTime: Int, mainPhotoLinkID: String?, exif: String?, contentHash: String, tags: [Int] = []) {
self.CaptureTime = captureTime
self.MainPhotoLinkID = mainPhotoLinkID
self.Exif = exif
self.ContentHash = contentHash
self.Tags = tags
}
}
}
struct UpdateRevisionEndpoint: Endpoint {
public struct UpdateRevisionEndpoint: Endpoint {
public struct Response: Codable {
var code: Int
public init(code: Int) {
self.code = code
}
}
var request: URLRequest
public var request: URLRequest
init(shareID: Share.ShareID, fileID: Link.LinkID, revisionID: Revision.RevisionID, parameters: UpdateRevisionParameters, service: APIService, credential: ClientCredential) {
// url
+51 -9
View File
@@ -19,27 +19,35 @@ import Foundation
// MARK: - List Photos Share
public protocol PhotoShareListing {
func getPhotosRoot() async throws -> PhotosRoot
func getPhotosRoot(listing: ShareListing) async throws -> PhotosRoot
func getActivePhotoShares() async throws -> [ShareListing]
}
extension Client: PhotoShareListing {
public func getPhotosRoot() async throws -> PhotosRoot {
let response = try await listPhotoShares()
async let share = try bootstrapPhotosShare(shareID: response.shareID)
async let root = try bootstrapPhotosRoot(shareID: response.shareID, nodeID: response.linkID, breadcrumbs: .startCollecting())
return try await getPhotosRoot(listing: response)
}
public func getPhotosRoot(listing: ShareListing) async throws -> PhotosRoot {
async let share = try bootstrapPhotosShare(shareID: listing.shareID)
async let root = try bootstrapPhotosRoot(shareID: listing.shareID, nodeID: listing.linkID, breadcrumbs: .startCollecting())
return try await PhotosRoot(link: root, share: share)
}
public func listPhotoShares() async throws -> ListSharesEndpoint.Response.Share {
public func listPhotoShares() async throws -> ShareListing {
let shares = try await getActivePhotoShares()
guard let share = shares.first else {
throw NSError(domain: "No Photos Share found", code: 0)
}
return share
}
public func getActivePhotoShares() async throws -> [ShareListing] {
let parameters = ListSharesEndpoint.Parameters(shareType: .photos, showAll: .default)
let endpoint = ListSharesEndpoint(parameters: parameters, service: service, credential: try credential())
let response = try await request(endpoint)
guard let shareDevice = response.shares.first(where: { $0.state == .active && $0.locked != true }) else {
throw NSError(domain: "No Photos Share found", code: 0)
}
return shareDevice
return response.shares.filter { $0.state == .active && $0.locked != true }
}
func bootstrapPhotosShare(shareID: String) async throws -> Share {
@@ -114,3 +122,37 @@ extension Client: PhotosDuplicatesRepository {
return try await request(endpoint, completionExecutor: .asyncExecutor(dispatchQueue: backgroundQueue))
}
}
// MARK: - TagMigration
public protocol TagsMigrationAPIClient {
func getTagsMigrationState(volumeID: String) async throws -> TagsMigrationState
func setTagsMigrationState(volumeID: String, request: TagsMigrationStateRequest) async throws
}
extension Client: TagsMigrationAPIClient {
public func getTagsMigrationState(volumeID: String) async throws -> TagsMigrationState {
let endpoint = GetTagsMigrationStateEndpoint(volumeID: volumeID, service: service, credential: try credential())
return try await request(endpoint).toDomain()
}
public func setTagsMigrationState(volumeID: String, request requestBody: TagsMigrationStateRequest) async throws {
let endpoint = SetTagsMigrationStateEndpoint(volumeID: volumeID, requestBody: requestBody, service: service, credential: try credential())
_ = try await request(endpoint)
}
}
extension GetTagsMigrationStateEndpoint.TagsMigrationStateResponse {
func toDomain() -> TagsMigrationState {
TagsMigrationState(
isFinished: self.finished,
anchor: anchor.map { response in
TagsMigrationState.Anchor(
lastProcessedLinkID: response.lastProcessedLinkID,
lastProcessedCaptureTime: Date(timeIntervalSince1970: TimeInterval(response.lastProcessedCaptureTime)),
lastMigrationTimestamp: Date(timeIntervalSince1970: TimeInterval(response.lastMigrationTimestamp)),
lastClientUID: response.lastClientUID
)
}
)
}
}
@@ -15,7 +15,4 @@
// You should have received a copy of the GNU General Public License
// along with Proton Drive. If not, see https://www.gnu.org/licenses/.
public protocol PhotosListing {
func getPhotosList(with parameters: PhotosListRequestParameters) async throws -> PhotosListResponse
func getLinksMetadata(with parameters: LinksMetadataParameters) async throws -> LinksResponse
}
public protocol PhotosListing: PhotosListingDataSource, LinksMetadataDataSource { }
@@ -0,0 +1,42 @@
// Copyright (c) 2025 Proton AG
//
// This file is part of Proton Drive.
//
// Proton Drive is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Proton Drive is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Proton Drive. If not, see https://www.gnu.org/licenses/.
import Foundation
public struct TagsMigrationState: Equatable {
public let isFinished: Bool
public let anchor: Anchor?
public init(isFinished: Bool, anchor: Anchor?) {
self.isFinished = isFinished
self.anchor = anchor
}
public struct Anchor: Equatable {
public let lastProcessedLinkID: String
public let lastProcessedCaptureTime: Date
public let lastMigrationTimestamp: Date
public let lastClientUID: String?
public init(lastProcessedLinkID: String, lastProcessedCaptureTime: Date, lastMigrationTimestamp: Date, lastClientUID: String?) {
self.lastProcessedLinkID = lastProcessedLinkID
self.lastProcessedCaptureTime = lastProcessedCaptureTime
self.lastMigrationTimestamp = lastMigrationTimestamp
self.lastClientUID = lastClientUID
}
}
}
@@ -0,0 +1,52 @@
// Copyright (c) 2025 Proton AG
//
// This file is part of Proton Drive.
//
// Proton Drive is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Proton Drive is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Proton Drive. If not, see https://www.gnu.org/licenses/.
import Foundation
public final class MockTagsMigrationAPIClient: TagsMigrationAPIClient {
public var simulatedState: TagsMigrationState
public init(
isFinished: Bool = false,
anchor: TagsMigrationState.Anchor? = TagsMigrationState.Anchor(
lastProcessedLinkID: "mock-link-id",
lastProcessedCaptureTime: Date(timeIntervalSince1970: 1_680_000_000),
lastMigrationTimestamp: Date(timeIntervalSince1970: 1_680_000_300),
lastClientUID: "mock-client-uid"
)
) {
self.simulatedState = TagsMigrationState(isFinished: isFinished, anchor: anchor)
}
public func getTagsMigrationState(volumeID: String) async throws -> TagsMigrationState {
return simulatedState
}
public func setTagsMigrationState(volumeID: String, request: TagsMigrationStateRequest) async throws {
simulatedState = TagsMigrationState(
isFinished: request.finished,
anchor: request.anchor.map {
TagsMigrationState.Anchor(
lastProcessedLinkID: $0.lastProcessedLinkID,
lastProcessedCaptureTime: Date(timeIntervalSince1970: TimeInterval($0.lastProcessedCaptureTime)),
lastMigrationTimestamp: Date(timeIntervalSince1970: TimeInterval($0.currentTimestamp)),
lastClientUID: $0.clientUID
)
}
)
}
}
@@ -21,10 +21,12 @@ public struct PhotosListRequestParameters {
public let volumeId: String
public let lastId: String?
public let pageSize: Int
public let tag: Int?
public init(volumeId: String, lastId: String?, pageSize: Int) {
public init(volumeId: String, lastId: String?, pageSize: Int, tag: Int?) {
self.volumeId = volumeId
self.lastId = lastId
self.pageSize = pageSize
self.tag = tag
}
}
@@ -23,22 +23,37 @@ public struct PhotosListResponse: Codable, Equatable {
public struct Photo: Codable, Equatable {
public let linkID: String
public let captureTime: Int
public let relatedPhotos: [RelatedPhoto]
// public let nameHash: String // TODO: next MR, BE not ready
/// nameHash
public let hash: String
public let contentHash: String
// TODO:album remove optional when backend ready
public let tags: [Int]?
public init(linkID: String, captureTime: Int, relatedPhotos: [RelatedPhoto] = []) {
public let relatedPhotos: [Photo]? // Optional to allow using the same type for related photos (which don't have it)
public let addedTime: Date?
public init(
linkID: String,
captureTime: Int,
addedTime: Date?,
hash: String,
contentHash: String,
relatedPhotos: [Photo] = [],
tags: [Int]? = nil
) {
self.linkID = linkID
self.captureTime = captureTime
self.addedTime = addedTime
self.hash = hash
self.contentHash = contentHash
self.relatedPhotos = relatedPhotos
self.tags = tags
}
}
public struct RelatedPhoto: Codable, Equatable {
public let linkID: String
public let captureTime: Int
}
public init(photos: [Photo]) {
self.photos = photos
}
}
public typealias RemotePhotoListing = PhotosListResponse.Photo
@@ -21,7 +21,12 @@ public struct DocumentIdentifier: Codable {
public let revisionID: String
}
public struct NewDocumentPayload: Codable, Equatable {
public struct NewProtonFilePayload: Codable, Equatable {
public enum DocumentType: Int, Codable {
case document = 1
case sheet = 2
}
let name: String
let hash: String
let parentLinkID: String
@@ -32,6 +37,7 @@ public struct NewDocumentPayload: Codable, Equatable {
let contentKeyPacket: String
let contentKeyPacketSignature: String
let manifestSignature: String
let documentType: DocumentType
public init(
name: String,
@@ -43,7 +49,8 @@ public struct NewDocumentPayload: Codable, Equatable {
signatureAddress: String,
contentKeyPacket: String,
contentKeyPacketSignature: String,
manifestSignature: String
manifestSignature: String,
documentType: DocumentType
) {
self.name = name
self.hash = hash
@@ -55,19 +62,20 @@ public struct NewDocumentPayload: Codable, Equatable {
self.contentKeyPacket = contentKeyPacket
self.contentKeyPacketSignature = contentKeyPacketSignature
self.manifestSignature = manifestSignature
self.documentType = documentType
}
}
public struct NewDocumentParameters {
public struct NewProtonFileParameters {
let shareId: String
let payload: NewDocumentPayload
let payload: NewProtonFilePayload
public init(shareId: String, payload: NewDocumentPayload) {
public init(shareId: String, payload: NewProtonFilePayload) {
self.shareId = shareId
self.payload = payload
}
}
public protocol NewDocumentRepository {
func create(with parameters: NewDocumentParameters) async throws -> DocumentIdentifier
public protocol NewProtonFileRepository {
func create(with parameters: NewProtonFileParameters) async throws -> DocumentIdentifier
}
@@ -17,9 +17,9 @@
import Foundation
extension Client: NewDocumentRepository {
public func create(with parameters: NewDocumentParameters) async throws -> DocumentIdentifier {
let endpoint = NewDocumentEndpoint(parameters: parameters, service: service, credential: try credential())
extension Client: NewProtonFileRepository {
public func create(with parameters: NewProtonFileParameters) async throws -> DocumentIdentifier {
let endpoint = NewProtonFileEndpoint(parameters: parameters, service: service, credential: try credential())
return try await request(endpoint, completionExecutor: .asyncExecutor(dispatchQueue: backgroundQueue)).document
}
}
+15 -14
View File
@@ -27,15 +27,6 @@
"version" : "1.8.2"
}
},
{
"identity" : "ellipticcurvekeypair",
"kind" : "remoteSourceControl",
"location" : "https://github.com/agens-no/EllipticCurveKeyPair",
"state" : {
"revision" : "944ae5c89ca045e9f1a113b736706c73fc51d1c2",
"version" : "2.0.0"
}
},
{
"identity" : "lottie-ios",
"kind" : "remoteSourceControl",
@@ -59,8 +50,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/ProtonMail/protoncore_ios.git",
"state" : {
"revision" : "b4b7037f9b0d4da80b3b9dec8ced5af4139fee3b",
"version" : "25.0.2"
"revision" : "d72ee3f8ec940f15374f4d98e3e2ece5340ac419",
"version" : "32.7.1"
}
},
{
@@ -86,8 +77,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/getsentry/sentry-cocoa.git",
"state" : {
"revision" : "3b9a8e69ca296bd8cd0e317ad7a448e5daf4a342",
"version" : "8.18.0"
"revision" : "930b78a63f47549c81e6e63c9172584f7d3dfdd6",
"version" : "8.52.1"
}
},
{
@@ -131,7 +122,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/ProtonMail/TrustKit.git",
"state" : {
"revision" : "d107d7cc825f38ae2d6dc7c54af71d58145c3506"
"revision" : "d107d7cc825f38ae2d6dc7c54af71d58145c3506",
"version" : "1.0.3"
}
},
{
@@ -142,6 +134,15 @@
"revision" : "e5a609875de09a5fcea3231b9a7f4c181b1b427b",
"version" : "1.1.0"
}
},
{
"identity" : "yams",
"kind" : "remoteSourceControl",
"location" : "https://github.com/jpsim/Yams.git",
"state" : {
"revision" : "3d6871d5b4a5cd519adf233fbb576e0a2af71c17",
"version" : "5.4.0"
}
}
],
"version" : 2
+4 -7
View File
@@ -6,17 +6,15 @@ import PackageDescription
let package = Package(
name: "PDClient",
platforms: [
.iOS(.v15),
.iOS(.v16),
.macOS(.v13),
],
products: [
.library(name: "PDClient", targets: ["PDClient"]),
],
dependencies: [
.package(name: "PDLoadTesting", path: "../PDLoadTesting"),
// exact version is defined by protoncore_ios
.package(url: "https://github.com/ProtonMail/protoncore_ios.git", .suitable),
.package(url: "https://github.com/ProtonMail/protoncore_ios.git", exact: "32.7.1"),
.package(url: "https://github.com/ProtonMail/apple-fusion.git", .suitable),
.package(url: "https://github.com/getsentry/sentry-cocoa.git", .suitable),
.package(url: "https://github.com/Unleash/unleash-proxy-client-swift.git", .suitable),
@@ -25,11 +23,10 @@ let package = Package(
.target(
name: "PDClient",
dependencies: [
.product(name: "PDLoadTesting", package: "PDLoadTesting"),
.product(name: "ProtonCoreUtilities", package: "protoncore_ios"),
.product(name: "ProtonCoreEnvironment", package: "protoncore_ios"),
.product(name: "ProtonCoreNetworking", package: "protoncore_ios"),
.product(name: "ProtonCoreServices", package: "protoncore_ios"),
.product(name: "ProtonCoreUtilities", package: "protoncore_ios"),
.product(name: "Sentry", package: "sentry-cocoa"),
.product(name: "UnleashProxyClientSwift", package: "unleash-proxy-client-swift"),
@@ -2,13 +2,7 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>SchemeUserState</key>
<dict>
<key>PDCore-Package.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>7</integer>
</dict>
</dict>
<key>IDEDidComputeMac32BitWarning</key>
<true/>
</dict>
</plist>
+149
View File
@@ -0,0 +1,149 @@
{
"pins" : [
{
"identity" : "alamofire",
"kind" : "remoteSourceControl",
"location" : "https://github.com/Alamofire/Alamofire",
"state" : {
"revision" : "d120af1e8638c7da36c8481fd61a66c0c08dc4fc",
"version" : "5.4.4"
}
},
{
"identity" : "apple-fusion",
"kind" : "remoteSourceControl",
"location" : "https://github.com/ProtonMail/apple-fusion.git",
"state" : {
"revision" : "4df5ec5ce3605e9c364803c28cd06730fdb7fac4",
"version" : "2.1.5"
}
},
{
"identity" : "cryptoswift",
"kind" : "remoteSourceControl",
"location" : "https://github.com/krzyzanowskim/CryptoSwift",
"state" : {
"revision" : "729e01bc9b9dab466ac85f21fb9ee2bc1c61b258",
"version" : "1.8.4"
}
},
{
"identity" : "lottie-ios",
"kind" : "remoteSourceControl",
"location" : "https://github.com/airbnb/lottie-ios",
"state" : {
"revision" : "45517c3cfec9469bbdd4f86e32393c28ae9df0bc",
"version" : "4.3.3"
}
},
{
"identity" : "ohhttpstubs",
"kind" : "remoteSourceControl",
"location" : "https://github.com/AliSoftware/OHHTTPStubs",
"state" : {
"revision" : "12f19662426d0434d6c330c6974d53e2eb10ecd9",
"version" : "9.1.0"
}
},
{
"identity" : "protoncore_ios",
"kind" : "remoteSourceControl",
"location" : "https://github.com/ProtonMail/protoncore_ios.git",
"state" : {
"revision" : "d72ee3f8ec940f15374f4d98e3e2ece5340ac419",
"version" : "32.7.1"
}
},
{
"identity" : "reachability.swift",
"kind" : "remoteSourceControl",
"location" : "https://github.com/ashleymills/Reachability.swift",
"state" : {
"revision" : "21d1dc412cfecbe6e34f1f4c4eb88d3f912654a6",
"version" : "5.2.4"
}
},
{
"identity" : "sdwebimage",
"kind" : "remoteSourceControl",
"location" : "https://github.com/SDWebImage/SDWebImage.git",
"state" : {
"revision" : "7f9fb5d43ecd4aa714c00746f54873f354403438",
"version" : "5.15.8"
}
},
{
"identity" : "sentry-cocoa",
"kind" : "remoteSourceControl",
"location" : "https://github.com/getsentry/sentry-cocoa.git",
"state" : {
"revision" : "930b78a63f47549c81e6e63c9172584f7d3dfdd6",
"version" : "8.52.1"
}
},
{
"identity" : "swift-custom-dump",
"kind" : "remoteSourceControl",
"location" : "https://github.com/pointfreeco/swift-custom-dump",
"state" : {
"revision" : "82645ec760917961cfa08c9c0c7104a57a0fa4b1",
"version" : "1.3.3"
}
},
{
"identity" : "swift-snapshot-testing",
"kind" : "remoteSourceControl",
"location" : "https://github.com/pointfreeco/swift-snapshot-testing",
"state" : {
"revision" : "37230a37e83f1b7023be08e1b1a2603fcb1567fb",
"version" : "1.18.4"
}
},
{
"identity" : "swift-syntax",
"kind" : "remoteSourceControl",
"location" : "https://github.com/swiftlang/swift-syntax",
"state" : {
"revision" : "f99ae8aa18f0cf0d53481901f88a0991dc3bd4a2",
"version" : "601.0.1"
}
},
{
"identity" : "swiftotp",
"kind" : "remoteSourceControl",
"location" : "https://github.com/lachlanbell/SwiftOTP",
"state" : {
"revision" : "93d4942c90dd498580988ffb9971e2ddf91d5a28",
"version" : "2.0.3"
}
},
{
"identity" : "trustkit",
"kind" : "remoteSourceControl",
"location" : "https://github.com/ProtonMail/TrustKit",
"state" : {
"revision" : "d107d7cc825f38ae2d6dc7c54af71d58145c3506",
"version" : "1.0.3"
}
},
{
"identity" : "xctest-dynamic-overlay",
"kind" : "remoteSourceControl",
"location" : "https://github.com/pointfreeco/xctest-dynamic-overlay",
"state" : {
"revision" : "39de59b2d47f7ef3ca88a039dff3084688fe27f4",
"version" : "1.5.2"
}
},
{
"identity" : "yams",
"kind" : "remoteSourceControl",
"location" : "https://github.com/jpsim/Yams.git",
"state" : {
"revision" : "3d6871d5b4a5cd519adf233fbb576e0a2af71c17",
"version" : "5.4.0"
}
}
],
"version" : 2
}
+37
View File
@@ -0,0 +1,37 @@
// swift-tools-version: 5.9
// The swift-tools-version declares the minimum version of Swift required to build this package.
import PackageDescription
let package = Package(
name: "PDContacts",
platforms: [
.iOS(.v16),
.macOS(.v13)
],
products: [
// Products define the executables and libraries a package produces, making them visible to other packages.
.library(name: "PDContacts", targets: ["PDContacts"]),
],
dependencies: [
// exact version is defined by PDClient
.package(url: "https://github.com/ProtonMail/protoncore_ios.git", exact: "32.7.1"),
.package(url: "https://github.com/ProtonMail/apple-fusion.git", .suitable),
],
targets: [
// Targets are the basic building blocks of a package, defining a module or a test suite.
// Targets can depend on other targets in this package and products from dependencies.
.target(
name: "PDContacts",
dependencies: [
.product(name: "ProtonCoreNetworking", package: "protoncore_ios"),
.product(name: "ProtonCoreServices", package: "protoncore_ios")
],
path: "Sources"
),
]
)
extension Range where Bound == Version {
static let suitable = Self(uncheckedBounds: ("0.0.0", "99.0.0"))
}
@@ -0,0 +1,303 @@
// Copyright (c) 2023 Proton AG
//
// This file is part of Proton Drive.
//
// Proton Drive is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Proton Drive is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Proton Drive. If not, see https://www.gnu.org/licenses/.
import Combine
import Foundation
import ProtonCoreNetworking
import ProtonCoreServices
public protocol ContactsManagerProtocol {
var contactUpdatedNotifier: AnyPublisher<Void, Never> { get }
func fetchIntegralContacts() async throws -> ([Contact], [ContactGroup])
func fetchUserContacts() async throws -> [Contact]
func fetchUserContactGroups() async throws -> [ContactGroup]
func fetchActivePublicKeys(email: String, internalOnly: Bool) async throws -> PublicKeyResponse
func create(contact: Contact, with emails: [ContactEmail])
func delete(contactID: String)
func update(contact: Contact, with emails: [ContactEmail])
func delete(groupID: String)
func create(group: ContactGroup)
func update(group: ContactGroup)
}
public final class ContactsManager: ContactsManagerProtocol {
private let jsonDecoder: JSONDecoder = JSONDecoder()
private let service: APIService
private var log: ((String) -> Void)?
private var error: ((String) -> Void)?
private var contacts: [Contact] = []
private var contactGroups: [ContactGroup] = []
private var isContactInitialized = false
private var isContactGroupInitialized = false
private var keyCache: [KeyQuery: PublicKeyResponse] = [:]
private let contactUpdateSubject = PassthroughSubject<Void, Never>()
public var contactUpdatedNotifier: AnyPublisher<Void, Never> { contactUpdateSubject.eraseToAnyPublisher() }
public init(service: APIService, log: ((String) -> Void)?, error: ((String) -> Void)?) {
self.service = service
self.log = log
self.error = error
}
/// Retrieve user integral contacts:
/// if they are available in the in-memory cache, return them from there;
/// otherwise, query the backend.
/// - Returns: (user contacts, user contact groups)
public func fetchIntegralContacts() async throws -> ([Contact], [ContactGroup]) {
let contacts = try await fetchUserContacts()
let contactGroups = try await fetchUserContactGroups()
return (contacts, contactGroups)
}
/// Retrieve user contacts:
/// if they are available in the in-memory cache, return them from there;
/// otherwise, query the backend.
/// - Returns: user contacts
public func fetchUserContacts() async throws -> [Contact] {
if isContactInitialized { return contacts }
async let contactsAsync = fetchAllContacts()
async let emailsAsync = fetchAllEmails()
let (contactsRes, emailsRes) = await (contactsAsync, emailsAsync)
switch (contactsRes, emailsRes) {
case (.success(let contacts), .success(let emails)):
self.contacts = map(contacts: contacts, contactEmails: emails)
isContactInitialized = true
log?("Successfully retrieved user contacts; total number of contacts: \(contacts.count)")
return self.contacts
case (.failure(let error), _):
throw error
case (_, .failure(let error)):
throw error
}
}
/// Retrieve user contact group:
/// if they are available in the in-memory cache, return them from there;
/// otherwise, query the backend.
/// - Returns: user contacts
public func fetchUserContactGroups() async throws -> [ContactGroup] {
defer {
log?("Successfully retrieved user contact groups; total number of groups: \(contactGroups.count)")
}
if isContactGroupInitialized { return contactGroups }
let contactGroups = try await fetchContactGroupLabel().labels
let contacts = try await fetchUserContacts()
self.contactGroups = map(contactGroups: contactGroups, contacts: contacts)
isContactGroupInitialized = true
return self.contactGroups
}
/// - Parameters:
/// - email: Mail address, e.g. tester@pm.me
/// - internalOnly: If true, it will not perform any external lookup, and only provide information from the Proton DB
public func fetchActivePublicKeys(email: String, internalOnly: Bool = true) async throws -> PublicKeyResponse {
let query = KeyQuery(email: email, internalOnly: internalOnly)
if let cache = keyCache[query] {
return cache
}
let request = PublicKeyRequest(email: email, internalOnly: internalOnly)
log(request: request)
do {
let response = try await service.perform(request: request)
let jsonDict = response.1
let jsonData = try JSONSerialization.data(withJSONObject: jsonDict)
let res = try jsonDecoder.decode(PublicKeyResponse.self, from: jsonData)
keyCache[query] = res
return res
} catch {
log(failedRequest: request, error: error)
throw error
}
}
public func delete(contactID: String) {
contacts.removeAll(where: { $0.id == contactID })
for var group in contactGroups {
group.delete(contactID: contactID)
}
contactUpdateSubject.send()
}
public func create(contact: Contact, with emails: [ContactEmail]) {
guard !contacts.map(\.id).contains(contact.id) else { return }
var contact = contact
for email in emails {
contact.append(contactEmail: email)
}
contacts.append(contact)
contactUpdateSubject.send()
}
public func update(contact: Contact, with emails: [ContactEmail]) {
var contact = contact
for email in emails {
contact.append(contactEmail: email)
}
if let index = contacts.firstIndex(where: { $0.id == contact.id }) {
contacts[index] = contact
} else {
contacts.append(contact)
}
for index in contactGroups.indices {
if contact.labelIDs.contains(contactGroups[index].id) {
contactGroups[index].updateOrInsert(contact: contact)
} else {
contactGroups[index].delete(contactID: contact.id)
}
}
contactUpdateSubject.send()
}
public func delete(groupID: String) {
contactGroups.removeAll(where: { $0.id == groupID })
for index in contacts.indices {
contacts[index].remove(labelID: groupID)
}
contactUpdateSubject.send()
}
public func create(group: ContactGroup) {
guard !contactGroups.map(\.id).contains(group.id) else { return }
contactGroups.append(group)
contactUpdateSubject.send()
}
public func update(group: ContactGroup) {
if let existingGroup = contactGroups.first(where: { $0.id == group.id }) {
let contacts = existingGroup.contacts
var group = group
group.append(contentsOf: contacts)
guard let index = contactGroups.firstIndex(where: { $0.id == group.id }) else { return }
contactGroups[index] = group
} else {
create(group: group)
}
contactUpdateSubject.send()
}
}
// MARK: - Fetch data
extension ContactsManager {
private func fetchAllContacts() async -> Result<[Contact], Error> {
var contacts: [Contact] = []
var page = 0
do {
while true {
let response = try await fetchContacts(page: page)
contacts.append(contentsOf: response.contacts)
if contacts.count == response.total {
break
} else {
page += 1
}
}
} catch {
return .failure(error)
}
return .success(contacts)
}
private func fetchContacts(page: Int) async throws -> ContactResponse {
let request = ContactRequest(page: page)
return try await perform(request: request)
}
private func fetchAllEmails() async -> Result<[ContactEmail], Error> {
var emails: [ContactEmail] = []
var page = 0
do {
while true {
let response = try await fetchEmails(page: page)
emails.append(contentsOf: response.contactEmails)
if emails.count == response.total {
break
} else {
page += 1
}
}
} catch {
return .failure(error)
}
return .success(emails)
}
private func fetchEmails(page: Int) async throws -> EmailResponse {
let request = EmailRequest(page: page)
return try await perform(request: request)
}
private func fetchContactGroupLabel() async throws -> GroupLabelResponse {
let request = GroupLabelRequest()
return try await perform(request: request)
}
private func perform<T: Decodable>(request: Request) async throws -> T {
log(request: request)
do {
let jsonDict = try await service.perform(request: request).1
let jsonData = try JSONSerialization.data(withJSONObject: jsonDict)
let res = try jsonDecoder.decode(T.self, from: jsonData)
return res
} catch {
log(failedRequest: request, error: error)
throw error
}
}
private func map(contacts: [Contact], contactEmails: [ContactEmail]) -> [Contact] {
var contacts = contacts
for email in contactEmails {
guard let contactIdx = contacts.firstIndex(where: { $0.id == email.contactID }) else { continue }
contacts[contactIdx].append(contactEmail: email)
}
return contacts
}
private func map(contactGroups: [ContactGroup], contacts: [Contact]) -> [ContactGroup] {
var contactGroups = contactGroups
for idx in 0..<contactGroups.count {
let id = contactGroups[idx].id
let contact = contacts.filter { $0.labelIDs.contains(id) }
contactGroups[idx].append(contentsOf: contact)
}
return contactGroups
}
}
// MARK: - Log
extension ContactsManager {
private func log(request: Request) {
let logStr = "REQUEST: 🌐🌐🌐🌐 \(request.method.rawValue) - \(request.self)"
log?(logStr)
}
private func log(failedRequest: Request, error: Error) {
let responseHeader = "RESPONSE: 📩📩📩📩 \(failedRequest.method.rawValue) - \(failedRequest.self)"
let desc = """
\(responseHeader)
++++++++++++++++++++++++++++++++
|- Error ❌: \(error.localizedDescription)
--------------------------------
"""
self.error?(desc)
}
}
@@ -0,0 +1,33 @@
// Copyright (c) 2024 Proton AG
//
// This file is part of Proton Drive.
//
// Proton Drive is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Proton Drive is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Proton Drive. If not, see https://www.gnu.org/licenses/.
import ProtonCoreNetworking
struct ContactRequest: Request {
let endpoint: String = "/contacts/v4/contacts"
let page: Int
let pageSize: Int
var path: String {
"\(endpoint)?Page=\(page)&PageSize=\(pageSize)"
}
init(page: Int = 0, pageSize: Int = 1000) {
self.page = page
self.pageSize = pageSize
}
}
@@ -0,0 +1,84 @@
// Copyright (c) 2024 Proton AG
//
// This file is part of Proton Drive.
//
// Proton Drive is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Proton Drive is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Proton Drive. If not, see https://www.gnu.org/licenses/.
import Foundation
struct ContactResponse: Codable {
let code: Int
let contacts: [Contact]
let total: Int
enum CodingKeys: String, CodingKey {
case code = "Code", contacts = "Contacts", total = "Total"
}
init(code: Int, contacts: [Contact], total: Int) {
self.code = code
self.contacts = contacts
self.total = total
}
}
public struct Contact: Codable, Equatable {
/// Encrypted contact ID
public let id: String
public let name: String
public let uid: String
public let createTime: Date
public let modifyTime: Date
public private(set) var labelIDs: [String]
public private(set) var contactEmails: [ContactEmail] = []
enum CodingKeys: String, CodingKey {
case id = "ID"
case name = "Name"
case uid = "UID"
case createTime = "CreateTime"
case modifyTime = "ModifyTime"
case labelIDs = "LabelIDs"
}
public init(
id: String,
name: String,
uid: String,
createTime: Date,
modifyTime: Date,
labelIDs: [String]
) {
self.id = id
self.name = name
self.uid = uid
self.createTime = createTime
self.modifyTime = modifyTime
self.labelIDs = labelIDs
}
public mutating func append(contactEmail: ContactEmail) {
if contactEmails.map(\.email).contains(contactEmail.email) { return }
contactEmails.append(contactEmail)
contactEmails.sort(by: { $0.lastUsedTime >= $1.lastUsedTime })
}
mutating func add(labelID: String) {
labelIDs.append(labelID)
}
mutating func remove(labelID: String) {
labelIDs.removeAll(where: { $0 == labelID })
}
}
@@ -0,0 +1,33 @@
// Copyright (c) 2024 Proton AG
//
// This file is part of Proton Drive.
//
// Proton Drive is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Proton Drive is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Proton Drive. If not, see https://www.gnu.org/licenses/.
import ProtonCoreNetworking
struct EmailRequest: Request {
let endpoint: String = "/contacts/v4/contacts/emails"
let page: Int
let pageSize: Int
var path: String {
"\(endpoint)?Page=\(page)&PageSize=\(pageSize)"
}
init(page: Int = 0, pageSize: Int = 1000) {
self.page = page
self.pageSize = pageSize
}
}
@@ -0,0 +1,88 @@
// Copyright (c) 2024 Proton AG
//
// This file is part of Proton Drive.
//
// Proton Drive is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Proton Drive is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Proton Drive. If not, see https://www.gnu.org/licenses/.
import Foundation
struct EmailResponse: Codable {
let code: Int
let contactEmails: [ContactEmail]
let total: Int
enum CodingKeys: String, CodingKey {
case code = "Code", contactEmails = "ContactEmails", total = "Total"
}
init(code: Int, contactEmails: [ContactEmail], total: Int) {
self.code = code
self.contactEmails = contactEmails
self.total = total
}
}
public struct ContactEmail: Codable, Equatable {
public let contactID: String
public let email: String
/// false if contact contains custom sending preferences or keys
/// true otherwise
public let defaults: Bool
public let order: Int
/// Tells whether this is an official Proton address
public let isProton: Bool
/// 2001-01-01 00:00:00 +0000 aka timeIntervalSinceReferenceDate : 0.0
/// This indicates that the address is never utilized.
public let lastUsedTime: Date
enum CodingKeys: String, CodingKey {
case contactID = "ContactID"
case email = "Email"
case defaults = "Defaults"
case order = "Order"
case isProton = "IsProton"
case lastUsedTime = "LastUsedTime"
}
public init(contactID: String, email: String, defaults: Bool, order: Int, isProton: Bool, lastUsedTime: Date) {
self.contactID = contactID
self.email = email
self.defaults = defaults
self.order = order
self.isProton = isProton
self.lastUsedTime = lastUsedTime
}
public init(from decoder: any Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.contactID = try container.decode(String.self, forKey: .contactID)
self.email = try container.decode(String.self, forKey: .email)
let defaultsValue = try container.decode(Int.self, forKey: .defaults)
self.defaults = defaultsValue == 1 ? true : false
self.order = try container.decode(Int.self, forKey: .order)
let isProtonValue = try container.decode(Int.self, forKey: .isProton)
self.isProton = isProtonValue == 1 ? true : false
self.lastUsedTime = try container.decode(Date.self, forKey: .lastUsedTime)
}
public func encode(to encoder: any Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(contactID, forKey: .contactID)
try container.encode(email, forKey: .email)
try container.encode(defaults ? 1 : 0, forKey: .defaults)
try container.encode(order, forKey: .order)
try container.encode(isProton ? 1 : 0, forKey: .isProton)
try container.encode(lastUsedTime, forKey: .lastUsedTime)
}
}
@@ -15,8 +15,13 @@
// You should have received a copy of the GNU General Public License
// along with Proton Drive. If not, see https://www.gnu.org/licenses/.
public struct ProtonDocumentConstants {
public static let fileExtension = "protondoc"
public static let uti = "me.proton.drive.doc"
public static let mimeType = "application/vnd.proton.doc"
import ProtonCoreNetworking
struct GroupLabelRequest: Request {
let endpoint: String = "/core/v4/labels"
let type: Int = 2
var path: String {
"\(endpoint)?Type=\(type)"
}
}
@@ -0,0 +1,68 @@
// Copyright (c) 2024 Proton AG
//
// This file is part of Proton Drive.
//
// Proton Drive is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Proton Drive is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Proton Drive. If not, see https://www.gnu.org/licenses/.
import Foundation
struct GroupLabelResponse: Codable {
let code: Int
let labels: [ContactGroup]
enum CodingKeys: String, CodingKey {
case code = "Code", labels = "Labels"
}
init(code: Int, labels: [ContactGroup]) {
self.code = code
self.labels = labels
}
}
public struct ContactGroup: Codable {
public let id: String
public let name: String
public let order: Int
/// HEX color code, e.g. #3CBB3A
public let color: String
public private(set) var contacts: [Contact] = []
enum CodingKeys: String, CodingKey {
case id = "ID", name = "Name", order = "Order", color = "Color"
}
public init(id: String, name: String, order: Int, color: String) {
self.id = id
self.name = name
self.order = order
self.color = color
}
public mutating func append(contentsOf contacts: [Contact]) {
self.contacts.append(contentsOf: contacts)
}
mutating func delete(contactID: String) {
contacts.removeAll(where: { $0.id == contactID })
}
mutating func updateOrInsert(contact: Contact) {
if let index = contacts.firstIndex(where: { $0.id == contact.id }) {
contacts[index] = contact
} else {
contacts.append(contact)
}
}
}
@@ -0,0 +1,33 @@
// Copyright (c) 2024 Proton AG
//
// This file is part of Proton Drive.
//
// Proton Drive is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Proton Drive is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Proton Drive. If not, see https://www.gnu.org/licenses/.
import ProtonCoreNetworking
struct PublicKeyRequest: Request {
let endpoint = "/core/v4/keys/all"
let email: String
let internalOnly: Int
var path: String {
"\(endpoint)?Email=\(email)&InternalOnly=\(internalOnly)"
}
init(email: String, internalOnly: Bool) {
self.email = email
self.internalOnly = internalOnly ? 1 : 0
}
}
@@ -0,0 +1,116 @@
// Copyright (c) 2024 Proton AG
//
// This file is part of Proton Drive.
//
// Proton Drive is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Proton Drive is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Proton Drive. If not, see https://www.gnu.org/licenses/.
import Foundation
public struct PublicKeyResponse: Decodable {
public let code: Int
public let isProton: Bool
/// True when domain has valid proton MX
public let protonMX: Bool
/// List of warnings to show to the user related to phishing and message routing
public let warnings: [String]
public let address: Address
public let unverified: Address?
enum CodingKeys: String, CodingKey {
case Code, IsProton, ProtonMX, Warnings, Address, Unverified
}
public init(from decoder: any Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.code = try container.decode(Int.self, forKey: .Code)
let isProtonValue = try container.decode(Int.self, forKey: .IsProton)
self.isProton = isProtonValue == 1 ? true : false
self.protonMX = try container.decode(Bool.self, forKey: .ProtonMX)
self.warnings = try container.decode([String].self, forKey: .Warnings)
self.address = try container.decode(Address.self, forKey: .Address)
self.unverified = try container.decodeIfPresent(Address.self, forKey: .Unverified)
}
public init(
code: Int,
isProton: Bool,
protonMX: Bool,
warnings: [String],
address: Address,
unverified: Address? = nil
) {
self.code = code
self.isProton = isProton
self.protonMX = protonMX
self.warnings = warnings
self.address = address
self.unverified = unverified
}
}
public struct Address: Decodable {
public let keys: [Key]
// SignedKeyList is not implemented
enum CodingKeys: String, CodingKey {
case keys = "Keys"
}
public init(keys: [Key]) {
self.keys = keys
}
}
public struct Key: Decodable {
/// Key usage flags
public let flags: Flags
/// Armored OpenPGP public key
public let publicKey: String
/// Always (0) internal for verified keys
public let source: Int
public struct Flags: OptionSet, Decodable {
public let rawValue: Int
/// 2^0 = 1 means the key is not compromised (i.e. if we can trust signatures coming from it)
public static let notCompromised = Self(rawValue: 1 << 0)
/// 2^1 = 2 means the key is still in use (i.e. not obsolete, we can encrypt messages to it)
public static let notObsolete = Self(rawValue: 2 << 0)
public init(rawValue: Int) {
self.rawValue = rawValue
}
}
enum CodingKeys: String, CodingKey {
case flags = "Flags", publicKey = "PublicKey", source = "Source"
}
public init(flags: Flags, publicKey: String, source: Int) {
self.flags = flags
self.publicKey = publicKey
self.source = source
}
}
struct KeyQuery: Hashable {
let email: String
let internalOnly: Bool
func hash(into hasher: inout Hasher) {
hasher.combine(email)
hasher.combine(internalOnly)
}
}
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
@@ -15,8 +15,6 @@
// You should have received a copy of the GNU General Public License
// along with Proton Drive. If not, see https://www.gnu.org/licenses/.
import ProtonCoreLoginUI
public enum DriveCoreAlert: Equatable {
case logout
case trustKitFailure
@@ -45,7 +43,7 @@ public enum DriveCoreAlert: Equatable {
public var message: String {
switch self {
case .logout:
return LUITranslation.info_session_expired.l10n
return "Your session has expired. Please log in again."
case .trustKitFailure:
return "TLS certificate validation failed. Your connection may be monitored and the app is temporarily blocked for your safety.\n\nswitch networks immediately"
case .trustKitHardFailure:
@@ -0,0 +1,46 @@
// Copyright (c) 2024 Proton AG
//
// This file is part of Proton Drive.
//
// Proton Drive is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Proton Drive is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Proton Drive. If not, see https://www.gnu.org/licenses/.
import Combine
// Object that holds population state of the user. Is the source of truth for start app flow.
// Just after sign in it will be `unpopulated` and become `populated` as soon as roots are fetched.
public protocol PopulatedStateControllerProtocol {
var state: AnyPublisher<PopulatedState, Never> { get }
func setState(_ state: PopulatedState)
}
public final class PopulatedStateController: PopulatedStateControllerProtocol {
private let subject = CurrentValueSubject<PopulatedState, Never>(.unpopulated)
public init() { }
public var state: AnyPublisher<PopulatedState, Never> {
subject
.removeDuplicates()
.eraseToAnyPublisher()
}
public func setState(_ state: PopulatedState) {
subject.send(state)
}
}
public enum PopulatedState: Equatable {
case populated
case unpopulated
}
@@ -0,0 +1,32 @@
// Copyright (c) 2024 Proton AG
//
// This file is part of Proton Drive.
//
// Proton Drive is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Proton Drive is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Proton Drive. If not, see https://www.gnu.org/licenses/.
import Combine
public final class PopulatedStateControllerStub: PopulatedStateControllerProtocol {
private let subject = CurrentValueSubject<PopulatedState, Never>(.populated)
public init() { }
public var state: AnyPublisher<PopulatedState, Never> {
subject.eraseToAnyPublisher()
}
public func setState(_ state: PopulatedState) {
// Do nothing to ensure the state is always `.populated`
}
}
@@ -33,10 +33,10 @@ public final class iOSApplicationRunningStateResource: ApplicationRunningStateRe
case .background:
return .background
case .inactive:
Log.error("Requesting state while in suspended state", domain: .application)
// Can happen due to race condition during transition or perhaps during extension BG mode.
return .background
@unknown default:
Log.error("Unknown application state", domain: .application)
Log.error("Unknown application state", error: nil, domain: .application)
return .foreground
}
}
@@ -21,3 +21,7 @@ public protocol ThrowingAsynchronousInteractor {
func execute(with input: Input) async throws -> Output
}
public protocol ThrowingAsynchronousWithoutDataInteractor {
func execute() async throws
}
@@ -0,0 +1,64 @@
// Copyright (c) 2025 Proton AG
//
// This file is part of Proton Drive.
//
// Proton Drive is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Proton Drive is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Proton Drive. If not, see https://www.gnu.org/licenses/.
import Combine
import ProtonCoreNetworking
/// Adds retry mechanism with exponential backoff
public final class NetworkErrorHandlingCommand: Command {
private let interactor: any ThrowingAsynchronousWithoutDataInteractor
private var task: Task<Void, Never>?
public init(interactor: any ThrowingAsynchronousWithoutDataInteractor) {
self.interactor = interactor
}
deinit {
task?.cancel()
}
public func execute() {
task = Task.detached(priority: .background) { [weak self] in
await self?.executeWithPotentialRetry()
}
}
private func executeWithPotentialRetry(attempt: Int = 0) async {
guard !Task.isCancelled else {
return
}
guard attempt < 10 else {
Log.error("Reached maximal number of network issues. Cancelling command now.", error: nil, domain: .networking)
return
}
do {
try await interactor.execute()
} catch let error as ResponseError {
if error.isRetryableIncludingInternetIssues {
Log.info("Received networking issue, will retry after exponential backoff", domain: .networking)
try? await Task.sleep(for: .seconds(ExponentialBackoffWithJitter.getDelay(attempt: attempt)))
await executeWithPotentialRetry(attempt: attempt + 1)
} else {
Log.error(nil, error: error, domain: .networking)
}
} catch {
Log.error(nil, error: error, domain: .networking)
}
}
}
@@ -26,7 +26,7 @@ public protocol AuthenticatedWebSessionInteractor {
func execute() async throws -> AuthenticatedWebSessionData
}
final class ProtonDocumentAuthenticatedWebSessionInteractor: AuthenticatedWebSessionInteractor {
final class ProtonFileAuthenticatedWebSessionInteractor: AuthenticatedWebSessionInteractor {
private let sessionStore: SessionStore
private let selectorRepository: ChildSessionSelectorRepositoryProtocol
private let encryptionResource: AESGCMEncryptionResource
+138 -31
View File
@@ -108,7 +108,7 @@ public class CloudSlot: CloudSlotProtocol {
private let client: Client
private let sessionVault: SessionVault
private var moc: NSManagedObjectContext {
public var moc: NSManagedObjectContext {
self.storage.backgroundContext
}
@@ -151,7 +151,7 @@ public protocol CloudSlotProtocol: AnyObject,
CloudFileCleaner,
FolderCreatorProtocol,
NodeRenamerProtocol,
NodeMoverProtocol,
CloudNodeMoverProtocol,
CloudEventProvider,
ThumbnailCloudClient,
CloudAsyncVolumeCreatorProtocol,
@@ -164,7 +164,9 @@ public protocol CloudSlotProtocol: AnyObject,
CloudUpdaterProtocol,
CloudTrasherProtocol,
ThumbnailsUpdateRepository
{ }
{
var moc: NSManagedObjectContext { get }
}
public protocol CloudShareScannerProtocol {
func scanShare(shareID: String, handler: @escaping (Result<Share, Error>) -> Void)
@@ -185,14 +187,19 @@ public protocol CloudTrashScannerProtocol {
public protocol CloudChildrenScannerProtocol {
func scanChildren(of parentID: NodeIdentifier, parameters: [FolderChildrenEndpointParameters]?, handler: @escaping (Result<[Node], Error>) -> Void)
func scanChildren(of parentID: NodeIdentifier, parameters: [FolderChildrenEndpointParameters]?) async throws -> [Node]
}
public protocol CloudNodeScannerProtocol {
func scanNode(_ nodeID: NodeIdentifier, linkProcessingErrorTransformer: @escaping (Link, Error) -> Error, handler: @escaping (Result<Node, Error>) -> Void)
func scanNode(_ nodeID: NodeIdentifier, linkProcessingErrorTransformer: @escaping (Link, Error) -> Error) async throws -> Node
}
public protocol CloudRevisionScannerProtocol {
/// Legacy function, can be removed after 2025 Feb, once macOS migrated to DDK
func scanRevision(_ revisionID: RevisionIdentifier, handler: @escaping (Result<Revision, Error>) -> Void)
func scanRevision(_ revisionID: RevisionIdentifier) async throws -> Revision
}
public protocol CloudFileCleaner {
@@ -208,7 +215,7 @@ public protocol NodeRenamerProtocol {
func rename(_ node: Node, to newName: String, mimeType: String?) async throws
}
public protocol NodeMoverProtocol {
public protocol CloudNodeMoverProtocol {
func move(node: Node, to newParent: Folder, name: String) async throws
}
@@ -229,7 +236,7 @@ public protocol CloudAsyncVolumeCreatorProtocol {
}
public protocol ThumbnailCloudClient {
func downloadThumbnailURL(parameters: RevisionThumbnailParameters, completion: @escaping(Result<URL, Error>) -> Void)
func downloadThumbnailURL(parameters: RevisionThumbnailParameters, completion: @escaping (Result<URL, Error>) -> Void)
}
public protocol CloudFileDraftCreator {
@@ -308,6 +315,8 @@ extension CloudSlot {
guard let volume = volumes.first(where: { $0.state == .active }) else {
return nil
}
update(volumes, in: moc)
let mainShare = try await scanRootShare(volume.share.shareID)
if isPhotosEnabled {
@@ -362,7 +371,7 @@ extension CloudSlot {
let linksResponse = try await client.getLinksMetadata(with: .init(shareId: batch.shareID, linkIds: batch.linkIDs))
try await moc.perform { [weak self] in
guard let self else { return }
_ = try self.update(links: linksResponse.parents + linksResponse.links, shareId: batch.shareID, managedObjectContext: self.moc)
_ = try self.update(links: linksResponse.sortedLinks, shareId: batch.shareID, managedObjectContext: self.moc)
try self.moc.saveOrRollback()
}
} catch {
@@ -399,6 +408,28 @@ extension CloudSlot {
}
}
}
public func scanChildren(of parentID: NodeIdentifier, parameters: [FolderChildrenEndpointParameters]?) async throws -> [Node] {
let mode: UpdateMode = (parameters?.containsPagination() ?? false) ? .append : .replace
let childrenLinksMeta = try await client.getFolderChildren(
parentID.shareID,
folderID: parentID.nodeID,
parameters: parameters
)
return try await moc.perform { [weak self] in
guard let self else { return [] }
let childrenLinksMetaWithoutDrafts = childrenLinksMeta.filter { $0.state != .draft }
let objs = self.update(
childrenLinksMetaWithoutDrafts,
under: parentID.nodeID,
of: parentID.shareID,
mode: mode,
in: self.moc
)
try self.moc.saveOrRollback()
return objs
}
}
public func scanNode(_ nodeID: NodeIdentifier,
linkProcessingErrorTransformer: @escaping (Link, Error) -> Error = { $1 },
@@ -422,6 +453,17 @@ extension CloudSlot {
}
}
public func scanNode(
_ nodeID: NodeIdentifier,
linkProcessingErrorTransformer: @escaping (PDClient.Link, any Error) -> any Error
) async throws -> Node {
try await withCheckedThrowingContinuation { continuation in
scanNode(nodeID,
linkProcessingErrorTransformer: linkProcessingErrorTransformer,
handler: continuation.resume(with:))
}
}
public func scanRevision(_ revisionID: RevisionIdentifier,
handler: @escaping (Result<Revision, Error>) -> Void)
{
@@ -441,6 +483,19 @@ extension CloudSlot {
}
}
}
public func scanRevision(_ revisionID: RevisionIdentifier) async throws -> Revision {
let revisionMeta = try await client.getRevision(
revisionID: revisionID.revision,
fileID: revisionID.file,
shareID: revisionID.share
)
return try await moc.perform {
let obj = self.update(revisionMeta, inFileID: revisionID.file, of: revisionID.share, in: self.moc)
try self.moc.saveOrRollback()
return obj
}
}
}
// MARK: - Delete Uploading files
@@ -534,7 +589,7 @@ extension CloudSlot {
self.client.postVolume(parameters: parameters) {
switch $0 {
case .failure(let error):
Log.error(DriveError(error), domain: .networking)
Log.error("CloudSlot postVolume failed", error: DriveError(error), domain: .networking)
handler(.failure(error))
case .success(let newVolume):
@@ -552,7 +607,7 @@ extension CloudSlot {
}
} catch {
Log.error(DriveError(error), domain: .encryption)
Log.error("CloudSlot createVolume failed", error: DriveError(error), domain: .encryption)
handler(.failure(error))
}
}
@@ -591,7 +646,7 @@ extension CloudSlot {
// MARK: - ThumbnailCloudClient
extension CloudSlot {
public func downloadThumbnailURL(parameters: RevisionThumbnailParameters, completion: @escaping(Result<URL, Error>) -> Void) {
public func downloadThumbnailURL(parameters: RevisionThumbnailParameters, completion: @escaping (Result<URL, Error>) -> Void) {
client.getRevisionThumbnailURL(parameters: parameters, completion: completion)
}
}
@@ -662,7 +717,13 @@ extension CloudSlot {
// Client platform and version
var photoParameter: UpdateRevisionParameters.Photo?
if let photo = revision.photo {
photoParameter = UpdateRevisionParameters.Photo(captureTime: photo.captureTime, mainPhotoLinkID: photo.mainPhotoLinkID, exif: nil, contentHash: photo.contentHash) // We don't upload exif until the format is aligned.
photoParameter = UpdateRevisionParameters.Photo(
captureTime: photo.captureTime,
mainPhotoLinkID: photo.mainPhotoLinkID,
exif: nil,
contentHash: photo.contentHash,
tags: photo.tags
) // We don't upload exif until the format is aligned.
}
let parameters = UpdateRevisionParameters(
manifestSignature: revision.manifestSignature,
@@ -726,6 +787,7 @@ public protocol CloudUpdaterProtocol {
public protocol CloudTrasherProtocol {
func trash(shareID: Client.ShareID, parentID: Client.LinkID, linkIDs: [Client.LinkID]) async throws
func trash(_ nodes: [TrashingNodeIdentifier]) async throws
func trashVolume(nodeIDs: [AnyVolumeIdentifier]) async throws
func delete(shareID: Client.ShareID, linkIDs: [Client.LinkID]) async throws
func emptyTrash(shareID: Client.ShareID) async throws
func restore(shareID: Client.ShareID, linkIDs: [Client.LinkID]) async throws -> [PartialFailure]
@@ -778,7 +840,7 @@ extension CloudSlot {
share?.setValue(node, forKey: #keyPath(ShareObj.root))
share?.setValue(volume, forKey: #keyPath(ShareObj.volume))
share?.fulfill(from: shareMeta)
share?.fulfillShare(with: shareMeta)
node?.directShares.insert(share!)
if shareMeta.flags.contains(.main) {
@@ -794,7 +856,7 @@ extension CloudSlot {
// Not part of the interface created just for tests, delete if possible
@discardableResult
func update(_ volumes: [VolumeMeta], in moc: NSManagedObjectContext) -> [VolumeObj] {
public func update(_ volumes: [VolumeMeta], in moc: NSManagedObjectContext) -> [VolumeObj] {
var result: [VolumeObj] = []
// switch to MOC's thread
@@ -808,7 +870,7 @@ extension CloudSlot {
// set up share and relationships
result = volumes.compactMap { volumeMeta in
let volume = uniqueVolumes.first { $0.id == volumeMeta.volumeID }
volume?.fulfill(from: volumeMeta)
volume?.fulfillVolume(with: volumeMeta)
return volume
}
}
@@ -818,9 +880,9 @@ extension CloudSlot {
// Not part of the interface created just for tests, delete if possible
@discardableResult
func update(_ shares: [ShareMeta], in moc: NSManagedObjectContext) -> [ShareObj] {
public func update(_ shares: [ShareMeta], in moc: NSManagedObjectContext) -> [ShareObj] {
let result: [ShareObj] = self.update(shares.map(ShareShortMeta.init), in: moc)
zip(result, shares).forEach { $0.fulfill(from: $1) }
zip(result, shares).forEach { $0.fulfillShare(with: $1) }
return result
}
@@ -868,7 +930,10 @@ extension CloudSlot {
case .folder:
affectedIds.folders.insert(link.linkID)
@unknown default: assert(false, "Unknown node type")
case .album:
Log.error("Trying to update Album by old update function", error: nil, domain: .metadata)
assertionFailure("Shouldn't be used by iOS and Albums feature isn't supported on macOS")
}
}
@@ -894,7 +959,7 @@ extension CloudSlot {
photo.addToRevisions(localRevision)
photo.photoRevision = localRevision
photo.activeRevision = localRevision
localRevision.fulfill(link: link, revision: revisionResponse)
localRevision.fulfillRevision(link: link, revision: revisionResponse)
if revisionResponse.hasThumbnail, let thumbnails = revisionResponse.thumbnails {
addThumbnails(thumbnails, revision: localRevision, in: moc)
@@ -909,7 +974,7 @@ extension CloudSlot {
{
fileObj.addToRevisions(localRevision)
fileObj.activeRevision = localRevision
localRevision.fulfill(from: revisionResponse)
localRevision.fulfillRevision(with: revisionResponse)
if revisionResponse.hasThumbnail, let thumbnails = revisionResponse.thumbnails {
addThumbnails(thumbnails, revision: localRevision, in: moc)
@@ -917,9 +982,17 @@ extension CloudSlot {
}
nodeObj?.setValue(parentLinkObj, forKey: #keyPath(NodeObj.parentLink))
nodeObj?.setValue(shareID, forKey: #keyPath(NodeObj.shareID))
(nodeObj as? FileObj)?.fulfill(from: link)
(nodeObj as? FolderObj)?.fulfill(from: link)
(nodeObj as? Photo)?.fulfillPhoto(from: link)
#if os(macOS)
// Important for when enumerating items of a kept downloaded parent
if let parentLinkObj, parentLinkObj.isAvailableOffline {
nodeObj?.setValue(true, forKey: #keyPath(NodeObj.isInheritingOfflineAvailable))
}
#endif
(nodeObj as? FileObj)?.fulfillFile(with: link)
(nodeObj as? FolderObj)?.fulfillFolder(with: link)
(nodeObj as? Photo)?.fulfillPhoto(with: link)
directShares.forEach { share in
share.setValue(nodeObj, forKey: #keyPath(ShareObj.root))
@@ -987,7 +1060,7 @@ extension CloudSlot {
folderObj.setValue(parentLinkObj, forKey: #keyPath(NodeObj.parentLink))
folderObj.setValue(shareID, forKey: #keyPath(NodeObj.shareID))
folderObj.fulfill(from: folder)
folderObj.fulfillFolder(with: folder)
result = folderObj
}
@@ -1033,7 +1106,7 @@ extension CloudSlot {
moc.performAndWait {
// set up share and relationships
let revisionObj: RevisionObj = self.storage.unique(with: Set([revision.ID]), allowSubclasses: true, in: moc).first!
revisionObj.fulfill(from: revision)
revisionObj.fulfillRevision(with: revision)
let fileObj: File = self.storage.unique(with: Set([fileID]), allowSubclasses: true, in: moc).first!
fileObj.setValue(shareID, forKey: #keyPath(NodeObj.shareID))
@@ -1045,7 +1118,7 @@ extension CloudSlot {
in: moc)
newBlocks.forEach { block in
let meta = revision.blocks.first { $0.URL.absoluteString == block.downloadUrl }!
block.fulfill(from: meta)
block.fulfillBlock(with: meta)
block.setValue(revisionObj, forKey: #keyPath(BlockObj.revision))
}
@@ -1084,7 +1157,7 @@ extension CloudSlot {
private func update(_ newFileDetails: NewFile, file: FileObj) -> FileObj {
let moc = file.managedObjectContext!
moc.performAndWait {
file.fulfill(from: newFileDetails)
file.fulfillFile(with: newFileDetails)
let revision: RevisionObj = self.storage.unique(with: Set([newFileDetails.revisionID]), in: moc).first!
file.activeRevision = revision
@@ -1097,7 +1170,7 @@ extension CloudSlot {
private func update(_ newFolderDetails: NewFolder, folder: FolderObj) -> FolderObj {
let moc = folder.managedObjectContext!
moc.performAndWait {
folder.fulfill(from: newFolderDetails)
folder.fulfillFolder(with: newFolderDetails)
}
return folder
}
@@ -1126,6 +1199,10 @@ extension CloudSlot {
try await client.trash(shareID: shareID, parentID: parentID, linkIDs: linkIDs)
}
public func trashVolume(nodeIDs: [AnyVolumeIdentifier]) async throws {
fatalError("Not to be used on legacy CloudSlot")
}
public func delete(shareID: Client.ShareID, linkIDs: [Client.LinkID]) async throws {
try await client.deletePermanently(shareID: shareID, linkIDs: linkIDs)
}
@@ -1143,12 +1220,31 @@ extension CloudSlot {
}
}
public struct DeviceIdentifier: VolumeIdentifiable, Equatable {
public let id: String
public let nodeID: String
public let shareID: String
public let volumeID: String
public init(id: String, nodeID: String, shareID: String, volumeID: String) {
self.id = id
self.nodeID = nodeID
self.shareID = shareID
self.volumeID = volumeID
}
public var nodeIdentifier: NodeIdentifier {
NodeIdentifier(nodeID, shareID, volumeID)
}
}
// MARK: - Temporary workaround to filter non supported shares
import Combine
protocol SupportedSharesValidator {
public protocol SupportedSharesValidator {
func isValid(_ id: String) -> Bool
}
class iOSSupportedSharesValidator: SupportedSharesValidator {
public class iOSSupportedSharesValidator: SupportedSharesValidator {
private let storage: StorageManager
private lazy var supportedShares: Set<String> = {
@@ -1164,14 +1260,25 @@ class iOSSupportedSharesValidator: SupportedSharesValidator {
return Set(shareIds)
}()
init(storage: StorageManager) {
public init(storage: StorageManager) {
self.storage = storage
}
func isValid(_ id: String) -> Bool {
supportedShares.contains(id)
public func isValid(_ id: String) -> Bool {
if hasComputers {
return true
} else {
return supportedShares.contains(id)
}
}
private var hasComputers: Bool {
// We cannot use FeatureFlagsController directly anymore because the code was moved to PDCoreiOS,
// This code should exist for a limited amount of time, until, computers become fully part of iOS
LocalSettings.shared.driveiOSComputers && !LocalSettings.shared.driveiOSComputersDisabled
}
}
class macOSSupportedSharesValidator: SupportedSharesValidator {
func isValid(_ id: String) -> Bool {
true

Some files were not shown because too many files have changed in this diff Show More