Skip to content
C.W.K.
Stream
Lesson 04 of 06 · published

Permission Strings: The Sentence That Can Crash Your App or Lie

~15 min · universal-ios, privacy, info-plist, speech, dictation

Level 0Bundle Opener
0 XP0/81 lessons0/17 achievements
0/100 XP to next level100 XP to go0% complete
"Auto cannot dictate a mix of Korean and English properly, however good the model is."

A Missing String Is a Crash, Not a Warning

Every privacy-protected resource on iOS (microphone, speech recognition, camera, photos, location and the rest) needs a usage description in the app's Info.plist, the sentence shown in the permission prompt. If the key is missing, nothing warns at build time and nothing warns at launch. For the microphone, speech recognition, the camera or photos, iOS terminates the app at the moment it requests the permission, which on a phone looks like the app dying the first time someone taps the microphone. Core Location is the exception: without its key the authorization request fails immediately instead, which is quieter and no easier to notice. Some features need more than one key: live dictation records audio and recognizes speech, so it needs both NSMicrophoneUsageDescription and NSSpeechRecognitionUsageDescription. The family guards this with a hosted test that reads the built app's Info.plist and fails with the missing key's name.

A Present String Can Still Lie

Two more traps came in the same afternoon. The journal app already had a microphone string, written when the microphone was only for video notes, promising that nothing is transcribed. Adding dictation made that sentence false, and it is shown at exactly the moment the user decides whether to trust the app. Reword a string whenever a feature changes what it covers. And Apple prints its own sentence above yours for speech recognition, saying speech data may be sent to Apple. An app string that answers "we will not send it to Apple" puts two contradicting sentences in one dialog. Say what your app does instead: that it requires the model on this device and refuses a language that has none.

The Keyboard Microphone Is Not Yours

A text field gets dictation for free through the microphone key on the system keyboard, and that dictation follows the keyboard's language. A bilingual user with a Korean keyboard who speaks English gets Korean nonsense, and the app hosting the field has no setting to change it. The only app-side lever is the Speech framework: SFSpeechRecognizer(locale:) recognizes in a locale the app names. The family's answer is one dictation button per language, side by side, and no auto-detect, because a sentence that mixes two languages defeats detection however good the model is.

The privacy posture that goes with it: set requiresOnDeviceRecognition, and when a language has no on-device model, refuse with a sentence that names the remedy instead of falling back to the server. One more silent bug belongs here: a recognizer delivers the whole transcript again each time it grows, so assigning it to the field replaces whatever was typed before the first word. Append against the text captured when dictation started.

Code

project.yml: usage strings that describe what this app actually does·yaml
    info:
      path: Resources/Info.plist
      properties:
        # Shown at the prompt, under Apple's own sentence. Say what THIS app does.
        NSMicrophoneUsageDescription: >-
          Spark records your voice while you hold the dictation button, and for video notes you record.
        NSSpeechRecognitionUsageDescription: >-
          Spark turns your speech into text with the language model on this device, and refuses a
          language that has no on-device model.
        NSCameraUsageDescription: >-
          Spark uses the camera when you take a photo for a note.
Name the language, stay on device, refuse with a remedy, and test the built plist·swift
// Sources/Dictation.swift
import Foundation
import Speech

enum DictationReadiness: Equatable, Sendable {
    case ready
    case refused(String)   // a sentence that names the remedy
}

/// The app names the language. It never guesses, and it never falls back to the server.
func dictationReadiness(for localeIdentifier: String) -> DictationReadiness {
    guard let recognizer = SFSpeechRecognizer(locale: Locale(identifier: localeIdentifier)) else {
        return .refused("Speech recognition does not support \(localeIdentifier).")
    }
    guard recognizer.supportsOnDeviceRecognition else {
        return .refused("Speech recognition is not available for \(localeIdentifier) on this device. "
            + "Add a keyboard for it and turn on Dictation in Settings > General > Keyboard.")
    }
    return .ready
}

func makeRequest() -> SFSpeechAudioBufferRecognitionRequest {
    let request = SFSpeechAudioBufferRecognitionRequest()
    request.requiresOnDeviceRecognition = true   // the privacy posture the usage string promises
    request.shouldReportPartialResults = true
    return request
}

// Tests/UsageStringTests.swift (a hosted test: Bundle.main is the app)
import Foundation
import XCTest

/// iOS terminates the app when it requests a permission whose usage string is missing,
/// and nothing warns before that first tap. Read the BUILT Info.plist instead.
final class UsageStringTests: XCTestCase {
    func testEveryRequestedPermissionHasAUsageString() {
        let required = [
            "NSMicrophoneUsageDescription",        // dictation records audio
            "NSSpeechRecognitionUsageDescription", // and recognizes it: TWO strings
            "NSCameraUsageDescription",
        ]
        let info = Bundle.main.infoDictionary ?? [:]   // a hosted test runs inside the app
        for key in required {
            let value = (info[key] as? String)?.trimmingCharacters(in: .whitespaces) ?? ""
            XCTAssertFalse(value.isEmpty, "\(key) is missing: the app would be terminated at the prompt")
        }
    }
}

External links

Exercise

Add UsageStringTests to SparkMobile's hosted test target and run it with one of the keys deleted from project.yml: confirm it fails with that key's name, then restore it. Next, call dictationReadiness(for:) for en-US and ko-KR on a simulator and on a real device, and record each answer. Finally, write a transcript-append function that takes the draft captured at start and the growing transcript, and a test proving a transcript delivered three times never repeats in the result.
Hint
Regenerate the project after editing project.yml, or the test reads the previous plist. The append function is pure: start + (start.isEmpty ? "" : " ") + transcript, recomputed from the captured start each time rather than added to the current text.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign inPlease sign in to comment.

No comments yet — be the first.