20200706のAndroidに関する記事は6件です。

TextView#setMovementMethodメソッドはsetTextメソッドの前に呼んではダメ

概要

TextViewの中にあるURLのリンクを押したときの動きをMovementMethodを使って書き換えようとしたのだが、RecyclerViewにBindされるViewHolderが保持してるViewの中にあるTextViewに適用したところうまく動かずハマった。

基本はこちら通り実装したのだが、文中にurlリンクがないときはMovementMethod#onTouchEventメソッドが呼ばれるのだが、リンクがあると文中のどこを触っても呼ばれない。

丸一日頭を悩ませていたが、最終的にTextViewのソースをあたってみたところ、setMovementMethodメソッドの呼ぶタイミングについてドキュメントにない条件があったことが分かったので共有。

原因・結論

TextViewのsetMovementMethodメソッドsetTextメソッドの前に呼んではダメ!!

詳細

TextViewのソース (https://android.googlesource.com/platform/frameworks/base/+/jb-release/core/java/android/widget/TextView.java) のsetTextメソッド(3412行目)からを見てみたところ、中ほどに。

textview.png

/*
 * We must go ahead and set the text before changing the
 * movement method, because setMovementMethod() may call
 * setText() again to try to upgrade the buffer type.
 */

【MovementMethodを置き換える前にTextは置き換える事】とのコメントと共に、リンクがあるときはMovementMethodをデフォルトの物にするようなコードが!!!

元々、itemのpositionやTextViewの中身で動きを変える必要がなかったので、RecyclerViewの中onCreateViewHolderでsetMovementMethodを呼んでその後onBindViewHolderでpositonに応じてTextViewの中身をセットしていた。のでまんまとこの罠にはまってしまっていた。setTextの後にsetMovementMethodを呼ぶようにしたら無事解決。

感想

コードのコメントに書いていてくれたおかげでソースを見た時にすぐに気づけたのはありがたかった。
けど、そもそもsetMovementMethodのドキュメントをみてもsetTextのドキュメントを見てもこんなこと一言も書いていなかったけど、そういう条件があるのがわかっているならドキュメントにも書いておいてほしかった…。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Flutterでパスワードロック・生体認証ロック機能を実装する方法

トップ2.jpg

7月にFlutter開発を初めてから2作目のメモアプリ「IdeaShuffleMemo」をリリースしました。
■AppStore
https://apps.apple.com/jp/app/id1517535550

■Google Play
https://play.google.com/store/apps/details?id=com.IdeaShuffleMemoApp&hl=ja

■アプリの詳細記事
https://yukio.site/idea_shuffle_memo/

その中でパスワードロックの機能を実装したのですが、今回はそのやり方を少しだけ紹介していきたいと思います。

パスワードロック

IMG_9928.PNG

注意事項

リファクタリングもせず、試行錯誤した状態なので、コードが冗長的な所もありますのでご了承ください。

今回実装する機能

  • メニューで、パスワードと生体認証によるロック画面の実装

※2020/6 時点の情報を元に作成をしています。

知っておいたほうがいい知識

  • アプリのライフサイクル パスワードロックでは、アプリのライフサイクルを知っておく必要があります。 ライフサイクルとは、簡単に言えばアプリの状態の流れのことです。アプリを閉じている状態、アプリを開いた状態など、アプリの状態には一連の流れがあるんです。 それでパスワードロック機能を実装するには、アプリが閉じた状態、開いた状態を感知して、適切な処理を走らせる必要があります。

ライフサイクルは、下記の記事がとても分かりやすく説明してくれているので、一読してみてください。素晴らしい記事をありがとうございます。
Flutterでアプリの復帰やサスペンドを検出して処理を実行する

大まかな流れ

パスワードの設定

  • パスワード設定のオンオフの記録
  • パスワードの保存

パスワードロック画面の表示

  • パスワード設定のオンオフをチェック。
  • オンの場合、アプリが閉じた、もしくは開いた状態を感知して、パスワードロック画面を表示させる処理を走らせる。 オフの場合は、何もしない。
  • パスワードロックもしくは生体認証を検証して合致したら、パスワードロック表示画面を閉じる

必要なパッケージ

passcode_screen

passcode_screen | Flutter Package
パスワードロック画面が簡単に作れるパッケージ

shared_preferences

shared_preferences | Flutter Package
データベースではなく、設定情報などが消えずに保存できるパッケージ

local_auth

local_auth | Flutter Package
生体認証を使うためのパッケージ。
生体認証がOKならTrueを返して、NGだったらFalseで返してくる。
難しそうに感じますが、結構シンプルな感じで、ありがたいパッケージです。

作るページ

  • パスワードオン・オフ設定画面
  • パスワード設定入力画面
  • パスワード設定入力確認画面
  • パスワードロック画面

作り方によっては、まとめられるかしれませんが、そっちのほうが分かりやすいので、バラバラに作ってます。

この記事の前提

この記事では、基本的にstaful Widget内での実装なので、共通する部分は省いています。

class Example extends StatefulWidget {
  @override
  _ExampleState createState() => _ExampleState();
}

class _ExampleState extends State<Example> {

    //この中に書いてあることが中心です。それ以外を書く場合には、分かりやすいようにその前後も記載するようにしています。

}

各パッケージなどはimportされているものとしています。
適宜、各パッケージのReadmeをチェックください。

わかりづらいところがあれば、コメントを気軽にしてください。
お答えできる部分があれば、お答えします。

構築の流れ

パスワードオン・オフ設定

この辺りは特に難しいことではありませんが、パスワードロックと生体認証のオンオフのスイッチを実装します。

 @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text("パスワードセッティング"), 
      ),
      body: passwordSetWidget(),//パスワードのオンオフのスイッチを定義したメソットを呼び出し。
      bottomNavigationBar: Footer(selectedIndex: 4,),//フッターを共通化しているので呼び出し。
    );
  }



passwordSetWidget() {
  return SingleChildScrollView(
    child: Column(
      children: <Widget>[
        Row(
          children: <Widget>[//設定のタイトル
            Padding(
              padding: const EdgeInsets.all(12.0),
              child: Text("設定", style: TextStyle(fontSize: 14.0),),
            ),
          ],
        ),
        Card(//パスワードロックのスイッチ
          child: SwitchListTile(
            value: _password,
            onChanged: _setPassword,//オンになった時の処理
            activeTrackColor: Colors.blue,
            title: Row(
              children: <Widget>[
                Padding(
                  padding: const EdgeInsets.only(right: 10.0),
                  child: Icon(Icons.lock),
                ),
                Expanded(child: Text("パスワードロック")),
              ],
            ),
          ),
        ),
        Card(//生体認証のスイッチ
          child: SwitchListTile(
            value: _facePass,
            onChanged: _setFacePass,//オンになった時の処理
            activeTrackColor: Colors.blue,
            title: Row(
              children: <Widget>[
                Padding(
                  padding: const EdgeInsets.only(right: 10.0),
                  child: Icon(Icons.face),
                ),
                Expanded(child: Text("生体認証")),
              ],
            ),
          ),
        ),

      ],
    ),
  );
}



パスワードロックのスイッチ

パスワードロックがオンになったら、オンオフの状態を記録します。
最終的にパスワード1回目と2回目が合致したら、最終的にオンの状態を維持することになります。
この時、「パスワード忘れると、解除できなくなります。忘れないようにご注意ください!!」みたいな警告を出してあげたほうが優しいと思います。今回のサンプルではわかりづらくなるので、入れてません。

bool _password;

@override
  void initState() {
    // TODO: implement initState
  _getPasswordSetting();
  super.initState();

}

//パスワードのオンオフを記録するメソッド
//shared_preferencesパッケージの機能を使って、設定情報などを端末に保存します。
_isPasswordLock(bool value) async {
//受け取った引数true or falseをisPasswordLockという名前で保存してね!ってことにあります。
  var prefs = await SharedPreferences.getInstance();
  await prefs.setBool("isPasswordLock", value);
}

//パスワードのオンオフ設定を読み出すメソッド
_getPasswordSetting() async {
//これをinitstateで読み出しておいて、初期値としてセットしておきます。
  var prefs = await SharedPreferences.getInstance();
  _password = await prefs.getBool('isPasswordLock') ?? false;
//??falseを忘れると、何も設定されていない初期状態の時にエラーになります。
//nullだったら、falseを入れなさいってことになります。
  setState(() {
    _password;
  });
}



//パスワード設定のスイッチが変更された時の処理
_setPassword(bool value) async{

  await _isPasswordLock(value);//引数のValueをパスワードの設定状態として保存

  if(value == true) {
//このタイミングで一度ダイアログを表示したりして、パスワードを忘れると解除できませんのような文言を表示してもいいかもしれません。
          Navigator.push(
              context,
              MaterialPageRoute(
                  builder: (context) => PasswordSetting()));//パスワード設定画面の表示

  } else {
    await _isFacePass(false);//パスワードがオフになったら生体認証も強制的にオフ
    Navigator.pushReplacement(
        context,
        MaterialPageRoute(
            builder: (context) => PasswordSetScreen()));//スイッチを切り替えた状態にするために再読み込み
  }


}
生体認証のスイッチ

生体認証がオンになったら、生体認証が起動。
生体認証がOKなら、オンにします。
ただし、パスワードロックが設定されていない場合には、オンにできない使用にします。
生体認証がうまくいかない場合に備えてです。
ちなみに公式ドキュメントで、かならずiOSとAndroidのネイティブ側の設定で、生体認証を使えるように設定しておきましょう。

