Skip to content

Instantly share code, notes, and snippets.

@GOROman
Last active May 12, 2020 17:42
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save GOROman/49ab4b6f757296c5717cd338fcff6c21 to your computer and use it in GitHub Desktop.
Save GOROman/49ab4b6f757296c5717cd338fcff6c21 to your computer and use it in GitHub Desktop.
Building an App to Notify Users of COVID-19 Exposure

https://developer.apple.com/documentation/exposurenotification/building_an_app_to_notify_users_of_covid-19_exposure

Building an App to Notify Users of COVID-19 Exposure

Inform people when they may have been exposed to COVID-19. (COVID-19に曝露された可能性がある場合に人々に知らせる)

概要

このコードプロジェクトでは、ExposureNotificationフレームワークを使用して、COVID-19のケースの基準を満たす人と接触したときに通知するサンプルアプリを構築します。このプロジェクトを通知アプリを設計する際の参考として使用すると、フレームワークがユーザーに報告するのに十分なほどリスクが高いかどうかをどのように判断するかの基準を定義することができます。

サンプルアプリには、サーバーの応答をシミュレートするコードが含まれています。このプロジェクトに基づいて暴露通知アプリを構築する場合は、診断キーと暴露基準を提供するサーバー環境を作成し、このサーバーと通信するためのコードをアプリに追加します。構築するアプリが COVID-19 の医療検査を認証する国で動作する場合は、これらの認証サービスと通信するためのネットワークコードを追加する必要があるかもしれません。

ExposureNotificationサービスのアーキテクチャとセキュリティの詳細については、プライバシー保護されたコンタクトトレースを参照してください。

サンプルコードプロジェクトの設定

Xcodeでサンプルコードプロジェクトを実行する前に、以下のことを確認してください。

  • iOS デバイスが iOS 13.5 以降を実行している。
  • Xcode 11.5 以降を実行している。
  • Exposure Notification エンタイトルメントを含むプロビジョニングプロファイルでプロジェクトを構成している。この権限を使用する権限を取得するには、「Exposure Notification エンタイトルメントの要求」を参照してください。

曝露通知の認可

露出通知に参加するためには、ユーザーがアプリに明示的に権限を付与する必要があります。ENManager クラスは、ユーザーの認証ステータスに関する情報を提供し、認証を要求します。アプリは、独自のクラスを使用してオブジェクトのライフタイムを管理し、シングルトンの ENManager オブジェクトを作成します。

   
    static let shared = ExposureManager()
    
    let manager = ENManager()
    
    init() {
        manager.activate { _ in
            // Ensure exposure notifications are enabled if the app is authorized. The app
            // could get into a state where it is authorized, but exposure
            // notifications are not enabled,  if the user initially denied Exposure Notifications
            // during onboarding, but then flipped on the "COVID-19 Exposure Notifications" switch
            // in Settings.
            if ENManager.authorizationStatus == .authorized && !self.manager.exposureNotificationEnabled {
                self.manager.setExposureNotificationEnabled(true) { _ in
                    // No error handling for attempts to enable on launch
                }
            }
        }
    }
    
    deinit {
        manager.invalidate()
    }

アプリが起動するたびに、ユーザーが通知を承認したかどうかを確認します。ユーザーがアプリを承認していない場合は、ユーザーにサービスを承認するかどうかを尋ねるユーザーインターフェイスが表示されます。

func sceneDidBecomeActive(_ scene: UIScene) {
    let rootViewController = window!.rootViewController!
    if !LocalStore.shared.isOnboarded && rootViewController.presentedViewController == nil {
        rootViewController.performSegue(withIdentifier: "ShowOnboarding", sender: nil)
    }
}

OnboardingViewControllerクラスは、許可要求を表示するための完全なワークフローを提供します。アプリは、ENManagerシングルトン上でsetexexposeriarNotificationEnabled(_:completionHandler:)メソッドを呼び出します。

