swiftuicore-datafile-manager

why cannot read or update my UI with images stored on disk, with path saved on CoreData


I download (here simulated) some data for a bunch of webApp. I download an image for each of them, store them on disk and save the path to the image in coreData, with the rest of single web app info.

My issue is: on first Run, everything is ok, at start are showed default images, then they are updated. images are saved on disk, all ok. From the second run and on, it seems the images are not saved (they are, I checked) and cannot find them. I think I have some issue about crating/reading the path, otherwise som issue in how ui is updated.

I checked inside the app Container via Xcode, images are present.

import CoreData
import Foundation

class PersistenceController: ObservableObject {
    
    var viewContext: NSManagedObjectContext
    let container: NSPersistentContainer
    
    
    //PreferredApps
    init() {
        container = NSPersistentContainer(name: "PersistentAppList")
        viewContext = container.viewContext
        
        let description = container.persistentStoreDescriptions.first
        description?.setOption(true as NSNumber, forKey: NSMigratePersistentStoresAutomaticallyOption)
        description?.setOption(true as NSNumber, forKey: NSInferMappingModelAutomaticallyOption)
        
        container.loadPersistentStores { description, error in
            if let error  {
                print("❌ error: \(error.localizedDescription)")
            } else {
                print("✅ loaded persistent stores")
            }
        }
    }
    
}

//*****************************************************************

import CoreData
import SwiftUI

class ClassFromEntryPoint: ObservableObject {
    
    @Published var downloadedAppsList: [WebAppLocalModel] = [] // Contains the entire downloaded app list
    @Published var availableAppsList: [WebAppLocalModel] = [] // Shows apps present both in core data and in the downloaded app list
    
    @Published var isLoading = false // Used for activityIndicator
    
    var completePureValueFromCoreData: [WebApp] {
        let fetchRequest: NSFetchRequest<WebApp> = WebApp.fetchRequest()
        let sortDescriptor = NSSortDescriptor(key: "id", ascending: true)
        fetchRequest.sortDescriptors = [sortDescriptor] // Apply the sortDescriptor
        do {
            return try persistenceController.container.viewContext.fetch(fetchRequest)
        } catch {
            print("Error fetching apps: \(error)")
        }
        return [] // In case of failure return empty array
    }
    
    
    let persistenceController: PersistenceController // Used for dependency injection
    let context: NSManagedObjectContext
    
    init(persistenceController: PersistenceController) {
        print("ClassFromEntryPoint initializied")

        self.persistenceController = persistenceController
        self.context = persistenceController.container.viewContext
        
        performMainCallAndUpdate()
    }
    
    func performMainCallAndUpdate() {
        Task {
            // Download and fill the downloaded list
            await self.downloadListFromWeb()
            
            // Verify if some of them must be added to the persistent store
            ifDownloadedSomeNewAppSaveItOnPersistentStore() //cicles and donwloads and saves images
            updateAvailableAppsListFilteredByDownloadedItems()
        }
    }
    
    func updateDownloadedAppsListFromCoreData() {
        let appsFromCoreData = self.completePureValueFromCoreData // Recupera le app da CoreData
        var updatedDownloadedList: [WebAppLocalModel] = []
        
        // Converti ogni WebApp (CoreData) in WebAppLocalModel (per la lista scaricata)
        for app in appsFromCoreData {
            let localModel = WebAppLocalModel(webAppFromCoreData: app)
            updatedDownloadedList.append(localModel)
        }
        
        DispatchQueue.main.async {
            self.downloadedAppsList = updatedDownloadedList // Aggiorna la lista scaricata
        }
    }

    @MainActor
    func downloadListFromWeb() async {
        print("downloadListFromWeb called")

        isLoading = true
        
        defer { isLoading = false }
        
        // Simulate a network call
        do {
            self.downloadedAppsList = try await simulateNetworkCall()
            print("✅ filled downloadedAppsList")
        } catch {
            // Handle error
            print("❌ Error during network call simulation: \(error)")
        }
    }
    
    // Function to simulate an asynchronous network call
    private func simulateNetworkCall() async throws -> [WebAppLocalModel] {
        
        try await Task.sleep(nanoseconds: 2_000_000_000) // Wait 2 seconds
        
        var listToReturn = [WebAppLocalModel]()

        for item in 1...5 {
            listToReturn.append(WebAppLocalModel(
                id: "\(item)",
                name: "name \(item)",
                logoUrl: "https://hws.dev/img/logo.png")
            )
        }
        return listToReturn
    }
    
    
    // MARK: saving on coredata after download
    
    /// Used to save any NEW element received from the server. To simulate, comment out the relevant element where you download the list from the internet
    func ifDownloadedSomeNewAppSaveItOnPersistentStore() {
        
        defer {
            print("verified if there are new apps")
            //updateLists()
        }
        
        for downloaded in self.downloadedAppsList {
            if self.completePureValueFromCoreData.contains(where: {$0.id == downloaded.id}) {
                print("id already present, not saving name: \(downloaded.name) id: \(downloaded.id)")
            } else {
                self.saveOnCoreData(webAppLocalModel: downloaded)
            }
        }
    }
    
