Phone theft is a major issue nowadays. According to the police statistics, 117,000 phones were stolen in London alone in 2024. The same year, Rio de Janeiro had 58,820 such cases.
Android has had Theft Detection Lock since 2024 – it uses ML to detect when your phone is grabbed and snatched. Apple has nothing equivalent. So I built one.
Here’s what I learned about accelerometers, Screen Time API, and keeping background processes alive on iOS, while building my first Swift project.
The detection algorithm
The core is pretty straightforward: n
motionManager.accelerometerUpdateInterval = 0.05 // 20 Hz
motionManager.startAccelerometerUpdates(to: .main) { data, error in
guard let data = data else { return }
let x = data.acceleration.x
let y = data.acceleration.y
let z = data.acceleration.z
let magnitude = sqrt(x * x + y * y + z * z)
if magnitude > threshold {
onSnatchDetected(magnitude)
}
}
This is it – just magnitude threshold at 4.0G. I’ve decided not to over-engineer it with ML or sliding windows.
4.0G was a real-world calibration. Initially, I started with 3G but got tired fast after several days of false positives at 3.2-3.8G from sharp everyday gestures. According to my observations, a firm snatch registered 8.4G in my testing. I’ve also added a sensitivity slider to let users calibrate this value.
Why 20Hz? At 50ms per sample, a snatch lasting ~100-200ms gets caught in 2-4 samples. Higher frequency costs battery; lower risks of missing the event entirely.
Why not verify user activity?
At first, I tried to use CMMotionActivityManager for snatch detection. The logic seemed reasonable: detect an unexpected “Running” state and that might mean the phone was snatched and the thief is running away.
Unfortunately, after running around the house for an hour I realized it was a total waste of time. The issues: it reports “Unknown” instead of “Running” unpredictably, flickers between states, and has a delay of 5–10 seconds – far too slow for real-time detection.
The real problem: iOS kills background processes
CMMotionManager is foreground-only. Without a keep-alive mechanism, iOS terminates your process in ~5 minutes in my testing – depending on memory pressure at the time. Since the point of the app is to work in the background, this was a serious issue.
The standard approaches don’t work here:
- Background fetch – fires on Apple’s schedule, not yours
- Push notifications – requires a server, adds latency
- Audio session – audible or detectable, bad UX
The solution I landed on is CLLocationManager as a heartbeat.
let locationManager = CLLocationManager()
locationManager.requestAlwaysAuthorization()
locationManager.desiredAccuracy = kCLLocationAccuracyThreeKilometers
locationManager.distanceFilter = 500
locationManager.allowsBackgroundLocationUpdates = true
locationManager.pausesLocationUpdatesAutomatically = false
locationManager.showsBackgroundLocationIndicator = true
locationManager.startUpdatingLocation()
This does three things:
- Keeps the process alive indefinitely
- Uses Apple’s lowest-power location mode
- Produces no meaningful location data at 3km accuracy / 500m filter
The location data itself is irrelevant to theft detection. It’s just the necessity required to keep a background process running. I verified: accelerometer still fires correctly after 15+ minutes in background with YouTube playing in foreground.
kCLLocationAccuracyThreeKilometers is reported as Apple’s lowest available accuracy setting, categorized as low power usage. I haven’t instrumented exact battery impact.
One tradeoff: you need “Always” location permission, which adds friction in onboarding. The purpose string has to be specific – Apple rejected a generic description and required a concrete usage example. The button that triggers the permission request also can’t use the word “Allow” – Guideline 5.1.1(iv) – I had to rename it from “Allow Location” to “Set Up Location”.
Screen Time API: two token types, one easy mistake
Locking apps uses FamilyControls + ManagedSettings. This requires the Family Controls distribution entitlement – which sounds scary after reading several threads about it, but in my case Apple approved it automatically, same day, no questions.
The non-obvious part: there are two token types, and you need to apply both.
// When user selects apps to protect:
let store = ManagedSettingsStore()
store.shield.applications = selectedApps.applicationTokens // app tokens
store.shield.applicationCategories = .specific(selectedApps.categoryTokens) // category tokens
If a user selects “All Games” via the FamilyActivityPicker, they get category tokens – not app tokens. Apply only shield.applications and half their selections stay unprotected. I hit this in testing and it took a while to understand why some apps weren’t shielding.
ManagedSettingsStore lives outside the app process. This allows shield to persist even after the force-close of the controller app.
One more thing worth noting for the App Review: the age rating form has a “Parental Controls” checkbox. I checked it – Apple rejected the build, pointing out that this is not a parental controls app. Obvious in hindsight.
The Face ID wall
My original unlock flow: tap the shield → Face ID → shield removed.
Apple doesn’t allow this. You cannot call LAContext.evaluatePolicy from inside a ShieldActionExtension. The extension runs in a sandboxed process with no biometric access.
The workaround: ShieldActionExtension returns .close on every button tap, which closes the shield UI but doesn’t remove the shield. The user has to open the main app, authenticate there, and the main app removes the shield programmatically.
// ShieldActionExtension
override func handle(action: ShieldAction, for application: ApplicationToken, completionHandler: @escaping (ShieldActionResponse) -> Void) {
completionHandler(.close) // close the locked app
}
Not the best UX, but iOS is pretty strict here. The extension boundary is a real security boundary.
Main weakness
Deleting the app removes the shields. They live inside the app process – no application, no shields. You can mitigate this with a Screen Time passcode and disabling app deletion in Settings, but it’s not automatic:
Screen Time passcode + Content & Privacy Restrictions → Deleting Apps → Don’t Allow
This configuration can’t even be detected programmatically. It’s a workaround on top of a workaround, but I couldn’t find a better solution to this structural limitation than asking the user to set it up manually.
If you’re working with FamilyControls or background motion detection on iOS, I’d be happy to compare notes.