static func enableExposureNotifications(from viewController: UIViewController) {
    ExposureManager.shared.manager.setExposureNotificationEnabled(true) { error in
        NotificationCenter.default.post(name: ExposureManager.authorizationStatusChangeNotification, object: nil)
        if let error = error as? ENError, error.code == .notAuthorized {
            viewController.show(ExposureNotificationsAreStronglyRecommendedSettingsViewController.make(), sender: nil)
        } else if let error = error {
            showError(error, from: viewController)
        } else {
            (UIApplication.shared.delegate as! AppDelegate).scheduleBackgroundTaskIfNeeded()
            viewController.show(NotifyingOthersViewController.make(independent: false), sender: nil)
        }
    }
}

フレームワークが完了ハンドラを呼び出すと、アプリはユーザーが公開通知を承認したかどうかをチェックします。ユーザーが許可していない場合、アプリは別の画面を表示してユーザーに動作を選択することの重要性を警告し、ユーザーにアプリを許可する2回目の機会を提供します。ユーザーは拒否することができます。

ユーザーデータをローカルに保存

アプリは、ユーザーのデフォルトディレクトリにテスト結果や高リスクの暴露に関する情報を保存します。ローカル データは非公開で、デバイス上に残ります。

カスタム プロパティ ラッパーは、ネイティブ形式と JSON 形式の同等の形式の間でデータを変換し、ユーザーのデフォルト辞書にデータを読み書きし、ローカル データが変更されたときにアプリに通知を投稿します。LocalStore クラスは、ユーザーのプライベート データを管理し、このプロパティ ラッパーを使用する一連のプロパティとして定義されます。

class LocalStore {
    
    static let shared = LocalStore()
    
    @Persisted(userDefaultsKey: "isOnboarded", notificationName: .init("LocalStoreIsOnboardedDidChange"), defaultValue: false)
    var isOnboarded: Bool
    
    @Persisted(userDefaultsKey: "nextDiagnosisKeyFileIndex", notificationName: .init("LocalStoreNextDiagnosisKeyFileIndexDidChange"), defaultValue: 0)
    var nextDiagnosisKeyFileIndex: Int
    
    @Persisted(userDefaultsKey: "exposures", notificationName: .init("LocalStoreExposuresDidChange"), defaultValue: [])
    var exposures: [Exposure]
    
    @Persisted(userDefaultsKey: "dateLastPerformedExposureDetection",
               notificationName: .init("LocalStoreDateLastPerformedExposureDetectionDidChange"), defaultValue: nil)
    var dateLastPerformedExposureDetection: Date?
    
    @Persisted(userDefaultsKey: "exposureDetectionErrorLocalizedDescription", notificationName:
        .init("LocalStoreExposureDetectionErrorLocalizedDescriptionDidChange"), defaultValue: nil)
    var exposureDetectionErrorLocalizedDescription: String?
    
    @Persisted(userDefaultsKey: "testResults", notificationName: .init("LocalStoreTestResultsDidChange"), defaultValue: [:])
    var testResults: [UUID: TestResult]
}

アプリは、永続化するデータに対して独自のデータ構造を定義します。たとえば、テスト結果は、ユーザーがテストを受けた日付と、ユーザーがこのデータをサーバーと共有したかどうかを記録します。この情報は、ユーザー インターフェイスを表示するために使用されます。

struct TestResult: Codable {
    var id: UUID                // A unique identifier for this test result
    var isAdded: Bool           // Whether the user completed the add positive diagnosis flow for this test result
    var dateAdministered: Date  // The date the test was administered
    var isShared: Bool          // Whether diagnosis keys were shared with the Health Authority for the purpose of notifying others
}

診断キーをサーバーと共有する

COVID-19の診断を持つユーザーは、診断キーをサーバーにアップロードすることができます。アプリの各インスタンスは、診断キーを定期的にダウンロードして、デバイスのプライベートインタラクションデータを検索し、一致するインタラクションを探します。

このプロジェクトでは、アプリが通信するリモートサーバーをシミュレートします。アプリには、受信した診断キーを保存し、オンデマンドで診断キーを提供する単一のServerオブジェクトがあります。サンプル・サーバーは、地域ごとにデータを分割しません。このサンプルサーバーは、キーの単一のリストを保持し、要求に応じてリスト全体を提供します。