生体認証はこの記事を参考にさせていただきました。
(https://qiita.com/coka__01/items/76af4ea73a6a8c8fa135)

IOSの設定でこれを追記

Info.plist
<key>NSFaceIDUsageDescription</key>
<string>Why is my app authenticating using face id?</string>

Android設定でこれを追記

AndroidManifest.xml
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
          package="com.example.app">//これすでにあるやつ
//これを追記するだけ。
  <uses-permission android:name="android.permission.USE_FINGERPRINT"/>
<manifest>//これすでにあるやつ

 生体認証の処理

bool _facePass;

@override
  void initState() {
  _getPasswordSetting();//さっき追加してます。
  _getFacePassSetting();
  super.initState();
}

//生体認証のオンオフを保存するメソッド
_isFacePass(bool value) async { 
  var prefs = await SharedPreferences.getInstance();
  await prefs.setBool("isFacePass", value);
}

//生体認証のオンオフ設定を読み出すメソッド
_getFacePassSetting() async {
//パスワードと同じくinitstateで読み出しておきましょう。
  var prefs = await SharedPreferences.getInstance();
  _facePass = await prefs.getBool('isFacePass') ?? false;
//パスワードと同様に?? falseをしないと、初期状態でエラーになります。
  setState(() {
    _facePass;
  });
}

//生体認証のスイッチを変えた時の処理
_setFacePass(bool value) async{
  await _getPasswordSetting();

  if (_password == false && value == true ) {
//パスワード設定がオフで、生体認証がオンだった場合は、生体認証がオンにできないように警告を表示する設定をしておきましょう。
    return;
  }

  if (_password == true && value == true ) {
//パスワード設定がオンで、生体認証がオンになった場合
    var check = _authenticate(); //生体認証を発動

    if(check == true) { 
//生体認証が承認されたら
      await _isFacePass(value);//生体認証設定状態をオンとして保存
      return;//
    }
  }


  await _isFacePass(value);//生体認証をオフにされたら、オフの状態で保存


  Navigator.pushReplacement(
      context,
      MaterialPageRoute(
          builder: (context) => PasswordSetScreen())); //オフにするために画面を再読み込み。
}


//生体認証のタイプをチェック
Future<List<BiometricType>> _getAvailableBiometricTypes() async {
  List<BiometricType> availableBiometricTypes;
  try {
    availableBiometricTypes = await _localAuth.getAvailableBiometrics();
  } on PlatformException catch (e) {
    //エラーの処理
  }
  return availableBiometricTypes; //生体認証はこれやーと返す
}

//生体認証を呼び出して、結果を返す処理
Future<bool> _authenticate() async {
  bool result = false;
  List<BiometricType> availableBiometricTypes = await _getAvailableBiometricTypes(); //生体認証が指紋か顔かチェック

  try {
    if (availableBiometricTypes.contains(BiometricType.face)
        || availableBiometricTypes.contains(BiometricType.fingerprint)) {
      result = await _localAuth.authenticateWithBiometrics(localizedReason: "生体認証");
    }
  } on PlatformException catch (e) {
    //エラーの処理
  }
  return result;//承認した結果をtrue or falseで返す。
}

パスワードの設定画面

パスワードがオンになったら、まず最初にパスワードを入力してもらう画面を表示します。

  final StreamController<bool> _verificationNotifier =
  StreamController<bool>.broadcast();
  int passwordDigits = 4; //パスワードの桁数

  @override
  void dispose() {
    // TODO: implement dispose
    _verificationNotifier.close();
    super.dispose();
  }


  @override
  Widget build(BuildContext context) {
//パスワードロック画面を発動
    return _showLockScreen(
      context,
      opaque: false,/
    );
  }

//パスワードロック画面の見た目の詳細
//ボタンの大きさや、色などカスタマイズできます。
//各色の設定はmain.dartで定義しているカラーなどを設定しています。

  _showLockScreen(BuildContext context,
      {bool opaque,
        List<String> digits}) {

            return  PasscodeScreen(
                title: Column(
                  children: <Widget>[
                    Icon(Icons.lock, size: 30),
                    Text("パスワードを入力してください",
                      textAlign: TextAlign.center,
                      style: TextStyle(fontSize: 15),
                    ),
                  ],
                ),
                passwordDigits: passwordDigits,
                circleUIConfig: CircleUIConfig(
                  borderColor: Theme.of(context).dividerColor,
                  fillColor: Theme.of(context).dividerColor,
                  circleSize: 20,
                ),
                keyboardUIConfig: KeyboardUIConfig(
                  primaryColor: Theme.of(context).dividerColor,
                  digitTextStyle: TextStyle(fontSize: 25),
                  deleteButtonTextStyle: TextStyle(fontSize: 15),
                  digitSize: 75,
                ),
                passwordEnteredCallback: _onPasscodeEntered,//パスワードが入力された時の処理
                deleteButton: Icon(Icons.backspace, size: 15.0),
                cancelButton:  Icon(Icons.cancel, size: 15.0),
                cancelCallback:  _onPasscodeCancelled,//パスワードが入力がキャンセルされた時の処理
                shouldTriggerVerification: _verificationNotifier.stream,
                backgroundColor: Theme.of(context).primaryColor,
                digits: digits,
              );
  }

//パスワードが桁数まで入力された発動する処理
  _onPasscodeEntered(String enteredPasscode)  {

//パスワード確認フォームに飛ばす
    Navigator.pushReplacement(
        context,
        MaterialPageRoute(
            builder: (context) => ConfirmPassword(password: enteredPasscode))); //入力されているパスワードを引数として渡す。
  }

//入力がキャンセルされたら発動する処理
   _onPasscodeCancelled() async{
     await _isPasswordLock(false); //とりあえず、パスワード設定をオフにしておく
     Navigator.maybePop(context);
     Navigator.pushReplacement(
         context,
         MaterialPageRoute(
             builder: (context) => PasswordSetScreen())); //パスワード設定を再読み込み
  }

//パスワード設定のオンオフを記録する(shared_preferencesパッケージの機能です)
  _isPasswordLock(bool value) async {
    var prefs = await SharedPreferences.getInstance();
    await prefs.setBool("isPasswordLock", value);
  }

パスワードの入力が確認できたら、パスワード入力確認ページで飛ばします。
パスワードの確認をして、合致すればパスワードを保存します。
この時、キャンセルされば全ての設定を破棄します。

//クラスをまたぐので、クラスまで一応書いておきますね。

class ConfirmPassword extends StatefulWidget {
//これから処理するにあたって、パスワードの引数は必須ですよという設定をしておきます。だってパスワードない状態でこのページ開けてしまうのは困るので。

  String password = ""; 

  ConfirmPassword({@required this.password});//ぜってぇーパスワードを引数をとして送ってこいよなと言ってます。

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

class _ConfirmPasswordState extends State<ConfirmPassword> {

  final StreamController<bool> _verificationNotifier =
  StreamController<bool>.broadcast();//入力状況を感知
  bool isAuthenticated = false;
  int passwordDigits = 4;  //パスワードの桁数
  String passLockMessage; //エラーメッセージ

//入力されたパスワードを保存する処理です。
  _setPassword(String value) async {
    var prefs = await SharedPreferences.getInstance();
    await prefs.setString("lockPassword", value);
  }

  @override
  void didChangeDependencies() {
    // TODO: implement didChangeDependencies
    super.didChangeDependencies();
   passLockMessage = "念のためパスワードを入力してください";
//機能実装都合でここに書いてありますが、initstate内に書いても通常は問題ないはずです。
  }

  @override
  void dispose() {
    // TODO: implement dispose
    _verificationNotifier.close();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return _showLockScreen(//パスワード設定画面をは発動します。
      context,
      opaque: false,
    );
  }

//再入力画面の設定
//ほぼほぼ前の方と同じです。
  _showLockScreen(BuildContext context,
      {bool opaque,
        List<String> digits}) {
      print(widget.password);
    return  PasscodeScreen(
      title: Column(
        children: <Widget>[
          Icon(Icons.lock, size: 30),
          Text(
           "パスワード",
            textAlign: TextAlign.center,
            style: TextStyle(fontSize: 15),
          ),
          Text(
            '$passLockMessage',//メッセージを表示します。
            textAlign: TextAlign.center,
            style: TextStyle(fontSize: 10),
          ),
        ],
      ),
      passwordDigits: passwordDigits,
      circleUIConfig: CircleUIConfig(
        borderColor: Theme.of(context).dividerColor,
        fillColor: Theme.of(context).dividerColor,
        circleSize: 20,
      ),
      keyboardUIConfig: KeyboardUIConfig(
        primaryColor: Theme.of(context).dividerColor,
        digitTextStyle: TextStyle(fontSize: 25),
        deleteButtonTextStyle: TextStyle(fontSize: 15),
        digitSize: 75,
      ),
      passwordEnteredCallback: _onPasscodeEntered,
      deleteButton: Icon(Icons.backspace, size: 15.0),
      cancelButton:  Icon(Icons.cancel, size: 15.0),
      cancelCallback:  _onPasscodeCancelled,
      shouldTriggerVerification: _verificationNotifier.stream,
      backgroundColor: Theme.of(context).primaryColor,
      digits: digits,
    );
  }

//パスワードが桁数入力されたときの処理
  _onPasscodeEntered(String enteredPasscode) async {

//パスワードのチェック
    bool isValid = widget.password == enteredPasscode;
    _verificationNotifier.add(isValid);//パスコードが正しいかどうかをパスコード画面に通知してます。
    if (isValid) {
      await _setPassword(enteredPasscode);//パスワードを保存
//忘れガチですが、パスワード設定のオンとオフ設定、スイッチをオンにした直後に設定しています。
      setState(() {
        passLockMessage = "";//メッセージを空に
        this.isAuthenticated = isValid; //trueにしています。
//
      });
    } else {
      setState(() {
        passLockMessage = "パスワードが一致しません";     //エラーメッセージを変数に格納

      });


    }
  }

//パスワードを再入力をキャンセルした場合
  _onPasscodeCancelled() async{
    await _isPasswordLock(false); //パスワードロック設定をオフ状態
    Navigator.maybePop(context);
    Navigator.pushReplacement(
        context,
        MaterialPageRoute(
            builder: (context) => PasswordSetScreen())); //パスワード設定画面に戻します。
  }

//パスワードロック設定のオンオフを記録
  _isPasswordLock(bool value) async {
    var prefs = await SharedPreferences.getInstance();
    await prefs.setBool("isPasswordLock", value);
  }


}

どこで処理を走らせるか

パスワードは設定できましたが、一つ考えるべきところが、どこでパスワード を表示される処理を走らせるか問題です。
後ほど、スマホの状態を感知して、処理を走らせるわけですが、すべてのページにその実装をするのは辛い。

今回作ったアプリでは、共通ページとして、フッター専用のページを作って、それぞれのページで参照しています。

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text("タイトル"), 

      ),
      body: passwordSetWidget(),
      bottomNavigationBar: Footer(),
  }

このフッター部分の状態を見て、パスワードロックの処理を走らせます。

パスワード機能を

スマホの状態で処理を発動

状態の管理として、4つに分類されます。今回はpuouseの時に処理を走らせるようにします。
というのも、それ以外の処理では、他の機能でページを読み込まれた時にも、パスワードロック画面が発動してしまうからです。

//状態を管理するには、Classにwith WidgetsBindingObserverが必要。
class _Footer extends State <Footer> with WidgetsBindingObserver{
 bool _isPasswordLock = false;

//パスワード設定のオンオフを読み出し
  _getPasswordSetting() async {
    var prefs = await SharedPreferences.getInstance();
    _isPasswordLock = await prefs.getBool('isPasswordLock');
    setState(()  {
    });
  }

  void initState() {
    // TODO: implement initState
    super.initState();
    WidgetsBinding.instance.addObserver(this);//スマホの状態を感知する
    WidgetsBinding.instance.addPostFrameCallback((_) async{
      await _getPasswordSetting();
    });//widgetの構築が終わったら、発動させる処理。
}

  @override
  void dispose() {
    // TODO: implement dispose
    WidgetsBinding.instance.removeObserver(this);//スマホの状態を感知終了
    super.dispose();
  }

//スマホ状態によって処理を走らせます。
  void didChangeAppLifecycleState(AppLifecycleState state) {
    // TODO: implement didChangeAppLifecycleState
//    super.didChangeAppLifecycleState(state);

    if (state == AppLifecycleState.inactive) {
//アプリの画面が非アクティブな時。
    } else if (state == AppLifecycleState.paused) {
      _passWordLock();//アプリが完全にバックグランドになったら、パスワードロック画面を表示を発動。ただし表示されるのは、画面が再び立ち上がったあと。

    } else if (state == AppLifecycleState.resumed) {
//アプリが復帰した時の処理。これでもいいが、他の処理で何度も呼び出されることがあり、変なところでパスワードロックがかかってしまった。
    }
  }
}

パスワードロックを表示

スマホのアプリがバックグランドになったら、パスワードロックを表示させます。

//状態を管理するには、Classにwith WidgetsBindingObserverが必要。2重でパスワードロック画面が表示されないように、ここでも状態管理をしています。

