Open9

Flutterで作成するモバイルアプリにリマインダー設定機能を実装する(ローカル通知)

manabiyamanabiya

はじめに

モバイルアプリにリマインダー機能が必要となったので、方法から検討する。
Push通知にはリモートとローカルがあり、今回のユースケース(リマインダを設定し通知)ではローカルで十分対応できると思われたので、ローカル通知を採用。

ただし、複数端末でアプリを利用する場合、リマインダーを設定した端末ではない端末では利用できないことが想定されるので調査する。

manabiyamanabiya

iOSの設定

セットアップ

ios/AppDelegate.swiftに以下のコードを追加する。
ファイル内のどこに記述するべきかは、パッケージのサンプルプロジェクトで確認した。

import Flutter
import UIKit

@main
@objc class AppDelegate: FlutterAppDelegate {
  override func application(
    _ application: UIApplication,
    didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
  ) -> Bool {

+    if #available(iOS 10.0, *) {
+      UNUserNotificationCenter.current().delegate = self as? UNUserNotificationCenterDelegate
+    }

    GeneratedPluginRegistrant.register(with: self)
    return super.application(application, didFinishLaunchingWithOptions: launchOptions)
  }
}

https://pub.dev/packages/flutter_local_notifications#-ios-setup

シンプルな通知を実装する

実装後のコード

実装後のコードは以下のようになる。
この実装で、ホーム画面のRequest Permissionボタン押下で通知権限のリクエスト、Show plain notification with payloadボタン押下で通知が表示される。この実装は、パッケージのサンプルコードを利用を改変したもの。

import 'dart:async';
import 'dart:io';

import 'package:flutter/material.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';

int id = 0;

final FlutterLocalNotificationsPlugin flutterLocalNotificationsPlugin =
    FlutterLocalNotificationsPlugin();

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();

  const DarwinInitializationSettings initializationSettingsDarwin =
      DarwinInitializationSettings(
    requestAlertPermission: false,
    requestBadgePermission: false,
    requestSoundPermission: false,
  );
  const InitializationSettings initializationSettings = InitializationSettings(
    iOS: initializationSettingsDarwin,
  );
  await flutterLocalNotificationsPlugin.initialize(initializationSettings);

  runApp(
    const MaterialApp(home: HomePage()),
  );
}

class HomePage extends StatefulWidget {
  const HomePage({super.key});

  
  State<HomePage> createState() => _HomePageState();
}

class _HomePageState extends State<HomePage> {
  bool _notificationsEnabled = false;

  
  void initState() {
    super.initState();
    _isIOSPermissionGranted();
  }

  Future<void> _isIOSPermissionGranted() async {
    if (Platform.isIOS) {
      final notificationsEnableOptions = await flutterLocalNotificationsPlugin
          .resolvePlatformSpecificImplementation<
              IOSFlutterLocalNotificationsPlugin>()
          ?.checkPermissions();

      final bool granted = notificationsEnableOptions?.isEnabled ?? false;

      setState(() {
        _notificationsEnabled = granted;
      });
    }
  }

  Future<void> _requestPermissions() async {
    if (Platform.isIOS) {
      final grantedNotificationPermission =
          await flutterLocalNotificationsPlugin
              .resolvePlatformSpecificImplementation<
                  IOSFlutterLocalNotificationsPlugin>()
              ?.requestPermissions(
                alert: true,
                badge: true,
                sound: true,
              );
      setState(() {
        _notificationsEnabled = grantedNotificationPermission ?? false;
      });
    } else if (Platform.isAndroid) {
      final AndroidFlutterLocalNotificationsPlugin? androidImplementation =
          flutterLocalNotificationsPlugin.resolvePlatformSpecificImplementation<
              AndroidFlutterLocalNotificationsPlugin>();

      final bool? grantedNotificationPermission =
          await androidImplementation?.requestNotificationsPermission();
      setState(() {
        _notificationsEnabled = grantedNotificationPermission ?? false;
      });
    }
  }

