方言を話すおしゃべり猫型ロボット『ミーア』をリリースしました(こちらをクリック)

【AppleHealth Kit】連携状態の取得がiOSのプライバシーの観点からややこしすぎた

この記事は約23分で読めます。

はじめに

現在、方言を話すおしゃべり猫型ロボット「ミーア」を開発中。

https://mia-cat.com


前回からAppleHealth Kitとの連携をflutterのhealthパッケージで進めているが、連携状態の取得に関して、iOSの場合がややこしすぎたので(Androidよりもユーザーへのプライバシー配慮が厳しい)備忘録として記載。

flutterのhealthパッケージにhasPermissionメソッドはあるが、実質iOSでは使えない!

Flutter の health パッケージでは、hasPermissions メソッドを提供しており、アプリが特定の健康データへのアクセス権を持っているかどうかを確認することができる。

しかし、iOS では、Apple のプライバシーポリシーにより、HealthKit はアプリが読み取りアクセス権を持っているかどうかをアプリ自体に知らせない(外部に開示しない)。そのため、hasPermissions メソッドは iOS では常に null を返す設計となっている。

これは、ユーザーのプライバシーを保護するための措置だが、開発者はこのメソッドを信頼してユーザーの許可状態を確認することができないという制約がある。

Dart
bool? hasPermissions = await health.hasPermissions([HealthDataType.STEPS]);
print('Permissions status: $hasPermissions');

このコードは、ユーザーがステップデータへのアクセスを許可しているかどうかを確認しようとするが、iOS では hasPermissions は、開発者が期待する true/false ではなくnullを返す。ちなみに、Androidでは開発者の期待通り、hasPermissionsメソッドでユーザーの健康データ許可状態を読み取ってtrue/falseで返してくれる。

詳細はこちら参照

https://pub.dev/documentation/health/latest/health/HealthFactory/hasPermissions.html

hasPermissions の制限への対応

実際のデータアクセスを確認する方法とその実装

hasPermissions が有用な情報を提供しないため、実際にデータを取得しようと試みることで、アクセス権限の有無を間接的に確認することができる。

以下のコードは、過去30日間のステップデータを取得しようと試み、データが取得できれば権限があると判断する。

Dart
try {
  List<HealthDataPoint> healthData = await health.getHealthDataFromTypes(
    DateTime.now().subtract(Duration(days: 30)),
    DateTime.now(),
    [HealthDataType.STEPS],
  );
  bool hasPermissions = healthData.isNotEmpty;
  print('Permissions status: $hasPermissions');
} catch (e) {
  print('Error checking health data access: $e');
}

ユーザーが Health アプリで設定を変更した場合の対応

ユーザーが Health アプリで設定を変更した場合、アプリはその変更を検知できまない。そのため、アプリは定期的にデータアクセスを確認するか、ユーザーがアプリに戻ってきたとき(例えば、AppLifecycleState.resumed で)にデータアクセスを再確認する必要がある。

Dart
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
  if (state == AppLifecycleState.resumed) {
    checkHealthDataAccess();
  }
}

WidgetsBinding.instance.addObserver(this) の説明

WidgetsBinding.instance.addObserver(this) は、現在のクラスにライフサイクルイベントの監視を追加するメソッド。このメソッドを使用することで、クラス内で WidgetsBindingObserver プロトコルのメソッドを実装し、アプリの状態変化(例: バックグラウンド移行、フォアグラウンド復帰)を検知できるようになる。これは、特定のアプリ状態に応じて特定の操作を行いたい場合に有用。

https://api.flutter.dev/flutter/widgets/WidgetsBindingObserver-class.html

クラス定義時に WidgetsBindingObserver をミックスインし、initState および dispose メソッド内で observer を追加・削除する。

Dart
class _MyAppState extends State<MyApp> with WidgetsBindingObserver {
  @override
  void initState() {
    super.initState();
    WidgetsBinding.instance.addObserver(this);
  }

  @override
  void dispose() {
    WidgetsBinding.instance.removeObserver(this);
    super.dispose();
  }

  @override
  void didChangeAppLifecycleState(AppLifecycleState state) {
    // アプリのライフサイクル状態が変化したときの処理
  }
}

連携解除メソッド(revokePermissions)もiOSでは使えない

Flutter の Health パッケージで提供される revokePermissions メソッドは、iOS では機能しない。つまり、プログラム的にユーザーの許可を取り消すことはできず、ユーザー自身が Health アプリ内で設定を変更する必要がある。こちらもAndroidでは機能する。

https://pub.dev/documentation/health/latest/health/HealthFactory/revokePermissions.html

revokePermissions の制限への対応:アプリ内でユーザーに設定変更を促す

iOS で revokePermissions メソッドが使えないため、アプリはユーザーに Health アプリ内で直接設定を変更するよう案内する必要がある。

ユーザーが設定を変更したことをアプリが検知する手段がないので、この案内は特に重要。