class _PassWordScreenState extends State<PassWordScreen>
    with WidgetsBindingObserver {
  final StreamController<bool> _verificationNotifier =
      StreamController<bool>.broadcast();//入力状態をチェック
  bool isAuthenticated = false; //パスワード入力が正しいか
  String passLockMessage = "";//エラーメッセージ
  int passwordDigits = 4;//入力する桁数
  bool passwordView = false;//パスワード画面が表示されているか?
  bool _facePass = false; //FacePassの設定
  String _password = ""; //パスワード
  LocalAuthentication _localAuth = LocalAuthentication(); //生体認証のインスタンス

//パスワードの読み出し
  _getPassword() async {
    var prefs = await SharedPreferences.getInstance();
    _password = await prefs.getString("lockPassword");
  }
//生体認証の読み出し
  _getFacePassSetting() async {
    var prefs = await SharedPreferences.getInstance();
    _facePass =  prefs.getBool('isFacePass');
    setState(() {

    });
  }

//生体認証の種類の確認
  Future<List<BiometricType>> _getAvailableBiometricTypes() async {
    List<BiometricType> availableBiometricTypes;
    try {
      availableBiometricTypes = await _localAuth.getAvailableBiometrics();
    } on PlatformException catch (e) {
        // エラーを入力
    }
    return availableBiometricTypes;
  }

//生体認証をチェックしてOKだったらパスワードを処理を飛ばす。
  Future<bool> _authenticate() async {
    bool result = false;
    List<BiometricType> availableBiometricTypes = await _getAvailableBiometricTypes();
    try {
      if (availableBiometricTypes.contains(BiometricType.face)
          || availableBiometricTypes.contains(BiometricType.fingerprint)) {
        result = await _localAuth.authenticateWithBiometrics(localizedReason: 生体認証);
      }
    } on PlatformException catch (e) {
      // エラーを入力
    }
    if (result == true) {
        return  _onPasscodeEntered(_password);//生体認証がOKだったら、ロックを解除するために、パスワード付きで解除するメソッドを発動。
    }
  }

  @override
  void initState() {
    // TODO: implement initState
    super.initState();
    WidgetsBinding.instance.addObserver(this);//スマホの状態をチェック
    _getPassword(); //パスワードを読み出し
    _getFacePassSetting(); //生体認証の読み出し
  }

  @override
  void didChangeDependencies()  async {
    super.didChangeDependencies();
//生体認証を少しだけ遅らせます。そうしないと、パスワードロック画面が表示しきる前に発動してしまい、パスワードロック画面の表示が中途半端にになってしまいます。
    await Future.delayed(Duration(milliseconds: 700));
    if(_facePass != false) {
      await _authenticate();
    }
  }

  @override
  void dispose() {
    // TODO: implement dispose
    _verificationNotifier.close();//入力状態確認の終了
    WidgetsBinding.instance.removeObserver(this);  WidgetsBinding.instance.addObserver(this);//スマホの状態確認の終了
    super.dispose();
  }

//スマホの状態確認
  void didChangeAppLifecycleState(AppLifecycleState state) {
    // TODO: implement didChangeAppLifecycleState
 super.didChangeAppLifecycleState(state);

    if (state == AppLifecycleState.inactive) {

    } else if (state == AppLifecycleState.paused) {
//パスワードロック画面が表示されているので、この状態でスマホ閉じて開くと、パスワードロック画面が2重の状態になります。なのでパスワードロック画面が開いている状態では、画面を入れ替えるようにします。
      Navigator.pushReplacement(
          context,
          MaterialPageRoute(
              builder: (context) => PassWordScreen()));

    } else if (state == AppLifecycleState.resumed) {

    }
  }

  @override
  Widget build(BuildContext context) {
    return WillPopScope(
        child:  _showLockScreen(
        context,
        opaque: false,
    ),
//アンドロイドの場合、これを入れないと、戻るボタンでパスワードロック画面が閉じてしまいます。
        onWillPop:() async => false);

  }


//パスワード設定画面とほぼ一緒

  _showLockScreen(BuildContext context,
      {bool opaque,
        List<String> digits})  {

    return  PasscodeScreen(
      title: Column(
        children: <Widget>[
          Icon(Icons.lock, size: 30),
          Text(
            'Enter App Passcode', 
            textAlign: TextAlign.center,
            style: TextStyle(fontSize: 15),
          ),
          Text(
            '$passLockMessage', 
            textAlign: TextAlign.center,
            style: TextStyle(fontSize: 10),
          ),
        ],
      ),
      passwordDigits: passwordDigits,
      circleUIConfig: CircleUIConfig(
        borderColor: Theme.of(context).dividerColor,
        fillColor: Theme.of(context).dividerColor,
        circleSize: 20,
      ),
      keyboardUIConfig: KeyboardUIConfig(
        primaryColor: Theme.of(context).dividerColor,
        digitTextStyle: TextStyle(fontSize: 25),
        deleteButtonTextStyle: TextStyle(fontSize: 15),
        digitSize: 75,
      ),
      passwordEnteredCallback: _onPasscodeEntered,
      deleteButton: Icon(Icons.backspace, size: 15.0),
      shouldTriggerVerification: _verificationNotifier.stream,
      backgroundColor: Theme.of(context).primaryColor,
      digits: digits,
    );
  }

  _onPasscodeEntered(String enteredPasscode) {
    bool isValid = _password == enteredPasscode;
    _verificationNotifier.add(isValid);
    if (isValid) {
      setState(() {
        passLockMessage = "";
        this.isAuthenticated = isValid;//パスワードが合致した状態にんすr。
      });
      Navigator.pop(context);
    } else {
      setState(() {
        passLockMessage = "パスワードが違います。再入力してください。";
      });
    }
  }
}

ハマりポイント

解決できなかった部分が、パスワードロック画面の表示のタイミングです。
本来であれば、アプリを閉じて、スマホを立ち上げた段階で、パスワードロック画面が表示されている状態が好ましいです。
しかし、Flutterの仕組み上、画面推移の処理が走るのが、画面が立ち上がったあとなので、少しだけ画面が見えてします。

ネイティブ側で処理をすれば、解決できるそうですが、ネイティブはさわれないので、今回は、問題なしと判断して、この部分は詰めませんでした。

まとめ

試行錯誤して作成したため、まだコードなどがうまくリファクタリングできていません。かなり冗長的なコードになっているので、時間を見つけて処理していきたいと思います。
完成の状態を見たい方は、ぜひアプリをダウンロードしてみてください。

■AppStore
https://apps.apple.com/jp/app/id1517535550

■Google Play
https://play.google.com/store/apps/details?id=com.IdeaShuffleMemoApp&hl=ja

それは、また。
次は、GoogleDriveのバックアップ処理について書いていきたいと思います。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Flutterメモアプリでパスワードロック・生体認証ロック機能をす方法

トップ2.jpg

7月にFlutter開発を始めてから2作目のメモアプリ「IdeaShuffleMemo」をリリースしました。
■AppStore
https://apps.apple.com/jp/app/id1517535550

■Google Play
https://play.google.com/store/apps/details?id=com.IdeaShuffleMemoApp&hl=ja

■アプリの詳細記事
https://yukio.site/idea_shuffle_memo/

その中でパスワードロックの機能を実装したのですが、今回はそのやり方を少しだけ紹介していきたいと思います。

■別の記事
【Flutter】GoogleDriveへのバックアップ・リストア機能を実装するまでの道のり
https://qiita.com/YuKiO-OO/items/67b471e6be6c4c4c26e9

【Flutter】メモアプリ開発で使ったオススメのパッケージを紹介
https://qiita.com/YuKiO-OO/items/283f44da64d304a6228e

【Flutter】もう怖くない!アプリ内課金・定期購入機能を実装する方法を丁寧に説明してみた。
https://qiita.com/YuKiO-OO/items/a0fe8e0a256afbb69fc7

パスワードロック

IMG_9928.PNG

注意事項

リファクタリングもせず、試行錯誤した状態なので、コードが冗長的な所もありますのでご了承ください。

今回実装する機能

  • メニューで、パスワードと生体認証によるロック画面の実装

※2020/6 時点の情報を元に作成をしています。

知っておいたほうがいい知識

  • アプリのライフサイクル パスワードロックでは、アプリのライフサイクルを知っておく必要があります。 ライフサイクルとは、簡単に言えばアプリの状態の流れのことです。アプリを閉じている状態、アプリを開いた状態など、アプリの状態には一連の流れがあるんです。 それでパスワードロック機能を実装するには、アプリが閉じた状態、開いた状態を感知して、適切な処理を走らせる必要があります。

ライフサイクルは、下記の記事がとても分かりやすく説明してくれているので、一読してみてください。素晴らしい記事をありがとうございます。
Flutterでアプリの復帰やサスペンドを検出して処理を実行する

大まかな流れ

パスワードの設定

  • パスワード設定のオンオフの記録
  • パスワードの保存

パスワードロック画面の表示

  • パスワード設定のオンオフをチェック。
  • オンの場合、アプリが閉じた、もしくは開いた状態を感知して、パスワードロック画面を表示させる処理を走らせる。 オフの場合は、何もしない。
  • パスワードロックもしくは生体認証を検証して合致したら、パスワードロック表示画面を閉じる

必要なパッケージ

passcode_screen

passcode_screen | Flutter Package
パスワードロック画面が簡単に作れるパッケージ

shared_preferences

shared_preferences | Flutter Package
データベースではなく、設定情報などが消えずに保存できるパッケージ

local_auth

local_auth | Flutter Package
生体認証を使うためのパッケージ。
生体認証がOKならTrueを返して、NGだったらFalseで返してくる。
難しそうに感じますが、結構シンプルな感じで、ありがたいパッケージです。

作るページ

  • パスワードオン・オフ設定画面
  • パスワード設定入力画面
  • パスワード設定入力確認画面
  • パスワードロック画面

作り方によっては、まとめられるかしれませんが、そっちのほうが分かりやすいので、バラバラに作ってます。

この記事の前提

この記事では、基本的にstaful Widget内での実装なので、共通する部分は省いています。

class Example extends StatefulWidget {
  @override
  _ExampleState createState() => _ExampleState();
}

class _ExampleState extends State<Example> {

    //この中に書いてあることが中心です。それ以外を書く場合には、分かりやすいようにその前後も記載するようにしています。

}

各パッケージなどはimportされているものとしています。
適宜、各パッケージのReadmeをチェックください。

わかりづらいところがあれば、コメントを気軽にしてください。
お答えできる部分があれば、お答えします。

構築の流れ

パスワードオン・オフ設定

この辺りは特に難しいことではありませんが、パスワードロックと生体認証のオンオフのスイッチを実装します。

 @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text("パスワードセッティング"), 
      ),
      body: passwordSetWidget(),//パスワードのオンオフのスイッチを定義したメソットを呼び出し。
      bottomNavigationBar: Footer(selectedIndex: 4,),//フッターを共通化しているので呼び出し。
    );
  }



passwordSetWidget() {
  return SingleChildScrollView(
    child: Column(
      children: <Widget>[
        Row(
          children: <Widget>[//設定のタイトル
            Padding(
              padding: const EdgeInsets.all(12.0),
              child: Text("設定", style: TextStyle(fontSize: 14.0),),
            ),
          ],
        ),
        Card(//パスワードロックのスイッチ
          child: SwitchListTile(
            value: _password,
            onChanged: _setPassword,//オンになった時の処理
            activeTrackColor: Colors.blue,
            title: Row(
              children: <Widget>[
                Padding(
                  padding: const EdgeInsets.only(right: 10.0),
                  child: Icon(Icons.lock),
                ),
                Expanded(child: Text("パスワードロック")),
              ],
            ),
          ),
        ),
        Card(//生体認証のスイッチ
          child: SwitchListTile(
            value: _facePass,
            onChanged: _setFacePass,//オンになった時の処理
            activeTrackColor: Colors.blue,
            title: Row(
              children: <Widget>[
                Padding(
                  padding: const EdgeInsets.only(right: 10.0),
                  child: Icon(Icons.face),
                ),
                Expanded(child: Text("生体認証")),
              ],
            ),
          ),
        ),

      ],
    ),
  );
}



パスワードロックのスイッチ

パスワードロックがオンになったら、オンオフの状態を記録します。
最終的にパスワード1回目と2回目が合致したら、最終的にオンの状態を維持することになります。
この時、「パスワード忘れると、解除できなくなります。忘れないようにご注意ください!!」みたいな警告を出してあげたほうが優しいと思います。今回のサンプルではわかりづらくなるので、入れてません。

bool _password;

@override
  void initState() {
    // TODO: implement initState
  _getPasswordSetting();
  super.initState();

}

//パスワードのオンオフを記録するメソッド
//shared_preferencesパッケージの機能を使って、設定情報などを端末に保存します。
_isPasswordLock(bool value) async {
//受け取った引数true or falseをisPasswordLockという名前で保存してね!ってことにあります。
  var prefs = await SharedPreferences.getInstance();
  await prefs.setBool("isPasswordLock", value);
}

