Ben Dodson

Freelance iOS, macOS, Apple Watch, and Apple TV Developer

Unlisted App Distribution on the App Store

Back in 2015 I was commissioned to rebuild an iOS app for models1, the leading modelling agency in Europe. The app performed double duty acting as a personal portfolio for models within the agency and a collection of all of the portfolios for the agents. As it was only for staff and models, the app was distributed via an Enterprise certificate which allowed me to generate a single .ipa and produce a website where users could download the app directly to their devices. Over the years, this process became slightly more arduous as the user would need to manually approve the enterprise certificate within their device settings but it worked.

A little while later I also built a small app that allowed talent scouts to choose multiple photos and fill out some details in a form which was then compiled into an email. This, again, was distributed via an Enterprise account.

Fast forward to 2022 and my client had allowed their developer account to lapse. Upon trying to renew, they were told by Apple that they no longer met the current program requirements and that they should seek to distribute via Apple Business Manager, Ad Hoc Distribution, TestFlight, or the App Store. The Apple Business Manager would not have worked well as that is essentially a full MDM system whereby the client would need to manage all devices (which would not be suitable for the models). Similarly, Ad Hoc Distribution would be a pain as we’d need UDIDs for every device we want to distribute to and TestFlight would require sending out new builds every 90 days; the App Store being public would not be an option given the niche aspect of the app.

Luckily I’d remembered that Apple had announced a new “unlisted” status back in January which would allow you to upload your app to the App Store but make it available only via a direct link, kind of like the public link system within TestFlight1. I browsed around App Store Connect but could find no mention of it within the “Pricing and Availability” tab which only allowed me to go down the business route. It turns out you have to apply for unlisted status via a form:

You’ll need to submit a request to receive a link to your unlisted app. If your app hasn’t been submitted for review or was already approved for public download on the App Store, simply complete the request form. If your app was already approved for private download on Apple Business Manager or Apple School Manager, you’ll need to create a new app record in App Store Connect, upload your binary, and set the distribution method to Public before completing the request form.

To start with, I uploaded a build and filled out all of the metadata on App Store Connect including screenshots, description, review notes, etc. I then attempted to fill out the form but was denied access, despite being an admin, as it is only available to the account holder. For reference, the questions they ask are:

  • App Name
  • Apple ID of App
  • Describe in detail the business problem your app solves
  • Why do you prefer to distribute this app unlisted on the App Store instead of privately to specific organizations on Apple Business Manager or Apple School Manager?
  • What is the estimated size of your app’s user base?
  • Who is the audience for your app?
  • In what regions will your app be available?

My client filled this out and within 24 hours they had a reply:

After careful review, we regret that we’re unable to approve your request for unlisted app distribution at this time.

In order to evaluate your app, please complete all the required metadata in App Store Connect, including app name, description, keywords, and screenshots and submit for review.

This was a surprise as everything was set up on App Store Connect. After another email back and forth, Apple replied with:

I will reiterate the note below, and say that all unlisted apps must be submitted for App Store review.

So it turns out you do need to still submit the app via App Store Connect but also fill in a request form. Obviously we did not want the app to go live publicly so I set everything to “Manual release” and then added an extra line to the review notes that read:

IMPORTANT: We will request “unlisted” status for this app, it is not for general release but we were told by unlistedapp_request@apple.com that we needed to submit this to App Review before requesting this status.

I submitted the app and my client filled out the request form again. The next day, I received notification that both apps had been rejected by App Review for Guideline 2.1 - Information Needed:

We need more information about your business model and your users to help you find the best distribution option for your app

Please review the following questions and provide as much detailed information as you can for each question.

  1. Is your app restricted to users who are part of a single company? This may include users of the company’s partners, employees, and contractors.
  2. Is your app designed for use by a limited or specific group of companies?
    • If yes, which companies use this app?
    • If not, can any company become a client and utilize this app?
  3. What features in the app, if any, are intended for use by the general public?
  4. Identify the specific countries or regions where you plan to distribute your app.
  5. How do users obtain an account?
  6. Is there are any paid content in the app? For example, do users pay for opening an account or using certain features in the app?
  7. Who pays for the paid content and how do users access it?

You’ll note that a lot of this is similar to the form that was already filled out 🤔

I was out of the office for the day so had not gotten around to replying to these questions when out of the blue I got two notifications to say the apps had been approved for the App Store! It looks like App Review had rejected the app as they were not suitable for App Store release (hence the questions above) but had completely ignored my note about the unlisted status. Then, later that day, the team that deals with unlisted apps looked at them and approved them.

When I went back to App Store Connect, it now showed a new “Unlisted App URL” within the “App Distribution Methods” on the Pricing and Availability page2.

A few thoughts:

  1. This process is not great and I’m not sure why they’ve decided to split it across two separate systems. It would surely make more sense to have “Unlisted” be an option within App Store Connect and then App Review can have the correct team contact you to ask whatever questions they need, especially as the distribution is done through App Store Connect.
  2. The documentation clearly says you only need to fill out the form if the app hasn’t been submitted for review. I’m not sure why we needed to submit the app only for it to be rejected by a different team?
  3. The “Unlisted App URL” you get is actually the same as what the public URL would be. This strikes me as odd, especially as you can convert an already public app into an unlisted one (though you can’t go back to being public). Those URLs are going to be cached by search engines, etc, so seems like a bit of a flaw. This wasn’t an issue for us but worth mentioning.
  4. Viewing the app on the App Store is exactly the same as if you’d shared any public URL; the store page is identical with screenshots, description, ratings, App Privacy, etc. There is no indication whatsoever that this is a private page.

Overall I like that this system exists and that there is a way to get apps to a very specific small audience without paying 3x the cost for an enterprise account or going through the Apple Business Manager. I was also pleasantly surprised that the very basic app for talent scouts was approved as I did not think it would be; it looks like the App Store Guidelines are much less strictly enforced than they are for a public release3. However, there are clearly some teething problems that need to be ironed out.

In any case, if you’re looking to get an unlisted app on the App Store, just be aware that a rejection may not necessarily be all it seems!

  1. Similar, but App Store apps don’t typically expire so a single build would be enough until iOS changes necessitate another build. ↩︎

  2. As the app was still set to “Manual release” I had to press the release button. I was a bit nervous of this as the language is still “Are you sure you want to release this to the App Store” but it did not make it publicly available; once you have the unlisted URL there is no way to make the app public. ↩︎

  3. Or we got lucky 😂 ↩︎

Web Inspector on iOS devices and Simulators

Over the past few weeks I’ve worked on a number of projects that have necessitated me working with HTML and JavaScript be that via Safari on iOS, an SFSafariViewController, or in an embedded WKWebView. In all of these cases, I’ve needed to dive into the DOM to see what styles are being applied to certain elements or dig into a JavaScript Console to work out why some code isn’t working. On desktop, this is trivial as Safari has a Web Inspector panel built in similar to other browsers. It turns out it is also trivial on mobile as the exact same tool can be used with both iOS simulators and physical devices.

Activating the Web Inspector for Safari in an iOS Simulator. Note that it is also possible to look at the "Extension Background Page" for the Browser Note Safari Extension that is also installed and running.

If you select the ‘Develop’ tab from the menu bar of Safari on macOS, you’ll see a list of all of your connected devices and actively running simulators1. Drilling into this will then show all of the active web instances you can interact with; notice how the content within Safari has highlighted blue within the Simulator as I’ve moused over the twitter.com web instance above. When you click, a web inspector panel is then produced which allows you to make all the usual interrogations and changes you can within desktop Safari such as interacting with the console or changing CSS values of elements to see how they would look in realtime.

Here’s an example using a WKWebView within one of my client projects, Yabla Spanish:

As I hover over the DOM in the web inspector, the same highlighting that appears in desktop Safari appears within the WKWebView on my physical device (note the green box showing the 24px padding within that div).

Discovering that simulators and devices could be interacted with in this way has been a huge timesaver for me. Whilst developing Browser Note, I was constantly needing to tweak CSS values and investigate the current state of the DOM as websites have various tricks to try and make ads or cookie notices appear on top of all content (and the note needed to be on top at all times - you should totally take a look at Browser Note whilst you’re here). In doing this, I was then able to put this knowledge to use on no less than 3 client projects in the past month; this validates my theory that by working on your own side projects you can improve your efficiency when it comes to work projects.

There are a few caveats to be aware of when using the Web Inspector with an iOS device or simulator:

  • If you want to use this on an actual device you’ll first need to enable the Web Inspector on iOS. To do this, go to the Settings app and then Safari > Advanced and toggle the Web Inspector on.
  • Whilst the Web Inspector can interrogate WKWebView, it can only do this for apps you are running via Xcode. You cannot look at the WKWebView of apps you did not create nor of your own apps from TestFlight / the App Store. However, you can inspect debug builds installed via Xcode even if the debugger is not active (i.e. you build to the device then quit Xcode).
  • Conversely, you can use the Web Inspector with any SFSafariViewController even if it is not in your own app.
  • Whilst it is still possible to submit apps that use UIWebView, the web inspector will not work with them; it needs to be WKWebView.
  • This also works for AppKit, SwiftUI, and Catalyst apps on macOS.
  1. I use an “iDod” naming prefix for all of my devices; a throwback pun to my first Apple product, the iPod. ↩︎

Browser Note and the process of building an iOS 15 Safari Extension

I order a Chinese takeaway most weeks and my regular order is half a crispy aromatic duck. This is a bit excessive and so I’ve been aiming to reduce this to a quarter. The problem is that it’s too easy to hit reorder at the end of a long day and whilst I could add a reminder in Things or an alert in my calendar these are very easy to ignore. My solution was to use the new support for browser extensions in iOS 15 to throw up a fullscreen prompt when I go to the takeaway website:

Problem solved.

After using this solution for a week or two, I quickly found myself wanting to use it in on other sites such as BBC News and Twitter to prevent me from habitually visiting them. I developed the extension further so I could add notes directly within Safari and instantly saw my phone usage drop by several hours a week. It turns out that adding a short “are you sure” dialogue before visiting a website is a great way to break the habit1.

Whilst I make a lot of apps for my own personal use, this seemed like something that could be beneficial for everybody, particularly as I could not find any apps that already do this. Therefore, I’m very happy to release my first self-published app in over 5 years: Browser Note.

Browser Note is an app that does one thing incredibly well; it adds fullscreen notes to webpages. It does this by making use of an iOS 15 Safari Extension that you can activate within Safari to add your note and later update or delete it. When a note is displayed on a page, a “View Website” button is available to let you continue into the site; when tapped, the note is snoozed for a period of time at which point it will appear again on any subsequent reloads (the snooze period is 15 minutes but you can change this within the app).

All of your notes are visible within the app (shown left). You can add, update, and delete your notes by tapping the Browser Note icon within Safari (shown right).

There is no App Store just for Safari Extensions so instead they have to come bundled within an app. From a development perspective, Safari Extensions are very similar to those made for Chrome and Firefox; Apple even provide a converter to migrate any extensions you’ve built for other browsers. Xcode has a template for Safari Extension based-apps which I used to get started but after that you’re on your own; whilst there is documentation from Apple, it’s predominantly for Safari Extensions on macOS which are very different. The most frustrating aspect is that the documentation does not say which platforms it supports and as many of the APIs are the same it was easy to spend a couple of hours wondering why something doesn’t work only to find it doesn’t apply to iOS2.

My initial thought was to use the Web Storage API to store notes as then everything would be wrapped up in a tidy extension. However, there isn’t a way for the app to access that data so if I wanted to add syncing in future (i.e. between iPad and iPhone) then this was not going to be a viable solution. Most apps that have extensions just give you a tutorial on how to enable them but I wanted to go a bit further and add some functionality within there as well, so again I wanted the app to be able to access the notes that were created.