// Replace this class with your own class that communicates with your server.
class Server {
    
    static let shared = Server()
    
    // For testing purposes, this object stores all of the TEKs it receives locally on device
    // In a real implementation, these would be stored on a remote server
    @Persisted(userDefaultsKey: "diagnosisKeys", notificationName: .init("ServerDiagnosisKeysDidChange"), defaultValue: [])
    var diagnosisKeys: [CodableDiagnosisKey]

ローカルストアと同様に、このローカルサーバは、同じPersistedプロパティラッパーを使用して、JSON形式でデータを保存します。

COVID-19指標の共有をユーザーに依頼する

サンプルアプリは、認知された医療機関がユーザーをテストし、COVID-19の指標が陽性であることを発見した戦略を示しています。サンプルアプリは、ユーザーが認証コードを入力する方法を提供しますが、このデータを認証サービスに提出しないため、すべてのコードは自動的に通過します。

ユーザーが陽性のテスト結果に関する情報を提供すると、アプリはローカル ストアにテスト結果を記録し、ユーザーにその結果を共有するよう求めます。結果を共有するには、アプリは診断キーのリストを取得し、そのリストをサーバーに送信する必要があります。キーを取得するには、以下のコードに示すように、アプリはシングルトン ENManager オブジェクトの getDiagnosisKeys(completionHandler:) メソッドを呼び出します。

func getAndPostDiagnosisKeys(completion: @escaping (Error?) -> Void) {
    manager.getDiagnosisKeys { temporaryExposureKeys, error in
        if let error = error {
            completion(error)
        } else {
            Server.shared.postDiagnosisKeys(temporaryExposureKeys!) { error in
                completion(error)
            }
        }
    }
}

アプリがこのメソッドを呼び出すたびに、ユーザーはトランザクションを承認する必要があります。次にフレームワークはアプリに鍵のリストを返します。アプリはこれらのキーをそのままサーバーに送信し、共有されたことを示すためにテストレコードを更新します。

サンプルアプリのサーバー実装では、保持するリストに鍵を追加し、既に存在する鍵はすべてスキップします。これにより、アプリはこれまでに受信したことのない鍵だけをリクエストできるように、鍵を順次保存します。

func postDiagnosisKeys(_ diagnosisKeys: [ENTemporaryExposureKey], completion: (Error?) -> Void) {
    
    // Convert keys to something that can be encoded to JSON and upload them.
    let codableDiagnosisKeys = diagnosisKeys.compactMap { diagnosisKey -> CodableDiagnosisKey? in
        return CodableDiagnosisKey(keyData: diagnosisKey.keyData,
                                   rollingPeriod: diagnosisKey.rollingPeriod,
                                   rollingStartNumber: diagnosisKey.rollingStartNumber,
                                   transmissionRiskLevel: diagnosisKey.transmissionRiskLevel)
    }
    
    // In a real implementation, these keys would be uploaded with URLSession instead of being saved here.
    // Your server needs to handle de-duplicating keys.
    for codableDiagnosisKey in codableDiagnosisKeys where !self.diagnosisKeys.contains(codableDiagnosisKey) {
        self.diagnosisKeys.append(codableDiagnosisKey)
    }
    completion(nil)
}

バックグラウンドタスクを作成して露出をチェック

このアプリはバックグラウンド タスクを使用して、ユーザーが COVID-19 を持つ個人に曝露された可能性があるかどうかを定期的にチェックします。アプリの Info.plist ファイルは、com.example.apple-samplecode.ExposureNotificationSampleApp.exposure-notification という名前のバックグラウンド タスクを宣言しています。BackgroundTask フレームワークは、Exposure Notification エンタイトルメントと exposure-notification で終わるバックグラウンド タスクを含むアプリを検出します。オペレーティング システムは、アプリが実行されていないときにこれらのアプリを自動的に起動し、アプリが迅速にテストして結果を報告できるように、より多くのバックグラウンド時間を保証します。

アプリの委任者はバックグラウンドタスクをスケジュールします。

func scheduleBackgroundTaskIfNeeded() {
    guard ENManager.authorizationStatus == .authorized else { return }
    let taskRequest = BGProcessingTaskRequest(identifier: AppDelegate.backgroundTaskIdentifier)
    taskRequest.requiresNetworkConnectivity = true
    do {
        try BGTaskScheduler.shared.submit(taskRequest)
    } catch {
        print("Unable to schedule background task: \(error)")
    }
}

まず、バックグラウンドタスクは、時間切れに備えてハンドラを提供します。次に、アプリの detectExposures メソッドを呼び出して、エクスポージャーをテストします。最後に、システムが次にバックグラウンド タスクを実行する時間をスケジュールします。