//パスワードのオンオフ設定を読み出すメソッド
_getPasswordSetting() async {
//これをinitstateで読み出しておいて、初期値としてセットしておきます。
  var prefs = await SharedPreferences.getInstance();
  _password = await prefs.getBool('isPasswordLock') ?? false;
//??falseを忘れると、何も設定されていない初期状態の時にエラーになります。
//nullだったら、falseを入れなさいってことになります。
  setState(() {
    _password;
  });
}



//パスワード設定のスイッチが変更された時の処理
_setPassword(bool value) async{

  await _isPasswordLock(value);//引数のValueをパスワードの設定状態として保存

  if(value == true) {
//このタイミングで一度ダイアログを表示したりして、パスワードを忘れると解除できませんのような文言を表示してもいいかもしれません。
          Navigator.push(
              context,
              MaterialPageRoute(
                  builder: (context) => PasswordSetting()));//パスワード設定画面の表示

  } else {
    await _isFacePass(false);//パスワードがオフになったら生体認証も強制的にオフ
    Navigator.pushReplacement(
        context,
        MaterialPageRoute(
            builder: (context) => PasswordSetScreen()));//スイッチを切り替えた状態にするために再読み込み
  }


}
生体認証のスイッチ

生体認証がオンになったら、生体認証が起動。
生体認証がOKなら、オンにします。
ただし、パスワードロックが設定されていない場合には、オンにできない使用にします。
生体認証がうまくいかない場合に備えてです。
ちなみに公式ドキュメントで、かならずiOSとAndroidのネイティブ側の設定で、生体認証を使えるように設定しておきましょう。

生体認証はこの記事を参考にさせていただきました。
(https://qiita.com/coka__01/items/76af4ea73a6a8c8fa135)

IOSの設定でこれを追記

Info.plist
<key>NSFaceIDUsageDescription</key>
<string>Why is my app authenticating using face id?</string>

Android設定でこれを追記

AndroidManifest.xml
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
          package="com.example.app">//これすでにあるやつ
//これを追記するだけ。
  <uses-permission android:name="android.permission.USE_FINGERPRINT"/>
<manifest>//これすでにあるやつ

 生体認証の処理

bool _facePass;

@override
  void initState() {
  _getPasswordSetting();//さっき追加してます。
  _getFacePassSetting();
  super.initState();
}

//生体認証のオンオフを保存するメソッド
_isFacePass(bool value) async { 
  var prefs = await SharedPreferences.getInstance();
  await prefs.setBool("isFacePass", value);
}

//生体認証のオンオフ設定を読み出すメソッド
_getFacePassSetting() async {
//パスワードと同じくinitstateで読み出しておきましょう。
  var prefs = await SharedPreferences.getInstance();
  _facePass = await prefs.getBool('isFacePass') ?? false;
//パスワードと同様に?? falseをしないと、初期状態でエラーになります。
  setState(() {
    _facePass;
  });
}

//生体認証のスイッチを変えた時の処理
_setFacePass(bool value) async{
  await _getPasswordSetting();

  if (_password == false && value == true ) {
//パスワード設定がオフで、生体認証がオンだった場合は、生体認証がオンにできないように警告を表示する設定をしておきましょう。
    return;
  }

  if (_password == true && value == true ) {
//パスワード設定がオンで、生体認証がオンになった場合
    var check = _authenticate(); //生体認証を発動

    if(check == true) { 
//生体認証が承認されたら
      await _isFacePass(value);//生体認証設定状態をオンとして保存
      return;//
    }
  }


  await _isFacePass(value);//生体認証をオフにされたら、オフの状態で保存


  Navigator.pushReplacement(
      context,
      MaterialPageRoute(
          builder: (context) => PasswordSetScreen())); //オフにするために画面を再読み込み。
}


//生体認証のタイプをチェック
Future<List<BiometricType>> _getAvailableBiometricTypes() async {
  List<BiometricType> availableBiometricTypes;
  try {
    availableBiometricTypes = await _localAuth.getAvailableBiometrics();
  } on PlatformException catch (e) {
    //エラーの処理
  }
  return availableBiometricTypes; //生体認証はこれやーと返す
}

//生体認証を呼び出して、結果を返す処理
Future<bool> _authenticate() async {
  bool result = false;
  List<BiometricType> availableBiometricTypes = await _getAvailableBiometricTypes(); //生体認証が指紋か顔かチェック

  try {
    if (availableBiometricTypes.contains(BiometricType.face)
        || availableBiometricTypes.contains(BiometricType.fingerprint)) {
      result = await _localAuth.authenticateWithBiometrics(localizedReason: "生体認証");
    }
  } on PlatformException catch (e) {
    //エラーの処理
  }
  return result;//承認した結果をtrue or falseで返す。
}

パスワードの設定画面

パスワードがオンになったら、まず最初にパスワードを入力してもらう画面を表示します。

  final StreamController<bool> _verificationNotifier =
  StreamController<bool>.broadcast();
  int passwordDigits = 4; //パスワードの桁数

  @override
  void dispose() {
    // TODO: implement dispose
    _verificationNotifier.close();
    super.dispose();
  }


  @override
  Widget build(BuildContext context) {
//パスワードロック画面を発動
    return _showLockScreen(
      context,
      opaque: false,/
    );
  }

//パスワードロック画面の見た目の詳細
//ボタンの大きさや、色などカスタマイズできます。
//各色の設定はmain.dartで定義しているカラーなどを設定しています。

  _showLockScreen(BuildContext context,
      {bool opaque,
        List<String> digits}) {

            return  PasscodeScreen(
                title: Column(
                  children: <Widget>[
                    Icon(Icons.lock, size: 30),
                    Text("パスワードを入力してください",
                      textAlign: TextAlign.center,
                      style: TextStyle(fontSize: 15),
                    ),
                  ],
                ),
                passwordDigits: passwordDigits,
                circleUIConfig: CircleUIConfig(
                  borderColor: Theme.of(context).dividerColor,
                  fillColor: Theme.of(context).dividerColor,
                  circleSize: 20,
                ),
                keyboardUIConfig: KeyboardUIConfig(
                  primaryColor: Theme.of(context).dividerColor,
                  digitTextStyle: TextStyle(fontSize: 25),
                  deleteButtonTextStyle: TextStyle(fontSize: 15),
                  digitSize: 75,
                ),
                passwordEnteredCallback: _onPasscodeEntered,//パスワードが入力された時の処理
                deleteButton: Icon(Icons.backspace, size: 15.0),
                cancelButton:  Icon(Icons.cancel, size: 15.0),
                cancelCallback:  _onPasscodeCancelled,//パスワードが入力がキャンセルされた時の処理
                shouldTriggerVerification: _verificationNotifier.stream,
                backgroundColor: Theme.of(context).primaryColor,
                digits: digits,
              );
  }

//パスワードが桁数まで入力された発動する処理
  _onPasscodeEntered(String enteredPasscode)  {

//パスワード確認フォームに飛ばす
    Navigator.pushReplacement(
        context,
        MaterialPageRoute(
            builder: (context) => ConfirmPassword(password: enteredPasscode))); //入力されているパスワードを引数として渡す。
  }

//入力がキャンセルされたら発動する処理
   _onPasscodeCancelled() async{
     await _isPasswordLock(false); //とりあえず、パスワード設定をオフにしておく
     Navigator.maybePop(context);
     Navigator.pushReplacement(
         context,
         MaterialPageRoute(
             builder: (context) => PasswordSetScreen())); //パスワード設定を再読み込み
  }

//パスワード設定のオンオフを記録する(shared_preferencesパッケージの機能です)
  _isPasswordLock(bool value) async {
    var prefs = await SharedPreferences.getInstance();
    await prefs.setBool("isPasswordLock", value);
  }

パスワードの入力が確認できたら、パスワード入力確認ページで飛ばします。
パスワードの確認をして、合致すればパスワードを保存します。
この時、キャンセルされば全ての設定を破棄します。

//クラスをまたぐので、クラスまで一応書いておきますね。

class ConfirmPassword extends StatefulWidget {
//これから処理するにあたって、パスワードの引数は必須ですよという設定をしておきます。だってパスワードない状態でこのページ開けてしまうのは困るので。

  String password = ""; 

  ConfirmPassword({@required this.password});//ぜってぇーパスワードを引数をとして送ってこいよなと言ってます。

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

class _ConfirmPasswordState extends State<ConfirmPassword> {

  final StreamController<bool> _verificationNotifier =
  StreamController<bool>.broadcast();//入力状況を感知
  bool isAuthenticated = false;
  int passwordDigits = 4;  //パスワードの桁数
  String passLockMessage; //エラーメッセージ

//入力されたパスワードを保存する処理です。
  _setPassword(String value) async {
    var prefs = await SharedPreferences.getInstance();
    await prefs.setString("lockPassword", value);
  }

  @override
  void didChangeDependencies() {
    // TODO: implement didChangeDependencies
    super.didChangeDependencies();
   passLockMessage = "念のためパスワードを入力してください";
//機能実装都合でここに書いてありますが、initstate内に書いても通常は問題ないはずです。
  }

  @override
  void dispose() {
    // TODO: implement dispose
    _verificationNotifier.close();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return _showLockScreen(//パスワード設定画面をは発動します。
      context,
      opaque: false,
    );
  }

//再入力画面の設定
//ほぼほぼ前の方と同じです。
  _showLockScreen(BuildContext context,
      {bool opaque,
        List<String> digits}) {
      print(widget.password);
    return  PasscodeScreen(
      title: Column(
        children: <Widget>[
          Icon(Icons.lock, size: 30),
          Text(
           "パスワード",
            textAlign: TextAlign.center,
            style: TextStyle(fontSize: 15),
          ),
          Text(
            '$passLockMessage',//メッセージを表示します。
            textAlign: TextAlign.center,
            style: TextStyle(fontSize: 10),
          ),
        ],
      ),
      passwordDigits: passwordDigits,
      circleUIConfig: CircleUIConfig(
        borderColor: Theme.of(context).dividerColor,
        fillColor: Theme.of(context).dividerColor,
        circleSize: 20,
      ),
      keyboardUIConfig: KeyboardUIConfig(
        primaryColor: Theme.of(context).dividerColor,
        digitTextStyle: TextStyle(fontSize: 25),
        deleteButtonTextStyle: TextStyle(fontSize: 15),
        digitSize: 75,
      ),
      passwordEnteredCallback: _onPasscodeEntered,
      deleteButton: Icon(Icons.backspace, size: 15.0),
      cancelButton:  Icon(Icons.cancel, size: 15.0),
      cancelCallback:  _onPasscodeCancelled,
      shouldTriggerVerification: _verificationNotifier.stream,
      backgroundColor: Theme.of(context).primaryColor,
      digits: digits,
    );
  }

//パスワードが桁数入力されたときの処理
  _onPasscodeEntered(String enteredPasscode) async {

//パスワードのチェック
    bool isValid = widget.password == enteredPasscode;
    _verificationNotifier.add(isValid);//パスコードが正しいかどうかをパスコード画面に通知してます。
    if (isValid) {
      await _setPassword(enteredPasscode);//パスワードを保存
//忘れガチですが、パスワード設定のオンとオフ設定、スイッチをオンにした直後に設定しています。
      setState(() {
        passLockMessage = "";//メッセージを空に
        this.isAuthenticated = isValid; //trueにしています。
//
      });
    } else {
      setState(() {
        passLockMessage = "パスワードが一致しません";     //エラーメッセージを変数に格納

      });


    }
  }

//パスワードを再入力をキャンセルした場合
  _onPasscodeCancelled() async{
    await _isPasswordLock(false); //パスワードロック設定をオフ状態
    Navigator.maybePop(context);
    Navigator.pushReplacement(
        context,
        MaterialPageRoute(
            builder: (context) => PasswordSetScreen())); //パスワード設定画面に戻します。
  }

//パスワードロック設定のオンオフを記録
  _isPasswordLock(bool value) async {
    var prefs = await SharedPreferences.getInstance();
    await prefs.setBool("isPasswordLock", value);
  }


}

どこで処理を走らせるか

パスワードは設定できましたが、一つ考えるべきところが、どこでパスワード を表示される処理を走らせるか問題です。
後ほど、スマホの状態を感知して、処理を走らせるわけですが、すべてのページにその実装をするのは辛い。

今回作ったアプリでは、共通ページとして、フッター専用のページを作って、それぞれのページで参照しています。

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text("タイトル"), 

      ),
      body: passwordSetWidget(),
      bottomNavigationBar: Footer(),
  }

