Teabyte

Mobile app development for iOS and Swift

LPMetadataProvider - Extract URL metadata

2023-04-16

In my side project I am handling various URLs to various websites a user can save to keep track of their analytical data. To enrich my UI with some metadata about the respective URLs I was searching for a convenient way to do so. What I found was LPMetadataProvider (Apple Documentation). A class I never heard of before but already exists for several years. I searched for some more information and found that most of the information is about how to use it in combination with LPLinkView or how to create instances of LPMetadataProvider to enrich the share sheet. All of them were very interesting but not quite what I was looking for. I want to extract all metadata from an URL and use it in my custom UI. Therefore I dug into LPMetadataProvider and searched for ways to use it how I want.

To make it a little bit more interesting, we can set up a task which is used as an anchor point for this post.

Show a list of URLs and asynchronously fetch their favicons, if available, using LPMetadataProvider.

The Basics

LPMetadataProvider can be used to fetch metadata for a URL. It consists of title, icon, image and video links. All of these properties are Optionals. If the website did not provide the data for these properties they are just not available.

Let's have an example: Given the URL of one of my blog posts: https://alexanderweiss.dev/blog/2023-03-05-swiftui-task-modifier, we can take a look at some of the HTML header tags.

<link rel="shortcut icon" href="/favicon.ico">
<meta property="og:title" content="SwiftUI's .task modifier">
<meta property="og:image" content="https://alexanderweiss.dev/api/og?title=SwiftUI%27s%20.task%20modifier">

They contain a favicon and two OpenGraph tags for an image and title. Most of today's websites have implemented OpenGraph tags, if you want to read a little bit more about that concept, please refer to https://ogp.me.

If we compare this with the data from the provider, we can already see similarities:

import LinkPresentation
 
Task {
    let metadataProvider = LPMetadataProvider()
    let url = URL(string: "https://alexanderweiss.dev/blog/2023-03-05-swiftui-task-modifier")!
 
    let metadata = try await metadataProvider.startFetchingMetadata(for: url)
 
    print(metadata.title)
    // Optional("SwiftUI\'s .task modifier")
 
    print(metadata.url)
    // Optional(https://alexanderweiss.dev/blog/2023-03-05-swiftui-task-modifier)
 
    print(metadata.imageProvider)
    // Optional(<NSItemProvider: 0x600002802610> {types = (
    //   "public.png"
    // )})
 
    print(metadata.iconProvider)
    // Optional(<NSItemProvider: 0x600002810380> {types = (
    //   "com.microsoft.ico"
    // )})
}

After the metadata is fetched, the title and url properties are already available for consumption. We can also see they are optional, meaning if a website does not implement its header/meta tags properly LPMetadataProvider is not capable of properly fetching the corresponding data.

Furthermore, it can be seen that there is an image and icon available for the website. To receive the image data out of the NSItemProvider we have to work with the item providers API

metadata.iconProvider?.loadDataRepresentation(for: .image, completionHandler: { data, error in
    if let data {
        let image = UIImage(data: data)
    } else if let error {
        print(error)
    }
})
 
metadata.imageProvider?.loadDataRepresentation(for: .image, completionHandler: { data, error in
    if let data {
        let image = UIImage(data: data)
    } else if let error {
        print(error)
    }
})

The above implementation then results in downloading and instantiating UIImage instances.

LPLinkMetadata icon and image providers

Custom usage

After covering the basics of LPMetadataProvider we can utilise this information to complete the task we set at the beginning of this post.

At first, we introduce an extension on NSItemProvider. While the metadata provider does offer some async/await conforming methods, the item provider does not. We are going to patch this by wrapping loadDataRepresentation(for:completionHandler:) into an async/await compatible method.

NSItemProvider+Extensions.swift
import LinkPresentation
import UniformTypeIdentifiers
 
extension NSItemProvider {
    func loadDataRepresentation(for type: UTType) async throws -> Data {
        return try await withCheckedThrowingContinuation { continuation in
            _ = self.loadDataRepresentation(for: type) { data, error in
                if let data {
                    continuation.resume(returning: data)
                } else if let error {
                    continuation.resume(throwing: error)
                }
            }
        }
    }
}

In the introduction, we saw that loading an icon from an URL is a two-step approach. First loading the metadata, then using the iconProvider property. To abstract away the two-step approach we can introduce an abstraction on the provider. It should hide the heavy lifting and provide an easy-to-use modern swifty API.