  void _showNotification() async {
    const DarwinNotificationDetails darwinNotificationDetails =
        DarwinNotificationDetails(
      presentAlert: false,
      presentBadge: true,
      presentSound: true,
    );
    const NotificationDetails notificationDetails = NotificationDetails(
      iOS: darwinNotificationDetails,
    );

    await flutterLocalNotificationsPlugin.show(
        id++, 'plain title', 'plain body', notificationDetails,
        payload: 'item x');
  }

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('example app'),
      ),
      body: SingleChildScrollView(
        child: Padding(
          padding: const EdgeInsets.all(8),
          child: Center(
            child: Column(
              children: <Widget>[
                Padding(
                  padding: const EdgeInsets.fromLTRB(0, 0, 0, 8),
                  child: Text('notification enabled: $_notificationsEnabled'),
                ),
                ElevatedButton(
                  onPressed: _requestPermissions,
                  child: const Text('Request Permission'),
                ),
                ElevatedButton(
                  onPressed: _showNotification,
                  child: const Text('Show plain notification with payload'),
                ),
              ],
            ),
          ),
        ),
      ),
    );
  }
}

コード解説

以下が初期化コード。DarwinInitializationSettingsでfalseに設定しているプロパティをtrueに設定している場合は、その権限をアプリ起動時にユーザーに通知の使用許可を取ることになる。今回は、通知の使用許可を任意のボタン押下で実行したいのでfalseとした。ただし、iOS, AndroidともにUXの観点から権限が必要となったタイミングで使用許可をとる方法を推奨している。

  const DarwinInitializationSettings initializationSettingsDarwin =
      DarwinInitializationSettings(
    requestAlertPermission: false,
    requestBadgePermission: false,
    requestSoundPermission: false,
  );
  const InitializationSettings initializationSettings = InitializationSettings(
    iOS: initializationSettingsDarwin,
  );
  await flutterLocalNotificationsPlugin.initialize(initializationSettings);
manabiyamanabiya

Androidの設定

アプリケーションのGradleファイルの設定

desugarについてのリンクからJava 8+ API の desugar のサポート(Android Gradle プラグイン 4.0.0+)の内容を参考に設定する。内容を一部変更して利用している。

android {
  defaultConfig {
     // APIバージョンが20以下の場合必須のようなので追加せず
-    multiDexEnabled true
  }

  compileOptions {
    // Flag to enable support for the new language APIs
    coreLibraryDesugaringEnabled true
    // Sets Java compatibility to Java 8
    sourceCompatibility JavaVersion.VERSION_1_8
    targetCompatibility JavaVersion.VERSION_1_8
  }
}

dependencies {
-  coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:1.2.2'
+  coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.0.3'
}

multiDexEnabledの設定は追加していない。
以下のソースに記載の内容から不要と判断。作成したFlutterアプリケーションのflutter.minSdkVersionが21だったため。

Java 8+ API の desugar のサポート(Android Gradle プラグイン 4.0.0+)

// Required when setting minSdkVersion to 20 or lower

Enable multidex support

Multidex support is natively included when targeting Android SDK 21 or later.

com.android.tools:desugar_jdk_libsのバージョンについては、Gradleのバージョンに合わせて2.0.3を設定。バージョン表にある通り。

https://pub.dev/packages/flutter_local_notifications#-android-setup

manabiyamanabiya

プラグインは、この機能を活用するためにAndroid Gradleプラグイン(AGP)7.3.1を使用することに注意してください。また、Android StudioはGradle 7.3以上でのみ動作するJava SDKの新しいバージョンをバンドルしているため、より高いバージョンを使用する必要があります(詳細はこちらを参照)。レガシーなapplyスクリプト構文を使うFlutterアプリの場合、これはandroid/build.gradleで指定され、主な部分は以下のようになります。

以下に、AGPのバージョンをDSLで記載する方法があり。
settings.gradlecom.android.applicationで指定するようだ。8.1.0に設定されていたためそのままとした。
https://docs.flutter.dev/release/breaking-changes/flutter-gradle-plugin-apply#androidsettings-gradle