    BGTaskScheduler.shared.register(forTaskWithIdentifier: AppDelegate.backgroundTaskIdentifier, using: .main) { task in
        
        // Perform the exposure detection
        let progress = ExposureManager.shared.detectExposures { success in
            task.setTaskCompleted(success: success)
        }
        
        // Handle running out of time
        task.expirationHandler = {
            progress.cancel()
            LocalStore.shared.exposureDetectionErrorLocalizedDescription = NSLocalizedString("BACKGROUND_TIMEOUT", comment: "Error")
        }
        
        // Schedule the next background task
        self.scheduleBackgroundTaskIfNeeded()
    }
    
    scheduleBackgroundTaskIfNeeded()
    
    return true
}

残りのセクションでは、アプリが診断キーのセットを取得し、評価のためのフレームワークに提出する方法を説明します。

診断キーのダウンロード

アプリはサーバーから診断キーをダウンロードしてフレームワークに渡し、アプリが以前にダウンロードしていない最初のキーから始めます。この設計により、アプリは任意のデバイス上で一度だけ各診断キーをチェックすることを保証します。

アプリは、フレームワークに署名されたキーファイルを提供する必要があります。アプリは、アプリがチェックした最後のファイルの後にサーバーが生成したキー ファイルの URL をサーバーに要求します。サーバーからURLを受信した後、アプリはディスパッチグループを使用してデバイスにファイルをダウンロードします。

let nextDiagnosisKeyFileIndex = LocalStore.shared.nextDiagnosisKeyFileIndex

