-
Notifications
You must be signed in to change notification settings - Fork 1
陽性者との接触を確認
【iOS】接触確認アプリの「陽性者との接触を確認する」ボタンを押した時の処理内容 · Issue #24 · openCACAO/cocoa-issues
の補足のためにコード解説
たびたび、iOSが通知として出す「接触数」と、COCOAが「陽性者との接触を確認する」のときに出す接触数が異なるために、混乱が生じています。これを解析するための前段階として、COCOA のホーム画面で「陽性者との接触を確認する」ボタンを押したときの動きの詳細を解説します。
- 「陽性者との接触を確認する」をクリックする.
GetExposureCount() で接触数を取得し、0より大きければ ContactedNotifyPage を表示する
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;
});
}
- userData.ExposureInformation コレクションの数を返す
userData は、UserDataModel 型で、ユーザーのデータを保持しているクラス
public class ExposureNotificationService
{
...
public int GetExposureCount()
{
return userData.ExposureInformation.Count();
}
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 コレクションへの追加は、スケジュール(バックグランドタスク)から呼び出される ExposureDetectedAsync メソッド内で行われる。 ExposureDetectedAsync メソッドは、サマリのデータである ExposureDetectionSummary 型と、接触情報の取得をコールバックするための getExposureInfo メソッドが渡される。
getExposureInfo がコールバックになっているのは、ExposureInfo の自前で内容を変換できるようにするため、と思われる。ちょっと面倒。ExposureInfo のまま扱ってもいいかもしれない。
[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 関数の内容はどういうものか?
を調べる。
実は iOS と Android で呼び出される場所が異なる。
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 の箇所になる。
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 の呼び出し時に、
- サマリを取得する PlatformDetectExposuresAsync
- 詳細を取得する ExposureDetectedAsync
が呼び出されている。
iOS では、UpdateKeysFromServer が呼び出されるタイミングは2箇所ある。
FetchExposureKeyAsync 関数内で、UpdateKeysFromServer が呼び出される。
public class ExposureNotificationService
{
public async Task FetchExposureKeyAsync()
{
await Xamarin.ExposureNotifications.ExposureNotification.UpdateKeysFromServer();
}
}
FetchExposureKeyAsync 関数は、ホームを開いたときの初期時に呼び出されている。つまり、
- ホームを開き直すと FetchExposureKeyAsync を呼び出し
- 更に UpdateKeysFromServer と呼び出され、
- PlatformDetectExposuresAsync でサマリチェックをし、
- ExposureDetectedAsync を呼び出すことにより、
- 詳細情報の詰め込みが ExposureInformation に行われる、
ことになる。 ただし、HomePageViewModel の Initialize が呼び出されるタイミングは、アプリを起動した時のみ(と思われる)なので、この部分が呼ばれるのはアプリ起動後に1回だけになる。
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 を更新している部分になる。
ホームのときと同じように、
- スケジュールが起動する。
- 定期的なスケジュール内で、UpdateKeysFromServer と呼び出され、
- PlatformDetectExposuresAsync でサマリチェックをし、
- ExposureDetectedAsync を呼び出すことにより、
- 詳細情報の詰め込みが 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 でもバックグラウンドで呼んでいる。
namespace Xamarin.ExposureNotifications
{
...
public class BackgroundFetchWorker : Worker
{
async Task DoAsyncWork()
{
if (await ExposureNotification.IsEnabledAsync())
await ExposureNotification.UpdateKeysFromServer();
}
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 関数のなかみは長いので適宜割愛しながら解説する。
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();
- Xamarin.ExposureNotifications.ExposureNotification.Init()
- Xamarin.ExposureNotifications.ExposureNotification.PlatformInit() in ios
- Xamarin.ExposureNotifications.ExposureNotification.ScheduleFetchAsync in share
- Xamarin.ExposureNotifications.ExposureNotification.PlatformScheduleFetch() in ios
この PlatformScheduleFetch 内で定期的に UpdateKeysFromServer が呼び出されることになる。
- 「陽性者との接触を確認する」をクリックする.
- userData.ExposureInformation の数をチェックする。
- 0より大きければ、接触者ありのページへ遷移
ExposureInformation の追加状態は、バックグラウンドで行われる。
- スケジュールにて、UpdateKeysFromServer が呼び出される。
- UpdateKeysFromServer 内で、FetchExposureKeyBatchFilesFromServerAsync で zip のダウンロード
- サマリを扱う PlatformDetectExposuresAsync 関数の呼び出し
- この時、詳細を詰め込むコールバック関数 GetInfo を返す
- サマリで summary?.MatchedKeyCount > 0 であれば、 ExposureDetectedAsync で詳細を取得する
- ExposureDetectedAsync 内で、詳細データの ExposureInfo から UserExposureInfo に変換して、ExposureInformation に追加する。
となる。
これにより、userData.ExposureInformation.Count() > 0 のときに「接触者あり」のページに遷移できる。
おそらく、以下の2つの数が異なっているために、接触者あり/なしや通知の時の接触者数の違いが発生していると思われる。
- summary?.MatchedKeyCount
- userData.ExposureInformation.Count()
本来ならば、コールバック関数 GetInfo で返す ExposureInfo の数とサマリの MatchedKeyCount が同じになるはずなのだが異なっているのかもしれない(これは推測)。