desugar を有効にすると、Android 12L 以降で Flutter アプリがクラッシュする可能性があるという報告があります。これはプラグインの問題ではなく、Flutter 自体の問題です。考えられる解決策の 1 つは、WindowManager ライブラリを依存関係として追加することです。

ということなので、android/app/build.gradleに以下を追加した。
どこに書くべきか分からなかったので、パッケージのexampleにあるコードを参照した。

dependencies {
    implementation 'androidx.window:window:1.0.0'
    implementation 'androidx.window:window-java:1.0.0'
}

また、compileSdkが34以上に設定されている必要があるそうで、プロジェクトを確認したところそれは満たしたが、それ以下の場合の対応は不明。

リリースビルド時の設定について

手順に記載のあるgsonのリポジトリのProGuard設定ファイルをそのまま利用。
https://github.com/google/gson/blob/main/examples/android-proguard-example/proguard.cfg

TODO: 通知アイコンなどのリソースが破棄されないようにする

manabiyamanabiya

Androidのマニフェストファイルについて

最低限の権限のみデフォルトで追加されている。通知のスケジュールが必要な場合は権限の追加が必要なようだ。

manabiyamanabiya

iOSで通知最低限の実装

通知に関する公式のドキュメント
https://developer.apple.com/documentation/usernotifications

DarwinNotificationDetails.categoryIdentifier
https://developer.apple.com/documentation/usernotifications/declaring-your-actionable-notification-types

interruptionLevel
https://developer.apple.com/documentation/usernotifications/unnotificationinterruptionlevel

requestPermission.critical
https://developer.apple.com/documentation/usernotifications/unauthorizationoptions/criticalalert

alertは非推奨。詳細は、DarwinNotificationDetailsを参照
https://developer.apple.com/documentation/usernotifications/unnotificationpresentationoptions

manabiyamanabiya

iOSについて

スクラップが見づらくなってきたので改めてまとめる。

通知についてのiOSのドキュメントは以下
Apple Developer Documentation - Asking permission to use notifications

プラグインの初期化

プラグインの初期化コード(iOSのみ)。

final DarwinInitializationSettings initializationSettingsDarwin = DarwinInitializationSettings();

final InitializationSettings initializationSettings = InitializationSettings(
  iOS: initializationSettingsDarwin,
);

await flutterLocalNotificationsPlugin.initialize(initializationSettings);

プラグインの初期化に必要となるiOSとmacOSの設定項目について以降で記載する。

  DarwinInitializationSettings({
    bool requestAlertPermission = true,
    bool requestSoundPermission = true,
    bool requestBadgePermission = true,
    bool requestProvisionalPermission = false,
    bool requestCriticalPermission = false,
    bool defaultPresentAlert = true,
    bool defaultPresentSound = true,
    bool defaultPresentBadge = true,
    bool defaultPresentBanner = true,
    bool defaultPresentList = true,
    List<DarwinNotificationCategory> notificationCategories =
        const <DarwinNotificationCategory>[],
  });

requestAlertPermissionrequestSoundPermissionrequestBadgePermissionrequestProvisionalPermissionrequestCriticalPermissionについては他のセクションで記載しているので、ここでは扱わない。

defaultが接頭辞としてつくものは、アプリがフォアグランドでの実行時に通知されたときの既定の挙動を決定する。

以下設定値についての説明

DarwinInitializationSettingsのソースコードを引用
import 'notification_category.dart';

/// iOSやmacOSなど、Darwin系オペレーティングシステム向けのプラグイン初期化設定
class DarwinInitializationSettings {
  /// [DarwinInitializationSettings] のインスタンスを構築します。
  const DarwinInitializationSettings({
    this.requestAlertPermission = true,
    this.requestSoundPermission = true,
    this.requestBadgePermission = true,
    this.requestProvisionalPermission = false,
    this.requestCriticalPermission = false,
    this.defaultPresentAlert = true,
    this.defaultPresentSound = true,
    this.defaultPresentBadge = true,
    this.defaultPresentBanner = true,
    this.defaultPresentList = true,
    this.notificationCategories = const <DarwinNotificationCategory>[],
  });