Dart
if (!hasPermissions && Platform.isIOS) {
  showDialog(
    context: context,
    builder: (BuildContext context) {
      return AlertDialog(
        title: Text('アクセス許可が必要'),
        content: Text('ヘルスケアアプリでの設定を変更してください。'),
        actions: [
          TextButton(
            child: Text('ヘルスケアアプリを開く'),
            onPressed: () async {
              Navigator.of(context).pop();
              const url = 'x-apple-health://';
              if (await canLaunchUrl(Uri.parse(url))) {
                await launchUrl(Uri.parse(url));
              }
            },
          ),
          TextButton(
            child: Text('キャンセル'),
            onPressed: () {
              Navigator.of(context).pop();
            },
          ),
        ],
      );
    },
  );
}

全体コード

Dart
// lib/screens/home/health_care_app_integration_screen.dart
import 'dart:io';

import 'package:clocky_app/api/user.dart';
import 'package:clocky_app/api/user_notifier.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:health/health.dart';
import 'package:url_launcher/url_launcher.dart';

class HealthCareAppIntegrationScreen extends ConsumerStatefulWidget {
  const HealthCareAppIntegrationScreen({Key? key}) : super(key: key);

  @override
  _HealthCareAppIntegrationScreenState createState() =>
      _HealthCareAppIntegrationScreenState();
}

class _HealthCareAppIntegrationScreenState
    extends ConsumerState<HealthCareAppIntegrationScreen>
    with WidgetsBindingObserver {
  late bool isHealthDataIntegrated;
  HealthFactory health = HealthFactory();

  @override
  void initState() {
    super.initState();
    WidgetsBinding.instance.addObserver(this);
    final user = ref.read(userProvider);
    isHealthDataIntegrated = user?.healthdataIntegrationStatus ?? false;
    checkHealthDataAccess();
  }

  @override
  void didChangeAppLifecycleState(AppLifecycleState state) {
    if (state == AppLifecycleState.resumed) {
      checkHealthDataAccess();
    }
  }

  @override
  void dispose() {
    WidgetsBinding.instance.removeObserver(this);
    super.dispose();
  }

  void checkHealthDataAccess() async {
    try {
      // データ取得の試みを行う
      List<HealthDataPoint> healthData = await health.getHealthDataFromTypes(
        DateTime.now().subtract(Duration(days: 30)),
        DateTime.now(),
        [HealthDataType.STEPS],
      );

      // データが取得できたかどうかでアクセス権限があるかを確認
      bool hasPermissions = healthData.isNotEmpty;
      print('>> Permissions status: $hasPermissions');
      setState(() {
        isHealthDataIntegrated = hasPermissions;
      });
      final userNotifier = ref.read(userProvider.notifier);
      userNotifier
          .updateUser(User(healthdataIntegrationStatus: hasPermissions));
    } catch (e) {
      print('Error checking health data access: $e');
      setState(() {
        isHealthDataIntegrated = false;
      });
    }
  }

  Future<void> requestPermissions(bool isRequested) async {
    final userNotifier = ref.read(userProvider.notifier);
    print('>> Request permissions called with isRequested: $isRequested');
    if (isRequested) {
      try {
        print('Requesting permissions...');
        bool requestResult = await health.requestAuthorization([
          HealthDataType.STEPS,
          // 他のデータタイプをここに追加
        ]);
        print('Permissions request result: $requestResult');

        // データ取得を試みる
        List<HealthDataPoint> healthData = await health.getHealthDataFromTypes(
          DateTime.now().subtract(Duration(days: 1)),
          DateTime.now(),
          [HealthDataType.STEPS],
        );

        // データが取得できたかどうかでアクセス権限があるかを確認
        bool hasDataAccess = healthData.isNotEmpty;
        print('Data fetch result: $hasDataAccess');

        setState(() {
          isHealthDataIntegrated = hasDataAccess;
        });
        userNotifier
            .updateUser(User(healthdataIntegrationStatus: hasDataAccess));
        if (!hasDataAccess && Platform.isIOS) {
          // iOSの場合、ユーザーに設定でのアクションを促す
          showDialog(
            context: context,
            builder: (BuildContext context) {
              return AlertDialog(
                title: const Text(
                  'アクセス許可が必要',
                  textAlign: TextAlign.center,
                ),
                content: const Text(
                    'ヘルスケアアプリでの設定を変更してください。ヘルスケアアプリを開き、フッターで「共有」を選択後、アプリおよびサービスを選択し、当アプリを探してアクセスを許可してください。'),
                actions: <Widget>[
                  TextButton(
                    child: const Text(
                      'ヘルスケアアプリを開く',
                      style: TextStyle(fontWeight: FontWeight.bold),
                    ),
                    onPressed: () async {
                      Navigator.of(context).pop();
                      if (Platform.isIOS) {
                        // ヘルスケアアプリを開く
                        final url = Uri.parse('x-apple-health://');
                        if (await canLaunchUrl(url)) {
                          await launchUrl(url);
                        } else {
                          throw 'Could not launch $url';
                        }
                      }
                    },
                  ),
                  TextButton(
                    child: const Text(
                      'キャンセル',
                      style: TextStyle(fontWeight: FontWeight.bold),
                    ),
                    onPressed: () {
                      Navigator.of(context).pop();
                    },
                  ),
                ],
              );
            },
          );
        }
      } catch (e) {
        print('Failed to request permissions: $e');
      }
    } else {
      // iOSの場合、設定から連携解除する必要がある旨を通知
      if (Platform.isIOS) {
        showDialog(
          context: context,
          builder: (BuildContext context) {
            return AlertDialog(
              title: const Text('連携解除の注意'),
              content: const Text(
                  'ヘルスケアアプリとの連携を解除するには、Appleのヘルスケアアプリで、設定を変更してください。nnヘルスケアアプリを開き、フッターで「共有」を選択後、アプリおよびサービスを選択し、当アプリを探してアクセスを解除してください。'),
              actions: <Widget>[
                TextButton(
                  child: const Text(
                    'ヘルスケアアプリを開く',
                    style: TextStyle(
                      fontWeight: FontWeight.bold,
                    ),
                  ),
                  onPressed: () async {
                    Navigator.of(context).pop();
                    final url = Uri.parse('x-apple-health://');
                    if (await canLaunchUrl(url)) {
                      await launchUrl(url);
                    } else {
                      throw 'Could not launch $url';
                    }
                  },
                ),
                TextButton(
                  child: const Text(
                    'キャンセル',
                    style: TextStyle(
                      fontWeight: FontWeight.bold,
                    ),
                  ),
                  onPressed: () {
                    Navigator.of(context).pop();
                  },
                ),
              ],
            );
          },
        );
      } else {
        // Androidの場合は通常通り処理
        try {
          print('Revoking permissions...');
          await health.revokePermissions();
          print('Permissions revoked');

          setState(() {
            isHealthDataIntegrated = false;
          });
          userNotifier.updateUser(User(healthdataIntegrationStatus: false));
        } catch (e) {
          print('Failed to revoke permissions: $e');
        }
      }
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('ヘルスケアアプリとの連携'),
      ),
      body: Column(
        children: [
          ListTile(
            title: const Text('ヘルスケアアプリとの連携'),
            trailing: Switch(
              value: isHealthDataIntegrated,
              onChanged: (value) async {
                if (value) {
                  await requestPermissions(true);
                } else {
                  await requestPermissions(false);
                }
              },
            ),
          ),
          const ListTile(
            subtitle: Text(
              'ヘルスケアアプリと同期すると、あなたの歩数、運動、睡眠データなどをもとに、ミーアが話をします。n'
              '(例:昨日はよく眠れたみたいですね。この調子!)。nn'
              'アプリへのアクセスを許可すると、データは自動的に連携されます。',
            ),
          ),
        ],
      ),
    );
  }
}

動作確認

初回連携と連携解除、再連携の3パターンに分けて動作確認する

1)初回連携

  • HealthKit連携の許可のポップアップが表示される
  • 許可しないを押すと、ステータスは未許可状態になり、連携しない状態のまま

許可するを押すと、ステータスが許可状態になり、連携済み状態にswitcherが動く

ShellScript
flutter: Request updateUser: {"healthdata_integration_status":false}
flutter: >> Request permissions called with isRequested: true
flutter: Requesting permissions...
flutter: >> trying to get permissions for [STEPS] with permissions [0]
flutter: >> isAuthorized: true
flutter: Permissions request result: true
flutter: Data fetch result: true
flutter: Request updateUser: {"healthdata_integration_status":true}

2)連携解除

次に、連携されている状態で、連携解除を試みる。iOSの場合は、SwitcherをOFFにして直接連携解除ができないので、アラートダイアログを表示する。

ヘルスケアアプリを開く をクリックして、ヘルスケアアプリに移動し、データ連携をユーザーに手動でオフにしてもらう。オフにした後に、アプリの画面を開くとSwitcherが自動でOffになる。

3)再連携時

ユーザーが一度ヘルスケアアプリとの連携を解除した後、再度健康データの連携を希望する場合、アプリ内のスイッチャーをONにしても、初回連携時のようにヘルスケアアプリが自動で開くことはない。

HealthKit ではユーザーが一度許可を与えた後、解除した場合、アプリからはその変更を検知する手段が提供されていないため。

HealthKit は新たにアクセス許可を要求するダイアログを表示しない。

アプリ側では、ユーザーが設定を変更した可能性を考慮し、スイッチャーをONにした際には、ヘルスデータの取得を試みることで実際のアクセス権限の状態を確認する。アクセス権限がないことが確認された場合、ユーザーにヘルスケアアプリの設定変更を促すガイダンスを表示する。

最後に(感想)

iOSのHealthKit のアクセス許可まわりが、プライバシー保護の優先度が高く、flutterのhealthパッケージが実質全く使えなかったため、実装がとても大変だった。。

開発者的にはAndroidと同じくらい、hasPermissions, revokePermissionsメソッド呼び出すだけでユーザーの健康データ許可状態をON/OFFできると楽なのだが。最初は、当たり前のようにそうだと思って開発してみたら、全然想定通りの挙動にならずに、想像を遥かに超えて、エグった orz

コメント

タイトルとURLをコピーしました