In the end, I came up with a model whereby the app is in control of all of the notes data within a local Realm database situated in an App Group so both the app and extension can communicate with it. When you load a website, the extension wakes up the app which checks whether there is a note matching the current URL; if there is, it responds with the data and the extension removes all of the HTML from the page and replaces it with it’s own note UI. Similarly, when you invoke the popup sheet to add a note or edit one, the commands are sent through to the app to perform those actions on the database. Within the iOS simulator this is quite slow – it would take a few seconds after visiting a page before the note would appear – but on a device it’s practically instantaneous.

In order to communicate between the extension and the app, there are a number of hoops to jump through. First of all we have the content.js file which is loaded on each page request:

browser.runtime.sendMessage(JSON.stringify({"request": "check", "url": window.location.href})).then((response) => {
    switch(response.type) {
        case "message":
            if (!response.isSnoozed) {
                createContentView(response.id, response.emoji, response.displayText);
            }
            break;
        case "ignore":
            break;
    }
});

This sends a message to the background.js file with the current url and also the request type (there is check, add, update, delete, and snooze - in this case we’re just checking to see if there is a note). If a response comes back with a type of message then we’ll make sure the note isn’t currently snoozed and then throw up the note UI. But how does the response get generated?

browser.runtime.onMessage.addListener((request, sender, sendResponse) => {
    browser.runtime.sendNativeMessage("application.id", request, function(response) {
        sendResponse(response);
    });
    return true;
});

This is the background.js file in its entirety. Not much there 🤣. In order to communicate with the native app, we have to use a sendNativeMessage API but this cannot be used from content.js, it has to be invoked by background.js, hence this background file just acts as a middleman taking any messages from the content and sending them to the native app before shuttling back the response. One important item to note here is that you must return true when you are responding to a message from the content otherwise the connection is closed instantly (i.e. it will not wait for the native app to respond).

Now that we’ve sent a message to the app, it’s time to look at the Swift code that is responding to these events. This is all handled by a SafariWebExtensionHandler:

import SafariServices

class SafariWebExtensionHandler: NSObject, NSExtensionRequestHandling {
    
    func beginRequest(with context: NSExtensionContext) {
        guard let item = context.inputItems.first as? NSExtensionItem, let userInfo = item.userInfo else { return }
        guard let arg = userInfo[SFExtensionMessageKey] as? CVarArg else { return }
        let message = String(format: "%@", arg)
        guard let data = message.data(using: .utf8), let request = try? JSONDecoder().decode(ExtensionRequest.self, from: data) else { return }
        
        guard let requestType = request.requestType else { return }
        
        DispatchQueue.main.async {
            var response: Any = ["type": "ignore"]
            switch requestType {
            case .check:
                guard let url = request.url else { return }
                let device = UIDevice.current.userInterfaceIdiom == .pad ? "ipad" : "iphone"
                if let reminder = DataStore.shared.fetch(with: url) {
                    response = ["type": "message", "id": reminder.id, "displayText": reminder.displayText.replacingOccurrences(of: "\n", with: "<br>"), "emoji": reminder.emoji, "text": reminder.text, "isSnoozed": reminder.isSnoozed, "device": device]
                } else {
                    response = ["type": "ignore", "host": url.host ?? "", "device": device]
                }
            case .add:
                guard let url = request.url, let text = request.text else { return }
                let blockHost = request.blockHost ?? true
                let reminder = Reminder(url: url, text: text, matchExactPath: !blockHost)
                DataStore.shared.add(reminder)
                response = ["type": "reload"]
            case .update:
                guard let id = request.id, let text = request.text else { return }
                DataStore.shared.updateText(id: id, text: text)
                response = ["type": "reload"]
            case .delete:
                guard let id = request.id else { return }
                DataStore.shared.delete(id: id)
                response = ["type": "reload"]
            case .snooze:
                guard let id = request.id, let reminder = DataStore.shared.fetch(with: id) else { return }
                reminder.snooze()
                response = ["type": "reload"]
            }
            let responseItem = NSExtensionItem()
            responseItem.userInfo = [SFExtensionMessageKey: response]
            context.completeRequest(returningItems: [responseItem], completionHandler: nil)
        }
    }

}

The data comes through as a string within the user info dictionary of an NSExtensionItem. I send JSON from the extension via this mechanism as then I can use the Codable protocol in Swift to unpack it into a native struct. I then look at the request type and perform what actions are needed on the database before responding with a dictionary of my own; if there is a note then that data is sent back, otherwise the response will tell the extension to either do nothing or reload the page. In the end, we have an extension that can talk with the native app and store all the data in the Swift world allowing me to display the notes and allow them to be deleted from within the app:

Notes are stored within the app so can be deleted natively. I could also add the option to add and edit notes within the native app but I cut that functionality from the initial release so I could get it shipped.

As I mentioned, I would like to add note syncing in a future version that would allow the iPhone and iPad apps to remain in sync. I’m also intrigued as to whether a Catalyst version of the app would get this working on macOS but after over 30 hours of development I decided it was best to get an initial version launched before adding additional features.

Conclusion

This project started as a simple alert for a single webpage but has quickly become a labor of love for me as the benefits of a pause in web browsing became apparent. I’ve spent far too much time tweaking aspects of the app and haven’t bought up a number of tiny details I’m very proud of:

  • I used SwiftUI via a UIHostingController to create the tutorial panels shown on app launch. These were able to be reused really easily within the settings page.
  • There are videos to show how to install the extension and use the app. I used data within an Asset Catalogue for the first time so I could have a different video depending on whether the app is installed on iPhone or iPad (and due to App Thinning it means the app doesn’t even include the other device video thus reducing download and install size).
  • I’m really pleased with the app icon I designed and am slightly surprised it got through App Review; it’s the Safari logo in green but using a pen for the compass arrow.
  • Speaking of App Review, I’m amazed this screenshot was allowed 🤣
  • The app resizes beautifully on iPad and all of it’s various multitasking window sizes. This also goes for the HTML within the extension which is in a modal popup on compact sizes but a floating panel on regular sizes.
  • Dark mode is fully supported so your note will reflect the system theme.

I’d love it if you would give Browser Note a try. It’s available on all devices running iOS 15 and above and is available worldwide3 now.

  1. I have attempted to do this in the past using Screen Time but it’s a blunt instrument. It can only apply to an entire domain and you can’t personalise the text that appears so it’s only really useful as a block; you don’t get the positive reinforcement from a message you’ve written. ↩︎

  2. I’m looking at you SFSafariExtensionManager; there is no way for your app to know if the extension is installed correctly on iOS. ↩︎

  3. Well, almost worldwide. 🇷🇺🇧🇾 ↩︎

Side Project: Stoutness

This is part of a series of blog posts in which I showcase some of the side projects I work on for my own use. As with all of my side projects, I’m not focused on perfect code or UI; it just needs to run!

Over the years I’ve been slowly tinkering with an idea I liked to call “The Morning Briefing”, a simple way to give me the information I want in the mornings such as the news, weather, and a list of my upcoming tasks. Originally this would have been read to me by Siri1 but in mid-2020 I decided instead it would be more fun to have it as a printed page; a personalised newspaper just for me2. It would have consisted of weather icons for the next 7 days, a list of my tasks I could physically check off, and then maybe some personal data from Health such as Apple Watch rings, heart rate data, and sleep averages.

I did make a start on this idea but then late last year my chiropractor told me that I desperately needed to improve my activity level. I also needed to perform the stretches I’d been assigned back in 2017 every day as I’d fallen out of the habit. The video I use for my stretches is by Straighten Up UK and whilst it’s very good there are several bits that can be fast forwarded through once you know the routine. It was also affecting my recommended videos on YouTube as I’d watch it every morning which meant YouTube kept assuming I wanted more and more stretch videos. Eventually I decided that I’d just download the video, remove the extraneous bits, and put it in a basic tvOS app. I then realised that my Morning Briefing idea might work better if it was paired along with the stretching video and thus Stoutness was born.

Tangent: Name and App Icon

As any good developer knows, half the battle is what to name your side project. Originally this was "Morning Briefing" and then became "Hummingbird" after one of the sillier stretches which you'll see shortly. The final name was eventually hit on when my wife remarked (rather cruelly) that I looked like Winnie-the-Pooh doing his stoutness exercise.

One of my favourite things about working on tvOS is the way in which parallax is used to convey focus. Naturally I wanted to have a nice icon if I was going to use it every day and so I decided to take Pooh Bear and let him move around a little. The icon is made of three layers; Pooh on top, the room (with a hole where the mirror is) in the middle, and then the reflection at the back. The result is that both of the Pooh's move opposingly as the icon is highlighted. The whole thing was taken from a very low-res screenshot and then I made judicious use of the "content aware fill" tool in Photoshop to fill in the extra bits of image I'd need such as the space behind Pooh and extending the reflection cut out. You can download a zip of the three individual layers if you're curious to see exactly how it works.

As with all of my side projects, I generally like to use them as an excuse to learn some new code technique. In this case, I mainly focused on how to mix-and-match SwiftUI with UIKit (as that seems like it may prove useful in the coming years) but I also dabbled in some Combine and with using Swift Package Manager over CocoaPods for the limited number of dependencies I use. In the end, I also needed to dabble with AppleScript, Shortcuts, and writing a couple of iOS and macOS apps to help power everything…

Weather

On opening the app the current date is shown along with a loading spinner whilst the five different data sources are called and cached; two of my own APIs, YouTube, Pocket, and OpenWeather. Only once they are all loaded does the “Get Started” button appear.

The weather icons are displayed as soon as the OpenWeather API response is received and show the weather for the next 7 days along with the high and low temperatures. This entire row is powered by SwiftUI with each day rendered like this:

struct DailyWeatherView: View {
    
    var weather: DailyWeather
    
    var body: some View {
        VStack(spacing: 30) {
            Text(weather.day)
                .font(.subheadline)
                .fontWeight(.bold)
                .multilineTextAlignment(.center)
            
            VStack {
                Image(systemName: weather.icon)
                    .font(.system(size: 80))
                Spacer()
            }.frame(maxHeight: 120)
            
            Text("\(weather.low)\(weather.high)↑")
                .font(.subheadline)
                .fontWeight(.bold)
                .multilineTextAlignment(.center)
                .opacity(0.7)
        }
        .frame(width: 220, height: 300, alignment: .center)
    }
}

I love how quick and easy it is to prototype something with SwiftUI and with the new Apple Silicon Macs it’s actually feasible to use the realtime preview in Xcode. The icons for each weather type are pulled from SF Symbols and you may notice I had to lock the VStack they appear in to 120pt high with a Spacer underneath; this is because the icons are all different sizes and so cloud would be misaligned when placed next to cloud.rain.

Newspapers

Stand Tall: To start with we're going to adopt the ideal posture. Try and retain this posture as often as you can. Stand straight and tall with your head high. Make sure your ears, shoulders, hips, knees, and ankles are in a straight line and pull your tummy button in.

In my original idea for “Morning Briefing” I had wanted to get the news headlines along with an excerpt. This is relatively easy to do via something like BBC News with screenscraping but the recent pandemic meant that nearly all of the “top read” stories were pandemic related which I didn’t really care to read about. I have a subscription to The Times so thought about scraping that site somehow but there was no easy way to get the same content as the newspaper so it ended up being whatever was breaking right now which I also didn’t want3. Perhaps I could hook into Apple News now that I’ve got a subscription with Apple One? Nope, Apple News has no APIs or SDK and one look at the network requests with Charles Proxy made me think I didn’t want to go down that rabbit hole. In the end, I opted for a much simpler solution of having a carousel of the front pages of today’s newspapers. This lets me see the main topics of the day without getting bogged down in article excerpts which are often a bit click-baity to get you to the actual article.

I tried a few ways to get front pages (originally from the BBC and Sky News websites) but in the end I wrote a very simple PHP script to scrape tomorrowspapers.co.uk every few hours:

