Skip to content

陽性者との接触を確認

Tomoaki Masuda edited this page Sep 16, 2020 · 1 revision

【iOS】接触確認アプリの「陽性者との接触を確認する」ボタンを押した時の処理内容 · Issue #24 · openCACAO/cocoa-issues

の補足のためにコード解説

接触者数のコード解析

たびたび、iOSが通知として出す「接触数」と、COCOAが「陽性者との接触を確認する」のときに出す接触数が異なるために、混乱が生じています。これを解析するための前段階として、COCOA のホーム画面で「陽性者との接触を確認する」ボタンを押したときの動きの詳細を解説します。

アプリ側で把握する接触数

  1. 「陽性者との接触を確認する」をクリックする.

GetExposureCount() で接触数を取得し、0より大きければ ContactedNotifyPage を表示する

https://github.com/cocoa-mhlw/cocoa/blob/master/Covid19Radar/Covid19Radar/ViewModels/HomePage/HomePageViewModel.cs#L65

public class HomePageViewModel : ViewModelBase {
    ...
    public Command OnClickExposures => new Command(async () =>
    {
        var count = exposureNotificationService.GetExposureCount(); // ※
        if (count > 0)
        {
            await NavigationService.NavigateAsync(nameof(ContactedNotifyPage));
            return;
        }
        await NavigationService.NavigateAsync(nameof(NotContactPage));
        return;
    });
}
  1. userData.ExposureInformation コレクションの数を返す

userData は、UserDataModel 型で、ユーザーのデータを保持しているクラス

