The iCal format, first defined as a standard as RFC 2445 in 1998, is the universally accepted format for distributing calendar files, mainly used for distributing events.
As part of my QR code scanning app Scanula I added support for detecting events in scanned objects. Thanks to the fantastic libical and the Swift wrapper swift-ical it's fairly easy to parse an iCal feed, but adding it to iOS is a bit trickier.
The best solution I have come up with is to host a small HTTP server within the app that redirects all requests to a data:
URL containing the iCal text. Hosting a server to serve a single file to be able to add calendar events may sound a bit farfetched. To explain why I think this is the best solution I'll first go over the alternatives, with the final solution at the end.
In versions prior to 1.2 of Scanula I would create a new EKEventEditViewController
, passing in a few pieces of information from the scanned iCal text. This is done by creating an EKEvent
object and populating the various properties. This has a few downside though:
- It's easy to miss a field
- Not all fields supported by iCal are supported by
EKEvent
- There's a lot of manual code required to setup the properties
So I started looking in to alternatives. The first thing that I tried was to write the iCal text to a temporary file and then providing the URL to a UIDocumentInteractionController
. I thought this would offer the user the option to add the event to their calendar, however, the bottom bar is simply missing. This isn't a commonly reported issue, but there others commenting on this (1, 2). Pre-requesting access to the calendar does not fix this; I assume that Apple's Safari and Mail can do this thanks to a special entitlement.
I thought that my best bet was to handoff the handling to Safari, but since the iCal text has been scanned via an image I can't provide a web URL for Safari to open. Since Shortcuts has special permissions it can open Safari with a non-http(s) URL, e.g. a data:
URL. So I created a Shortcut that opens Safari with a hardcoded data:text/calendar;base64,{base64-encoded-ical}
URL. It worked! But how can I have this be done by my own app?
SFSafariViewController
has the same limitation and will crash when opening a non-http(s) URL. This is where the local web server comes in. By hosting a web server on port 8080 within the app and passing in localhost:8080
as the URL it will query the local web server. All this web server does it redirect to data:text/calendar;base64,{base64-encoded-ical}
and then stop itself. For this I used Embassy because it seemed the most lightweight.
The UX of this isn't quite perfect because it will present an SFSafariViewController
, which will then present the view controller containing the calendar event. When the calendar event view controller is dismissed the user is left with an empty SFSafariViewController
, but I feel that these are all fair tradeoffs.
I do this with a fairly simple server:
import Embassy import Foundation public final class RedirectionServer { public private(set) static var shared = RedirectionServer() private var loop: EventLoop? private var server: HTTPServer? private var loopQueue: DispatchQueue? public func redirectNextRequestTo(_ url: URL) throws -> URL { tearDown() let loop = try SelectorEventLoop(selector: try KqueueSelector()) let server = DefaultHTTPServer(eventLoop: loop, port: 8080) { [weak self] _, startResponse, sendBody in startResponse("302", [ ("Location", url.absoluteString), ]) // Empty data ends response sendBody(Data()) self?.tearDown() } try server.start() let loopQueue = DispatchQueue(label: "Redirection Server") self.loop = loop self.server = server self.loopQueue = loopQueue loopQueue.async { loop.runForever() } return URL(string: "http://localhost:8080")! } private func tearDown() { server?.stop() loop?.stop() server = nil loop = nil loopQueue = nil } }
Presenting the UI to the user is also fairly simple:
let data = Data(iCalString.utf8) let base64Calendar = data.base64EncodedString() let calendarDataURL = URL(string: "data:text/calendar;base64," + base64Calendar)! DispatchQueue.global().async { do { let redirectionURL = try RedirectionServer.shared.redirectNextRequestTo(calendarDataURL) DispatchQueue.main.async { let safariViewController = SFSafariViewController(url: redirectionURL) safariViewController.modalPresentationStyle = .formSheet parentViewController.present(safariViewController, animated: true, completion: nil) } } catch { print("Failed to start redirection server:", error) } }