require_once('simplehtmldom/HtmlWeb.php');
use simplehtmldom\HtmlWeb;
$client = new HtmlWeb();
$html = $client->load('https://www.tomorrowspapers.co.uk');

$images = [];
foreach ($html->find('img.size-full') as $imgTag) {
    if (strpos($imgTag->src, 'DESIGNFORM-Logo') !== false) {
        continue;
    }
    $images[] = $imgTag->src;
}

shuffle($images);

$json = json_encode($images);
file_put_contents('newspapers.txt', $json);
echo $json;

The front pages themselves are image views within a UIPageViewController. This has the nice side benefit that they are automatically placed into an endless carousel that can be swiped through with the Siri Remote. I randomise the pages each day so that I see each newspaper in a different order.

Tangent: Page Layout

There are 12 stretches in my chiropractic video which I've trimmed from the original downloaded video and saved as individual files. I then typed up the narration for each exercise and placed it in a JSON file.

The newspaper page and all the subsequent pages are all the same UIViewController which use the data in the JSON file to display the appropriate video, stretch name, and instructions. They then load a child view controller on the right hand side of the page; this was a UIPageViewController for the newspapers but is a UIHostingController with a SwiftUI view in it for all of the following stretches.

Tasks

Tilting Star: From the stand tall position, spread your arms and legs into a star. Facing forwards, place one hand in the air and the other at your side. Breathe in as you slowly stretch one arm overhead while slowly bending to the opposite side and sliding the hand down the thigh. Relax at the end of the stretch and don't forget to breathe. Perform slowly twice each side.

I’ve been a long time user of Things, the task manager based on the Getting Things Done methodology. I have several repeating tasks in intervals along with ad hoc tasks I create throughout the day, all sorted into areas and projects prefixed with an emoji so they don’t look quite so dull. Unfortunately, there is no online component with Things4 and thus no public API as there is with something like Todoist. Whilst there is some support for Siri Shortcuts they are for opening the app rather than extracting data out of it. However, the macOS app does have comprehensive support for AppleScript and so that offered me a way to get at the data:

on zero_pad(value, string_length)
    set string_zeroes to ""
    set digits_to_pad to string_length - (length of (value as string))
    if digits_to_pad > 0 then
        repeat digits_to_pad times
            set string_zeroes to string_zeroes & "0" as string
        end repeat
    end if
    set padded_value to string_zeroes & value as string
    return padded_value
end zero_pad

set now to (current date)
set today to (year of now as integer) as string
set today to result & "-"
set today to result & zero_pad(month of now as integer, 2)
set today to result & "-"
set today to result & zero_pad(day of now as integer, 2)

tell application "Things3"
    
    set todayIdentifiers to {}
    repeat with task in to dos of list "Today"
        set props to properties of task
        copy id of props to the end of todayIdentifiers
    end repeat
    set taggedTasks to {}
    repeat with toDo in to dos of list "Anytime"
        if tag names of toDo is "One A Day" then
            set props to properties of toDo
            if todayIdentifiers does not contain id of props then
                copy toDo to the end of taggedTasks
            end if
        end if
    end repeat
    set listSize to count of taggedTasks
    if listSize > 0 then
        set randomNumber to (random number from 1 to listSize)
        set toDo to item randomNumber of taggedTasks
        move toDo to list "Today"
    end if
    
    
    set listOfProjects to {}
    repeat with proj in projects
        set props to properties of proj
        set projectId to id of props
        set projectName to name of props
        set object to {projectName:projectName, projectId:projectId}
        copy object to the end of listOfProjects
    end repeat
    
    set listOfTasks to {}
    
    set tasks to to dos of list "Inbox"
    repeat with task in tasks
        set props to properties of task
        set projectName to "Inbox"
        set taskName to name of props
        set object to {|name|:taskName, |project|:projectName}
        copy object to the end of listOfTasks
    end repeat
    
    set tasks to to dos of list "Today"
    repeat with task in tasks
        set props to properties of task
        set projectName to ""
        if exists project of props then
            set projectId to id of project of props
            repeat with proj in listOfProjects
                if projectId in proj is projectId then
                    set projectName to projectName in proj
                end if
            end repeat
        end if
        
        set taskName to name of props
        
        set object to {|name|:taskName, |project|:projectName}
        
        copy object to the end of listOfTasks
    end repeat
end tell


set exportPath to "~/Dropbox/Apps/Morning Briefing/things.plist"
tell application "System Events"
    set plistData to {|date|:today, tasks:listOfTasks}
    set rootDictionary to make new property list item with properties {kind:record, value:plistData}
    set export to make new property list file with properties {contents:rootDictionary, name:exportPath}
end tell

There’s quite a lot there but essentially it boils down to going through my Today list and sorting each task by its project. These all then get added to a plist (as easier to deal with in AppleScript than JSON) as an array of dictionaries containing the task name and project name which is then saved to my Dropbox where a PHP script on my server can access it and return it through my own API.

One habit I’ve gotten into over the years is to create a number of short tasks that need to be done at some point and then tagging them “One A Day” with the aim being to do one of them every day. In this way, small jobs around the house or minor tweaks to projects slowly get done over time rather than lingering around. To help with that, the script above will find all of my tasks tagged in such a way, pick one at random, and then move it to Today before the list is exported.

On the app side, this plist is fetched and then parsed in order to make it more palatable to the app by grouping the tasks into their projects and fixing a few minor issues i.e. tasks in Things marked as “This Evening” still show up in the AppleScript as their project name so I rename their project to “🌘 This Evening” mimicking the waning crescent moon icon Things uses in its apps. I could likely do this within the AppleScript file but it’s just easier to do it in Swift!

In an ideal world this script would run automatically first thing in the morning so everything is synced up when I open the Apple TV app. Unfortunately this isn’t possible for me at the moment as I’m exclusively using an Apple Silicon MacBook Pro and I don’t leave it running continuously as I used to with my Mac Pro. For now, I need to run the script manually each morning (which is as simple as double clicking an exported app from Script Editor) but this will change once the Apple Silicon desktop machines launch!

Schedule

Twirling Star: In the star position with your tummy button drawn inward, gently turn your head to look at one hand. Slowly turn to watch your hand as it goes behind you, relaxing in this position. Breathe normally. Perform slowly twice each side.

Another habit I’ve gotten into recently is scheduling my week in advance. I do this for my client work but also for things like what exercises I’m going to do or what meals I’m going to eat5. Whilst I could have just typed it into a database to get it out of the API easily, it isn’t much fun manually entering data into an SQL client. Instead, I decided to use the calendar app on my mac and sync the data using Shortcuts on iOS. This is far easier as I can quickly type up my schedule on a Sunday and then sync it each day prior to starting the app.

Tangent: Shortcuts

Shortcuts née Workflow is a powerful system app that allows you to join data from various apps together. I use it extensively for this app, mostly to get otherwise inaccessible data out of my iPhone and into my database so my API can send it to the app. For example:

  • Fetching my calendar entries for the day and formatting them into a JSON array
  • Retrieving my exercise minutes and step count from a custom HealthKit app I wrote
  • Getting the amount of water I drank yesterday from Health
  • Fetching my sleep stats from the AutoSleep app

All of this data can then be packaged up in JSON and posted to my server using the poorly named "Get contents of" network action.

The only downside is that I do have to trigger this manually as no app, not even Shortcuts, can access your Health database when your iPhone is locked so a shortcut set to run at a specific time won't work unless I'm using my phone. This hasn't proved too annoying though as I'm now in the habit of running the shortcut directly from a widget on my home screen before I launch the Apple TV app.

I divide the day up into five sections; Anytime (all-day tasks), Morning (before midday), Lunchtime (midday - 2pm), Afternoon (2pm - 5pm), and Evening (after 5pm). If there are items at a specific time then that time is displayed but mostly items will be from my Schedule calendar which is comprised of tasks tagged in a specific way. For example, for dinner I will create an all-day task named “[food:dinner] Penne Bolognese” and the app will know to use the correct prefix and colour. Once again, SwiftUI becomes a joy to use for short interfaces like this:

struct ScheduleView: View {
    
    var schedule: MorningBriefingResponse.Schedule
    
    var body: some View {

        VStack() {
        
            Text("Schedule")
                .font(.title)
                .padding(.bottom, 50.0)
                        
            VStack(alignment: .leading, spacing: 10) {
                
                ForEach(schedule.formatted, id: \.self) { section in
                    
                    VStack(alignment: .leading, spacing: 2) {
                        Text(section.heading)
                            .font(.headline)
                        Divider()
                            .background(Color.primary)
                    }
                        .padding(.bottom, 10)
                        .padding(.leading, 210)
                    
                    ForEach(section.entries, id: \.self) { entry in
                        HStack(spacing: 10) {
                            Text(entry.label.uppercased())
                                .font(.caption2)
                                .bold()
                                .multilineTextAlignment(.leading)
                                .foregroundColor(Color(entry.color ?? UIColor.label))
                                .frame(width: 200, alignment: .trailing)
                            
                            Text(entry.title)
                                .font(.body)
                                .multilineTextAlignment(.leading)
                            Spacer()
                        }.opacity(0.7)
                    }
                    Spacer()
                        .frame(height: 40)
                }
            }
            Spacer()
        }
    }
}

The entire interface took less than 5 minutes to create and avoided the usual need to create either a reusable cell in a table view or a custom view to be added to a stack view dynamically.

Sleep

Twisting Star: From the star position, raise your arms and put your hands up. Bring your left elbow across your body to your raised right knee. Repeat the movement using your right elbow and left knee. Remain upright as you continue to alternate sides for 15 seconds. Breathe freely and enjoy.

Getting more sleep is usually the simplest way you can improve your life. If you’re getting less than 8 hours a day then sleeping more will improve your mood, cognition, memory, and help with weight loss and food cravings. I used to be terrible for sleep, typically getting 5 hours or less a day, but a concerted effort this year had me at an 8 hour average for most of January and February.

I use the excellent AutoSleep app on Apple Watch to track my sleep. The counterpart iOS app can reply to a Siri Shortcut by pasting a dictionary of your sleep data to the clipboard making it trivial to export and use as part of a workflow (although it also saves to HealthKit so you could just export it from there as well depending on what stats you are after).

The interface above is about to become fairly familiar as I use it for a number of metrics within the app. It comprises of 3 rings in an Apple Activity style giving me the sleep duration for last night, the nightly average over the last week, and the nightly average for the current year. Each ring is aiming to hit the 8 hour target and I write the duration as both time and percentage within each ring.

Tangent: Rings

The rings themselves are provided by MKRingProgressView which are Swift views for use within UIKit apps. To get them to work in SwiftUI was relatively straightforward requiring only that I write a small class conform to UIViewRepresentable:

import SwiftUI
import MKRingProgressView

struct ProgressRingView: UIViewRepresentable {
    var progress: Double
    var ringWidth: CGFloat = 30
    var startColor: UIColor
    var endColor: UIColor

    func makeUIView(context: Context) -> RingProgressView {
        let view = RingProgressView()
        view.ringWidth = ringWidth
        return view
    }

    func updateUIView(_ uiView: RingProgressView, context: Context) {
        uiView.progress = progress
        uiView.startColor = startColor
        uiView.endColor = endColor
    }
}

Each ring can then be created in SwiftUI as simply as:

ProgressRingView(progress: progress, ringWidth: size.ringWidth, startColor: startColor, endColor: endColor)

The only issue with this is that it's slow, especially on the Apple TV hardware which is several chip cycles behind modern iPhones. It may be the MKRingProgressView class itself is quite heavy going but I get the feeling translating it to work with SwiftUI and then pushing the whole SwiftUI view back to UIKit via UIHostingController might also be adding some lag. Overall it isn't a problem but it does mean that the view takes a half second to load.

Alcohol