このフッター部分の状態を見て、パスワードロックの処理を走らせます。

パスワード機能を

スマホの状態で処理を発動

状態の管理として、4つに分類されます。今回はpuouseの時に処理を走らせるようにします。
というのも、それ以外の処理では、他の機能でページを読み込まれた時にも、パスワードロック画面が発動してしまうからです。

//状態を管理するには、Classにwith WidgetsBindingObserverが必要。
class _Footer extends State <Footer> with WidgetsBindingObserver{
 bool _isPasswordLock = false;

//パスワード設定のオンオフを読み出し
  _getPasswordSetting() async {
    var prefs = await SharedPreferences.getInstance();
    _isPasswordLock = await prefs.getBool('isPasswordLock');
    setState(()  {
    });
  }

  void initState() {
    // TODO: implement initState
    super.initState();
    WidgetsBinding.instance.addObserver(this);//スマホの状態を感知する
    WidgetsBinding.instance.addPostFrameCallback((_) async{
      await _getPasswordSetting();
    });//widgetの構築が終わったら、発動させる処理。
}

  @override
  void dispose() {
    // TODO: implement dispose
    WidgetsBinding.instance.removeObserver(this);//スマホの状態を感知終了
    super.dispose();
  }

//スマホ状態によって処理を走らせます。
  void didChangeAppLifecycleState(AppLifecycleState state) {
    // TODO: implement didChangeAppLifecycleState
//    super.didChangeAppLifecycleState(state);

    if (state == AppLifecycleState.inactive) {
//アプリの画面が非アクティブな時。
    } else if (state == AppLifecycleState.paused) {
      _passWordLock();//アプリが完全にバックグランドになったら、パスワードロック画面を表示を発動。ただし表示されるのは、画面が再び立ち上がったあと。

    } else if (state == AppLifecycleState.resumed) {
//アプリが復帰した時の処理。これでもいいが、他の処理で何度も呼び出されることがあり、変なところでパスワードロックがかかってしまった。
    }
  }
}

パスワードロックを表示

スマホのアプリがバックグランドになったら、パスワードロックを表示させます。

//状態を管理するには、Classにwith WidgetsBindingObserverが必要。2重でパスワードロック画面が表示されないように、ここでも状態管理をしています。

class _PassWordScreenState extends State<PassWordScreen>
    with WidgetsBindingObserver {
  final StreamController<bool> _verificationNotifier =
      StreamController<bool>.broadcast();//入力状態をチェック
  bool isAuthenticated = false; //パスワード入力が正しいか
  String passLockMessage = "";//エラーメッセージ
  int passwordDigits = 4;//入力する桁数
  bool passwordView = false;//パスワード画面が表示されているか?
  bool _facePass = false; //FacePassの設定
  String _password = ""; //パスワード
  LocalAuthentication _localAuth = LocalAuthentication(); //生体認証のインスタンス

//パスワードの読み出し
  _getPassword() async {
    var prefs = await SharedPreferences.getInstance();
    _password = await prefs.getString("lockPassword");
  }
//生体認証の読み出し
  _getFacePassSetting() async {
    var prefs = await SharedPreferences.getInstance();
    _facePass =  prefs.getBool('isFacePass');
    setState(() {

    });
  }

//生体認証の種類の確認
  Future<List<BiometricType>> _getAvailableBiometricTypes() async {
    List<BiometricType> availableBiometricTypes;
    try {
      availableBiometricTypes = await _localAuth.getAvailableBiometrics();
    } on PlatformException catch (e) {
        // エラーを入力
    }
    return availableBiometricTypes;
  }

//生体認証をチェックしてOKだったらパスワードを処理を飛ばす。
  Future<bool> _authenticate() async {
    bool result = false;
    List<BiometricType> availableBiometricTypes = await _getAvailableBiometricTypes();
    try {
      if (availableBiometricTypes.contains(BiometricType.face)
          || availableBiometricTypes.contains(BiometricType.fingerprint)) {
        result = await _localAuth.authenticateWithBiometrics(localizedReason: 生体認証);
      }
    } on PlatformException catch (e) {
      // エラーを入力
    }
    if (result == true) {
        return  _onPasscodeEntered(_password);//生体認証がOKだったら、ロックを解除するために、パスワード付きで解除するメソッドを発動。
    }
  }

  @override
  void initState() {
    // TODO: implement initState
    super.initState();
    WidgetsBinding.instance.addObserver(this);//スマホの状態をチェック
    _getPassword(); //パスワードを読み出し
    _getFacePassSetting(); //生体認証の読み出し
  }

  @override
  void didChangeDependencies()  async {
    super.didChangeDependencies();
//生体認証を少しだけ遅らせます。そうしないと、パスワードロック画面が表示しきる前に発動してしまい、パスワードロック画面の表示が中途半端にになってしまいます。
    await Future.delayed(Duration(milliseconds: 700));
    if(_facePass != false) {
      await _authenticate();
    }
  }

  @override
  void dispose() {
    // TODO: implement dispose
    _verificationNotifier.close();//入力状態確認の終了
    WidgetsBinding.instance.removeObserver(this);  WidgetsBinding.instance.addObserver(this);//スマホの状態確認の終了
    super.dispose();
  }

//スマホの状態確認
  void didChangeAppLifecycleState(AppLifecycleState state) {
    // TODO: implement didChangeAppLifecycleState
 super.didChangeAppLifecycleState(state);

    if (state == AppLifecycleState.inactive) {

    } else if (state == AppLifecycleState.paused) {
//パスワードロック画面が表示されているので、この状態でスマホ閉じて開くと、パスワードロック画面が2重の状態になります。なのでパスワードロック画面が開いている状態では、画面を入れ替えるようにします。
      Navigator.pushReplacement(
          context,
          MaterialPageRoute(
              builder: (context) => PassWordScreen()));

    } else if (state == AppLifecycleState.resumed) {

    }
  }

  @override
  Widget build(BuildContext context) {
    return WillPopScope(
        child:  _showLockScreen(
        context,
        opaque: false,
    ),
//アンドロイドの場合、これを入れないと、戻るボタンでパスワードロック画面が閉じてしまいます。
        onWillPop:() async => false);

  }


//パスワード設定画面とほぼ一緒

  _showLockScreen(BuildContext context,
      {bool opaque,
        List<String> digits})  {

    return  PasscodeScreen(
      title: Column(
        children: <Widget>[
          Icon(Icons.lock, size: 30),
          Text(
            'Enter App Passcode', 
            textAlign: TextAlign.center,
            style: TextStyle(fontSize: 15),
          ),
          Text(
            '$passLockMessage', 
            textAlign: TextAlign.center,
            style: TextStyle(fontSize: 10),
          ),
        ],
      ),
      passwordDigits: passwordDigits,
      circleUIConfig: CircleUIConfig(
        borderColor: Theme.of(context).dividerColor,
        fillColor: Theme.of(context).dividerColor,
        circleSize: 20,
      ),
      keyboardUIConfig: KeyboardUIConfig(
        primaryColor: Theme.of(context).dividerColor,
        digitTextStyle: TextStyle(fontSize: 25),
        deleteButtonTextStyle: TextStyle(fontSize: 15),
        digitSize: 75,
      ),
      passwordEnteredCallback: _onPasscodeEntered,
      deleteButton: Icon(Icons.backspace, size: 15.0),
      shouldTriggerVerification: _verificationNotifier.stream,
      backgroundColor: Theme.of(context).primaryColor,
      digits: digits,
    );
  }

  _onPasscodeEntered(String enteredPasscode) {
    bool isValid = _password == enteredPasscode;
    _verificationNotifier.add(isValid);
    if (isValid) {
      setState(() {
        passLockMessage = "";
        this.isAuthenticated = isValid;//パスワードが合致した状態にんすr。
      });
      Navigator.pop(context);
    } else {
      setState(() {
        passLockMessage = "パスワードが違います。再入力してください。";
      });
    }
  }
}

ハマりポイント

解決できなかった部分が、パスワードロック画面の表示のタイミングです。
本来であれば、アプリを閉じて、スマホを立ち上げた段階で、パスワードロック画面が表示されている状態が好ましいです。
しかし、Flutterの仕組み上、画面推移の処理が走るのが、画面が立ち上がったあとなので、少しだけ画面が見えてします。

ネイティブ側で処理をすれば、解決できるそうですが、ネイティブはさわれないので、今回は、問題なしと判断して、この部分は詰めませんでした。

まとめ

試行錯誤して作成したため、まだコードなどがうまくリファクタリングできていません。かなり冗長的なコードになっているので、時間を見つけて処理していきたいと思います。
完成の状態を見たい方は、ぜひアプリをダウンロードしてみてください。

あと、四苦八苦して作ったアプリも是非ともよろしくお願いします。アプリのアイデア出しに使ってみてください。

■AppStore
https://apps.apple.com/jp/app/id1517535550

■Google Play
https://play.google.com/store/apps/details?id=com.IdeaShuffleMemoApp&hl=ja

■アプリの詳細記事
https://yukio.site/idea_shuffle_memo/

あとツイッターもやってますので、ぜひチェックください。


それは、また。
次は、GoogleDriveのバックアップ処理について書いていきたいと思います。
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【Flutter】メモアプリでパスワードロック・生体認証ロック機能の実装に挑戦してみた!

トップ2.jpg

7月にFlutter開発を始めてから2作目のメモアプリ「IdeaShuffleMemo」をリリースしました。
■AppStore
https://apps.apple.com/jp/app/id1517535550

■Google Play
https://play.google.com/store/apps/details?id=com.IdeaShuffleMemoApp&hl=ja

■アプリの詳細記事
https://yukio.site/idea_shuffle_memo/

その中でパスワードロックの機能を実装したのですが、今回はそのやり方を少しだけ紹介していきたいと思います。

■別の記事
【Flutter】GoogleDriveへのバックアップ・リストア機能を実装するまでの道のり
https://qiita.com/YuKiO-OO/items/67b471e6be6c4c4c26e9

【Flutter】メモアプリ開発で使ったオススメのパッケージを紹介
https://qiita.com/YuKiO-OO/items/283f44da64d304a6228e

【Flutter】もう怖くない!アプリ内課金・定期購入機能を実装する方法を丁寧に説明してみた。
https://qiita.com/YuKiO-OO/items/a0fe8e0a256afbb69fc7

パスワードロック

IMG_9928.PNG

注意事項

リファクタリングもせず、試行錯誤した状態なので、コードが冗長的な所もありますのでご了承ください。

今回実装する機能

  • メニューで、パスワードと生体認証によるロック画面の実装

※2020/6 時点の情報を元に作成をしています。

知っておいたほうがいい知識

  • アプリのライフサイクル パスワードロックでは、アプリのライフサイクルを知っておく必要があります。 ライフサイクルとは、簡単に言えばアプリの状態の流れのことです。アプリを閉じている状態、アプリを開いた状態など、アプリの状態には一連の流れがあるんです。 それでパスワードロック機能を実装するには、アプリが閉じた状態、開いた状態を感知して、適切な処理を走らせる必要があります。

ライフサイクルは、下記の記事がとても分かりやすく説明してくれているので、一読してみてください。素晴らしい記事をありがとうございます。
Flutterでアプリの復帰やサスペンドを検出して処理を実行する

大まかな流れ

パスワードの設定

  • パスワード設定のオンオフの記録
  • パスワードの保存

パスワードロック画面の表示

  • パスワード設定のオンオフをチェック。
  • オンの場合、アプリが閉じた、もしくは開いた状態を感知して、パスワードロック画面を表示させる処理を走らせる。 オフの場合は、何もしない。
  • パスワードロックもしくは生体認証を検証して合致したら、パスワードロック表示画面を閉じる

