Sharing Apple HealthKit data to a website

I recently saw a personal trainer’s website because I liked the layout they were using and was looking at it for purely aesthetics reasons. I noticed they were sharing some details about their current health – I dug a little deeper to see that it seemed that they were hard-coding the values in place. Although it could have been rendered using PHP or something else to burn the data into the page on load.

Regardless, I thought it was interesting and I wanted to create this magic for myself. Why? As a proof of concept to see if I could do it. I originally looked into iOS applications on the AppStore and saw some allowed for data export as XML and JSON. That got me thinking about the problem some more – but then I thought to myself… why use someone else’s application when I can code up my own?

SO I made a sample functional flow model for myself in anticipation. I could work out the details in my head before committing any development time to this little pet project.

I broke the process into steps of operation. The illustration I whipped together for myself explains things at a high-level.

Step A: iOS Application

This step represents an iOS application that needs to be develop[ed. Health is only available right now on an iPhone (syncs with Apple Watch if you have one and choose to use it for health). So this application will need user-approved access to health data to be shared. In my proof of concept I chose to reveal my step count, but you could access whatever metrics you’d like (with user approval). This application also needs to send the data (in my case step count) to my website hosting server for known and unfettered browser access. Since I want no user interaction to have any of this happen, the application collects the data and sends it when it opens. No button presses are needed. Open it and it does its thing. You just need to manually allow sharing access on its first launch however. Do that once and you’re all set.

Step B: Automation

I wanted the system to run itself. So once the application was written, I didn’t want to have to remember to run it every day to keep the data fresh. I create a Shortcut Automation (on the phone) that runs every night at 11:45pm. All it does is open the iOS application on the phone. Since the app does its thing when opened, it requires no user interaction. The automation runs it every day so I can forget about doing it and it will still happen.

Step C: The Server

There is a script on my hosting server that takes the data sent to it from the iOS application (as JSON) and writes that data to a known file on that hosting server. Essentially I am transferring the shared data from HealthKit on your phone to the cloud where there is known access. Since the location and filename are known, this sets things up for Step D.

Step D: Render the Data

Javascript (or you could use PHP, etc) is loaded on a page on a website – loading that JSON data from its known location and displays it. The data is as fresh as the last update (11:45pm every evening). This is kicked off when a visitor requests the webpage your script lives on, loads the data, parses it, and displays it based on your CSS preferences.

The system only updates itself once a day – which for a proof of concept is fine. If you wanted more updates, you could always set up different automations, or perhaps use a different technique to stream the data to the server instead – let’s say you were a marathon runner and wanted people viewing a page of yours to consume live data as it happened. I am not covering those kind of updates, but that might be more interesting than a page that shows a step count from the previous day for someone.

Now what?

The system is up and running on my website. I am thinking about releasing it – or at least the code for everything. To be honest, there isn’t really very much. I could turn things into a more automated system and launch it as a full-scale solution. But I am not sure if more than a few people would ever really be instead in such a thing. So maybe I’ll throw it all on GitHub and share it that way. Soon. It just needs cleaned up before I did something like that.

You can see this being rendered on my personal website’s about page – just below the subheading “Where things stand“.

Here is the PHP script that takes the JSON payload and writes it to the server. In the script I am receiving the key for the key “steps” which is an Integer. I am adding the current time in Boston when the operation takes place so it can be displayed (last sync date and time).

<?php
// Define the file path
$filePath = 'MyDailySteps.json';
// Get the raw POST data
$postData = file_get_contents("php://input");
// Decode the JSON data
$data = json_decode($postData, true);
// Check if the "steps" key exists in the decoded data
if (isset($data['steps'])) {
    // Get the value of "steps"
    $steps = intval($data['steps']);
    // Create an associative array with the "steps" key
    $dataToWrite = array("steps" => $steps);
    // Set the timezone to Boston, MA
    date_default_timezone_set('America/New_York');
    // Get the current time and date in Boston, MA
    $currentDateTime = date('Y-m-d H:i:s');
    // Add the "submitted" key with the current time and date
    $dataToWrite["submitted"] = $currentDateTime;
    // Convert the array to JSON
    $jsonData = json_encode($dataToWrite, JSON_PRETTY_PRINT);
    // Write the JSON data to the file
    if (file_put_contents($filePath, $jsonData)) {
        echo "Step count successfully written to MyDailySteps.json";
    } else {
        echo "Failed to write to file.";
    }
} else {
    echo "No steps data received.";
}
?>

An example of the JSON written to the MyDailySteps.json file looks like this:

{
    "steps": 1816,
    "submitted": "2024-06-17 20:48:27"
}

One would need to modify the iOS application in order to send anything other than step count, but that should be pretty easy as they are identifiers – as long as you add permission requests for the additional identifiers you’d like to collect data on.

Here is the Javascript I am using to render the JSON in my interface.

async function getStepsQtyValue() {
      try {
          const response = await fetch('MyDailySteps.json');
          if (!response.ok) {
              throw new Error('Network response was not ok');
          }
          const jsonData = await response.json();
          const qty = jsonData.steps;
          const syncDate = jsonData.submitted;
          document.getElementById('stepsDisplay').innerHTML = `<i class="fa-solid fa-shoe-prints" style="margin-left:8px;">
</i> taken today: <span style="font-weight: 600;">${qty}</span> &nbsp;&nbsp; 
<i class="fa-solid fa-rotate"></i>&nbsp;${syncDate}`;
      } catch (error) {
          console.error('There was a problem with the fetch operation:', error);
      }
  }
  getStepsQtyValue();

The Shortcut Automation is dead-simple. At 11pm every day, it simply opens the app which is built to my iPhone.

The Swift code for the project needs to have a .plist addition with a few keys. One would be the App Transport Security Settings / Allow Arbitrary Loads setting for allowing the POST to your domain – instead of setting my domain, I simply allow arbitrary loads. Since I own the project and all its code, I do not have any security concerns there. The HealthKit .plist additions ask for permission to the HealthKit data on the phone. Privacy – Health Share Usage Description. I also added Privacy – Health Update Usage Description – just in case I want to augment the total from another device if I wanted to.

Here is the entire shebang for the actual project. This is in my single ViewController.

import UIKit
import HealthKit
import OSLog

class ViewController: UIViewController {

    private let healthStore = HKHealthStore()
    private let scriptString = "yourSciptLocation.php"
    private let logger = Logger()
    private var dailyStepCount: Int = 0
    @IBOutlet var stepsLabel: UILabel!
    @IBOutlet var serverLabel: UILabel!
    @IBOutlet var activitySpinner: UIActivityIndicatorView!
    @IBOutlet var sendButton: UIButton!
    private let generator = UINotificationFeedbackGenerator()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        // If the app was backgrounded and Shortcuts Automation fires
        // and doesn't launch the open but rather brings it into focus
        // because of the Automation "open" command, this ensures it
        // will still call the flow into action.
        NotificationCenter.default.addObserver(self, selector: #selector(authorizeHealthKit), name: UIApplication.willEnterForegroundNotification, object: nil)
    }
    
    @objc private func authorizeHealthKit() {
        guard HKHealthStore.isHealthDataAvailable() else {
            logger.error("HealthKit is not available on this device.")
            return
        }

        // Define the health data type to read
        let stepCountType = HKQuantityType.quantityType(forIdentifier: .stepCount)!
        
        // Request authorization to read the step count data
        healthStore.requestAuthorization(toShare: nil, read: [stepCountType]) { (success, error) in
            if let error = error {
                self.logger.error("HealthKit authorization failed with error: \(error.localizedDescription)")
                return
            }
            
            if success {
                self.logger.log("HealthKit authorization succeeded.")
                self.fetchDailyStepCount()
            } else {
                self.logger.error("HealthKit authorization was not granted.")
            }
        }
    }
    
    private func fetchDailyStepCount() {
        // Define the step count sample type
        let stepCountType = HKQuantityType.quantityType(forIdentifier: .stepCount)!
        
        // Create a date range predicate for today
        let calendar = Calendar.current
        let startOfDay = calendar.startOfDay(for: Date())
        let endOfDay = calendar.date(byAdding: .day, value: 1, to: startOfDay)!
        let predicate = HKQuery.predicateForSamples(withStart: startOfDay, end: endOfDay, options: .strictStartDate)
        
        // Create the query to fetch the steps count
        let query = HKStatisticsQuery(quantityType: stepCountType, quantitySamplePredicate: predicate, options: .cumulativeSum) { (query, result, error) in
            guard let result = result, let sum = result.sumQuantity() else {
                if let error = error {
                    self.logger.error("Failed to fetch step count with error: \(error.localizedDescription)")
                } else {
                    self.logger.error("No step count data available.")
                }
                return
            }
            
            // Get the step count as an integer value
            let stepCount = Int(sum.doubleValue(for: HKUnit.count()))
            self.dailyStepCount = stepCount
            
            DispatchQueue.main.async() {
                self.stepsLabel.text = "\(stepCount)"
                self.sendButton.isEnabled = true
            }
            
            self.logger.log("Today's step count: \(stepCount)")
            print("Today's step count: \(stepCount)")
            
            // Send the step count to the PHP script
            self.logger.log("Sending to the server.")
            self.sendStepCountToServer(stepCount: stepCount)
        }
        
        // Execute the query
        healthStore.execute(query)
    }
    
    @IBAction func sendStepCount(){
        self.logger.log("Sending to the server.")
        serverLabel.text = ""
        self.sendButton.isEnabled = false
        sendStepCountToServer(stepCount: dailyStepCount)
        activitySpinner.startAnimating()
    }
    
    private func sendStepCountToServer(stepCount: Int) {
        DispatchQueue.main.async() {
            self.activitySpinner.startAnimating()
            self.sendButton.isEnabled = false
        }
        
        let url = URL(string: scriptString)!
        var request = URLRequest(url: url)
        request.httpMethod = "POST"
        
        let json: [String: Any] = ["steps": stepCount]
        guard let jsonData = try? JSONSerialization.data(withJSONObject: json, options: []) else {
            self.logger.error("Failed to serialize JSON.")
            DispatchQueue.main.async() {
                self.activitySpinner.stopAnimating()
                self.sendButton.isEnabled = true
                self.generator.notificationOccurred(.error)
            }
            return
        }
        
        request.setValue("application/json", forHTTPHeaderField: "Content-Type")
        request.httpBody = jsonData
        
        let task = URLSession.shared.dataTask(with: request) { data, response, error in
            if let error = error {
                self.logger.error("Failed to send step count with error: \(error.localizedDescription)")
                DispatchQueue.main.async() {
                    self.activitySpinner.stopAnimating()
                    self.sendButton.isEnabled = true
                    self.generator.notificationOccurred(.error)
                }
                return
            }
            
            guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else {
                self.logger.error("Failed to send step count: Server responded with an error")
                DispatchQueue.main.async() {
                    self.activitySpinner.stopAnimating()
                    self.sendButton.isEnabled = true
                    self.generator.notificationOccurred(.error)
                }
                return
            }
            
            if let data = data, let responseMessage = String(data: data, encoding: .utf8) {
                self.logger.log("Response from server: \(responseMessage)")
                DispatchQueue.main.async() {
                    self.serverLabel.text = "Server: \(responseMessage)"
                    DispatchQueue.main.async() {
                        self.activitySpinner.stopAnimating()
                        self.sendButton.isEnabled = true
                        self.generator.notificationOccurred(.success)
                    }
                }
            } else {
                self.logger.error("Failed to read response data.")
                DispatchQueue.main.async() {
                    self.activitySpinner.stopAnimating()
                    self.sendButton.isEnabled = true
                    self.generator.notificationOccurred(.error)
                }
            }
        }
        task.resume()
    }

}

Leave a Reply

Your email address will not be published. Required fields are marked *

Time limit is exhausted. Please reload CAPTCHA.

This site uses Akismet to reduce spam. Learn how your comment data is processed.