Trap Openers: Breathe deeply, calmly, and relax your tummy. Let your head hang slightly forward and gently turn your head from one side to the other. Then, using your fingers, gently massage the area just below the back of your head. Move down to the base of your neck, then relax your shoulders and slowly roll them backwards and forwards. Repeat for 15 seconds.

As part of a concerted effort to improve my health, the amount of alcohol I was drinking had to come down fairly significantly. I’ve tried many habit tracking apps in the past mostly in the form of maintaining a chain; the problem I have with them is that once the chain is broken I tend to go all out (i.e. if I’m going to get a black mark to say “drank alcohol” for having a glass of wine I may as well have a bottle of wine). To remedy that I take the idea of chaining but also pair it with the government guidelines for alcohol consumption which is 14 units a week for men.

The data for this is manually input into my SQL database for now but I have other apps I’m working on that interact with those tables. For now, I add what I’ve drank and how many units along with a date and the API then returns the data you see above; how many units I drank yesterday, how many days since I last had something alcoholic (if yesterday was 0 units), the number of units I’ve drank in the last 7 days, and a weekly average for the past 3 months. This differs slightly from the other ring-based metrics in the app as rather than a daily average over 7 days I needed to fetch a cumulative sum to ensure I wasn’t going over 14 units in a 7 day period. The weekly average is also interesting as I needed to go through each Monday-Sunday period of the past 3 months and get a cumulative total before averaging that.

This system has worked very well for me with the result being an average of 1 glass of wine and 1 beer a week which is down significantly from what was likely around 4 bottles of wine and several beers a week. I also had a dry spell of over 2 weeks which is likely the longest duration in around 10 years. I have a lot more thoughts around alcohol tracking and logging which I’ll come back to in a future side project.

The one update I might make to this page is to show how much water I’m drinking a day. I track my water intake via WaterMinder and already sync the data to my server using the Shortcut I’ve mentioned previously. Originally I had a whole page for showing this data but I dropped it in favour of some more important metrics as I found my water levels were pretty consistent once I’d gotten into the habit of drinking more. It looks like it’s not something I need to keep an eye on quite so much but if I do bring it back I think it will be on this page.

Steps

The Eagle: Take your arms out to the side and slowly raise them above your head breathing in and keeping your shoulder blades together. Touch your hands together above your head. Slowly lower your hands to your sides while breathing out. Perform 3 times. This will help the movement of air in and out of your lungs.

Along with doing my morning stretches, my chiropractor was also very adamant that I needed to increase my physical exercise as my step count was a rather woeful average of 6000 steps per day in 20206. To that end I’ve started running again on my treadmill and going for multiple walks each day.

Whilst the data above is rendered in much the same way as the sleep data, getting it is far tricker than you might think due to the complication of having an Apple Watch and an iPhone. If you use Shortcuts to fetch your data from Health, then it isn’t possible to deduplicate properly and so you’ll end up with a total number of steps from both your Apple Watch and iPhone which is bad if you ever walk around with both devices at the same time which I obviously do. You can limit the fetch to a single device but then you run into problems as you won’t be recording any steps whilst your watch is charging for instance. I don’t know how this error has been allowed to continue for so long in Shortcuts but the solution is rather simple for iOS apps: you use a HKStatisticsQuery along with the .cumulativeSum and .separateBySource options. Unfortunately this meant I needed to write an iOS app to fetch my steps data along with an Intent Extension to allow that to then be exported via Siri Shortcuts.

class StepsService: NSObject {
    
    let store = HKHealthStore()
    
    func authorize() {
        guard let quantityType = HKObjectType.quantityType(forIdentifier: HKQuantityTypeIdentifier.stepCount) else {
            return
        }

        store.requestAuthorization(toShare: [], read: [quantityType]) { (success, error) in
            DispatchQueue.main.async {
                print("Authorized")
            }
        }
    }
    
    func fetch(for date: Date, onCompletion completionHandler: @escaping (Int) -> Void) {
        guard let quantityType = HKObjectType.quantityType(forIdentifier: HKQuantityTypeIdentifier.stepCount) else {
            fatalError("StepCount quantity doesn't exist")
        }
        
        let start = Calendar.current.startOfDay(for: date)
        
        var dayComponent = DateComponents()
        dayComponent.day = 1
        guard let tomorrow = Calendar.current.date(byAdding: dayComponent, to: date) else { return }
        let end = Date(timeIntervalSince1970: Calendar.current.startOfDay(for: tomorrow).timeIntervalSince1970 - 1)

        let predicate = HKQuery.predicateForSamples(withStart: start, end: end, options: [.strictStartDate, .strictEndDate])
        let query = HKStatisticsQuery(quantityType: quantityType, quantitySamplePredicate: predicate, options: [.cumulativeSum, .separateBySource]) { _, result, _ in
            DispatchQueue.main.async {
                guard let result = result, let sum = result.sumQuantity() else {
                    completionHandler(0)
                    return
                }
                
                let count = Int(sum.doubleValue(for: HKUnit.count()))
                completionHandler(count)
            }
        }
        store.execute(query)
    }
}
class StepsIntentHandler: NSObject, StepsIntentHandling {
    func handle(intent: StepsIntent, completion: @escaping (StepsIntentResponse) -> Void) {
        
        guard let components = intent.date, let date = Calendar.current.date(from: components) else {
            let response = StepsIntentResponse(code: .failure, userActivity: nil)
            completion(response)
            return
        }
        
        let stepsService = StepsService()
        let exerciseService = ExerciseService()
        stepsService.fetch(for: date) { (steps) in
            exerciseService.fetch(for: date) { (minutes) in
                let response = StepsIntentResponse(code: .success, userActivity: nil)
                
                var activity = [String: Int]()
                activity["steps"] = steps
                activity["exercise"] = minutes
                guard let data = try? JSONEncoder().encode(activity), let output = String(data: data, encoding: .utf8) else {
                    completion(StepsIntentResponse(code: .failure, userActivity: nil))
                    return
                }
                
                response.output = output
                completion(response)
            }
        }
    }
}

After opening the app and authorising it for read access on my HealthKit store, it now sits hidden in the App Library and is woken each morning as my sync shortcut requests the data and sends it to my server. It’s a long winded solution but it works!

Exercise

The Hummingbird: Put your arms to the side with your hands up. Pull your shoulders together. Next, make small backward circles with your hands and arms drawing your shoulder blades together. Sway gently from side to side in The Hummingbird. Keep it going while you count to 10.

The exercise view is pretty much identical to the steps view previously only with different data input, colour, and a goal of 30 minutes. Originally I used the native Health actions within Shortcuts to extract the exercise minutes but then I ran into similar duplication issues if I wore my Apple Watch whilst doing a work out on my Peleton bike as that too would add an exercise. To make things simple I added exercise minutes to the same exporter app I mentioned above.

YouTube

The Butterfly: Place your hands behind your head and gently draw your elbows backwards. Slowly and gently press your head backwards against your hands for a count of two. Release, again slowly and gently. Keeping your hands in this position, perform 3 times. This will make you more relaxed and improve your sense of wellbeing.

Whilst I’m on my treadmill I’ll typically watch a variety of videos I’ve saved on YouTube. This screen shows me the number of videos left in my “To Watch” playlist along with the total runtime7. I then show the details of five videos; the most recently added, the two oldest, and then two random ones from those that are left. In this way I get to know which videos have been sat a while and that I should really watch whilst also seeing something that is likely more exciting to me right now as I added it most recently. I’m not forced to watch these particular videos but I find I usually do choose them as it’s one less thing to think about when I get onto the treadmill.

Fetching the videos is relatively easy using the YouTube API as a single request can give you all the video details for a single playlist. Unfortunately, and for reasons I don’t understand, YouTube doesn’t provide access to the default “Watch Later” playlist even with an authenticated request so I had to create a public playlist named “To Watch” and then remember to add all of my videos to that. It’s a bit of a pain as I nearly always want to just click the “Watch Later” button on YouTube but I’m slowly getting used to adding to the playlist instead.

Pocket

Tight Rope: In the stand tall position pull your tummy in. Take a step forward as if on a tightrope. Make sure your knee is over your ankle and not over your toes. Allow the heel of your back foot to lift. Balance in this position for 20 seconds. Don't worry if you wobble a bit, it's normal. Repeat on the opposite side.

In a similar vein to the YouTube page, I do something very similar to show me the articles I currently have to read in Pocket8. I connect directly to the Pocket API and return the number of articles remaining, the estimated reading time of them all, and then 5 articles as a mixture of newest, oldest, and random entries.

Coding

The Rocker: Stand tall with your feet wider than shoulders. Gently rotate your upper body from side to side. Let your arms flop loosely as you shift your weight from knee to knee. Breathe calmly and deeply. Continue for 15 seconds.

I mentioned previously that I like to have a couple of “One A Day” tasks so I can make slow and steady progress on items over time. This is also true of my own personal projects such as this app which will often remain nothing but ideas unless time is carved out to work on them. To help with that, I maintain a list of the projects I’m working on and then have the API pick one at random each day to show me; the idea then is that I will work on that app for at least 18 minutes9 during the day. It’s a system that has worked well for me and gets around the issue of the current favourite project getting all of my attention.

Tangent: Choices Choices

There is a theory that "Overchoice" or "Choice Overload" occurs when we are presented with too many options and that it causes us stress and slows down our process of choosing whilst also sapping our energy reserves. The original studies of this from the '70s have since been called into question but I personally find that oftentimes if I'm given the freedom to do anything I'll end up doing nothing as I can't choose between the numerous options available to me. This is true of what I should watch, read, play, and work on in my spare time. My solution, as briefly alluded to above with YouTube videos and Pocket articles, is to take a subsection and return them randomly so that I don't have to pick.

This is something I also use with my personal projects, books, and games; rather than have all the options available to me, I take the items that are currently active and then the app will return a single result each day. This avoids any decision making on my part as if I want to do some coding I already know what project it will be on or if I'm going to read I know which of my current books it will be. The only rules I set myself are that if I really want to do something else, I must meet the minimum requirement for the day on the original choice first (i.e. lets say I want to work on my Lord of the Rings LCG app but I've been assigned Pocket Rocket, then I work on Pocket Rocket for the 18 minutes to fulfill my daily goal and then I'm free to work on whatever I like).

The one thing I'm particularly mindful of with this approach is that I may need to tweak how random it actually is. With true randomness, I could get the same result every day for a week and generally I'll want to mix things up a bit. It's also the case that I don't necessarily have time for my hobbies every day so I might have 2 days reading the same book and then go 3 days without reading anything; when I get to the 6th day the same book is randomly returned and I've missed out on the other books on the previous days. The only way around this is likely to remove results if my database sees I've spent concurrent days on the same item. I've also started restricting these choices to just 3 active items at a time as otherwise there are a few too many for the randomness to work nicely.

For logging time on projects, I have written a small app named “Time Well Spent” which I’ll be covering in a future side projects article. This data is then stored in my database where my API returns it for rendering the three activity rings in a similar way to the steps or exercise rings covered earlier.

Reading

The Triangle: Stand tall with your feet wider than your shoulders and your tummy in. Turn your foot outwards as you lean to that side. Feel the gentle stretch on the inside of your leg. Your knee should be above your ankle. Lean over so your elbow can rest on your bent knee as you stretch your arm over your upper body. Do this slowly so you have more control. Stretch for 10 seconds on each side.

I’m a big believer in the power of Idea Sex, the notion that by juggling several different thoughts together you’ll trigger more creative thinking. One way to do this is to read multiple books at the same time rather than reading in a linear fashion. As you may be able to guess from the tangent box above, I do this with books by having the app tell me what book I’m going to read today from the pool of books I’m currently reading. Beneath that I render the familiar three activity rings based on data I’ve logged via my “Time Well Spent” app and a 30 minute daily goal. I might also add a tally of how many books I’ve completed this year at some point but for now this is working well.