  /// アラート表示の許可をリクエストします。
  ///
  /// デフォルト値は true です。
  final bool requestAlertPermission;

  /// サウンド再生の許可をリクエストします。
  ///
  /// デフォルト値は true です。
  final bool requestSoundPermission;

  /// アプリアイコンのバッジ表示許可をリクエストします。
  ///
  /// デフォルト値は true です。
  final bool requestBadgePermission;

  /// iOS 12以上で暫定通知の許可をリクエストします。
  ///
  /// Appleの特別な承認が必要です: https://developer.apple.com/documentation/usernotifications/asking_permission_to_use_notifications#3544375
  ///
  /// デフォルト値は false です。
  ///
  /// iOSでは、iOS 12以降に適用されます。
  /// macOSでは、macOS 10.14以降に適用されます。
  final bool requestProvisionalPermission;

  /// 重要な通知の表示許可をリクエストします。
  ///
  /// Appleの特別な承認が必要です: https://developer.apple.com/contact/request/notifications-critical-alerts-entitlement/
  ///
  /// デフォルト値は false です。
  final bool requestCriticalPermission;

  /// 通知がアプリのフォアグラウンドでトリガーされたときにアラートを表示するかを設定します。
  ///
  /// Appleのドキュメント: https://developer.apple.com/documentation/usernotifications/unnotificationpresentationoptions/1649506-alert
  ///
  /// デフォルト値は true です。
  ///
  /// iOSでは、iOS 10以降、iOS 14未満で適用されます。
  /// macOSでは、macOS 10.14以降、macOS 11未満で適用されます。
  final bool defaultPresentAlert;

  /// 通知がアプリのフォアグラウンドでトリガーされたときにサウンドを再生するかを設定します。
  ///
  /// Appleのドキュメント: https://developer.apple.com/documentation/usernotifications/unnotificationpresentationoptions/1649521-sound
  ///
  /// デフォルト値は true です。
  ///
  /// iOSでは、iOS 10以降に適用されます。
  /// macOSでは、macOS 10.14以降に適用されます。
  final bool defaultPresentSound;

  /// 通知がアプリのフォアグラウンドでトリガーされたときにバッジを適用するかを設定します。
  ///
  /// Appleのドキュメント: https://developer.apple.com/documentation/usernotifications/unnotificationpresentationoptions/1649515-badge
  ///
  /// デフォルト値は true です。
  ///
  /// iOSでは、iOS 10以降に適用されます。
  /// macOSでは、macOS 10.14以降に適用されます。
  final bool defaultPresentBadge;

  /// 通知がフォアグラウンドでトリガーされたときにバナー形式で表示するかを設定します。
  ///
  /// Appleのドキュメント: https://developer.apple.com/documentation/usernotifications/unnotificationpresentationoptions/3564812-banner
  ///
  /// デフォルト値は true です。
  ///
  /// iOSでは、iOS 14以降に適用されます。
  /// macOSでは、macOS 11以降に適用されます。
  final bool defaultPresentBanner;

  /// 通知がフォアグラウンドでトリガーされたときに通知センターに表示するかを設定します。
  ///
  /// Appleのドキュメント: https://developer.apple.com/documentation/usernotifications/unnotificationpresentationoptions/3564813-list
  ///
  /// デフォルト値は true です。
  ///
  /// iOSでは、iOS 14以降に適用されます。
  /// macOSでは、macOS 11以降に適用されます。
  final bool defaultPresentList;

  /// 利用可能な通知カテゴリ ([DarwinNotificationCategory]) を設定します。
  ///
  /// アクション設定の変更にはアプリの再インストールまたはカテゴリ識別子の変更が必要です。
  ///
  /// iOSでは、iOS 10以降に適用されます。
  /// macOSでは、macOS 10.14以降に適用されます。
  final List<DarwinNotificationCategory> notificationCategories;
}