必要なパッケージ

passcode_screen

passcode_screen | Flutter Package
パスワードロック画面が簡単に作れるパッケージ

shared_preferences

shared_preferences | Flutter Package
データベースではなく、設定情報などが消えずに保存できるパッケージ

local_auth

local_auth | Flutter Package
生体認証を使うためのパッケージ。
生体認証がOKならTrueを返して、NGだったらFalseで返してくる。
難しそうに感じますが、結構シンプルな感じで、ありがたいパッケージです。

作るページ

  • パスワードオン・オフ設定画面
  • パスワード設定入力画面
  • パスワード設定入力確認画面
  • パスワードロック画面

作り方によっては、まとめられるかしれませんが、そっちのほうが分かりやすいので、バラバラに作ってます。

この記事の前提

この記事では、基本的にstaful Widget内での実装なので、共通する部分は省いています。

class Example extends StatefulWidget {
  @override
  _ExampleState createState() => _ExampleState();
}

class _ExampleState extends State<Example> {

    //この中に書いてあることが中心です。それ以外を書く場合には、分かりやすいようにその前後も記載するようにしています。

}

各パッケージなどはimportされているものとしています。
適宜、各パッケージのReadmeをチェックください。

わかりづらいところがあれば、コメントを気軽にしてください。
お答えできる部分があれば、お答えします。

構築の流れ

パスワードオン・オフ設定

この辺りは特に難しいことではありませんが、パスワードロックと生体認証のオンオフのスイッチを実装します。

 @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text("パスワードセッティング"), 
      ),
      body: passwordSetWidget(),//パスワードのオンオフのスイッチを定義したメソットを呼び出し。
      bottomNavigationBar: Footer(selectedIndex: 4,),//フッターを共通化しているので呼び出し。
    );
  }



passwordSetWidget() {
  return SingleChildScrollView(
    child: Column(
      children: <Widget>[
        Row(
          children: <Widget>[//設定のタイトル
            Padding(
              padding: const EdgeInsets.all(12.0),
              child: Text("設定", style: TextStyle(fontSize: 14.0),),
            ),
          ],
        ),
        Card(//パスワードロックのスイッチ
          child: SwitchListTile(
            value: _password,
            onChanged: _setPassword,//オンになった時の処理
            activeTrackColor: Colors.blue,
            title: Row(
              children: <Widget>[
                Padding(
                  padding: const EdgeInsets.only(right: 10.0),
                  child: Icon(Icons.lock),
                ),
                Expanded(child: Text("パスワードロック")),
              ],
            ),
          ),
        ),
        Card(//生体認証のスイッチ
          child: SwitchListTile(
            value: _facePass,
            onChanged: _setFacePass,//オンになった時の処理
            activeTrackColor: Colors.blue,
            title: Row(
              children: <Widget>[
                Padding(
                  padding: const EdgeInsets.only(right: 10.0),
                  child: Icon(Icons.face),
                ),
                Expanded(child: Text("生体認証")),
              ],
            ),
          ),
        ),

      ],
    ),
  );
}



パスワードロックのスイッチ

パスワードロックがオンになったら、オンオフの状態を記録します。
最終的にパスワード1回目と2回目が合致したら、最終的にオンの状態を維持することになります。
この時、「パスワード忘れると、解除できなくなります。忘れないようにご注意ください!!」みたいな警告を出してあげたほうが優しいと思います。今回のサンプルではわかりづらくなるので、入れてません。

bool _password;

@override
  void initState() {
    // TODO: implement initState
  _getPasswordSetting();
  super.initState();

}

//パスワードのオンオフを記録するメソッド
//shared_preferencesパッケージの機能を使って、設定情報などを端末に保存します。
_isPasswordLock(bool value) async {
//受け取った引数true or falseをisPasswordLockという名前で保存してね!ってことにあります。
  var prefs = await SharedPreferences.getInstance();
  await prefs.setBool("isPasswordLock", value);
}

//パスワードのオンオフ設定を読み出すメソッド
_getPasswordSetting() async {
//これをinitstateで読み出しておいて、初期値としてセットしておきます。
  var prefs = await SharedPreferences.getInstance();
  _password = await prefs.getBool('isPasswordLock') ?? false;
//??falseを忘れると、何も設定されていない初期状態の時にエラーになります。
//nullだったら、falseを入れなさいってことになります。
  setState(() {
    _password;
  });
}



//パスワード設定のスイッチが変更された時の処理
_setPassword(bool value) async{

  await _isPasswordLock(value);//引数のValueをパスワードの設定状態として保存

  if(value == true) {
//このタイミングで一度ダイアログを表示したりして、パスワードを忘れると解除できませんのような文言を表示してもいいかもしれません。
          Navigator.push(
              context,
              MaterialPageRoute(
                  builder: (context) => PasswordSetting()));//パスワード設定画面の表示

  } else {
    await _isFacePass(false);//パスワードがオフになったら生体認証も強制的にオフ
    Navigator.pushReplacement(
        context,
        MaterialPageRoute(
            builder: (context) => PasswordSetScreen()));//スイッチを切り替えた状態にするために再読み込み
  }


}
生体認証のスイッチ

生体認証がオンになったら、生体認証が起動。
生体認証がOKなら、オンにします。
ただし、パスワードロックが設定されていない場合には、オンにできない使用にします。
生体認証がうまくいかない場合に備えてです。
ちなみに公式ドキュメントで、かならずiOSとAndroidのネイティブ側の設定で、生体認証を使えるように設定しておきましょう。

生体認証はこの記事を参考にさせていただきました。
(https://qiita.com/coka__01/items/76af4ea73a6a8c8fa135)

IOSの設定でこれを追記

Info.plist
<key>NSFaceIDUsageDescription</key>
<string>Why is my app authenticating using face id?</string>

Android設定でこれを追記

AndroidManifest.xml
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
          package="com.example.app">//これすでにあるやつ
//これを追記するだけ。
  <uses-permission android:name="android.permission.USE_FINGERPRINT"/>
<manifest>//これすでにあるやつ

 生体認証の処理

bool _facePass;

@override
  void initState() {
  _getPasswordSetting();//さっき追加してます。
  _getFacePassSetting();
  super.initState();
}

//生体認証のオンオフを保存するメソッド
_isFacePass(bool value) async { 
  var prefs = await SharedPreferences.getInstance();
  await prefs.setBool("isFacePass", value);
}

//生体認証のオンオフ設定を読み出すメソッド
_getFacePassSetting() async {
//パスワードと同じくinitstateで読み出しておきましょう。
  var prefs = await SharedPreferences.getInstance();
  _facePass = await prefs.getBool('isFacePass') ?? false;
//パスワードと同様に?? falseをしないと、初期状態でエラーになります。
  setState(() {
    _facePass;
  });
}

//生体認証のスイッチを変えた時の処理
_setFacePass(bool value) async{
  await _getPasswordSetting();

  if (_password == false && value == true ) {
//パスワード設定がオフで、生体認証がオンだった場合は、生体認証がオンにできないように警告を表示する設定をしておきましょう。
    return;
  }

  if (_password == true && value == true ) {
//パスワード設定がオンで、生体認証がオンになった場合
    var check = _authenticate(); //生体認証を発動

    if(check == true) { 
//生体認証が承認されたら
      await _isFacePass(value);//生体認証設定状態をオンとして保存
      return;//
    }
  }


  await _isFacePass(value);//生体認証をオフにされたら、オフの状態で保存


  Navigator.pushReplacement(
      context,
      MaterialPageRoute(
          builder: (context) => PasswordSetScreen())); //オフにするために画面を再読み込み。
}


//生体認証のタイプをチェック
Future<List<BiometricType>> _getAvailableBiometricTypes() async {
  List<BiometricType> availableBiometricTypes;
  try {
    availableBiometricTypes = await _localAuth.getAvailableBiometrics();
  } on PlatformException catch (e) {
    //エラーの処理
  }
  return availableBiometricTypes; //生体認証はこれやーと返す
}

//生体認証を呼び出して、結果を返す処理
Future<bool> _authenticate() async {
  bool result = false;
  List<BiometricType> availableBiometricTypes = await _getAvailableBiometricTypes(); //生体認証が指紋か顔かチェック

  try {
    if (availableBiometricTypes.contains(BiometricType.face)
        || availableBiometricTypes.contains(BiometricType.fingerprint)) {
      result = await _localAuth.authenticateWithBiometrics(localizedReason: "生体認証");
    }
  } on PlatformException catch (e) {
    //エラーの処理
  }
  return result;//承認した結果をtrue or falseで返す。
}

パスワードの設定画面

パスワードがオンになったら、まず最初にパスワードを入力してもらう画面を表示します。

  final StreamController<bool> _verificationNotifier =
  StreamController<bool>.broadcast();
  int passwordDigits = 4; //パスワードの桁数

  @override
  void dispose() {
    // TODO: implement dispose
    _verificationNotifier.close();
    super.dispose();
  }


  @override
  Widget build(BuildContext context) {
//パスワードロック画面を発動
    return _showLockScreen(
      context,
      opaque: false,/
    );
  }

//パスワードロック画面の見た目の詳細
//ボタンの大きさや、色などカスタマイズできます。
//各色の設定はmain.dartで定義しているカラーなどを設定しています。

  _showLockScreen(BuildContext context,
      {bool opaque,
        List<String> digits}) {

            return  PasscodeScreen(
                title: Column(
                  children: <Widget>[
                    Icon(Icons.lock, size: 30),
                    Text("パスワードを入力してください",
                      textAlign: TextAlign.center,
                      style: TextStyle(fontSize: 15),
                    ),
                  ],
                ),
                passwordDigits: passwordDigits,
                circleUIConfig: CircleUIConfig(
                  borderColor: Theme.of(context).dividerColor,
                  fillColor: Theme.of(context).dividerColor,
                  circleSize: 20,
                ),
                keyboardUIConfig: KeyboardUIConfig(
                  primaryColor: Theme.of(context).dividerColor,
                  digitTextStyle: TextStyle(fontSize: 25),
                  deleteButtonTextStyle: TextStyle(fontSize: 15),
                  digitSize: 75,
                ),
                passwordEnteredCallback: _onPasscodeEntered,//パスワードが入力された時の処理
                deleteButton: Icon(Icons.backspace, size: 15.0),
                cancelButton:  Icon(Icons.cancel, size: 15.0),
                cancelCallback:  _onPasscodeCancelled,//パスワードが入力がキャンセルされた時の処理
                shouldTriggerVerification: _verificationNotifier.stream,
                backgroundColor: Theme.of(context).primaryColor,
                digits: digits,
              );
  }

//パスワードが桁数まで入力された発動する処理
  _onPasscodeEntered(String enteredPasscode)  {

//パスワード確認フォームに飛ばす
    Navigator.pushReplacement(
        context,
        MaterialPageRoute(
            builder: (context) => ConfirmPassword(password: enteredPasscode))); //入力されているパスワードを引数として渡す。
  }

//入力がキャンセルされたら発動する処理
   _onPasscodeCancelled() async{
     await _isPasswordLock(false); //とりあえず、パスワード設定をオフにしておく
     Navigator.maybePop(context);
     Navigator.pushReplacement(
         context,
         MaterialPageRoute(
             builder: (context) => PasswordSetScreen())); //パスワード設定を再読み込み
  }

//パスワード設定のオンオフを記録する(shared_preferencesパッケージの機能です)
  _isPasswordLock(bool value) async {
    var prefs = await SharedPreferences.getInstance();
    await prefs.setBool("isPasswordLock", value);
  }

パスワードの入力が確認できたら、パスワード入力確認ページで飛ばします。
パスワードの確認をして、合致すればパスワードを保存します。
この時、キャンセルされば全ての設定を破棄します。

//クラスをまたぐので、クラスまで一応書いておきますね。

class ConfirmPassword extends StatefulWidget {
//これから処理するにあたって、パスワードの引数は必須ですよという設定をしておきます。だってパスワードない状態でこのページ開けてしまうのは困るので。

  String password = ""; 

  ConfirmPassword({@required this.password});//ぜってぇーパスワードを引数をとして送ってこいよなと言ってます。

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

class _ConfirmPasswordState extends State<ConfirmPassword> {

  final StreamController<bool> _verificationNotifier =
  StreamController<bool>.broadcast();//入力状況を感知
  bool isAuthenticated = false;
  int passwordDigits = 4;  //パスワードの桁数
  String passLockMessage; //エラーメッセージ

//入力されたパスワードを保存する処理です。
  _setPassword(String value) async {
    var prefs = await SharedPreferences.getInstance();
    await prefs.setString("lockPassword", value);
  }

  @override
  void didChangeDependencies() {
    // TODO: implement didChangeDependencies
    super.didChangeDependencies();
   passLockMessage = "念のためパスワードを入力してください";
//機能実装都合でここに書いてありますが、initstate内に書いても通常は問題ないはずです。
  }

  @override
  void dispose() {
    // TODO: implement dispose
    _verificationNotifier.close();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return _showLockScreen(//パスワード設定画面をは発動します。
      context,
      opaque: false,
    );
  }

//再入力画面の設定
//ほぼほぼ前の方と同じです。
  _showLockScreen(BuildContext context,
      {bool opaque,
        List<String> digits}) {
      print(widget.password);
    return  PasscodeScreen(
      title: Column(
        children: <Widget>[
          Icon(Icons.lock, size: 30),
          Text(
           "パスワード",
            textAlign: TextAlign.center,
            style: TextStyle(fontSize: 15),
          ),
          Text(
            '$passLockMessage',//メッセージを表示します。
            textAlign: TextAlign.center,
            style: TextStyle(fontSize: 10),
          ),
        ],
      ),
      passwordDigits: passwordDigits,
      circleUIConfig: CircleUIConfig(
        borderColor: Theme.of(context).dividerColor,
        fillColor: Theme.of(context).dividerColor,
        circleSize: 20,
      ),
      keyboardUIConfig: KeyboardUIConfig(
        primaryColor: Theme.of(context).dividerColor,
        digitTextStyle: TextStyle(fontSize: 25),
        deleteButtonTextStyle: TextStyle(fontSize: 15),
        digitSize: 75,
      ),
      passwordEnteredCallback: _onPasscodeEntered,
      deleteButton: Icon(Icons.backspace, size: 15.0),
      cancelButton:  Icon(Icons.cancel, size: 15.0),
      cancelCallback:  _onPasscodeCancelled,
      shouldTriggerVerification: _verificationNotifier.stream,
      backgroundColor: Theme.of(context).primaryColor,
      digits: digits,
    );
  }

//パスワードが桁数入力されたときの処理
  _onPasscodeEntered(String enteredPasscode) async {

//パスワードのチェック
    bool isValid = widget.password == enteredPasscode;
    _verificationNotifier.add(isValid);//パスコードが正しいかどうかをパスコード画面に通知してます。
    if (isValid) {
      await _setPassword(enteredPasscode);//パスワードを保存
//忘れガチですが、パスワード設定のオンとオフ設定、スイッチをオンにした直後に設定しています。
      setState(() {
        passLockMessage = "";//メッセージを空に
        this.isAuthenticated = isValid; //trueにしています。
//
      });
    } else {
      setState(() {
        passLockMessage = "パスワードが一致しません";     //エラーメッセージを変数に格納

      });


    }
  }

//パスワードを再入力をキャンセルした場合
  _onPasscodeCancelled() async{
    await _isPasswordLock(false); //パスワードロック設定をオフ状態
    Navigator.maybePop(context);
    Navigator.pushReplacement(
        context,
        MaterialPageRoute(
            builder: (context) => PasswordSetScreen())); //パスワード設定画面に戻します。
  }

//パスワードロック設定のオンオフを記録
  _isPasswordLock(bool value) async {
    var prefs = await SharedPreferences.getInstance();
    await prefs.setBool("isPasswordLock", value);
  }


}

どこで処理を走らせるか

パスワードは設定できましたが、一つ考えるべきところが、どこでパスワード を表示される処理を走らせるか問題です。
後ほど、スマホの状態を感知して、処理を走らせるわけですが、すべてのページにその実装をするのは辛い。

今回作ったアプリでは、共通ページとして、フッター専用のページを作って、それぞれのページで参照しています。

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text("タイトル"), 

      ),
      body: passwordSetWidget(),
      bottomNavigationBar: Footer(),
  }