    func saveOnCoreData(webAppLocalModel: WebAppLocalModel) {
        
        let newApp = WebApp(context: self.context)
        newApp.id = webAppLocalModel.id
        newApp.name = webAppLocalModel.name
        newApp.logoUrl = webAppLocalModel.logoUrl
        newApp.localLogoUrl = webAppLocalModel.localLogoUrl
        
        let _ = print("Saving on CoreData...")
        
        do {
            try self.context.save()
            let _ = print("... ✅ saved \(webAppLocalModel.name)")
        } catch {
            print("❌ \(error.localizedDescription)")
        }
        
        Task {
            await downloadAndSaveImage(from: webAppLocalModel.logoUrl, for: newApp)
        }
    
    }
    
    func downloadAndSaveImage(from urlString: String, for webApp: WebApp) async {
        guard let url = URL(string: urlString) else { return }

        do {
            let (data, _) = try await URLSession.shared.data(from: url)
            if let localUrl = saveImageLocally(data: data) {
                DispatchQueue.main.async {
                    
                    #warning("this defer resolves issue awhen data is downloaded first time")
                    defer {
                        self.updateDownloadedAppsListFromCoreData()
                        
//                        self.updateLists() //real main update
                        self.updateAvailableAppsListFilteredByDownloadedItems()//real main update

                    }
                    
                    webApp.localLogoUrl = localUrl.absoluteString
                    do {
                        try self.context.save()
                        print("✅ Image saved in coreData at \(webApp.localLogoUrl!)")
                        
                    } catch {
                        print("❌ Error saving local image URL: \(error)")
                    }
                }
                

            }
        } catch {
            print("❌ Error downloading the image: \(error)")
        }
    }
    
    func saveImageLocally(data: Data) -> URL? {
        let fileManager = FileManager.default
        guard let documentsDirectory = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first else { return nil }
        let fileName = UUID().uuidString + ".png" // Or the image format
        let fileURL = documentsDirectory.appendingPathComponent(fileName)
        
        do {
            try data.write(to: fileURL)
            print("OK file saved at: \(fileURL)")
            return fileURL
        } catch {
            print("❌ Error saving the image locally: \(error)")
            return nil
        }
    }

    // MARK: filtered calls - for the store
    
    /// availableAppsList must show only apps both in coredata and call response
    private func updateAvailableAppsListFilteredByDownloadedItems() {
        
        
        guard !downloadedAppsList.isEmpty else {
            print("🔍 ❌ Not loading availableAppsList, even if there is no networking error, downloadedAppsList is empty")
            return
        }

        defer {
            print("🔍 ✅ updated availableAppsList, updateAvailableAppsListFilteredByDownloadedItems done")
        }
        
        // Grab preferred app in coreData
        //----------------------------------------------------------------------
        let fetchRequest: NSFetchRequest<WebApp> = WebApp.fetchRequest()
        fetchRequest.sortDescriptors = []
        var coreDataApps: [WebApp] = []
        do {
            coreDataApps = try persistenceController.container.viewContext.fetch(fetchRequest)
        } catch {
            print("Error fetching apps: \(error)")
        }
        //----------------------------------------------------------------------
        
        
        // Create a Set of IDs from coreDataApps for more efficient search
        let coreDataAppIDs = Set(coreDataApps.map { $0.id })
        // Filter downloadedAppsList keeping only the apps
        // whose ID is in the Set coreDataAppIDs
        DispatchQueue.main.async {
            self.availableAppsList = self.downloadedAppsList.filter { coreDataAppIDs.contains($0.id) }
            self.test()
        }
        
    }
    

    func test() {
        guard !downloadedAppsList.isEmpty, !completePureValueFromCoreData.isEmpty else { return }

        let downloadedAppsDict = Dictionary(uniqueKeysWithValues: downloadedAppsList.map { ($0.id, $0) })

        availableAppsList = completePureValueFromCoreData.compactMap { localInCore in
            guard var found = downloadedAppsDict[localInCore.id!] else { return nil }
            found.localLogoUrl = localInCore.localLogoUrl
            return found
        }

        print("test \(availableAppsList.first?.localLogoUrl ?? "❌")")
    }

    
    
}


//*****************************************************************

import CoreData
import SwiftUI
// Used only to display data in lists
struct WebAppLocalModel: Codable, Identifiable, Equatable {
    
    var id: String
    var name: String
    var logoUrl: String // This should be optional
    var localLogoUrl: String?
    // Initializer for CoreData objects, used to create the object you use when displaying data
    init(webAppFromCoreData: WebApp) {
        self.id = webAppFromCoreData.id ?? "N/A"
        self.name = webAppFromCoreData.name ?? "N/A"
        self.logoUrl = webAppFromCoreData.logoUrl ?? "N/A"
        self.localLogoUrl = webAppFromCoreData.localLogoUrl ?? "N/A"
    }
    
    /// Initializer with specific parameters
    /// - used only for mock response from the web
    init(id: String = "N/A",
         name: String = "N/A",
         logoUrl: String = "N/A") {
        self.id = id
        self.name = name
        self.logoUrl = logoUrl
    }
    
}