Server.shared.getDiagnosisKeyFileURLs(startingAt: nextDiagnosisKeyFileIndex) { result in
    
    let dispatchGroup = DispatchGroup()
    var localURLResults = [Result<URL, Error>]()
    
    switch result {
    case let .success(remoteURLs):
        for remoteURL in remoteURLs {
            dispatchGroup.enter()
            Server.shared.downloadDiagnosisKeyFile(at: remoteURL) { result in
                localURLResults.append(result)
                dispatchGroup.leave()
            }
        }
        
    case let .failure(error):
        finish(.failure(error))
    }

最後に、アプリはダウンロードしたファイルのローカルURLの配列を作成します。

dispatchGroup.notify(queue: .main) {
    for result in localURLResults {
        switch result {
        case let .success(localURL):
            localURLs.append(localURL)
        case let .failure(error):
            finish(.failure(error))
            return
        }
    }

リスクを推定するための基準を設定する

フレームワークは、ローカルに保存された相互作用データをアプリによって提供された診断キーと比較します。フレームワークが一致するものを見つけると、フレームワークは、相互作用がいつ行われたか、デバイスがお互いに近接していた時間など、多くの異なる要因に基づいて、その相互作用のリスクスコアを計算します。

リスクがどのように評価されるべきかについてフレームワークに具体的なガイダンスを提供するために、アプリは ENExposureConfiguration オブジェクトを作成します。アプリはサーバーオブジェクトから基準を要求し、以下に示すように ENExposureConfiguration オブジェクトを作成して返します。

func getExposureConfiguration(completion: (Result<ENExposureConfiguration, Error>) -> Void) {
    
    let dataFromServer = """
    {"minimumRiskScore":0,
    "attenuationLevelValues":[1, 2, 3, 4, 5, 6, 7, 8],
    "attenuationWeight":50,
    "daysSinceLastExposureLevelValues":[1, 2, 3, 4, 5, 6, 7, 8],
    "daysSinceLastExposureWeight":50,
    "durationLevelValues":[1, 2, 3, 4, 5, 6, 7, 8],
    "durationWeight":50,
    "transmissionRiskLevelValues":[1, 2, 3, 4, 5, 6, 7, 8],
    "transmissionRiskWeight":50}
    """.data(using: .utf8)!
    
    do {
        let codableExposureConfiguration = try JSONDecoder().decode(CodableExposureConfiguration.self, from: dataFromServer)
        let exposureConfiguration = ENExposureConfiguration()
        exposureConfiguration.minimumRiskScore = codableExposureConfiguration.minimumRiskScore
        exposureConfiguration.attenuationLevelValues = codableExposureConfiguration.attenuationLevelValues as [NSNumber]
        exposureConfiguration.attenuationWeight = codableExposureConfiguration.attenuationWeight
        exposureConfiguration.daysSinceLastExposureLevelValues = codableExposureConfiguration.daysSinceLastExposureLevelValues as [NSNumber]
        exposureConfiguration.daysSinceLastExposureWeight = codableExposureConfiguration.daysSinceLastExposureWeight
        exposureConfiguration.durationLevelValues = codableExposureConfiguration.durationLevelValues as [NSNumber]
        exposureConfiguration.durationWeight = codableExposureConfiguration.durationWeight
        exposureConfiguration.transmissionRiskLevelValues = codableExposureConfiguration.transmissionRiskLevelValues as [NSNumber]
        exposureConfiguration.transmissionRiskWeight = codableExposureConfiguration.transmissionRiskWeight
        completion(.success(exposureConfiguration))
    } catch {
        completion(.failure(error))
    }
}

診断キーをフレームワークに提出

キーファイルをダウンロードした後、アプリは一連の非同期ステップを使用してエクスポージャーの検索を実行します。最初に、アプリはサーバーから基準を要求し、上記の「リスクを推定するための基準の設定」セクションで示したコードを呼び出します。次に、アプリはENManagerオブジェクトの detectExposures(configuration:dagnosisKeyURLs:completionHandler:)メソッドを呼び出し、基準とダウンロードしたキーファイルのURLを渡します。このメソッドは、検索結果の概要を返します。

次に、アプリは ENManager オブジェクトの getExposureInfo(summary:userExplanation:completionHandler:) メソッドを呼び出して、リスク基準を満たしたすべてのエクスポージャー イベントの詳細を取得します。このメソッドは ENExposureInfo オブジェクトの配列を返します。アプリは、アプリのローカルストアにデータを保存できるように、露出情報を独自の構造体にマッピングします。

Server.shared.getExposureConfiguration { result in
    switch result {
    case let .success(configuration):
        ExposureManager.shared.manager.detectExposures(configuration: configuration, diagnosisKeyURLs: localURLs) { summary, error in
            if let error = error {
                finish(.failure(error))
                return
            }
            let userExplanation = NSLocalizedString("USER_NOTIFICATION_EXPLANATION", comment: "User notification")
            ExposureManager.shared.manager.getExposureInfo(summary: summary!, userExplanation: userExplanation) { exposures, error in
                    if let error = error {
                        finish(.failure(error))
                        return
                    }
                    let newExposures = exposures!.map { exposure in
                        Exposure(date: exposure.date,
                                 duration: exposure.duration,
                                 totalRiskScore: exposure.totalRiskScore,
                                 transmissionRiskLevel: exposure.transmissionRiskLevel)
                    }
                    finish(.success((newExposures, nextDiagnosisKeyFileIndex + localURLs.count)))
            }
        }
        
    case let .failure(error):
        finish(.failure(error))
    }
}

最後に、アプリはfinishメソッドを呼び出して検索を完了します。finishメソッドは、露出、アプリが検索を実行した日時、次回チェックするキーファイルのインデックスを含む新しいデータでローカルストアを更新します。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment