見出し画像

Flutterで起動中のアプリ一覧のスナップショット表示をガードするためのブログ


はじめに


みなさん、お元気ですか。
SHIFT DAAEグループ所属のsakuraiです。

今回はモバイルアプリのセキュリティに関する話題です。
銀行やクレジットカードのアプリなど、センシティブな情報を扱うアプリでは起動中のアプリを一覧表示する場合に画面をそのまま表示せず、ロゴ画面などに変更して表示しているアプリがあります。
OSにスナップショットを取得させず、ふとしたタイミングで残高やカード番号などが見えてしまうことを防ぐ効果があります。

私が関わるアプリでもパスワードやクレジットカード番号を入力する画面があり、これらの画面のスナップショットが意図せず取得されないように対応を行いました。
Flutterでの実装事例を見かけませんでしたので、参考になれば幸いです。

実装方法の検討


まずはスクリーンショットの無効化などを検討しましたが、ユーザーの利便性を損なう可能性もありますので却下としました。
代わりにアプリがバックグラウンドに切り替わったタイミングで画面表示をマスク用の画面に差し替える対応としました。

FlutterではAppLifecycleListenerAppLifecycleStateを利用してアプリのライフサイクルの状態を取得できます。
これを利用することでアプリがバックグラウンドであるかを判断することができました。

参考

上記の説明では以下の状態の場合にAppLifecycleState.inactiveとなるようです。
ただし、AndroidとiOSでバックグラウンドの判定方法が異なるようですので確認が必要です。
Flutterのドキュメントでは以下のように記載されていました。

  • Androidの場合
    AndroidでのライフサイクルのActivity.onPause、Activity.onResumeのタイミングで切り替わります。

  • iOSの場合
    明確にどの場合という記載ではなく非アクティブの状態に対応と記載されています。
    バックグラウンドに切り替わった場合や通話中やTouchIDやFace ID、アプリ切り替え時、コントロールセンターを表示する場合などと記載されています。

iOSではアプリをバックグラウンドにした場合以外にもTouchIDやFace IDの認証画面が表示された場合などもAppLifecycleState.inactiveとなるようです。
より細かく制御したい場合はMethodChannelを利用してネイティブコードでAndroidとiOSのライフサイクルを制御すれば可能かもしれませんが、私の関わるアプリケーションではAppLifecycleStateのタイミングでもマスクされても問題はありませんでしたので、AppLifecycleState.inactiveを利用して表示の切り替えを行うよう対応しました。

それでは次に具体的にどのように実装したかを記載したいと思います。

前提、環境


Flutterの環境構築が完了していて、 flutter createコマンドでプロジェクト作成が可能であることを前提とします。

Flutterのバージョンは3.16.0とします。

% flutter doctor
Doctor summary (to see all details, run flutter doctor -v):
[✓] Flutter (Channel stable, 3.16.8, on macOS 14.2.1 23C71 darwin-arm64,
    locale ja-JP)
[✓] Android toolchain - develop for Android devices (Android SDK version
    34.0.0)
[✓] Xcode - develop for iOS and macOS (Xcode 15.2)
[✓] Chrome - develop for the web
[✓] Android Studio (version 2023.1)
[✓] VS Code (version 1.85.2)
[✓] Connected device (3 available)
[✓] Network resources

実装手順


flutter createコマンドで確認用のプロジェクトを作成します

% cd ~ // ホームにFlutterプロジェクトを作成
% flutter create --platforms ios,android -t app --org com.example.sakurai --project-name screen_blocker ./screen_blocker

次に差し替え用の画面をウィジェットとして作成します。 ScreenBlockerとしてみました。

# ./lib/screen_blocker.dart
import 'package:flutter/material.dart';

class ScreenBlocker extends StatefulWidget {
  const ScreenBlocker({super.key, required this.child});

  final Widget child;

  @override
  State createState() => _ScreenBlockerState();
}

class _ScreenBlockerState extends State<ScreenBlocker> {
  late final AppLifecycleListener _listener;
  bool _blockScreen = false;

  @override
  void initState() {
    super.initState();

    // 念の為inactive以降の状態を非表示の対象にしています。
    _listener = AppLifecycleListener(
      onStateChange: (AppLifecycleState state) {
        switch (state) {
          case AppLifecycleState.inactive:
          case AppLifecycleState.hidden:
          case AppLifecycleState.paused:
          case AppLifecycleState.detached:
            setState(() {
              _blockScreen = true;
            });
          case AppLifecycleState.resumed:
            setState(() {
              _blockScreen = false;
            });
        }
      },
    );
  }

  @override
  void dispose() {
    _listener.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Stack(
      children: [
        widget.child,
        if (_blockScreen)
          ColoredBox(
            color: Colors.purple.shade50,
            child: Center(
              child: Text(
                '表示をブロックするよ!',
                style: Theme.of(context).textTheme.headlineMedium,
              ),
            ),
          ),
      ],
    );
  }
}

最後にmain.dartの画面に作成したScreenBlockerを追加します。

# ./lib/main.dart
import 'package:flutter/material.dart';
import 'package:screen_blocker/screen_blocker.dart';

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  // This widget is the root of your application.
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
        useMaterial3: true,
      ),
      home: const MyHomePage(title: 'Flutter Demo Home Page'),
    );
  }
}

class MyHomePage extends StatefulWidget {
  const MyHomePage({super.key, required this.title});

  final String title;

  @override
  State<MyHomePage> createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  int _counter = 0;

  void _incrementCounter() {
    setState(() {
      _counter++;
    });
  }

  @override
  Widget build(BuildContext context) {
    // ScreenBlockerを追加
    return ScreenBlocker(
      child: Scaffold(
        appBar: AppBar(
          backgroundColor: Theme.of(context).colorScheme.inversePrimary,
          title: Text(widget.title),
        ),
        body: Center(
          child: Column(
            mainAxisAlignment: MainAxisAlignment.center,
            children: <Widget>[
              const Text(
                'You have pushed the button this many times:',
              ),
              Text(
                '$_counter',
                style: Theme.of(context).textTheme.headlineMedium,
              ),
            ],
          ),
        ),
        floatingActionButton: FloatingActionButton(
          onPressed: _incrementCounter,
          tooltip: 'Increment',
          child: const Icon(Icons.add),
        ),
      ),
    );
  }
}

これでバックグラウンドの場合はアプリの画面を表示しないようにすることができました!

終わりに


今回は簡単なサンプルなので表示をブロックしたい画面に直接ウィジェットを追加しましたが、全体に適用したい場合はMaterialAppの下に表示ブロック処理を入れるようにしても良いと思います。
最後までお読みいただきありがとうございました。

\もっと身近にもっとリアルに!DAAE公式Twitter/


執筆者プロフィール:sakurai
SHIFT DAAEグループ所属の開発エンジニアです。

お問合せはお気軽に

SHIFTについて(コーポレートサイト)
https://www.shiftinc.jp/

SHIFTのサービスについて(サービスサイト)
https://service.shiftinc.jp/

SHIFTの導入事例
https://service.shiftinc.jp/case/

お役立ち資料はこちら

SHIFTの採用情報はこちら
https://recruit.shiftinc.jp/career/