//*****************************************************************



import SwiftUI

//here I should see only apps that are already in the download list, but also in the download list"
struct StoreAppView: View {
    @EnvironmentObject var classFromEntryPoint: ClassFromEntryPoint
    
    var body: some View {
        NavigationView {
            VStack {
                if classFromEntryPoint.isLoading {
                    ProgressView("Loading...")
                } else {
                    List {
                        ForEach(classFromEntryPoint.availableAppsList) { webAppLocalModel in
                            HStack {
                                if let localLogoUrl = webAppLocalModel.localLogoUrl,
                                   let url = URL(string: localLogoUrl),
                                   let imageData = try? Data(contentsOf: url),
                                   let image = UIImage(data: imageData) {
                                    Image(uiImage: image)
                                        .resizable()
                                        .aspectRatio(contentMode: .fit)
                                        .frame(width: 50, height: 50)
                                } else {
                                    Image("imgDefault")
                                        .resizable()
                                        .aspectRatio(contentMode: .fit)
                                        .frame(width: 50, height: 50)
                                }
                                
                                Text("\(webAppLocalModel.name)")
                                    .font(.body)
                                    .background(Color.green)
                                    .onTapGesture {
                                        print("Tapped: \(webAppLocalModel.name)")
                                    }
                            }
                        }
                    }
                    .onAppear {
                        print("appeared")
//                        classFromEntryPoint.updateLists() //should not be required, view should upload via @Published
                        print("appeared downloadedAppsList - \(classFromEntryPoint.downloadedAppsList.count)")
                        print("appeared availableAppsList - \(classFromEntryPoint.availableAppsList.count)")
                    }
                }
            }
            .navigationBarTitle("Available Apps")
        }
    }
}

temp version with a solution

    //updated
    func downloadAndSaveImage(from urlString: String, for webApp: WebApp) async {
        guard let url = URL(string: urlString) else { return }

        do {
            let (data, _) = try await URLSession.shared.data(from: url)
            if let fileName = saveImageLocally(data: data) { // Saves image and returns file name
                DispatchQueue.main.async {
                    defer {
                        self.updateDownloadedAppsListFromCoreData()
                        self.updateAvailableAppsListFilteredByDownloadedItems()
                    }
                    
                    webApp.localLogoUrl = fileName // Saves file name in CoreData
                    do {
                        try self.context.save()
                        print("✅ Image file name saved in CoreData as \(fileName)")
                    } catch {
                        print("❌ Error saving image file name in CoreData: \(error)")
                    }
                }
            }
        } catch {
            print("❌ Error downloading the image: \(error)")
        }
    }

    //updated
    func saveImageLocally(data: Data) -> String? {
        let fileManager = FileManager.default
        guard let documentsDirectory = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first else { return nil }
        let fileName = UUID().uuidString + ".png" // choose preferred image format
        let fileURL = documentsDirectory.appendingPathComponent(fileName)

        do {
            try data.write(to: fileURL)
            print("OK file saved at: \(fileURL)") //but this will change every launch
            return fileName // Return only name of the file
        } catch {
            print("❌ Error saving the image locally: \(error)")
            return nil
        }
    }


List {
                        ForEach(classFromEntryPoint.availableAppsList) { webAppLocalModel in
                            HStack {
                                    if let imageName = webAppLocalModel.localLogoUrl,
                                       let documentsDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first
                                        {
                                        let imageURL = URL(fileURLWithPath: imageName, relativeTo: documentsDirectory)
                                        //it would be better use it with a remote url, but shuld be a good optimization
                                        AsyncImage(url: imageURL) { phase in
                                            switch phase {
                                            case .empty:
                                                ProgressView() // Visualizzato durante il caricamento
                                            case .success(let image):
                                                image.resizable() // success image
                                                     .aspectRatio(contentMode: .fit)
                                                     .frame(width: 50, height: 50)
                                            case .failure:
                                                Image("imgDefault") // default image
                                                     .resizable()
                                                     .aspectRatio(contentMode: .fit)
                                                     .frame(width: 50, height: 50)
                                            @unknown default:
                                                EmptyView() // future cases not yet known
                                            }
                                        }
                                        
                                    } else {
                                        Image("imgDefault") // Default image in case path is not valid
                                            .resizable()
                                            .aspectRatio(contentMode: .fit)
                                            .frame(width: 50, height: 50)
                                    }

                                    Text("\(webAppLocalModel.name)")
                                        .font(.body)
                                        .background(Color.green)
                                        .onTapGesture {
                                            print("Tapped: \(webAppLocalModel.name)")
                                        }
                                }
                        }
                    }

Solution

  • This happens because your saveImageLocally function saves the full, absolute URL to the file-- but the URL you need changes. Absolute paths to places like the documents directory include a UUID that iOS changes every time the app launches, so the documents directory path is different every time. If you put a print in that function that prints the document directory path, you'll see this happen.

    Since you're saving right in the documents directory, you should save only the filename in Core Data. That is, save only the relative path, in this case relative to the documents directory. When you want to use the photo, look up the document directory URL and then add the filename to it to get an absolute path. That way you'll get the correct documents directory URL every time the app launches.