Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions Permanent.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -396,6 +396,8 @@
5E7B9BAC2FB1300200C1D3D0 /* ActivityFeedAndInviteViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E7B9B9B2FB1300200C1D3D0 /* ActivityFeedAndInviteViewModelTests.swift */; };
5E7B9BAD2FB1300200C1D3D0 /* ArrayAndSequenceExtensionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E7B9B9C2FB1300200C1D3D0 /* ArrayAndSequenceExtensionTests.swift */; };
5E7B9BAE2FB1300200C1D3D0 /* DonateViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E7B9B9D2FB1300200C1D3D0 /* DonateViewModelTests.swift */; };
9CBB9ACA108840A69B585109 /* UIViewControllerSpinnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 39D35817F0254FCE858EC0E6 /* UIViewControllerSpinnerTests.swift */; };
13F57C8DB2DB4A4EB984A0BE /* FolderV2ModelsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDC60F3AAB1F46C3BF13FF68 /* FolderV2ModelsTests.swift */; };
615BE4F55BBD469EBDCCA6CD /* BillingEndpointTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C26E991100BD450E8FD1389C /* BillingEndpointTests.swift */; };
5E7B9BAF2FB1300200C1D3D0 /* GiftStorageViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E7B9BA02FB1300200C1D3D0 /* GiftStorageViewModelTests.swift */; };
5E7B9BBB2FB139DF00C1D3D0 /* OnboardingPathAndChartTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E7B9BB62FB139DF00C1D3D0 /* OnboardingPathAndChartTests.swift */; };
Expand Down Expand Up @@ -1558,6 +1560,8 @@
5E7B9B9B2FB1300200C1D3D0 /* ActivityFeedAndInviteViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActivityFeedAndInviteViewModelTests.swift; sourceTree = "<group>"; };
5E7B9B9C2FB1300200C1D3D0 /* ArrayAndSequenceExtensionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArrayAndSequenceExtensionTests.swift; sourceTree = "<group>"; };
5E7B9B9D2FB1300200C1D3D0 /* DonateViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DonateViewModelTests.swift; sourceTree = "<group>"; };
39D35817F0254FCE858EC0E6 /* UIViewControllerSpinnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIViewControllerSpinnerTests.swift; sourceTree = "<group>"; };
CDC60F3AAB1F46C3BF13FF68 /* FolderV2ModelsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FolderV2ModelsTests.swift; sourceTree = "<group>"; };
C26E991100BD450E8FD1389C /* BillingEndpointTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BillingEndpointTests.swift; sourceTree = "<group>"; };
5E7B9B9E2FB1300200C1D3D0 /* EditMetadataViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditMetadataViewModelTests.swift; sourceTree = "<group>"; };
5E7B9B9F2FB1300200C1D3D0 /* FABViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FABViewModelTests.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -3669,6 +3673,8 @@
5E7B9B9B2FB1300200C1D3D0 /* ActivityFeedAndInviteViewModelTests.swift */,
5E7B9B9C2FB1300200C1D3D0 /* ArrayAndSequenceExtensionTests.swift */,
5E7B9B9D2FB1300200C1D3D0 /* DonateViewModelTests.swift */,
39D35817F0254FCE858EC0E6 /* UIViewControllerSpinnerTests.swift */,
CDC60F3AAB1F46C3BF13FF68 /* FolderV2ModelsTests.swift */,
C26E991100BD450E8FD1389C /* BillingEndpointTests.swift */,
5E7B9B9E2FB1300200C1D3D0 /* EditMetadataViewModelTests.swift */,
5E7B9B9F2FB1300200C1D3D0 /* FABViewModelTests.swift */,
Expand Down Expand Up @@ -6197,6 +6203,8 @@
5E7B9BAD2FB1300200C1D3D0 /* ArrayAndSequenceExtensionTests.swift in Sources */,
5E7B9BC72FB13D9000C1D3D0 /* FilePreviewViewModelTests.swift in Sources */,
5E7B9BAE2FB1300200C1D3D0 /* DonateViewModelTests.swift in Sources */,
9CBB9ACA108840A69B585109 /* UIViewControllerSpinnerTests.swift in Sources */,
13F57C8DB2DB4A4EB984A0BE /* FolderV2ModelsTests.swift in Sources */,
615BE4F55BBD469EBDCCA6CD /* BillingEndpointTests.swift in Sources */,
5E7B9BAF2FB1300200C1D3D0 /* GiftStorageViewModelTests.swift in Sources */,
5E7B9B7B2FB0D11B00C1D3D0 /* GiftStorageViewTests.swift in Sources */,
Expand Down
9 changes: 8 additions & 1 deletion Permanent/Common/Extensions/UIViewControllerExtension.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,16 @@
import UIKit
import SwiftUI