このフッター部分の状態を見て、パスワードロックの処理を走らせます。

パスワード機能を

スマホの状態で処理を発動

状態の管理として、4つに分類されます。今回はpuouseの時に処理を走らせるようにします。
というのも、それ以外の処理では、他の機能でページを読み込まれた時にも、パスワードロック画面が発動してしまうからです。

//状態を管理するには、Classにwith WidgetsBindingObserverが必要。
class _Footer extends State <Footer> with WidgetsBindingObserver{
 bool _isPasswordLock = false;

//パスワード設定のオンオフを読み出し
  _getPasswordSetting() async {
    var prefs = await SharedPreferences.getInstance();
    _isPasswordLock = await prefs.getBool('isPasswordLock');
    setState(()  {
    });
  }

  void initState() {
    // TODO: implement initState
    super.initState();
    WidgetsBinding.instance.addObserver(this);//スマホの状態を感知する
    WidgetsBinding.instance.addPostFrameCallback((_) async{
      await _getPasswordSetting();
    });//widgetの構築が終わったら、発動させる処理。
}

  @override
  void dispose() {
    // TODO: implement dispose
    WidgetsBinding.instance.removeObserver(this);//スマホの状態を感知終了
    super.dispose();
  }

//スマホ状態によって処理を走らせます。
  void didChangeAppLifecycleState(AppLifecycleState state) {
    // TODO: implement didChangeAppLifecycleState
//    super.didChangeAppLifecycleState(state);

    if (state == AppLifecycleState.inactive) {
//アプリの画面が非アクティブな時。
    } else if (state == AppLifecycleState.paused) {
      _passWordLock();//アプリが完全にバックグランドになったら、パスワードロック画面を表示を発動。ただし表示されるのは、画面が再び立ち上がったあと。

    } else if (state == AppLifecycleState.resumed) {
//アプリが復帰した時の処理。これでもいいが、他の処理で何度も呼び出されることがあり、変なところでパスワードロックがかかってしまった。
    }
  }
}

パスワードロックを表示

スマホのアプリがバックグランドになったら、パスワードロックを表示させます。

//状態を管理するには、Classにwith WidgetsBindingObserverが必要。2重でパスワードロック画面が表示されないように、ここでも状態管理をしています。

class _PassWordScreenState extends State<PassWordScreen>
    with WidgetsBindingObserver {
  final StreamController<bool> _verificationNotifier =
      StreamController<bool>.broadcast();//入力状態をチェック
  bool isAuthenticated = false; //パスワード入力が正しいか
  String passLockMessage = "";//エラーメッセージ
  int passwordDigits = 4;//入力する桁数
  bool passwordView = false;//パスワード画面が表示されているか?
  bool _facePass = false; //FacePassの設定
  String _password = ""; //パスワード
  LocalAuthentication _localAuth = LocalAuthentication(); //生体認証のインスタンス

//パスワードの読み出し
  _getPassword() async {
    var prefs = await SharedPreferences.getInstance();
    _password = await prefs.getString("lockPassword");
  }
//生体認証の読み出し
  _getFacePassSetting() async {
    var prefs = await SharedPreferences.getInstance();
    _facePass =  prefs.getBool('isFacePass');
    setState(() {

    });
  }

//生体認証の種類の確認
  Future<List<BiometricType>> _getAvailableBiometricTypes() async {
    List<BiometricType> availableBiometricTypes;
    try {
      availableBiometricTypes = await _localAuth.getAvailableBiometrics();
    } on PlatformException catch (e) {
        // エラーを入力
    }
    return availableBiometricTypes;
  }

//生体認証をチェックしてOKだったらパスワードを処理を飛ばす。
  Future<bool> _authenticate() async {
    bool result = false;
    List<BiometricType> availableBiometricTypes = await _getAvailableBiometricTypes();
    try {
      if (availableBiometricTypes.contains(BiometricType.face)
          || availableBiometricTypes.contains(BiometricType.fingerprint)) {
        result = await _localAuth.authenticateWithBiometrics(localizedReason: 生体認証);
      }
    } on PlatformException catch (e) {
      // エラーを入力
    }
    if (result == true) {
        return  _onPasscodeEntered(_password);//生体認証がOKだったら、ロックを解除するために、パスワード付きで解除するメソッドを発動。
    }
  }

  @override
  void initState() {
    // TODO: implement initState
    super.initState();
    WidgetsBinding.instance.addObserver(this);//スマホの状態をチェック
    _getPassword(); //パスワードを読み出し
    _getFacePassSetting(); //生体認証の読み出し
  }

  @override
  void didChangeDependencies()  async {
    super.didChangeDependencies();
//生体認証を少しだけ遅らせます。そうしないと、パスワードロック画面が表示しきる前に発動してしまい、パスワードロック画面の表示が中途半端にになってしまいます。
    await Future.delayed(Duration(milliseconds: 700));
    if(_facePass != false) {
      await _authenticate();
    }
  }

  @override
  void dispose() {
    // TODO: implement dispose
    _verificationNotifier.close();//入力状態確認の終了
    WidgetsBinding.instance.removeObserver(this);  WidgetsBinding.instance.addObserver(this);//スマホの状態確認の終了
    super.dispose();
  }

//スマホの状態確認
  void didChangeAppLifecycleState(AppLifecycleState state) {
    // TODO: implement didChangeAppLifecycleState
 super.didChangeAppLifecycleState(state);

    if (state == AppLifecycleState.inactive) {

    } else if (state == AppLifecycleState.paused) {
//パスワードロック画面が表示されているので、この状態でスマホ閉じて開くと、パスワードロック画面が2重の状態になります。なのでパスワードロック画面が開いている状態では、画面を入れ替えるようにします。
      Navigator.pushReplacement(
          context,
          MaterialPageRoute(
              builder: (context) => PassWordScreen()));

    } else if (state == AppLifecycleState.resumed) {

    }
  }

  @override
  Widget build(BuildContext context) {
    return WillPopScope(
        child:  _showLockScreen(
        context,
        opaque: false,
    ),
//アンドロイドの場合、これを入れないと、戻るボタンでパスワードロック画面が閉じてしまいます。
        onWillPop:() async => false);

  }


//パスワード設定画面とほぼ一緒

  _showLockScreen(BuildContext context,
      {bool opaque,
        List<String> digits})  {

    return  PasscodeScreen(
      title: Column(
        children: <Widget>[
          Icon(Icons.lock, size: 30),
          Text(
            'Enter App Passcode', 
            textAlign: TextAlign.center,
            style: TextStyle(fontSize: 15),
          ),
          Text(
            '$passLockMessage', 
            textAlign: TextAlign.center,
            style: TextStyle(fontSize: 10),
          ),
        ],
      ),
      passwordDigits: passwordDigits,
      circleUIConfig: CircleUIConfig(
        borderColor: Theme.of(context).dividerColor,
        fillColor: Theme.of(context).dividerColor,
        circleSize: 20,
      ),
      keyboardUIConfig: KeyboardUIConfig(
        primaryColor: Theme.of(context).dividerColor,
        digitTextStyle: TextStyle(fontSize: 25),
        deleteButtonTextStyle: TextStyle(fontSize: 15),
        digitSize: 75,
      ),
      passwordEnteredCallback: _onPasscodeEntered,
      deleteButton: Icon(Icons.backspace, size: 15.0),
      shouldTriggerVerification: _verificationNotifier.stream,
      backgroundColor: Theme.of(context).primaryColor,
      digits: digits,
    );
  }

  _onPasscodeEntered(String enteredPasscode) {
    bool isValid = _password == enteredPasscode;
    _verificationNotifier.add(isValid);
    if (isValid) {
      setState(() {
        passLockMessage = "";
        this.isAuthenticated = isValid;//パスワードが合致した状態にんすr。
      });
      Navigator.pop(context);
    } else {
      setState(() {
        passLockMessage = "パスワードが違います。再入力してください。";
      });
    }
  }
}

