본문 바로가기
C.W.K.
Lesson 04 of 06 · published

권한 문구: 앱을 죽이거나 거짓말할 수 있는 문장

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

Level 0번들 열어본 사람
0 XP0/84 lessons0/17 achievements
0/100 XP to next level100 XP to go0% complete
"auto로는 한국어와 영어 섞인 말을 제대로 딕테이션 못해 아무리 좋은 모형이라 해도."

빠진 문구는 경고가 아니라 크래시야

iOS의 개인정보 보호 자원(마이크, 음성 인식, 카메라, 사진, 위치 등)은 전부 앱 Info.plist에 사용 설명이 필요해. 권한 창에 보이는 그 문장이야. 키가 없어도 빌드할 때나 실행할 때나 경고가 하나도 없어. 그러다 앱이 마이크, 음성 인식, 카메라, 사진 권한을 요청하는 순간 iOS가 앱을 끝내버려. 폰에선 누군가 마이크 버튼을 처음 누르자마자 앱이 죽는 걸로 보여. 위치는 예외야. 키가 없으면 앱을 끝내지 않고 권한 요청이 바로 실패해. 조용한 만큼 알아채기도 어려워. 키가 하나로 안 끝나는 기능도 있어. 실시간 받아쓰기는 소리를 녹음하고 음성을 인식하니까 NSMicrophoneUsageDescription과 NSSpeechRecognitionUsageDescription 둘 다 필요해. 가족은 빌드된 앱의 Info.plist를 읽고 빠진 키 이름을 대며 실패하는 호스트 테스트로 이걸 막아.

있는 문구도 거짓말할 수 있어

같은 날 오후에 함정이 두 개 더 나왔어. 일지 앱엔 이미 마이크 문구가 있었어. 마이크를 영상 노트에 쓰던 시절에 쓴 문구라서, 아무것도 받아쓰지 않는다고 약속하고 있었지. 받아쓰기를 넣자 그 문장은 거짓말이 됐어. 게다가 그 문장은 사용자가 앱을 믿을지 말지 정하는 바로 그 순간에 보여. 기능이 바뀌어서 문구가 설명하는 범위가 달라지면 문구도 고쳐 써. 그리고 음성 인식에선 애플이 네 문장 위에 음성 데이터가 애플로 보내질 수 있다는 자기 문장을 덧붙여. 여기에 "애플로 안 보낸다"고 받아치는 앱 문구를 쓰면, 한 창에 서로 반대되는 문장이 두 개 들어가. 대신 네 앱이 뭘 하는지 말해. 이 기기에 있는 모델로만 인식하고, 모델이 없는 언어는 거절한다고.

키보드 마이크는 네 것이 아니야

텍스트 필드엔 시스템 키보드의 마이크 키 덕분에 받아쓰기가 공짜로 따라와. 근데 그 받아쓰기는 키보드 언어를 따라가. 한국어 키보드를 쓰는 이중 언어 사용자가 영어로 말하면 엉터리 한국어가 나오고, 그 필드를 담은 앱엔 이걸 바꿀 설정이 없어. 앱이 쓸 수 있는 유일한 수단은 Speech 프레임워크야. SFSpeechRecognizer(locale:)는 앱이 이름 붙인 언어로 인식해. 가족이 택한 답은 언어마다 받아쓰기 버튼을 하나씩 나란히 두고, 자동 감지는 쓰지 않는 거야. 두 언어가 섞인 문장은 모델이 아무리 좋아도 감지를 이기거든.

여기에 따라붙는 개인정보 원칙은 이래. requiresOnDeviceRecognition을 켜고, 기기 모델이 없는 언어는 서버로 넘기지 말고 해결 방법을 알려주는 문장으로 거절해. 조용한 버그 하나도 여기 속해. 인식기는 전사가 길어질 때마다 전체 전사를 다시 보내니까, 그걸 필드에 그대로 넣으면 첫 단어가 나오는 순간 먼저 쳐둔 글이 사라져. 받아쓰기를 시작할 때 저장해둔 글 뒤에 붙여.

Code

project.yml: 이 앱이 실제로 하는 일을 말하는 사용 설명·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.
언어는 이름으로, 인식은 기기에서, 거절엔 해결 방법을, 그리고 빌드된 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

SparkMobile의 호스트 테스트 타깃에 UsageStringTests를 넣고, project.yml에서 키 하나를 지운 채로 돌려서 그 키 이름으로 실패하는지 확인한 다음 되돌려. 이어서 시뮬레이터와 실제 기기에서 en-US와 ko-KR로 dictationReadiness(for:)를 불러서 대답을 적어. 마지막으로 시작할 때 저장해둔 초안과 점점 길어지는 전사를 받아서 합치는 함수를 쓰고, 전사가 세 번 들어와도 결과에 반복이 생기지 않는다는 테스트를 써.
Hint
project.yml을 고친 뒤엔 프로젝트를 다시 생성해. 안 그러면 테스트가 이전 plist를 읽어. 합치는 함수는 순수 함수야. start + (start.isEmpty ? "" : " ") + transcript를 지금 글에 덧붙이지 말고, 매번 저장해둔 시작 글을 기준으로 다시 계산해.

Progress

Progress is local-only — sign in to sync across devices.
이 페이지에서 버그를 발견하셨거나 피드백이 있으세요?문제 신고

댓글 0

🔔 답글 알림 (로그인 필요)
로그인 — 댓글을 남기려면 로그인해 주세요.

아직 댓글이 없어요. 첫 댓글을 남겨보세요.