以下は、NotificationCategory(Notification types)について。今回の実装では利用しないため深掘りしていない。
https://developer.apple.com/documentation/usernotifications/declaring-your-actionable-notification-types

通知使用の許可

IOSFlutterLocalNotificationsPlugin#requestPermissionsでユーザーに通知使用の確認を取ることができる。以下にIOSFlutterLocalNotificationsPlugin#requestPermissionsの引数について記載する。

Future<bool?> requestPermissions({
  bool sound = false,
  bool alert = false,
  bool badge = false,
  bool provisional = false,
  bool critical = false,
})

soundbadgeは、iOSの設定にあるアプリの通知設定にある項目と対応する。

alertとは?

iOSのプッシュ通知におけるアラートが指す正確な範囲がわからないが、
バナーと通知センターに表示されるリストを指すとして理解する。
Apple Developer Documentation - alert

provisionalとは?

ユーザーから事前に通知の使用許可を得ることなく、通知を送信できる機能。ただし、サウンドやバナー表示はされず、通知センターの履歴に表示されるだけとなる。
これはユーザーから通知の使用許可を得る通常のケースでは、ユーザーはアプリからどのような通知がされるのかなどわからない状態で許可または拒否の判断をする必要があるため、必要な通知に関しても拒否してしまう可能性があるため、それを回避するための1つのユースケースであるようだ。

provisionalを有効にした場合のアプリの通知設定の一例

アプリ設定画面
「目立たない形で配信」と表示されており、provisionalが有効となっていることがわかる。
アプリ設定画面

通知設定画面
通知設定画面

通知センター
ユーザーは、実際に通知が届いてから使用許可の決定をすることができる
通知センターでのprovisional通知の表示

ユーザーが「続ける」を選択した場合
以降使用許可の選択は表示されることがないため、ユーザーは通知センターへの表示以外の機能(バッジ、バナー)も許可したい場合は、アプリの通知設定から許可する必要がある。
通知センターでprovisional通知の続けるを選択した場合

Apple Developer Documentation - Use provisional authorization to send trial notifications

criticalとは?

以下にあるように、災害情報など重要度の高い通知の場合設定する必要がある項目のようだ。

重大なアラートはミュート スイッチと「着信拒否」を無視します。システムは、デバイスのミュートまたは「着信拒否」設定に関係なく、重大なアラートのサウンドを再生します。カスタム サウンドと音量を指定できます。

Apple Developer Documentationより引用

通知

const DarwinNotificationDetails darwinNotificationDetails = DarwinNotificationDetails(
  subtitle: 'the subtitle',
);

const NotificationDetails notificationDetails = NotificationDetails(iOS: darwinNotificationDetails);

await flutterLocalNotificationsPlugin.show(
  id++,
  'title of notification with a subtitle',
  'body of notification with a subtitle',
  notificationDetails,
  payload: 'item x'
);
DarwinNotificationDetailsのソースコードを引用
import 'interruption_level.dart';
import 'notification_attachment.dart';

/// iOSやmacOSなど、Darwin系オペレーティングシステム向けの通知の詳細設定
class DarwinNotificationDetails {
  /// [DarwinNotificationDetails]のインスタンスを構築します。
  const DarwinNotificationDetails({
    this.presentAlert,
    this.presentBadge,
    this.presentSound,
    this.presentBanner,
    this.presentList,
    this.sound,
    this.badgeNumber,
    this.attachments,
    this.subtitle,
    this.threadIdentifier,
    this.categoryIdentifier,
    this.interruptionLevel,
  });

  /// 通知がアプリのフォアグラウンドでトリガーされたときにアラートを表示するかを示します。
  ///
  /// Appleのドキュメント: https://developer.apple.com/documentation/usernotifications/unnotificationpresentationoptions/1649506-alert
  ///
  /// この値が `null` の場合、[DarwinInitializationSettings.defaultPresentAlert] のデフォルト設定が使用されます。
  ///
  /// iOSでは、iOS 10~14に適用されます。
  /// macOSでは、macOS 10.14~15に適用されます。
  final bool? presentAlert;