https://github.com/cocoa-mhlw/cocoa/blob/master/Covid19Radar/Covid19Radar/Services/ExposureNotificationService.cs#L81

    public class ExposureNotificationService
    {
        ...
        public int GetExposureCount()
        {
            return userData.ExposureInformation.Count();
        }

ExposureInformation コレクションの保持

UserDataModel クラス https://github.com/cocoa-mhlw/cocoa/blob/master/Covid19Radar/Covid19Radar/Model/UserDataModel.cs#L98

    public class UserDataModel : IEquatable<UserDataModel>
    {
        ...
        public ObservableCollection<UserExposureInfo> ExposureInformation { get; set; } = new ObservableCollection<UserExposureInfo>();
    }

UserExposureInfo クラスは、TEKと同じ値を持つクラスになる https://github.com/cocoa-mhlw/cocoa/blob/master/Covid19Radar/Covid19Radar/Model/UserExposureInfo.cs

    public class UserExposureInfo
    {
        ...
        public DateTime Timestamp { get; }
        public TimeSpan Duration { get; }
        public int AttenuationValue { get; }
        public int TotalRiskScore { get; }
        public UserRiskLevel TransmissionRiskLevel { get; }
    }

この ExposureInformation コレクションが何処かでデータを入れられることにより、アプリ側で「接触数」が1以上になる。

ExposureInformation コレクションへの追加

ExposureInformation コレクションへの追加は、スケジュール(バックグランドタスク)から呼び出される ExposureDetectedAsync メソッド内で行われる。 ExposureDetectedAsync メソッドは、サマリのデータである ExposureDetectionSummary 型と、接触情報の取得をコールバックするための getExposureInfo メソッドが渡される。

getExposureInfo がコールバックになっているのは、ExposureInfo の自前で内容を変換できるようにするため、と思われる。ちょっと面倒。ExposureInfo のまま扱ってもいいかもしれない。

https://github.com/cocoa-mhlw/cocoa/blob/master/Covid19Radar/Covid19Radar/Services/ExposureNotificationHandler.cs#L70

    [Xamarin.Forms.Internals.Preserve] // Ensure this isn't linked out
    public class ExposureNotificationHandler : IExposureNotificationHandler
    {
        // スケジュールから呼び出される
        // this will be called when a potential exposure has been detected
        public async Task ExposureDetectedAsync(ExposureDetectionSummary summary, Func<Task<IEnumerable<ExposureInfo>>> getExposureInfo)
        {
            // サマリを保持しておく
            UserExposureSummary userExposureSummary = new UserExposureSummary(summary.DaysSinceLastExposure, summary.MatchedKeyCount, summary.HighestRiskScore, summary.AttenuationDurations, summary.SummationRiskScore);
            userData.ExposureSummary = userExposureSummary;
            // 接触データの詳細を取得する
            var exposureInfo = await getExposureInfo();

            // UI のために別スレッドにしてある
            // Add these on main thread in case the UI is visible so it can update
            await Device.InvokeOnMainThreadAsync(() =>
            {
                // 取得した exposureInfo のデータを、ExposureInformation に追加していく
                foreach (var exposure in exposureInfo)
                {
                    Debug.WriteLine($"C19R found exposure {exposure.Timestamp}");

                    UserExposureInfo userExposureInfo = new UserExposureInfo(exposure.Timestamp, exposure.Duration, exposure.AttenuationValue, exposure.TotalRiskScore, (Covid19Radar.Model.UserRiskLevel)exposure.TransmissionRiskLevel);
                    // ExposureInformation に追加しているところ
                    userData.ExposureInformation.Add(userExposureInfo);
                }
            });
            // userData を保存する
            await userDataService.SetAsync(userData);

ExposureInformation コレクションに詰め込まれる場所がわかったので、今度は

  • いつ ExposureDetectedAsync が呼び出されるのか?
  • コールバックされる getExposureInfo 関数の内容はどういうものか?

を調べる。

いつ ExposureDetectedAsync 関数が呼び出されるのか?

実は iOS と Android で呼び出される場所が異なる。

https://github.com/cocoa-mhlw/cocoa/blob/master/Covid19Radar/Xamarin.ExposureNotification/ExposureNotification.shared.cs#L84

namespace Xamarin.ExposureNotifications
{
	public static partial class ExposureNotification
    {
		public static async Task<bool> UpdateKeysFromServer(CancellationToken cancellationToken = default)
		{
			var processedAnyFiles = false;

			await Handler?.FetchExposureKeyBatchFilesFromServerAsync(async downloadedFiles =>
			{
				cancellationToken.ThrowIfCancellationRequested();

				if (!downloadedFiles.Any())
					return;

				if (nativeImplementation != null)
				{
					var r = await nativeImplementation.DetectExposuresAsync(downloadedFiles);

					var hasMatches = (r.summary?.MatchedKeyCount ?? 0) > 0;

					if (hasMatches)
						await Handler.ExposureDetectedAsync(r.summary, r.getInfo);  // ※1
				}
				else
				{
#if __IOS__
					// On iOS we need to check this ourselves and invoke the handler
					var (summary, info) = await PlatformDetectExposuresAsync(downloadedFiles, cancellationToken);

					// Check that the summary has any matches before notifying the callback
					if (summary?.MatchedKeyCount > 0)
						await Handler.ExposureDetectedAsync(summary, info);         // ※2
#elif __ANDROID__
					// on Android this will happen in the broadcast receiver
					await PlatformDetectExposuresAsync(downloadedFiles, cancellationToken);
#endif
				}

				processedAnyFiles = true;
			}, cancellationToken);

			return processedAnyFiles;
		}
    }

FetchExposureKeyBatchFilesFromServerAsync でサーバーから zip をダウンロード

※1は、DEBUG_MOCK を指定したときに、呼び出される。テスト環境として nativeImplementation が設定される。 iOS の場合は、※2 で ExposureDetectedAsync が呼び出される Android 場合は、次の ※3 の箇所になる。

https://github.com/cocoa-mhlw/cocoa/blob/master/Covid19Radar/Xamarin.ExposureNotification/CallbackService.android.cs#L38

namespace Xamarin.ExposureNotifications
{
    [Service(
        Permission = "android.permission.BIND_JOB_SERVICE")]
    [Preserve]
    class ExposureNotificationCallbackService : JobIntentService
    {
        protected override async void OnHandleWork(Intent workIntent)
        {
            Console.WriteLine($"C19R {nameof(ExposureNotificationCallbackService)}");
            var token = workIntent.GetStringExtra(ExposureNotificationClient.ExtraToken);

            var summary = await ExposureNotification.PlatformGetExposureSummaryAsync(token);

            Task<IEnumerable<ExposureInfo>> GetInfo()
            {
                return ExposureNotification.PlatformGetExposureInformationAsync(token);
            }

            // Invoke the custom implementation handler code with the summary info
            Console.WriteLine($"C19R {nameof(ExposureNotificationCallbackService)}{summary?.MatchedKeyCount} Matched Key Count");

            if (summary?.MatchedKeyCount > 0)
            {
                await ExposureNotification.Handler.ExposureDetectedAsync(summary, GetInfo); // ※3
            }
        }

iOS の場合は PlatformDetectExposuresAsync 関数を呼びサマリ情報を取得し、summary?.MatchedKeyCount > 0 をチェックしたのちに、詳細情報を取得するための ExposureDetectedAsync を呼び出す。

Android の場合は PlatformGetExposureSummaryAsync 関数を呼び情報を取得し、summary?.MatchedKeyCount > 0 をチェックしたのちに、詳細情報を取得するために ExposureDetectedAsync を呼び出す。

このとき、コールバック関数の getExposureInfo にあたるものは、 iOS の場合は、PlatformDetectExposuresAsync 関数が返す info

					// On iOS we need to check this ourselves and invoke the handler
					var (summary, info) = await PlatformDetectExposuresAsync(downloadedFiles, cancellationToken);

Android の場合は、PlatformGetExposureInformationAsync の中身になる。

            Task<IEnumerable<ExposureInfo>> GetInfo()
            {
                return ExposureNotification.PlatformGetExposureInformationAsync(token);
            }

iOS の UpdateKeysFromServer 呼び出しタイミング

iOS の場合は、UpdateKeysFromServer の呼び出し時に、

  • サマリを取得する PlatformDetectExposuresAsync
  • 詳細を取得する ExposureDetectedAsync

が呼び出されている。

iOS では、UpdateKeysFromServer が呼び出されるタイミングは2箇所ある。

FetchExposureKeyAsync 関数内で、UpdateKeysFromServer が呼び出される。

https://github.com/cocoa-mhlw/cocoa/blob/master/Covid19Radar/Covid19Radar/Services/ExposureNotificationService.cs#L76

    public class ExposureNotificationService
    {
        public async Task FetchExposureKeyAsync()
        {
            await Xamarin.ExposureNotifications.ExposureNotification.UpdateKeysFromServer();
        }
    }

FetchExposureKeyAsync 関数は、ホームを開いたときの初期時に呼び出されている。つまり、

  1. ホームを開き直すと FetchExposureKeyAsync を呼び出し
  2. 更に UpdateKeysFromServer と呼び出され、
  3. PlatformDetectExposuresAsync でサマリチェックをし、
  4. ExposureDetectedAsync を呼び出すことにより、
  5. 詳細情報の詰め込みが ExposureInformation に行われる、

ことになる。 ただし、HomePageViewModel の Initialize が呼び出されるタイミングは、アプリを起動した時のみ(と思われる)なので、この部分が呼ばれるのはアプリ起動後に1回だけになる。

https://github.com/cocoa-mhlw/cocoa/blob/master/Covid19Radar/Covid19Radar/ViewModels/HomePage/HomePageViewModel.cs#L49

namespace Covid19Radar.ViewModels
{
    public class HomePageViewModel : ViewModelBase
    {
        public override async void Initialize(INavigationParameters parameters)
        {
            // Check Version
            AppUtils.CheckVersion();
            try
            {
                await exposureNotificationService.StartExposureNotification();
                await exposureNotificationService.FetchExposureKeyAsync();
                base.Initialize(parameters);
            }
            catch (Exception ex)
            {
                Debug.WriteLine(ex.ToString());
            }
        }
    }

もうひとつ iOS ではバックグラウンドでスケジュールが動き ExposureNotification::PlatformScheduleFetch の中で、UpdateKeysFromServer が呼び出されている。こっちがメインで ExposureInformation を更新している部分になる。

ホームのときと同じように、

  1. スケジュールが起動する。
  2. 定期的なスケジュール内で、UpdateKeysFromServer と呼び出され、
  3. PlatformDetectExposuresAsync でサマリチェックをし、
  4. ExposureDetectedAsync を呼び出すことにより、
  5. 詳細情報の詰め込みが ExposureInformation に行われる、
namespace Xamarin.ExposureNotifications
{
	public static partial class ExposureNotification
	{
		static ENManager manager;
        ...
		static Task PlatformScheduleFetch()
		{
			// This is a special ID suffix which iOS treats a certain way
			// we can basically request infinite background tasks
			// and iOS will throttle it sensibly for us.
			var id = AppInfo.PackageName + ".exposure-notification";

			var isUpdating = false;
			BGTaskScheduler.Shared.Register(id, null, task =>
			{
				// Disallow concurrent exposure detection, because if allowed we might try to detect the same diagnosis keys more than once
				if (isUpdating)
				{
					task.SetTaskCompleted(false);
					return;
				}
				isUpdating = true;

				var cancelSrc = new CancellationTokenSource();
				task.ExpirationHandler = cancelSrc.Cancel;

				// Run the actual task on a background thread
				Task.Run(async () =>
				{
					try
					{
						await UpdateKeysFromServer(cancelSrc.Token);    // ※
						task.SetTaskCompleted(true);
					}
					catch (OperationCanceledException)
					{
						Debug.WriteLine($"[Xamarin.ExposureNotifications] Background task took too long to complete.");
					}
					catch (Exception ex)
					{
						Debug.WriteLine($"[Xamarin.ExposureNotifications] There was an error running the background task: {ex}");
						task.SetTaskCompleted(false);
					}

					isUpdating = false;
				});

				scheduleBgTask();
			});

			scheduleBgTask();

			return Task.CompletedTask;
            ...
        }
    }

Android でもバックグラウンドで呼んでいる。

https://github.com/cocoa-mhlw/cocoa/blob/master/Covid19Radar/Xamarin.ExposureNotification/ExposureNotification.android.cs#L238

namespace Xamarin.ExposureNotifications
{
    ...
	public class BackgroundFetchWorker : Worker
	{
		async Task DoAsyncWork()
		{
			if (await ExposureNotification.IsEnabledAsync())
				await ExposureNotification.UpdateKeysFromServer();
		}

iOS でサマリを取得する PlatformDetectExposuresAsync の詳細を調べる

PlatformDetectExposuresAsync の動作を調べていく。

#if __IOS__
					// On iOS we need to check this ourselves and invoke the handler
					var (summary, info) = await PlatformDetectExposuresAsync(downloadedFiles, cancellationToken);

					// Check that the summary has any matches before notifying the callback
					if (summary?.MatchedKeyCount > 0)
						await Handler.ExposureDetectedAsync(summary, info);         // ※2

PlatformDetectExposuresAsync 関数のなかみは長いので適宜割愛しながら解説する。

https://github.com/cocoa-mhlw/cocoa/blob/master/Covid19Radar/Xamarin.ExposureNotification/ExposureNotification.ios.cs#L160

namespace Xamarin.ExposureNotifications
{
	public static partial class ExposureNotification
	{
		static ENManager manager;
        ...
		static async Task<(ExposureDetectionSummary, Func<Task<IEnumerable<ExposureInfo>>>)> PlatformDetectExposuresAsync(IEnumerable<string> keyFiles, CancellationToken cancellationToken)
		{
			// Submit to the API
			var c = await GetConfigurationAsync();
			var m = await GetManagerAsync();

			// Extract all the files from the zips
			var allFiles = new List<string>();
			foreach (var file in keyFiles)
			{
                // key ファイルの読み出し export.bin と export.sig を扱う
                ...
				allFiles.Add(sigTmp); // 詰め込み
			}

            // Apple の サマリ取得の API を呼び出し
            // https://github.com/xamarin/XamarinComponents/blob/master/iOS/ExposureNotification/source/ApiDefinition.cs#L402
            // https://developer.apple.com/documentation/exposurenotification/enmanager/3586331-detectexposureswithconfiguration?language=objc
            // [Export ("detectExposuresWithConfiguration:diagnosisKeyURLs:completionHandler:")]
			// Start the detection
			var detectionSummaryTask = m.DetectExposuresAsync(
				c,
				allFiles.Select(k => new NSUrl(k, false)).ToArray(),
				out var detectProgress);
			cancellationToken.Register(detectProgress.Cancel);
			var detectionSummary = await detectionSummaryTask;

            // 不要ファイルの削除
			// Delete all the extracted files
            ...

			var attDurTs = new List<TimeSpan>();
			var dictKey = new NSString("attenuationDurations");
            // リスク値の取得など
            ..

            // サマリ情報の保持
			var summary = new ExposureDetectionSummary(
				(int)detectionSummary.DaysSinceLastExposure,
				detectionSummary.MatchedKeyCount,
				maxRisk,
				attDurTs.ToArray(),
				sumRisk);

            // 詳細情報を詰め込むコールバック関数
			async Task<IEnumerable<ExposureInfo>> GetInfo()
			{
				// Get the info
				IEnumerable<ExposureInfo> info = Array.Empty<ExposureInfo>();
				if (summary?.MatchedKeyCount > 0)
				{
                    // Apple の EN API の呼び出し
                    // https://github.com/xamarin/XamarinComponents/blob/master/iOS/ExposureNotification/source/ApiDefinition.cs#L407
                    // [Export ("getExposureInfoFromSummary:userExplanation:completionHandler:")]
                    // https://developer.apple.com/documentation/exposurenotification/enmanager/3586332-getexposureinfofromsummary?changes=l_6&language=objc
                    // これは Deprecated になっている。
					var exposures = await m.GetExposureInfoAsync(detectionSummary, Handler.UserExplanation, out var exposuresProgress);
					cancellationToken.Register(exposuresProgress.Cancel);
					info = exposures.Select(i =>
					{
						var totalRisk = 0;
						var dictKey = new NSString("totalRiskScoreFullRange");
						if (i.Metadata.ContainsKey(dictKey))
						{
							var sro = i.Metadata.ObjectForKey(dictKey);
							if (sro is NSNumber sron)
								totalRisk = sron.Int32Value;
						}
						else
						{
							totalRisk = i.TotalRiskScore;
						}

						return new ExposureInfo(
							((DateTime)i.Date).ToLocalTime(),   // UTCのままで十分だが、特に問題なし
							TimeSpan.FromMinutes(i.Duration),
							i.AttenuationValue,
							totalRisk,
							i.TransmissionRiskLevel.FromNative());
					});
				}
				return info;
			}

            // サマリ情報を詳細取得のためのコールバック関数を返す
			// Return everything
			return (summary, GetInfo);  
		}

iOS の場合、ダウンロードした zip を自前で解凍して、Apple の EN API に渡す必要があるのがややこしいが、

  • detectExposuresWithConfiguration:diagnosisKeyURLs:completionHandler:
  • getExposureInfoFromSummary:userExplanation:completionHandler:

の2つの EN API を使っている。

※コールバック処理の GetInfo 内で、totalRisk 値を入れている。実際は、ここで Risk 値の計算などができる気がする。

バックグラウンドのスケジュールの起動タイミング

バックグラウンドの起動設定は、アプリの起動時に行われる。

https://github.com/cocoa-mhlw/cocoa/blob/master/Covid19Radar/Covid19Radar/App.xaml.cs#L57

[assembly: XamlCompilation(XamlCompilationOptions.Compile)]
namespace Covid19Radar
{
    public partial class App : PrismApplication
    {
        ...
#if USE_MOCK
            // For debug mode, set the mock api provider to interact
            // with some fake data
            Xamarin.ExposureNotifications.ExposureNotification.OverrideNativeImplementation(new Services.TestNativeImplementation());
#endif
            Xamarin.ExposureNotifications.ExposureNotification.Init();  // ※

            // Local Notification tap event listener
            //NotificationCenter.Current.NotificationTapped += OnNotificationTapped;
            LogUnobservedTaskExceptions();

この PlatformScheduleFetch 内で定期的に UpdateKeysFromServer が呼び出されることになる。

iOS の処理のまとめ

  1. 「陽性者との接触を確認する」をクリックする.
  2. userData.ExposureInformation の数をチェックする。
  3. 0より大きければ、接触者ありのページへ遷移

ExposureInformation の追加状態は、バックグラウンドで行われる。

  1. スケジュールにて、UpdateKeysFromServer が呼び出される。
  2. UpdateKeysFromServer 内で、FetchExposureKeyBatchFilesFromServerAsync で zip のダウンロード
  3. サマリを扱う PlatformDetectExposuresAsync 関数の呼び出し
  4. この時、詳細を詰め込むコールバック関数 GetInfo を返す
  5. サマリで summary?.MatchedKeyCount > 0 であれば、 ExposureDetectedAsync で詳細を取得する
  6. ExposureDetectedAsync 内で、詳細データの ExposureInfo から UserExposureInfo に変換して、ExposureInformation に追加する。

となる。

これにより、userData.ExposureInformation.Count() > 0 のときに「接触者あり」のページに遷移できる。

おそらく、以下の2つの数が異なっているために、接触者あり/なしや通知の時の接触者数の違いが発生していると思われる。

  • summary?.MatchedKeyCount
  • userData.ExposureInformation.Count()

本来ならば、コールバック関数 GetInfo で返す ExposureInfo の数とサマリの MatchedKeyCount が同じになるはずなのだが異なっているのかもしれない(これは推測)。

Clone this wiki locally