I listen to an audiobook on most of my evening walks and have recently begun tracking that data as well. It’s likely that I may try juggling multiple audiobooks and use a similar system to the above to pick one for me each day.

Games

Shaking Loose: Now it's time to let go. Shake your limbs loosely for 15 seconds.

The final screen was originally intended to be a bit of a sweetener to force me to do my stoutness routine every day; it tells me what video game I can play today. However, the amount of time I’m spending on video games has dropped fairly dramatically this year taken up by an increase in exercise and board games and (soon) a newborn baby. It’s no longer quite the pull it once was but I’ve managed to go through this routine every day so far this year so I’m not too concerned!

I’ve long tracked my gaming hobby as I used to run a website where I’d output my currently logged time and write reviews. I have a dedicated app for this purpose which I’m currently migrating into “Time Well Spent” but I also have some automated scripts to fetch my play time from services such as Steam and Xbox Live. In addition, I have a number of game collections which I can query such as “Currently Playing” and “To Play”. Using these, it was very easy to render the above page showing which game I’m allowed to play today but also giving me a secondary choice should I complete that game10.

I don’t use the activity rings to render the average data as I don’t have a goal for how much time I want to play for whereas steps, exercise, projects, and reading are all things I want to do a certain minimum of (or maximum in the case of alcohol).

Conclusion

As of the time of writing, I’ve spent 24.4 hours working on the Stoutness app from its origins as a printed daily report to the Apple TV app it is now complete with its suite of data providers from AppleScript apps to Shortcut workflows. Since using it daily from the start of the year I’ve had huge improvements in nearly all of the metrics I track; I’m sleeping longer, drinking less, exercising more, and reading more. I’ve also noticed big improvements in my health with my daily stretching leading to more flexibility11 and less aches and my average resting heart rate falling from ~70bpm to ~50bpm. Finally, I’ve lost over 22lbs since using the app which is just over 10% of my starting weight; that isn’t all attributable to this app but it has definitely helped by encouraging me to keep working on the various rings that I see every morning.

With a lot of side project apps I often think about how they can be adapted so others can make use of them but in this case what I have is a highly specialised app bespoke to me… and that’s OK! One of the great joys of being a developer is that you can take seemingly disparate data and merge it together into something just for you. Hopefully this article will inspire you to do likewise and learn some new coding skills whilst building something to help you in your own life.

  1. Originally I would have done this using text-to-voice within an app but obviously it’s far easier to do it with Siri Shortcuts nowadays such as this Morning Routine by MacStories. Also, with HomePod update 14.2 it is now possible to say “What’s my update” and get something similar. ↩︎

  2. I’d envisioned it being a little like the Personal Navigator that used to get printed on Disney Cruises showing you the schedule for the next day along with weather and other pertinent information. ↩︎

  3. I despise rolling news and the culture of “information now” which is why I subscribe to a newspaper. I like to get a clear picture of the days news after the dust has settled rather than the incessant guessing at what is happening, about to happen, and the reaction of what just happened. Don’t get me started on “[public figure] is expected to say” with announcement previews. ↩︎

  4. Well that’s not strictly true. There is a private API as data is synced to Things Cloud but I don’t fancy reverse engineering that! ↩︎

  5. I used a meal planning app for a while but it was too much hassle to peck everything in on an iPad; now I just create an all-day event in a dedicated calendar. Why plan meals? Mostly because I’m terrible with letting food expire especially at the moment where recent food shortages at supermarkets have led to shorter shelf lives on several products. ↩︎

  6. The real average was likely lower as there were 2 weeks in Walt Disney World where I was over 20k steps every day which would skew the averages. ↩︎

  7. Rather than returning something normal like seconds, YouTube returns video durations in a format like PT24M49S which required an extra bit of working around. ↩︎

  8. Obligatory “go check out my Pocket app” comment. ↩︎

  9. 18 minutes is a bit specific you might very well think but there’s a (sort of) good reason. My database first started logging games and these were done in a decimal format of hours to one decimal place as that’s how they came from Steam thus all of my gaming time is broken down into 6 minute chunks. When I added other items to the tracking system I kept the same format and this persisted when I started tracking how much time I was spending on personal projects; whilst I’d probably prefer it to be a goal of 15 or 20 minutes a day, it’s 18 so it can be represented cleanly as 0.3 hours in the database. I could make the ring show 20 minutes but then it would always be not quite full or over full and that seems messy. ↩︎

  10. This is something I’ve also thought about adding for books but it’s slightly different as I know how long it will take me to finish a book — you can see how many pages are left — but it isn’t always obvious how much you have left of a game hence the need for a backup if I sit down and finish the current one in short order. ↩︎

  11. At the start of the year I used to struggle to touch my knees with my elbows for the “Twisting Star” exercise as I just couldn’t bend that well; now I can do it with no problems. Hooray for basic levels of fitness! ↩︎

Exorbitantcy and the fight against the App Store

Last month, outspoken CEO of Epic Games Tim Sweeney ranted about the App Store in an interview with CNBC:

Apple has locked down and crippled the ecosystem by inventing an absolute monopoly on the distribution of software, on the monetization of software. They are preventing an entire category of businesses and applications from being engulfed in their ecosystem by virtue of excluding competitors from each aspect of their business that they’re protecting.

If every developer could accept their own payments and avoid the 30% tax by Apple and Google we could pass the savings along to all our consumers and players would get a better deal on items. And you’d have economic competition.

Today, the rhetoric turned into action as Epic announced “direct payment on mobile”:

Today, we’re also introducing a new way to pay on iOS and Android: Epic direct payment. When you choose to use Epic direct payments, you save up to 20% as Epic passes along payment processing savings to you.

Currently, when using Apple and Google payment options, Apple and Google collect a 30% fee, and the up to 20% price drop does not apply. If Apple or Google lower their fees on payments in the future, Epic will pass along the savings to you.

Here’s how it looks in the UK version of the game1.

This is obviously in clear contravention of the App Store Review Guidelines2, specifically 3.1.1 In-App Purchase:

If you want to unlock features or functionality within your app, (by way of example: subscriptions, in-game currencies, game levels, access to premium content, or unlocking a full version), you must use in-app purchase. Apps may not use their own mechanisms to unlock content or functionality, such as license keys, augmented reality markers, QR codes, etc. Apps and their metadata may not include buttons, external links, or other calls to action that direct customers to purchasing mechanisms other than in-app purchase.

There isn’t a scenario where Epic doesn’t know this and it’s also highly unlikely that Apple accidentally approved an app update which made these changes. It’s probably the case that the code for this was included in the app binary but was disabled via a server switch until today. This means that Apple reviewed the app update, approved it, and then Epic changed the functionality which is also against the rules. This is more likely when you consider that the “what’s new” text for the update which is meant to outline what changed in the version doesn’t mention anything about these new payment options.

Regardless of where you stand on the issue of Apple’s payment cut, the action taken by Epic here is in clear violation of the platform guidelines leaving Apple with a difficult choice; remove Fortnite from the App Store or try and resolve this directly with Epic which will likely lead to every other aggrieved developer trying something similar.

Of course, within a few hours Apple had removed Fortnite from the App Store. This isn’t that much of a big deal as it is likely installed on millions of devices already and I doubt there are many more users flocking to the game at this stage. The app isn’t being forcibly removed from user’s devices and if you’ve ever downloaded it in the past you can still redownload it right now and get the current version. That said, it’s quite a striking image to see Apple remove what is undoubtedly one of the biggest, if not the biggest, games from their store especially when they were likely making millions of dollars from it thanks to their 30% cut of in-app purchases.

Now clearly Epic knew this was going to happen as minutes after the app was removed they were advertising a short film called “Nineteen Eighty-Fortnite3” (a clear callback to Apple’s famous Ridley Scott directed 1984 advert) and had filed a complaint for injunctive relief. In short, this is a well executed publicity stunt in which Epic knew they could goad Apple into removing them from the App Store.

From here on out it’s likely to be an escalating war between the two companies as Apple tries to explain that their rules have been broken and Epic tries to get them to completely change their rules. I don’t see a situation in which either company comes out looking particularly good nor do I see Apple suddenly doing an about turn on a business practice they’ve maintained since the App Store was launched back in 2008. And why should they? It’s their platform and the rules are clear and unambiguous; you can’t circumvent App Store review and you can’t offer your own payment system for these types of transactions. If Epic doesn’t like that, then they don’t have to sell their products on Apple devices. Nobody is entitled to run on any platform they want at the price that they want.

The thing that irks me most about this is that Epic are choosing to call Apple’s take an “exhorbitant [sic] 30% fee on all payments”. It can’t really be described as exorbitant when it’s been the industry standard for over a decade on multiple platforms including Google Play on Android and Steam on PC.

What I find exorbitant is the selling of costumes in a game for upwards of £20. I find it exorbitant that to purchase anything within Fortnite you first have to purchase an intermediary currency whereby you can’t ever get the exact amount you need. For example, here are the current prices on Fortnite today including the new 20% price drop they’ve added if you purchase direct from Epic:

And here are some of the items available for purchase:

Note that there is no equality between the two. If you want to buy a 1200 V-Buck “The Brat” outfit you’ll need to either pay £12.98 to buy two packs of 1000 V-Bucks (leaving you 800 spare) or pay £3.01 more to buy 2800 V-Bucks (leaving you 1600 spare). This is nothing new of course4 and is a well trodden psychological trick designed to give you a form of credit so purchases have less friction; I can thoroughly recommend Jamie Madigan’s excellent website “The psychology of video games” and his article on “The perils of in-game currency” if you want to find out more.

These kind of spending tricks are the worst aspects of the App Store along with the £99 gem bundles that are frequently seen in Match-3 timer games. Worse yet are the loot boxes that are there merely to get children hooked on gambling and which governments around the world are now starting to investigate. Fortnite isn’t as bad as these, but the fact that Epic was able to reduce it’s 30% cut to 12% on it’s PC storefront due to the huge profit they’d made on selling kids overpriced cosmetics in Fortnite isn’t exactly laudable.

Apple are by no means blameless in any of this either. They’ve created the store that allowed these high-price consumables to be commonplace and whilst they could easily improve developers lives by reducing the 30% fee they’ve chosen not to. I’ve long thought a good compromise would be some form of sliding scale along the lines of £0-10k is free, £10k-100k is 30%, then £100k+ is 15%. Of course, doing that means taking a huge financial hit to a multi-billion dollar business5.

For my part, I’d be glad to see a reduction in the 30% fee but I don’t want to see multiple store fronts being allowed on iOS; that there is only once place to get apps is a huge benefit to most users and developers. Whilst I doubt it will happen, I would dearly love for Apple to fight back by banning the selling of intermediary currencies in apps and instead only allowing direct purchases (i.e. that “The Brat” outfit would be clearly labelled as a £7.99 purchase). That sort of change would make genuine shock-waves through the industry and actually let something good come of this entire situation. It’s far more likely however that this is just going to end in endless courtroom battles and some form of App Store regulation.

  1. I don’t know whether it’s A/B testing or a change since the update was released but in all of the promo shots of this on Epic’s blog the Apple payment system comes first but in the app Epic’s payment system is on top. ↩︎

  2. They’re called guidelines but they’re rules. They really should be named as such. ↩︎

  3. It even has today’s date on the screen within the video so this was clearly very well planned. ↩︎

  4. And it’s something I’m guilty of myself. I ran a popular freemium game called WallaBee back in 2011 which had it’s own intermediate currency known as honeycombs. Of course, we provided plenty of ways for players to get these for free (usually by doing more exercise by travelling outside long before Pokémon Go came along) and the cheapest pack at £0.99 would be more than enough to buy any of the items in the game. If somebody spent over a certain amount within a certain time period, we’d reach out to them to make sure there weren’t any issues (in one case somebody was frequently gifted App Store cards from their work so they spent them all on our game). I had a blueprint for a more ethical in-app purchasing system for the long worked on v2 of the game but I ended up selling the company before I could put that in action. ↩︎

  5. Whilst the App Store is small fry compared to the money Apple rakes in from hardware it is still a significant business. It’s very well to say “they’ve got so much money, they can reduce their fees” but, again, why should they? Part of being a successful business is not giving your money away! ↩︎