  /// 通知がアプリのフォアグラウンドでトリガーされたときにサウンドを再生するかを示します。
  ///
  /// Appleのドキュメント: https://developer.apple.com/documentation/usernotifications/unnotificationpresentationoptions/1649521-sound
  ///
  /// この値が `null` の場合、[DarwinInitializationSettings.defaultPresentSound] のデフォルト設定が使用されます。
  ///
  /// iOS 10以降でのみ適用されます。
  final bool? presentSound;

  /// 通知がアプリのフォアグラウンドでトリガーされたときにバッジ値を適用するかを示します。
  ///
  /// Appleのドキュメント: https://developer.apple.com/documentation/usernotifications/unnotificationpresentationoptions/1649515-badge
  ///
  /// この値が `null` の場合、[DarwinInitializationSettings.defaultPresentBadge] のデフォルト設定が使用されます。
  ///
  /// iOS 10以降、macOS 10.14以降でのみ適用されます。
  final bool? presentBadge;

  /// 通知がアプリのフォアグラウンドでトリガーされたときにバナー形式で表示するかを示します。
  ///
  /// Appleのドキュメント: https://developer.apple.com/documentation/usernotifications/unnotificationpresentationoptions/3564812-banner
  ///
  /// この値が `null` の場合、[DarwinInitializationSettings.defaultPresentBanner] のデフォルト設定が使用されます。
  ///
  /// iOS 14以降、macOS 11以降でのみ適用されます。
  final bool? presentBanner;

  /// 通知がアプリのフォアグラウンドでトリガーされたときに通知センターに表示するかを示します。
  ///
  /// Appleのドキュメント: https://developer.apple.com/documentation/usernotifications/unnotificationpresentationoptions/3564813-list
  ///
  /// この値が `null` の場合、[DarwinInitializationSettings.defaultPresentList] のデフォルト設定が使用されます。
  ///
  /// iOS 14以降、macOS 11以降でのみ適用されます。
  final bool? presentList;

  /// 通知用に再生するファイル名を指定します。
  ///
  /// [presentSound] が true に設定される必要があります。[presentSound] が true であり [sound] が指定されない場合、デフォルトの通知音が使用されます。
  final String? sound;

  /// 通知到着時にアプリアイコンのバッジに表示する数値を指定します。
  ///
  /// `0` を指定すると現在のバッジを削除します。
  /// `0` より大きい値を指定すると、その数値がバッジに表示されます。
  /// `null` を指定すると、現在のバッジは変更されません。
  final int? badgeNumber;

  /// 通知に含まれる添付ファイルのリストを指定します。
  ///
  /// iOS 10以降、macOS 10.14以降でのみ適用されます。
  final List<DarwinNotificationAttachment>? attachments;

  /// サブタイトルを指定します。
  ///
  /// iOS 10以降、macOS 10.14以降でのみ適用されます。
  final String? subtitle;

  /// 通知をグループ化するために使用できるスレッド識別子を指定します。
  ///
  /// iOS 10以降、macOS 10.14以降でのみ適用されます。
  final String? threadIdentifier;

  /// アプリ定義のカテゴリ識別子を指定します。
  ///
  /// この識別子は、[InitializationSettings] を介して構成された [DarwinNotificationCategory] に対応する必要があります。
  ///
  /// iOS 10以降、macOS 10.14以降でのみ適用されます。
  final String? categoryIdentifier;

  /// 通知の優先度と配信タイミングを示す割り込みレベルを指定します。
  ///
  /// iOS 15.0 および macOS 12.0 以降でのみ適用されます。
  /// Appleのドキュメント: https://developer.apple.com/documentation/usernotifications/unnotificationcontent/3747256-interruptionlevel
  final InterruptionLevel? interruptionLevel;
}