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)")
}
}
}
}
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.