Side Project: (Feed Me) Seymour

This is part of a series of blog posts in which I showcase some of the side projects I work on for my own use. As with all of my side projects, I’m not focused on perfect code or UI; it just needs to run!

I’ve cheated slightly this month by working on two side projects; a simple iOS app and a SwiftUI watchOS app. The Apple Watch app isn’t quite ready yet so instead I’m going to show you “Seymour”, an app that sends me push notifications when relevant articles are posted online.

The Problem

I’m travelling to The Happiest Place On Earth™ next week and as I’m staying in a Disney hotel I was able to book fastpasses for rides 60 days in advance. The issue is that a new ride, Mickey & Minnie’s Runaway Railway, is due to open whilst I’m there but fastpasses were not yet available to be booked as the opening date hadn’t been formally announced. When the announcement finally came, I didn’t see it for 5 hours as I was busy and hadn’t been checking my numerous theme park RSS feeds1. Fortunately I was still able to grab one of the remaining fastpasses for later in my holiday but I was determined that this shouldn’t happen again…

I use the excellent Feedbin service2 to keep up with my feeds and it turns out they have an app called Feedbin Notifier, but it doesn’t seem to work. It only supports one search term and the examples all use single words which didn’t give me total faith that the phrase “rise of the resistence OR space 220 OR fastpass” would parse correctly. There was also no detail on how regularly the checking was done and whilst I managed to get a notification for the term “apple” I couldn’t get it to work for much else. As I couldn’t trust it would do what I needed it to, I decided it was quicker and easier to just build it myself.

“Couldn’t you just use Google News notifications?”. I already do for some things3 but it’s far too slow for my use case. The types of articles I’m interested in require me to act within around 15 minutes so too slow for Google News which typically takes 12-24 hours to send a notification.

The Name

Like most other developers, I start a side project with the most important decision; choosing a name. Sometimes this can be a days long process but for this app it was relatively quick. I wanted to do some kind of play on words with Feedbin and was looking at things like “Feed Trash Can” or “Feed Trough” but I didn’t really like any of them. I was saying them out loud in bed whilst working on the app and my wife instinctively said “Feed Me Seymour”. I shortened it down to just “Seymour” and decided this was a perfect name as it was letting me “See More” of the things I wanted to see.

As the app isn’t going to be released publicly I did a Google Image Search for “Feed Me Seymour” in the hope of finding some sort of silhouetted version of the singing plant. Instead I found this Mario crossover at Snorgtees which was perfect.

Feed Me

The iOS app is ridiculous simple so I’m not going to spend too much time describing that. It consists of a table view listing my search terms and each row can be swiped to delete the term. The + button in the top right presents a UIAlertController with a text field for adding a search term. The fetch, add, and delete commands are all sent to an incredibly basic PHP API I wrote which syncs to my MySQL database.

The real work is to fetch the latest feeds from my Feedbin account and then search each article for any matching text strings:

$pdo = new PDO("mysql:charset=utf8mb4;host=" . $db_host . ";dbname=" . $db_name, $db_user, $db_pass);
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$pdo->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);

$headers = [];
$url = 'https://api.feedbin.com/v2/entries.json';
$ch = curl_init($url);
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
curl_setopt($ch, CURLOPT_TIMEOUT, 30);
curl_setopt($ch, CURLOPT_USERPWD, 'ben@bendodson.com:keepitsecretkeepitsafe');
curl_setopt($ch, CURLOPT_HTTPHEADER, ['If-None-Match: \''.file_get_contents('etag.txt').'\'']);
curl_setopt($ch, CURLOPT_HEADERFUNCTION,
  function($curl, $header) use (&$headers)
  {
    $len = strlen($header);
    $header = explode(':', $header, 2);
    if (count($header) < 2) // ignore invalid headers
      return $len;

    $headers[strtolower(trim($header[0]))][] = trim($header[1]);

    return $len;
  }
);
$json = curl_exec($ch);
$info = curl_getinfo($ch);
curl_close($ch);

file_put_contents('etag.txt', $headers['etag'][0]);

$stmt = $pdo->prepare('select * from keywords');
$stmt->execute();
$keywords = array_column($stmt->fetchAll(), 'phrase');

$ids = [];
$entries = [];
$array = json_decode($json);
$countStmt = $pdo->prepare('select count(id) as total from entries where id = :id');
foreach ($array as $entry) {
    $countStmt->execute(['id' => $entry->id]);
    $count = (int)$countStmt->fetch()['total'];
    if ($count) {
        continue;
    }

    foreach ($keywords as $keyword) {
        if (stripos($entry->title, $keyword) !== false || stripos($entry->content, $keyword) !== false) {
            $entries[] = $entry;
            break;
        }
    }

    $ids[] = $entry->id;
}

$notificationStmt = $pdo->prepare('insert into notifications (title, message, url, created_at) values (:title, :message, :url, :created)');
$idStmt = $pdo->prepare('insert into entries (id) values (:id)');

$pdo->beginTransaction();
foreach ($ids as $id) {
    $idStmt->execute(['id' => $id]);
}
$pdo->commit();

foreach ($entries as $entry) {
    $notificationStmt->execute(['title' => $entry->title, 'message' => $entry->summary, 'url' => $entry->url, 'created' => date('Y-m-d H:i:s')]);
    
    $data = ["app_id" => "my-onesignal-app-id", "contents" => ["en" => $entry->summary], "headings" => ["en" => $entry->title], "included_segments" => ["All"], "data" => ["url" => $entry->url]];
    $ch = curl_init('https://onesignal.com/api/v1/notifications');
    curl_setopt($ch, CURLOPT_CUSTOMREQUEST, "POST");
    curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($data));
    curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
    curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json', 'Authorization: Basic [OBFUSCATED]']);
    $response = curl_exec($ch);
    curl_close($ch);

}

Feedbin provide an API for fetching the latest articles from all of your feeds. It has support for ETag caching so I store the latest tag to avoid unnecessary loading on their end as this script will run every 60 seconds.

Once the articles are fetched, I ignore any that I’ve already seen before (worked out by storing the ids in my database after each run) and then proceed to do a case insensitive check for my keywords against both the title and content of the article. If there are any matches, these get added to an array which is later looped over in order to send a push notification via OneSignal containing the title, article summary, and the URL.

This runs on a 1 minute CRON job so in theory I should get alerted of any matching articles within 60 seconds of them being published. I don’t know how often Feedbin polls the various RSS feeds I track but in practice I have found that I’m getting notified almost immediately so evidently they’re doing some form of dark magic.

Viewing the content

Once the notification is delivered, I thought it would be a nice bonus to be able to tap to open up the article in a browser. It turns out that the OneSignal SDK can do this automatically if you send a “url” parameter as part of your Push Notification request but it has a few issues; there’s a short lag between the app opening and the browser being shown, the controller is within a modal dialogue that can only be dismissed by swiping at the top of the page (tricky on a big phone), and it’s using either a UIWebView or a WKWebView which means there is no support for content blockers, no Reader View, and no easy way to open the page in Safari or other apps. The solution is to send the URL as part of the data payload and parse it myself within the app to open an SFSafariViewController:

Can you see the difference in the image above? With the webview provided by OneSignal we can just see the start of the title of the article4 obscured by the home indicator, and this is on the Max-sized iPhone! The SFSafariViewController in Reader View is far superior showing the full title, the first paragraph, and an image. It also loaded faster thanks to the support for content blockers and I have easy access to open this in Safari or share it along with controls for changing fonts, etc.

Conclusion

This whole thing took about 2 hours of which the majority was fiddling around with ETags. At the moment I have only two search terms set up:

  • Rise of the Resistance: Quite possibly the best theme park attraction ever made, Rise of the Resistance opened in December 2019 and since then has been using a virtual queueing system which typically gets booked out for the day within an hour. Whilst I don’t expect this is going to be moved to FastPass any time soon, I keep this notification just in case!

  • Space 220: This is a new restaurant in Epcot that was supposed to be open last year and was then rumoured to be opening in February. That still hasn’t happened but it’s feasible the opening will happen whilst I’m there and I definitely want to be one of the first to book a reservation.

Whilst I could have set up a screen scraper of some sort to monitor these pages for changes5, I much prefer operating on top of RSS as it tends to be that the feeds I follow will post updates ridiculously quickly and are likely more reliable than tracking HTML changes. It’s also more flexible as I’m sure there will be things in future that I’ll want to monitor in this way. For example, I tested the app a few weeks ago to get notified when Pokémon HOME was released on the App Store.

As ever, I hope this will inspire you to work on your own side projects! Next month I’ll be talking about the watch app that has ended up taking a lot longer than I’d originally anticipated…

  1. I’ve blogged about this before but RSS is how I consume nearly everything I read online. Twitter doesn’t even come close. With RSS you can subscribe to nearly any website that publishes articles and get them all in a nice app with no ads, no Javascript, and no other superfluous crap. At the time of writing, I subscribe to around 500 different feeds encompassing everything from theme park news and video game updates to Swift developer blogs and philosophy articles. ↩︎

  2. In conjunction with the Reeder apps on iOS and macOS. ↩︎

  3. I use Google News Notifier to see if my name comes up anywhere and also to keep tabs on a court case I sat on as part of Jury Duty. ↩︎

  4. Disney Food Blog is one of the best theme park sites I follow but their website is very hard to view on mobile hence the need for Reader View. Whilst it’s more of a necessity for that page, I find myself using Reader View for most websites as it is nearly always far better. ↩︎

  5. I did do this when the AirPods were first announced; fetch the HTML of the store page every five minutes and then send a notification when it changed so I could jump in and buy a pair. ↩︎

Side Project: Sealed

This is part of a series of blog posts in which I showcase some of the side projects I work on for my own use. As with all of my side projects, I’m not focused on perfect code or UI; it just needs to run!

I am a huge advocate of side projects, small apps that let you test an idea in isolation usually for your own personal use. Over the course of my 14 year career as a software developer I’ve always tried to encourage new developers to work on side projects as a way of honing their craft. There are three reasons for this: firstly, building something for yourself is far more rewarding than building something for a client; secondly, it gives you an excuse to try out new technologies or methodologies that can then improve future client work without running the risk of derailing a major project1; and thirdly, it’s a great way of building up a portfolio if you’re starting out. I’ve always built bizarre little side projects and apps ranging from an iPad app to manage my wine collection to various PHP scripts that extract my time playing video games on Steam. Sometimes these side projects turn into full apps such as Music Library Tracker and Pocket Rocket but usually they are highly bespoke utilities for me that nobody else gets to see. Until now…

This year I’ve decided to start a new series of articles where I’ll show a side project I’ve built over the past month. Today’s article is all about “Sealed”, an iPad app I build in January 2020 to simulate the opening of Magic the Gathering booster packs.

I’m assuming that most people reading this article have little to no interest in Magic The Gathering and so I’m not going to explain that side of it in much detail. Suffice to say that the game consists of you opening blind packs containing 15 cards that you can play with. In sealed play, you open 6 of these blind packs (named “boosters”) and then build a 40 card deck out of the cards you opened. The idea for this app is that it will simulate this process allowing me and my good friend John (who lives in Sweden) to open 6 packs each and build a deck with the random contents within. We can then export them to the game Tabletop Simulator so we can play with them in a realistic 3D physics-based environment…

Tabletop Simulator even supports VR so we can simulate playing a few rounds in the same room even though we’re around 870 miles apart.