LPLoader.swift
import LinkPresentation
import UniformTypeIdentifiers
 
class LPLoader { }

The next step now is to model some error cases that can occur while loading a favicon.

LPLoaderError.swift
enum LPLoaderError: Error {
 
    /// Metadata loading failed
    case metadataLoadingFailed(error: Error)
 
    /// Favicon loading failed
    case faviconCouldNotBeLoaded
 
    /// Favicon data is invalid
    case faviconDataInvalid
}

Having the base class and error model in place, we can come up with a method to load a favicon of a given URL.

LPLoader.swift
class LPLoader {
    func favicon(for url: URL) async throws -> UIImage {
        let metadataProvider = LPMetadataProvider()
 
        let metadata: LPLinkMetadata
        do {
            metadata = try await metadataProvider.startFetchingMetadata(for: url)
        } catch {
            throw LPLoaderError.metadataLoadingFailed(error: error)
        }
 
        guard let iconProvider = metadata.iconProvider else {
            throw LPLoaderError.faviconCouldNotBeLoaded
        }
 
        let iconData: Data
        do {
            iconData = try await iconProvider.loadDataRepresentation(for: .image)
        } catch {
            throw LPLoaderError.metadataLoadingFailed(error: error)
        }
 
        guard let icon = UIImage(data: iconData) else {
            throw LPLoaderError.faviconDataInvalid
        }
        return icon
    }
}

Let us have a more detailed look at what is happening in the snippet above.

  1. First, the metadata associated with the given URL is loaded
  2. Then, check whether it is possible to load some icon
  3. If the check is successful, the icon is loaded
  4. Last step, is to convert the loaded data into an UIImage instance and return it to the caller.

Usage

Structuring and coming up with a solution is one part. Using that solution in an application is the other one. To finish up this post, the following lines demonstrate how our solution can be used to fulfil the task we set at the beginning. The following code snippets set up a simple UI with a list of rows that asynchronously load the favicon of a given URL.

  1. Create a list of URLs
URLList.swift
struct URLList: View {
    // MARK: - State Properties
    @State private var urls: [URL] =
        [
            URL(string: "https://apple.com")!,
            URL(string: "https://stackoverflow.com")!,
            URL(string: "https://alexanderweiss.dev")!
        ]
 
    // MARK: -  Body
    var body: some View {
        List {
            ForEach(urls, id: \.self) { url in
                URLRow(url: url)
            }
        }
    }
}
  1. Implement the list row where, where LPLoader comes into play
URLRow.swift
struct URLRow: View {
    // MARK: - State Properties
    @State private var favicon: UIImage?
 
    // MARK: - Private Properties
    private let url: URL
    private var loader: LPLoader
 
    // MARK: - Init
    init(url: URL) {
        self.url = url
        self.loader = .init()
    }
 
    // MARK: - Body
    var body: some View {
        Label {
            Text(verbatim: url.absoluteString)
        } icon: {
            if let favicon {
                Image(uiImage: favicon)
                    .resizable()
                    .aspectRatio(contentMode: .fit)
                    .frame(width: 20, height: 20)
                    .background(.white)
                    .cornerRadius(2)
            } else {
                ProgressView()
            }
        }
        .task(id: url) {
            do {
                self.favicon = try await loader.favicon(for: url)
            } catch {
                self.favicon = UIImage(systemName: "exclamationmark.octagon.fill")
            }
        }
    }
}
  1. Final look

❗ Caveats ❗

Before coming to an end I want to highlight some caveats I encountered implementing LPMetadataProvider:

  • Internally it seems to spin up a WKWebView to receive the metadata, this can be expensive and causes calls to be ´MainActor´ isolated
  • Caching is highly recommended since the calls to fetch the metadata are expensive. I did not cover caching in this post since I wanted it to be more focused on how to use the LPMetadataProvider in a more custom environment. There are multiple ways of tackling this issue, some ideas I have:
    • Using NSCache for in-memory caching
    • Using a custom on-disk cache solution utilising the fact that the metadata is NSSecureCoding conforming
  • The API is not intuitive and needs some digging to understand what's going on

Conclusion

That's a wrap for today. I hope this post was helpful to understand how LPMetadataProvider works in general and you can use it in your apps. We did take a look at how the provider can be used in a general way as well in a more independent way in combination with some loading data into a custom UI.

If you find any mistake or would like to reach out to me, please do so 🙂

See you next time! 👋