ハマりポイント

解決できなかった部分が、パスワードロック画面の表示のタイミングです。
本来であれば、アプリを閉じて、スマホを立ち上げた段階で、パスワードロック画面が表示されている状態が好ましいです。
しかし、Flutterの仕組み上、画面推移の処理が走るのが、画面が立ち上がったあとなので、少しだけ画面が見えてします。

ネイティブ側で処理をすれば、解決できるそうですが、ネイティブはさわれないので、今回は、問題なしと判断して、この部分は詰めませんでした。

まとめ

試行錯誤して作成したため、まだコードなどがうまくリファクタリングできていません。かなり冗長的なコードになっているので、時間を見つけて処理していきたいと思います。
完成の状態を見たい方は、ぜひアプリをダウンロードしてみてください。

あと、四苦八苦して作ったアプリも是非ともよろしくお願いします。アプリのアイデア出しに使ってみてください。

■AppStore
https://apps.apple.com/jp/app/id1517535550

■Google Play
https://play.google.com/store/apps/details?id=com.IdeaShuffleMemoApp&hl=ja

■アプリの詳細記事
https://yukio.site/idea_shuffle_memo/

あとツイッターもやってますので、ぜひチェックください。


それは、また。
次は、GoogleDriveのバックアップ処理について書いていきたいと思います。
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

ExoPlayerで再生中にイヤホンやbluetoothが外れた際に停止させるには

単発です 備忘録として

環境

build.gradle
def exoplayer_version = "2.11.6"
implementation "com.google.android.exoplayer:exoplayer:$exoplayer_version"

実装

sample.kt
private var player: SimpleExoPlayer? = null

// 中略

player?.setHandleAudioBecomingNoisy(true)

※ SimpleExoPlayerのinit処理部分は省略しています
※ バージョン2.11.0からある模様です

自前で実装するとなるとACTION_AUDIO_BECOMING_NOISY intentをブロードキャストし処理する必要があるのでたったのこれだけですむとはとても便利ですね

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Eclipse+ADT(build:Maven2)で作ったAndroidアプリをAndroid Studio(build:gradle)に移行した記録

マシンスペック

  • Mac mini 2018
  • macOS Catalina(10.15.x)
  • Intel Core-i7 3.2GHz 6コア
  • メモリ 32GB
  • SSD 512GB

今までの開発環境

  • Windows 10
  • DELL XPS 13
  • Intel Core-i7
  • メモリ 8GB
  • SSD 256GB
  • Eclipse 4.5(だったかな?)
  • ADT最新版

やること

  • 開発環境をEclipse+ADT → Android Studioへ移行
  • ビルド環境をMaven2 → gradleへ移行

何故やるか?

  • AdMobの広告ポリシー違反で広告を止められ、広告収入がゼロになった
  • 推定2年放置してたが、再審査の改修が必要なので、開発環境をMacに移行する
  • せっかくなので、モダンな開発環境にしたい

移行するアプリ

どんなアプリか?

  • バス接近情報をスクレイピングして、スマホで通知する
  • バス時刻表を表示する

環境を移行する

今までの開発環境

スクリーンショット 2020-06-15 3.15.43.png
Eclipse環境はBootcampに作ってるけど、Bootcamp起動するの面倒なので、Bitbucketのスクショにて。

Eclipse+ADTをAndroid Studioでインポートする

ほほう、Eclipse+ADTインポートできるみたい!
スクリーンショット 2020-06-15 3.20.43.png

インポートしてみた。

スクリーンショット 2020-06-15 3.18.44.png

あれ?、、、ソースは!?

うーむ、、、無理か、、、手動で移行するしか術がないな。。。

Eclipse環境をAndroid Studioに手動で移行する

スクリーンショット 2020-06-15 3.32.13.png
何か、テンプレート沢山ある!?
とりあえず「基本アクティビティ」で新規プロジェクトを作ってみる。
スクリーンショット 2020-06-15 3.30.26.png

ここに旧環境のソースを手動で移動してみる。

スクリーンショット 2020-07-04 18.48.58.png

コンパイルエラーは「Google Play Service」と「org.apache.http.〜」が無いから。
あと、「AndroidManifest.xml」で、幾つかエラー出てる。
それ以外は大丈夫そう。

コンパイルエラー修正

Google Play Serviceの導入

メニューの「ツール」→「SDK マネージャー」を開いて、「Google Play services」をインストール。
スクリーンショット 2020-07-04 19.09.02.png

build.gradle(app直下)に追加。

dependencies {
    implementation 'com.google.android.gms:play-services:+'
}

※参考
https://qiita.com/ryo0301/items/e2722f0a8d5528d0f257

org.apache.http対応

build.gradle(app直下)に追加。

android {
    ・・・
    useLibrary "org.apache.http.legacy"

    defaultConfig {

※参考
https://cpoint-lab.co.jp/article/201803/1850/

コンパイルエラーが消えた!
スクリーンショット 2020-07-04 19.30.34.png

AndroidManifest.xmlのエラー修正

ERROR: The minSdk version should not be declared in the android manifest file. You can move the version from the manifest to the defaultConfig in the build.gradle file.

AndroidManifest.xmlのversionCode、versionName、minSdkVersion、targetSdkVersionは、build.gradle(app直下)へ移動する。

<manifest xmlns:android="http://schemas.android.com/apk/res/android"
        android:versionCode="24"
        android:versionName="1.2.7">

    <uses-sdk android:minSdkVersion="9" android:targetSdkVersion="17" />

targetSdkVersionを26以上にしろと怒られたので、28 に変更。

defaultConfig {
    minSdkVersion 9
    targetSdkVersion 28
    versionCode 24
    versionName "1.2.7"
}

※参考
https://qiita.com/riversun/items/78353cb2b3a8c7aa97a5

あと3箇所がエラーになってる。

<uses-permission android:name="android.permission.READ_LOGS" />
<application android:debuggable="false">
<activity android:screenOrientation="portrait">

android.permission.READ_LOGSのエラー

Permission is only granted to system apps

Android Studioの設定を変更。
スクリーンショット 2020-07-04 21.39.02.png

※参考
https://blog.integrityworks.co.jp/2017/12/20/android-permission-is-only-granted-to-system-apps/

android:debuggable="false"のエラー

Avoid hardcoding the debug mode; leaving it out allows debug and release builds to automatically assign one

昔はandroid:debuggable="true"フラグをつけないとDebug実行ができませんでしたが、SDKのバージョンアップ(SDKr8, 2010.12)で自動的に付くようになったのでdebuggableフラグの指定は不要になりました。

との事なので、「android:debuggable="false"」を削除。

※参考
http://greety.sakura.ne.jp/redo/2012/05/do-not-use-debuggable-in-manifest.html

android:screenOrientation="portrait"のエラー

Expecting android:screenOrientation="unspecified" or "fullSensor" for this activity so user can use the application in any orientation and provide a great experience on Chrome OS devices

縦向き固定はダメらしい。
applicationタグに「tools:ignore="LockedOrientationActivity"」を追加したが警告は消えなかったので、デフォルトの「unspecified」を設定。

※参考
https://qiita.com/Capotasto/items/d8e67ab505a7cab9ab73

build.gradleのエラー修正

build.gradle(app直下)でエラー

dependencies {
    implementation 'com.android.support:appcompat-v7:28.0.0'
    implementation 'com.android.support:design:28.0.0'
    androidTestImplementation 'com.android.support.test:runner:1.0.2'
}

build.gradle(プロジェクト直下)にGoogle Mavenリポジトリを追加。

buildscript {
    repositories {
        ・・・
        maven {
            url 'https://maven.google.com/'
        }
    }
    ・・・
}

allprojects {
    repositories {
        ・・・
        maven {
            url 'https://maven.google.com/'
        }
    }
}

build.gradle(app直下)に、support-v4、animated-vector-drawable、mediarouter-v7を追加。

dependencies {
    implementation 'com.android.support:support-v4:28.0.0'
    implementation 'com.android.support:appcompat-v7:28.0.0'
    implementation 'com.android.support:design:28.0.0'
    implementation 'com.android.support:animated-vector-drawable:28.0.0'
    implementation 'com.android.support:mediarouter-v7:28.0.0'
}

※参考
解決に失敗しました:com.Android.support:appcompat-v7:28.0
All com.android.support libraries must use the exact same version specification

ビルドエラー修正

FAILURE: Build failed with an exception.

* What went wrong:
Execution failed for task ':app:extractDeepLinksDebug'.
> org.xml.sax.SAXParseException; lineNumber: 1; columnNumber: 1; 途中でファイルの末尾に達しました。

上記ビルドエラーの原因が分からずハマる、SAXだからXMLなんだろうけども。。。
以前の環境がWindowsの為、改行コードがCRLFだったのでLFに修正、でもエラー消えず。。。
BOM付きUTF-8!?、、、かと思ったが違った。。。
関係ないだろうけど、旧ビルドファイル(Maven、Ant)など不要ファイルを削除、もちろんエラー消えず。。。

res配下の未使用ディレクトリを削除してみた。
スクリーンショット 2020-07-05 13.00.42.png

おっ、別のエラー出た!?
res配下に空ディレクトリを置いちゃダメらしい。

Manifest merger failed : uses-sdk:minSdkVersion 9 cannot be smaller than version 14 declared in library [com.google.android.gms:play-services:12.0.1] /Users/satoshi/.gradle/caches/transforms-2/files-2.1/dd50b5c4da9d853295d7932aef6bc7b9/play-services-12.0.1/AndroidManifest.xml as the library might be using APIs not available in 9
    Suggestion: use a compatible library with a minSdk of at most 9,
        or increase this project's minSdk version to at least 14,
        or use tools:overrideLibrary="com.google.android.gms.play_services" to force usage (may lead to runtime failures)

minSdkVersionを14にしろという事らしいので、build.gradle(app直下)を修正。

defaultConfig {
    minSdkVersion 14
    ・・・
}

新たなビルドエラー。

FAILURE: Build failed with an exception.

* What went wrong:
Execution failed for task ':app:mergeDexDebug'.
> A failure occurred while executing com.android.build.gradle.internal.tasks.Workers$ActionFacade
   > com.android.builder.dexing.DexArchiveMergerException: Error while merging dex archives: 
     The number of method references in a .dex file cannot exceed 64K.

build.gradle(app直下)を修正。

defaultConfig {
    ・・・
    multiDexEnabled true
}

※参考
https://qiita.com/tetsukick/items/25d42ce3281291e5946a

スクリーンショット 2020-07-05 13.21.16.png

やったー!!

実行する

メニューの「ツール」→「ADV マネージャー」を開いて、仮想デバイスを作成して実行する。
スクリーンショット 2020-07-05 23.21.37.png

実行したらエラー、、、うーむ。。。

PANIC: Cannot find AVD system path. Please define ANDROID_SDK_ROOT

Java8をインストールして、環境変数を設定する。

$ vim ~/.bash_profile
★点線内を追記
---
export JAVA_HOME=/usr/bin/javac
export ANDROID_HOME=/Users/xxx/Library/Android/sdk
export ANDROID_SDK_ROOT=/Users/xxx/Library/Android/sdk
export ANDROID_AVD_HOME=/Users/xxx/.android/avd
---
:wq
$ source ~/.bash_profile

※参考
https://qiita.com/jun68ykt/items/3432f44e9635581f3621
https://stackoverflow.com/questions/59288003/how-to-install-android-sdk-root-on-mac-os

スクリーンショット 2020-07-06 1.23.13.png

キタコレ!!

以上で開発環境は完成〜、さて広告違反の改修をするかな。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む