private var spinnerView: UIView?
private var spinnerViewKey: UInt8 = 0

extension UIViewController {
/// One overlay per screen. A single shared slot let one screen's hide remove another screen's
/// overlay, or turn its show into a no-op, leaving a spinner nothing could dismiss.
private var spinnerView: UIView? {
get { objc_getAssociatedObject(self, &spinnerViewKey) as? UIView }
set { objc_setAssociatedObject(self, &spinnerViewKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) }
}

func showSpinner(colored color: UIColor = .primary) {
if spinnerView != nil {
return
Expand Down
2 changes: 2 additions & 0 deletions Permanent/Common/Managers/Download/DownloadManagerGCD.swift
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,8 @@ class DownloadManagerGCD: Downloader {
}
}

/// Still V1: its consumers read the legacy `shareVOS` list, so moving it needs a FolderV2Data to
/// FolderVO adapter like `RecordV2Data.toRecordVOPayload()`. Stela does return pending shares to managers.
func getFolder(_ file: FileDownloadInfo, then handler: @escaping GetFolderResponse) {
let apiOperation = APIOperation(FilesEndpoint.getFolder(itemInfo: (file.folderLinkId, file.parentFolderLinkId)))
self.operation = apiOperation
Expand Down
7 changes: 6 additions & 1 deletion Permanent/Common/Models/Data/FolderV2Models.swift
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@ struct FolderV2Data: Model {
let thumbnailUrls: ThumbnailUrlsV2?
let shares: [RecordShareV2]?
let pendingShares: [PendingShareV2]?
/// The caller's effective role on this folder, as Stela computes it across membership, shares,
/// share token and public access. Short form ("owner"), which `AccessRole.roleForValue` accepts.
let accessRole: String?

init(folderId: String? = nil,
displayName: String? = nil,
Expand All @@ -52,7 +55,8 @@ struct FolderV2Data: Model {
paths: FolderPathsV2? = nil,
thumbnailUrls: ThumbnailUrlsV2? = nil,
shares: [RecordShareV2]? = nil,
pendingShares: [PendingShareV2]? = nil) {
pendingShares: [PendingShareV2]? = nil,
accessRole: String? = nil) {
self.folderId = folderId
self.displayName = displayName
self.size = size
Expand All @@ -72,6 +76,7 @@ struct FolderV2Data: Model {
self.thumbnailUrls = thumbnailUrls
self.shares = shares
self.pendingShares = pendingShares
self.accessRole = accessRole
}
}

Expand Down
6 changes: 3 additions & 3 deletions Permanent/Common/Network/FolderV2Endpoint.swift
Original file line number Diff line number Diff line change
Expand Up @@ -38,9 +38,9 @@ extension FolderV2Endpoint: RequestProtocol {
let baseURL = APIEnvironment.defaultEnv.apiServer
switch self {
case .getFolderById(let folderId, _):
// Canonical plural route. The singular `/folder` form is a deprecated
// backend alias that hits the same handler; we use the documented one.
return "\(baseURL)api/v2/folders?folderIds[]=\(folderId)"
// Canonical plural route; the singular `/folder` alias is deprecated. `pageSize` is required even for
// one id (400 without it); 9999 also covers a future multi-id call without paging.
return "\(baseURL)api/v2/folders?folderIds[]=\(folderId)&pageSize=9999"
case .getFolderChildren(let folderId, _, let pageSize):
return "\(baseURL)api/v2/folders/\(folderId)/children?pageSize=\(pageSize)"
}
Expand Down
15 changes: 13 additions & 2 deletions Permanent/Modules/Shares/ViewController/SharesViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,9 @@ class SharesViewController: BaseViewController<SharedFilesViewModel> {
private var isGridView = false
private var sortActionSheet: SortActionSheet?
private var sharesRefreshRequestId = UUID()
/// Archive whose share list is loading right now, nil once it lands. A second fetch for the same archive
/// would supersede this one in the view model, and the spinner would then wait on the duplicate.
private var inFlightSharesArchiveId: Int?

override func viewDidLoad() {
super.viewDidLoad()
Expand Down Expand Up @@ -300,15 +303,21 @@ class SharesViewController: BaseViewController<SharedFilesViewModel> {
private func syncSharesForCurrentArchive() {
guard viewModel != nil else { return }

if loadedArchiveId != sessionArchiveId {
if Self.shouldFetchShares(loadedArchiveId: loadedArchiveId, sessionArchiveId: sessionArchiveId, inFlightArchiveId: inFlightSharesArchiveId) {
getShares(shouldShowSpinner: true)
} else if needsCollectionViewReloadOnAppear {
} else if loadedArchiveId == sessionArchiveId, needsCollectionViewReloadOnAppear {
needsCollectionViewReloadOnAppear = false
collectionView.reloadData()
configureCollectionViewBgView()
}
}

/// The list needs a fetch when it shows another archive than the selected one and no fetch for the
/// selected archive is already in flight. `viewDidLoad` starts the first load; `viewWillAppear` must not repeat it.
static func shouldFetchShares(loadedArchiveId: Int?, sessionArchiveId: Int?, inFlightArchiveId: Int?) -> Bool {
loadedArchiveId != sessionArchiveId && inFlightArchiveId != sessionArchiveId
}

fileprivate func refreshCollectionView(_ completion: (() -> ())? = nil) {
collectionView.reloadData()
configureCollectionViewBgView()
Expand Down Expand Up @@ -1224,10 +1233,12 @@ class SharesViewController: BaseViewController<SharedFilesViewModel> {

let requestId = UUID()
sharesRefreshRequestId = requestId
inFlightSharesArchiveId = sessionArchiveId

runRequest({ status in
guard self.sharesRefreshRequestId == requestId else { return }

self.inFlightSharesArchiveId = nil
self.hideSpinner()
switch status {
case .success:
Expand Down
8 changes: 5 additions & 3 deletions Permanent/Modules/Shares/ViewModel/SharedFilesViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -70,11 +70,13 @@ class SharedFilesViewModel: FilesViewModel {

let apiOperation = APIOperation(ShareEndpoint.getShares)

apiOperation.execute(in: APIRequestDispatcher()) { result in
apiOperation.execute(in: APIRequestDispatcher()) { [weak self] result in
DispatchQueue.main.async {
guard let self = self else { return handler(.error(message: .errorMessage)) }
// A superseded fetch must not touch published state: a newer request is already
// in flight and its response is the authoritative one.
guard generation == self.sharesRequestGeneration else { return }
// in flight and its response is the authoritative one. Still call back so the caller
// can release its spinner — a stranded spinner is worse than a redundant hide.
guard generation == self.sharesRequestGeneration else { return handler(.success) }

switch result {
case .json(let response, _):
Expand Down
165 changes: 95 additions & 70 deletions Permanent/ViewModels/ViewModel/FileMenuViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -238,96 +238,121 @@ class FileMenuViewModel: ObservableObject {
}

// MARK: - Access Role Update

/// Test seam for the V2 folder read behind the role refresh. Production leaves it nil.
var folderFetchV2Request: ((String, @escaping (OperationResult) -> Void) -> Void)?

#if DEBUG
/// Which path served the last role refresh, so a test cannot pass on the V1 failsafe while
/// claiming V2. DEBUG-only, with no Release behaviour.
static var lastAccessRoleSource = "none"
#endif

func fetchUpdatedAccessRole() {
guard showArchiveInfo, fileViewModel.sharedByArchive != nil else {
return
}


// Folders read the caller's role from Stela. Records, and folders without a folderId, stay on V1.
if fileViewModel.type.isFolder, fileViewModel.folderId > 0 {
fetchFolderAccessRoleV2(folderId: String(fileViewModel.folderId))
} else {
fetchUpdatedAccessRoleV1()
}
}

/// GET /api/v2/folders?folderIds[]= carries the caller's effective role. Anything short of a decoded
/// role falls through to V1, so a V2 hiccup never leaves the badge stale.
private func fetchFolderAccessRoleV2(folderId: String) {
let fetch: (String, @escaping (OperationResult) -> Void) -> Void = folderFetchV2Request ?? { folderId, completion in
let operation = APIOperation(FolderV2Endpoint.getFolderById(folderId: folderId, shareToken: ""))
operation.execute(in: APIRequestDispatcher()) { completion($0) }
}

fetch(folderId) { [weak self] result in
DispatchQueue.main.async {
guard let self = self else { return }

guard case .json(let response, _) = result,
let model: FolderV2Response = JSONHelper.decoding(from: response, with: FolderV2Response.decoder),
let accessRole = model.items?.first?.accessRole else {
self.fetchUpdatedAccessRoleV1()
return
}

#if DEBUG
Self.lastAccessRoleSource = "v2"
#endif
self.applyAccessRole(accessRole)
}
}
}

private func fetchUpdatedAccessRoleV1() {
#if DEBUG
Self.lastAccessRoleSource = "v1"
#endif

let itemInfo = (folderLinkId: fileViewModel.folderLinkId, parentFolderLinkId: fileViewModel.parentFolderLinkId)

let endpoint: FilesEndpoint
if fileViewModel.type.isFolder {
endpoint = FilesEndpoint.getFolder(itemInfo: itemInfo)
} else {
endpoint = FilesEndpoint.getRecord(itemInfo: itemInfo)
}

let apiOperation = APIOperation(endpoint)

apiOperation.execute(in: APIRequestDispatcher()) { [weak self] result in
guard let self = self else { return }

DispatchQueue.main.async {
switch result {
case .json(let response, _):
if self.fileViewModel.type.isFolder {
guard let model: APIResults<FolderVO> = JSONHelper.decoding(
from: response,
with: APIResults<FolderVO>.decoder
),
model.isSuccessful,
let folderData = model.results.first?.data?.first?.folderVO else {
return
}

var accessRoleString: String?
if let accessVO = folderData.accessVO?.value as? [String: Any],
let role = accessVO["accessRole"] as? String {
accessRoleString = role
} else if let role = folderData.accessRole {
accessRoleString = role
}

guard let accessRoleString = accessRoleString else {
return
}

let newAccessRole = AccessRole.roleForValue(accessRoleString)
if self.fileViewModel.accessRole != newAccessRole {
self.fileViewModel.accessRole = newAccessRole
self.updatePermissions(forAccessRole: accessRoleString)
self.regenerateMenuItemsAndAnimateHeight()
}
} else {
guard let model: APIResults<RecordVO> = JSONHelper.decoding(
from: response,
with: APIResults<RecordVO>.decoder
),
model.isSuccessful,
let recordData = model.results.first?.data?.first?.recordVO else {
return
}

var accessRoleString: String?
if let accessVO = recordData.accessVO?.value as? [String: Any],
let role = accessVO["accessRole"] as? String {
accessRoleString = role
} else if let role = recordData.accessRole {
accessRoleString = role
}

guard let accessRoleString = accessRoleString else {
return
}

let newAccessRole = AccessRole.roleForValue(accessRoleString)
if self.fileViewModel.accessRole != newAccessRole {
self.fileViewModel.accessRole = newAccessRole
self.updatePermissions(forAccessRole: accessRoleString)
self.regenerateMenuItemsAndAnimateHeight()
}
guard case .json(let response, _) = result else { return }

let accessRoleString: String?
if self.fileViewModel.type.isFolder {
guard let model: APIResults<FolderVO> = JSONHelper.decoding(from: response, with: APIResults<FolderVO>.decoder),
model.isSuccessful,
let folderData = model.results.first?.data?.first?.folderVO else {
return
}

case .error:
break

default:
break
accessRoleString = Self.accessRoleString(accessVO: folderData.accessVO, accessRole: folderData.accessRole)
} else {
guard let model: APIResults<RecordVO> = JSONHelper.decoding(from: response, with: APIResults<RecordVO>.decoder),
model.isSuccessful,
let recordData = model.results.first?.data?.first?.recordVO else {
return
}
accessRoleString = Self.accessRoleString(accessVO: recordData.accessVO, accessRole: recordData.accessRole)
}

if let accessRoleString {
self.applyAccessRole(accessRoleString)
}
}
}
}


/// V1 sends the role either nested in `accessVO` or flat; the nested one wins when both are present.
private static func accessRoleString(accessVO: JSONAny?, accessRole: String?) -> String? {
if let accessVO = accessVO?.value as? [String: Any],
let role = accessVO["accessRole"] as? String {
return role
}
return accessRole
}

/// Shared tail of both reads: only a changed role touches permissions and the menu.
private func applyAccessRole(_ accessRoleString: String) {
let newAccessRole = AccessRole.roleForValue(accessRoleString)
guard fileViewModel.accessRole != newAccessRole else { return }

fileViewModel.accessRole = newAccessRole
updatePermissions(forAccessRole: accessRoleString)
regenerateMenuItemsAndAnimateHeight()
}

private func updatePermissions(forAccessRole accessRoleString: String) {
let newPermissions = ArchiveVOData.permissions(forAccessRole: accessRoleString)
self.fileViewModel.permissions = newPermissions
Expand Down
Loading