As with all of the side projects I’m going to be working on I’m not focused on perfect code or UI; it just needs to work. That said, I did spend a bit more time prettying this one up as I wasn’t the sole user.

A brief tour

The iPad app first does a brief download of data before opening on a selection screen that allows you to pick which expansion you want to play2. You can also choose to load one of your previously created decks.

Each physical booster pack has a specific breakdown of cards based on rarity usually comprising of 10 commons, 3 uncommons, 1 rare or mythic rare, and 1 basic land. The mythic rare is the tricky piece as there is a 1:8 chance it will replace the rare card. As I wanted things to be more “fair” in this app, I’ve fudged the numbers such that you’ll always get 5 rare cards and 1 mythic rare card; this avoids the issue (which could happen in a completely random system) of somebody ending up with far more mythic rares.

As these rare cards are usually the best of the bunch, these are shown immediately after the contents of the packs have been decided by the app:

Once you press continue, you are taken into the deck building interface with those 6 cards automatically added to your deck:

The bulk of the interface is dedicated to showing the cards you’ve opened with a number of diamonds above to show how many copies you have available; these fill in when the cards are added to the deck on the right hand side and will fade to 50% opacity if you’ve used every copy available. The top right hand section shows a “mana curve” (which is just a graph for showing the various costs of the cards in the game) along with a break down of the various types of card you’ve selected (as typically you want more creatures than anything else). Underneath is your deck in a scrollable list with a design mimicking the top of each card showing both the name and mana cost.

If you tap on an item in the deck list or long press on a card in the card picker then you’ll get a blown up version of the card which is easier to read. You’ll also be able to use the plus and minus buttons to add or remove the card from your deck (although if you only have one copy you can just tap to add them and remove them directly from the card picker as this is nearly always quicker).

The final two items are the sample hand and export; the former shows you a random draw of 7 cards from your deck to simulate the first action you take in the game3 whilst the latter generates the tiled image you’ll use to import the deck into Tabletop Simulator.

Reusing code

In total this app took around 6 hours to build mostly thanks to a huge amount of reusable code I could make use of from previous side projects.

I’m a big fan of Magic The Gathering and so last year I built myself a private app called “Gatherer” that gives me access to lots of information about each card thanks to the Scryfall API. For that app I wanted everything to work offline so I duplicated the data into my own hosted database and then made a single request when online to download all of the data and store it on the device in a Realm database. I used the exact same system here with the only new functionality being a new database table to list which expansions I wanted to be available within the app4. Within a few minutes of starting I had a local database and access to all of the card information I needed.

The next major piece of reused code was the design of the card cells in the deck creator. I wanted to mimic the headings of the cards in a similar way to Magic The Gathering Arena, an online version of the card game. The heading should show the title of the card (which will shrink as necessary to avoid word wrapping), the mana cost, and the border should be the colour of the card using a gradient if necessary:

Fortunately I had already built this exact design for another side project of mine, an iPad app to control the overlay for my Twitch stream:

Nearly everything in the above screenshot aside from the game and webcam is powered by an iPad app plugged into my PC capture card via HDMI. It was a fun experience to play with the external window APIs but it also allowed me to do animations to show off the deck list I’m currently playing with on MTG Arena; the sidebar scrolls every minute or so to reveal the full list and I can trigger certain animations from my iPad to show off a particular card in more detail. In any case, you’ll notice the deck list table cells on the right hand side are identical to the ones in this Sealed app. They are fairly straightforward with the most complex piece being some string replacement to convert a mana cost such as {1}{U}{U} into the icons you see.

The lesson here is that huge chunks of UI or functionality can be reused from side projects into your client projects saving you development time and speeding up your learning. As a practical example, I’m currently working on an app which requires a Tinder-style card swiping system; whilst I could embed an unknown 3rd party component which may have bugs and not be updated in future, I can instead use a card swiping system I built for a music-based side project a year ago. This saved me a significant amount of time and resulted in me being able to give a better price to my client than I might otherwise have been able to.

Random Aside

One of the areas I had a lot of fun with in this project was putting together the randomisation for opening packs. There are very specific rules when it comes to Magic packs with a set number of cards in specific rarity slots, no duplication unless there is a foil card5, and some weird edge cases for certain expansions. Here is the full code for this particular feature:

func sealTheDeal(set: String) -> Deck {
    guard let expansion = realm.object(ofType: Expansion.self, forPrimaryKey: set) else { abort() }
    let locations = expansion.locations.sorted(by: { Int($0.key) ?? 0 < Int($1.key) ?? 0})
    let max = Int(locations.first?.key ?? "0") ?? 0
    let cardsInSet = realm.objects(Card.self).filter("set = %@ and number <= %d", set, max)

    var mythics = cardsInSet.filter("rarity = %@", "mythic")
    var rares = cardsInSet.filter("rarity = %@", "rare")
    var uncommons = cardsInSet.filter("rarity = %@", "uncommon")
    var commons = cardsInSet.filter("rarity = %@ AND NOT (typeLine CONTAINS[c] %@)", "common", "basic land")
    
    switch set {
    case "dom":
        uncommons = uncommons.filter("NOT (typeLine CONTAINS[c] %@)", "legendary")
    case "grn", "rna":
        commons = commons.filter("NOT (name CONTAINS[c] %@)", "guildgate")
    case "war":
        mythics = mythics.filter("NOT (typeLine CONTAINS[c] %@)", "planeswalker")
        rares = rares.filter("NOT (typeLine CONTAINS[c] %@)", "planeswalker")
        uncommons = uncommons.filter("NOT (typeLine CONTAINS[c] %@)", "planeswalker")
    default:
        break
    }
    
    
    let mythicIndex = Int.random(in: 0..<6)
    
    var boosters = [[Card]]()
    for boosterIndex in 0..<6 {
        var cards = [Card]()
        var commonPool = Array(commons.map { $0 })
        for _ in 0..<10 {
            guard let card = commonPool.randomElement(), let index = commonPool.firstIndex(of: card) else { continue }
            cards.append(card)
            commonPool.remove(at: index)
        }
        
        var uncommonPool = Array(uncommons.map { $0 })
        for _ in 0..<3 {
            guard let card = uncommonPool.randomElement(), let index = uncommonPool.firstIndex(of: card) else { continue }
            cards.append(card)
            uncommonPool.remove(at: index)
        }
        
        let rareAndMythicRarePool = boosterIndex == mythicIndex ? mythics : rares
        if let card = rareAndMythicRarePool.randomElement() {
            cards.append(card)
        }
        
        switch set {
        case "dom":
            if let card = cardsInSet.filter("rarity = %@ AND (typeLine CONTAINS[c] %@)", "uncommon", "legendary").randomElement() {
                cards[cards.count - 2] = card
            }
        case "grn", "rna":
            if let card = cardsInSet.filter("name CONTAINS[c] %@", "guildgate").randomElement() {
                cards.append(card)
            }
        case "war":
            let uncommonChance = boosterIndex == mythicIndex ? 92 : 78
            if Int.random(in: 1...100) <= uncommonChance {
                if let card = cardsInSet.filter("rarity = %@ AND (typeLine CONTAINS[c] %@)", "uncommon", "planeswalker").randomElement() {
                    cards[cards.count - 2] = card
                }
            } else {
                if let card = cardsInSet.filter("rarity = %@ AND (typeLine CONTAINS[c] %@)", boosterIndex == mythicIndex ? "mythic" : "rare", "planeswalker").randomElement() {
                    cards[cards.count - 1] = card
                }
            }
        default:
            break
        }
        
        boosters.append(cards)
    }
    
    let boosterCards = boosters.flatMap({$0})
    let topCards = boosterCards.filter { return $0.rarity == "rare" || $0.rarity == "mythic" }
    
    let deck = Deck()
    deck.id = NSUUID().uuidString
    deck.expansion = expansion
    deck.topCards.append(objectsIn: topCards)
    
    let cards = boosterCards.sorted(by:{ $0.number < $1.number })
    var currentCard: Card?
    var quantity = 0
    for card in cards {
        if card != currentCard && currentCard != nil {
            let deckCard = DeckCard()
            deckCard.card = currentCard
            deckCard.quantityAvailable = quantity
            deck.allCards.append(deckCard)
            quantity = 0
        }
        
        currentCard = card
        quantity += 1
    }
    
    let deckCard = DeckCard()
    deckCard.card = currentCard
    deckCard.quantityAvailable = quantity
    deck.allCards.append(deckCard)
    
    deck.allCards.append(objectsIn: expansion.lands())
    
    let topIdentifiers = Array(deck.topCards.map({$0.id}))
    for index in 0..<deck.allCards.count {
        let card = deck.allCards[index]
        if topIdentifiers.contains(card.card?.id ?? "") {
            for _ in 0..<card.quantityAvailable {
                deck.add(card)
            }
        }
    }
    
    return deck
}

Essentially I take the following steps:

  • Build an array containing the cards at each rarity level
  • Remove any edge cases for specific expansions (i.e. removing Guildgates from Guilds of Ravnica)
  • For each booster pack, loop through each array randomly selecting a card and removing it from the pool to avoid duplication
  • For Dominaria (“dom”), change one of the uncommon cards to be a Legendary card
  • For Guilds of Ravnica (“grn”) and Ravnica Allegiance (“rna”) add a random Guildgate card.
  • For War of the Spark (“war”), replace one of the uncommon, rare, or mythic rare cards with a random Planeswalker card of the same rarity
  • Once the cards for each booster are known, group the 6 rare / mythic rare cards into their own array for UI simplicity and then bundle everything together in a nice object that groups any duplicates found across the packs

There is nothing particularly difficult about the above but it was still fun the first time I got it working to see my console filling up with cards as if I’d opened a physical pack!

Exporting

The “killer feature” of the app is the ability to export cards to Tabletop Simulator, a task that is surprisingly easy. To import custom cards, all you need to do is supply a 4096x3994px image that comprises of 10 columns and 7 rows. Here’s an example image of a 40 card deck that was exported from Sealed which uses the top 4 rows and leaves the remaining 3 blank (although it will use them if you build a deck larger than 40 cards although this isn’t usually recommended for sealed play).

In order to generate the large image I simply render UIImageViews onto a UIView that is the correct size, loop through each image and download it, and then use the snapshotting APIs to capture the view as a UIImage ready for exporting as a JPEG that usually weighs in at around 3MB. Here’s the full code:

import UIKit
import SDWebImage

class TabletopSimulatorDeck: UIView {
    
    static let cardWidth = 410
    static let cardHeight = 571
    static let maxColumns = 10
    
    var cards = [Card]()
    private var imageViews = [UIImageView]()
    private var downloadedImageCount = 0

    class func instanceFromNib() -> TabletopSimulatorDeck {
        return Bundle.main.loadNibNamed("TabletopSimulatorDeck", owner: nil, options: nil)?.first as! TabletopSimulatorDeck
    }
    
    static var fileURL: URL {
        return FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!.appendingPathComponent("deck.jpg")
    }
    
    func export(onCompletion completionHandler: @escaping (Data?) -> Void) {
        downloadedImageCount = 0
        var row = 0
        var column = 0
        for _ in cards {
            let rect = CGRect(x: column * TabletopSimulatorDeck.cardWidth, y: row * TabletopSimulatorDeck.cardHeight, width: TabletopSimulatorDeck.cardWidth, height: TabletopSimulatorDeck.cardHeight)
            let imageView = UIImageView(frame: rect)
            imageViews.append(imageView)
            addSubview(imageView)
            
            column += 1
            if column == TabletopSimulatorDeck.maxColumns {
                column = 0
                row += 1
            }
        }
        
        for index in 0..<cards.count {
            let card = cards[index]
            let imageView = imageViews[index]
            imageView.sd_setImage(with: card.url(for: .card), placeholderImage: nil, options: .retryFailed) { (_, _, _, _) in
                self.downloadedImageCount += 1
                self.render(onCompletion: completionHandler)
            }
        }
    }
    
    func render(onCompletion completionHandler: @escaping (Data?) -> Void) {
        if downloadedImageCount != cards.count {
            return
        }
        
        DispatchQueue.main.async {
            UIGraphicsBeginImageContextWithOptions(self.bounds.size, true, 1.0)
            self.layer.render(in: UIGraphicsGetCurrentContext()!)
            let image = UIGraphicsGetImageFromCurrentImageContext()
            UIGraphicsEndImageContext()
            
            if let image = image, let data = image.jpegData(compressionQuality: 0.75) {
                try? data.write(to: TabletopSimulatorDeck.fileURL, options: .atomicWrite)
                completionHandler(data)
                return
            }
            
            completionHandler(nil)
        }
    }
}

It’s a dirty solution and it could possibly cause some memory issues on a really old iPad, but I don’t need to worry about that for this project where both devices that will use the app are more than capable of rendering all of this in milliseconds.

Once the image is generated, I use a standard UIActivityViewController to allow for simple sharing. One annoying gotcha that catches me every time is that the controller will provide a “save image” button that you can use to save to your photo library but the app will crash when this is pressed unless you’ve added a NSPhotoLibraryAddUsageDescription key to your info.plist. I’m not sure why Xcode can’t flag this in advance or why this requirement can’t be removed bearing in mind the user is making an informed action.

Conclusion

John and I have played three games of sealed using this app so far and I’m really pleased with how it’s turned out. I can build a deck in around 10 minutes whereas usually it would take 30-45 minutes using real packs. The exports work great in Tabletop Simulator and I can see us using this for a long time to come. I’ll likely add some extra functionality over time such as the ability to duplicate decks or updating the artwork to use the new showcase variants that are super rare but for now this app is definitely a success.

Whilst on the face of it this would be easy to publish to the App Store, the legal and moral implications prevent me from doing so. I’ve spent literally thousands of pounds on this game both in physical cards and digital ones on MTG Arena so I don’t have any qualms about using the artwork to play with a friend I otherwise wouldn’t be able to play with. That said, it’s very different doing something like this for your own private use than it is to publish it and enable it for others who may not have made the same investment in the real world product. For that reason it’s unlikely this will ever be available for wider consumption.

For February’s side project I’m working on something a bit different that will work as a form of learning for me; a standalone watchOS app built with SwiftUI! Be sure to check back next month to learn more about that project and to see how it ended up…

  1. “Oh that new framework looks good, I’ll try that in my next project” - Nope! I’ve learned the hard way that you do not want to use your clients as a guinea pig for the latest thing. By way of example, look at SwiftUI announced at WWDC 2019. You should not be using that in a client project, but it would be perfect in a side project. ↩︎

  2. There are around 3 expansions each year and you typically play sealed within one expansion i.e. you’ll get 6 packs of Guilds of Ravnica or 6 packs of Throne of Eldraine but you wouldn’t build a deck with 3 packs from each. This is all due to the careful balancing the games creators do to ensure that things stay relatively fair within these sealed games. ↩︎

  3. This is an important tool as it allows you to very quickly perform a few draws to see if the cards you are getting are balanced, especially when it comes to mana costs, lands, and colours. ↩︎

  4. My database is updated every morning in order to get the latest pricing information but that means I often get partial expansions if a new one is in the middle of being unveiled. This can last a few weeks so I needed the ability to hide certain expansions until they were ready for playing. ↩︎

  5. Although I made things easier by ignoring foil cards. Usually they replace a common card to give you a random card from the set with a shiny foil treatment but again this can lead to an imbalance as my opponent might end up with 3 mythic rare cards if they get lucky with the randomiser. ↩︎

Revival

I’m pleased to announce the release of a new client app I’ve been working on over the past few months: Revival, truly uncomplicated task planning for everyone:

I was originally contacted by Wonderboy Media with the intention of working on updating their existing reminders app which was looking a little dated and had some issues with broken functionality. After a detailed examination, it was determined that a complete rebuild was needed in order to make a sustainable v2.0 which would last as a solid foundation for many years to come. I worked as the sole iOS developer on the project over many months1 as I got to grips with what was a deceptively complex project including such things as local notifications (with snoozing), timezones, locations, subtasks, priorities, lists, tags, notes, files, contacts, subscriptions, integration with the iOS Reminders app, and Siri support! In addition, I completely redesigned the app giving it a far more modern look and feel complete with fluid gestures, sounds, and haptics. I even redesigned the app icon!2 It should also go without saying that the app was built entirely in Swift 5 using AutoLayout to ensure a flexible design from the 4.7” iPhone SE right up to the 12.9” iPad Pro including the various adaptations that can occur when multitasking on iPadOS.

One of the most complicated pieces was a desire for syncing not only between devices but also via sharing and sending tasks between users. I’m particularly proud of the seamless and accountless syncing system within the app which uses your CloudKit identifier along with Firebase in order to provide real time syncing between devices – you can literally complete a task on your iPhone and see it update in milliseconds on your iPad! In addition, everything is stored locally on device using Realm in order to keep things incredibly fast and to provide advanced searching capabilities. When it came to sharing a task with another user, I removed the old system which required creating user accounts and sending codes back and forth and instead created a system which provides a simple URL; when opened, the app pulls all of the information you need quickly and securely to either create a copy or grant access to the shared task.

Another complex piece revolved around theming in that users can change both the overall colour of the app and also switch between a light and dark mode. This required a custom solution for being able to change all of the UI on a whim whilst also recognising that iOS 13 was around the corner and would likely include a dark mode. The work I put in paid off and I was able to easily add an “automatic” mode that would change the theme between light and dark based on the iOS preferences3 when iOS 13 was launched.

Whilst there are many aspects to this project that provided additional complications4, one of the most satisfying to work on was the localisation of the app to eight additional languages. We worked with Babble-on in order to get the required translation files which were easily loaded into the app but I also wanted to improve the experience on the App Store. The previous versions of the app had custom artwork for each language with translated text above but showed the same English interface. I wanted to automate this and improve it, especially as Apple required screenshots for four devices across nine languages. The solution was to use the XCTest framework (along with a custom data loader) to open the app at various pages and take snapshots; these were then used by the deliver and snapshot parts of Fastlane to wrap them in a device frame and add the localised text. The result is 180 screenshots each with localised text, the correct frame for the targeted device, and a localised screenshot.

I really enjoyed working with Wonderboy Media on this project and having the opportunity to work on such a complex project. I’m incredibly pleased with both the design and development work that I’ve put into this project and I’m excited to see how it grows in future.

You can download Revival on the App Store for free and subscriptions are available to gain access to advanced features.

  1. Another developer has now been added to the team to help with a number of exciting new updates which will be rolling out over the next few months. ↩︎

  2. As a reminders app it’s almost law that you have to use a tick but I wanted to have a homage to the skeuomorphic clock-based interface of the old app as well. My solution was to make a clock face with the tick rendered from the hands. ↩︎

  3. This is far more complex than it seems as if you choose to have iOS change between light and dark automatically based on the time of day then it is possible for the theme to change whilst you are in the middle of using the app; every view needed to be able to handle the possibility that the underlying theme could change at any second rather than just occurring from the settings panel. ↩︎

  4. Auto renewable subscriptions and supporting promoted in-app purchases, getting around the 64-notification limits of the UNUserNotificationCentre when you have 300 notifications to load in, how to efficiently deal with syncing contact information when it could be different on each device, etc. Don’t even talk to me about recreating the entirety of the custom repeat options from the iOS Reminders app! ↩︎

Gyfted

I’m pleased to announce the release of a new client app I’ve been working on recently: Gyfted, the free universal wishlist.

I worked as the only iOS developer on the project working remotely from the UK. The app provides a free and easy way for users to create their own wishlists and populate them with items from a data driven explore page or by manually entering details. It is also possible to add an item via a web link (including via a system wide share sheet) which is then used to automatically add metadata including images, descriptions, and more.

Whilst I originally started building the app using Cloud Firestore from Firebase, this quickly proved to be insufficient for the ways in which we wanted to generate the various social feeds and explore pages. To remedy this, I built a custom server backend and API including such features as feed generation, friend requests, likes, shares, and profile data collection.

The beautiful design was provided by Gyfted but I did need to make a number of adjustments to ensure it would scale correctly on all devices from the iPhone 4 up to the iPhone 11 Pro Max. The app is written entirely in Swift 5 and there are several pieces of swiping interactivity and subtle animated bounces to make the app feel right at home in the modern iOS ecosystem. Other Apple technologies used include push notifications, accessing the address book1, and a full sharing suite powered by Universal Links allowing the app to be opened directly to specific sections of the app or redirecting a user to the App Store if they don’t have the app installed.

I really enjoyed working with Gyfted on this project and having a chance to build both an interesting wishlist app and the server infrastructure to support it. You can download Gyfted on the App Store for free and learn more about it at gyfted.it.

  1. Done in a secure and privacy-focussed way. The user doesn’t need to give full access to their contacts or provide access permissions. ↩︎

Introducing the Apple TV Shows & Movies Artwork Finder

With iOS 12.3, Apple unveiled a new design for the TV app featuring an industry unstandard 16:9 aspect ratio for cover artwork. This new design was used for both TV shows and movies which had been 1:1 squares and 3:2 portraits respectively. Apple doubled down on this design with the preview of macOS Catalina over the summer and the imminent removal of iTunes.

Apple TV and Movie artwork: before and after iOS 12.3 redesign

This new art style is notable for a few reasons. Firstly, it is almost the exact opposite to every other platform that uses portrait style artwork. Secondly, there must have been an insane amount of work done by the graphics department at Apple to get this ready. These aren’t just automated crops but brand new artwork treatments across tens of thousands of films and TV shows (which get this new treatment for each season).

The Big Bang Theory artwork before and after the iOS 12.3 TV update

This update doesn’t extend to every single property on the store but the vast majority of popular titles seem to have been updated. For those that haven’t, Apple typically places the old rectangular artwork into the 16:9 frame with an aspect fit and then uses a blurred version of the artwork in aspect fill to produce a passable thumbnail.

Since this new style debuted, I’ve received a lot of email asking when my iTunes Artwork Finder would be updated to support it. Unfortunately the old iTunes Search API does not provide this new artwork as it relates to the now defunct iTunes and a new API has not been forthcoming. Instead, I had to do some digging around and a bit of reverse engineering in order to bring you the Apple TV Shows & Movies Artwork Finder, a brand new tool designed specifically to fetch these new artwork styles.

The Walking Dead in Ben Dodson's Apple TV Shows Artwork Finder

Jurassic World in Ben Dodson's Apple Movies Artwork Finder

When you perform a search, you’ll receive results for TV shows and movies in the same way as searching within the TV app. For each show or film, you’ll get access to a huge array of artwork including such things as the new 16:9 cover art, the old iTunes style cover art, preview frames, full screen imagery and previews, transparent PNG logos, and even parallax files as used by the Apple TV. Clicking on a TV show will give you similar options for each season of the show.

I’m not going to be open sourcing or detailing exactly how this works at present as the lack of a public API makes it far more likely that Apple would take issue with this tool. However, in broad terms your search is sent to my server1 to generate the necessary URLs and then your own browser makes the requests directly to Apple in order that IP blocking or rate limiting won’t affect the tool for everybody.

As always, this artwork finder is completely free and I do not accept financial donations. If you want to thank me, you can drop me an email, follow me on Twitch, check out some of my iOS apps, or share a link to the finder on your own blog.

Apple TV Shows & Movies Artwork Finder »

  1. I don’t log search terms in any way. I don’t even use basic analytics on my website as it is information I neither need nor want. I only know how many people use these tools due to the overwhelming number of emails I get about them every day! ↩︎

« Older Entries Newer Entries »