モバイルに本物のログインを足す連載の続き。前回はブラウザで自分の web ログインを開いてトークンを受け取る土台を作った。今回は web をつないで実際に一周し、ログインを「動く」から「使える」にする回——アプリが自分のログイン状態を持って、見せる。
ここで言う「web」は、綴ったものを web でも見られるように開発中のアプリ(紡Book)のことです。
綴Photo はデータをスマホのローカルに溜めます。ネットがなくても全部使えます。ログインが要るのは、その記録をクラウドに同期したいときだけです。前回ブラウザ経由でトークンを取れるようにして、実機で一度ログインを通した。確かに動いた。でも設定画面を開いても、自分がログイン中かどうかはどこにも出ません。
ログインは起動の壁にせず、設定の中に静かに置くと決めていた(その判断は別記事に書いた)。壁にしないぶん、ログイン状態は「設定を見れば分かる」程度に静かに見せればいい——はずだった。ところがその「見せる」ができていなかった。
動いてはいたが、アプリは状態を持っていなかった
設定に出すために、まず「アプリは今ログイン済みか」をどう持っているか確かめた。コードを読んだら、復元する処理(restore)はちゃんと書いてある。トークンを金庫から読んで、あれば「ログイン済み」にします。
// token を金庫から読んで、あれば「ログイン済み」にするだけ。ネットは呼ばない
Future<void> restore() async {
final access = await _store.readAccess();
state = AuthState(
status: access == null ? AuthStatus.unauthenticated : AuthStatus.authenticated,
);
}問題は、この restore がどこからも呼ばれていなかったことだ。起動のどこにも繋がっていません。だからアプリは起動するたびに「ログイン状態は不明」のまま立ち上がっていた。前回ログインが「動いた」のは、ログインした直後の一回だけ状態が立っていたからで、再起動したら忘れます。
面白かったのは、それでも同期自体は再起動後も動くことだった。
直し方はシンプルで、起動時に一度だけ restore を走らせる配線を足してもらった(ここでの「配線」=起動の流れに処理を繋いで、実際に走るようにすること。電源はあるのにスイッチから線が伸びていない状態を、繋ぎ直すイメージです)。ひとつ気をつけたのは、この復元でネットを呼ばないこと。金庫を読むだけにして、起動が一瞬で済むようにした。ローカル中心のアプリなら、オフラインでも一瞬で立ち上がってほしい。
// これが無かった=起動時にどこからも restore() が呼ばれていなかった
// FutureProvider=アプリ起動時に一度だけ中の処理を自動で走らせる仕掛け
final authRestoreProvider = FutureProvider<void>(
(ref) => ref.read(authControllerProvider.notifier).restore(),
);
// この一行で「起動時に restore を走らせる」配線が初めて繋がる(金庫を読むだけ=ネット非依存)
ref.watch(authRestoreProvider);名前を出すには、裏で一回聞きに行くしかなかった
状態が持てたら、次は「tsuzu_taro でログイン中」みたいに名前を出したい。ところが、トークンを交換したときに web が返すのはトークンのペアだけで、名前が入っていません。
名前を知るには、別のエンドポイント(me/)に聞きに行くしかなかった。
これをいつ叩くかを決めた。起動時に毎回叩くと、ネットが要る処理を起動に混ぜることになります(さっきオフライン起動を守ったばかりなのに)。なので設定画面を開いたときだけ聞きに行く形にした。名前はあくまで「見せられたら見せる」扱いで、取れなければ名前なしの「ログイン中」のままでいいです。
切れていたら黙って取り直す、でもオフラインでは何もしない
me/ を叩く処理で一番考えたのが、失敗したときの振り分けだ。失敗には2種類あります。「トークンが切れている(401)」のと「そもそも通信できない(オフライン等)」です。この二つを混ぜると事故ります。
ネットが繋がっていないだけなのに「ログアウト扱い」にしてしまうと、地下鉄で設定を開いただけで弾き出されます。
final access = await _store.readAccess();
if (access == null) { /* 未ログイン扱い */ return; }
try {
final username = await _api.fetchMe(access); // me/ に「今ログインしてるのは誰?」と聞く
state = AuthState(status: AuthStatus.authenticated, username: username);
} on AuthApiException catch (e) {
if (e.statusCode != 401) return; // 401(失効)以外は状態をいじらない
final ok = await tryRefresh(); // 失効してたら裏で取り直す
if (!ok) return; // 取り直しも失敗=本当に未ログインへ
final access2 = await _store.readAccess();
state = AuthState(
status: AuthStatus.authenticated,
username: await _api.fetchMe(access2!), // 新しいトークンでもう一回
);
} catch (_) {
// 通信失敗など=状態を壊さない(オフラインで誤ログアウトしない)
}実機で、1時間前に切れたトークンが勝手に蘇った
全部組んでテストを通して、実機で設定を開いたら「tsuzu_taro でログイン中」と出た(テスト用のアカウント)。名前まで出ている。裏でサーバのログを見たら、地味にうれしい順番になっていた。
GET /me/ 401 ← まず弾かれた(前のログインから1時間、入館証は切れていた)
GET /me/ 200 ← 取り直してもう一回 → tsuzu_taro
1時間前にログインの一周通しをやったとき取った入館証(15分有効)が、当然切れていた。設定を開いた瞬間 me/ が 401 で弾かれ、アプリが裏で引換券を出して入館証を取り直し、もう一度 me/ を叩いて名前を取って表示していた。テストで一番ややこしく書いた「401 のときだけ取り直す」分岐が、本番の経路でそのまま動いていたのを、サーバのログで確かめられた。ログアウトを押せば「未ログイン」に戻って同期タイルが消え、もう一度ログインすれば名前ごと戻る、も確認できた。
学びポイント
- 「動く」と「アプリが状態を持っている」は別。 トークンがあって API が通っても、起動時に状態を復元していないと、画面は自分がログイン済みだと知りません。復元の配線は、書いてあっても繋ぎ忘れます。
- 表示のための情報は、起動の邪魔をしない所で best-effort に取ります。 名前は起動時じゃなく設定を開いたときに聞きに行き、取れなければ名前なしで通します。起動はネット非依存に保ちます。
- 失効(401)と通信失敗(例外)を分けます。 前者は裏で取り直し、後者は状態に触りません。混ぜると、オフラインなだけで誤ログアウトします。
おわりに
これでログインは「動く」から「使える」になった。土台と状態の見せ方はここまで。この時点で手つかずだったのが、iOS / Android でブラウザからアプリに戻ってくる「戻り口」と、web の中継を本番に出すことだった。
その戻り口をどの方式にするかは、同じ日に別記事 アプリのログイン、結局どういう形? — 『誰が認証するか』と『どう戻すか』を分けたら、ほどけた で決めている。アプリ独自の合図(custom scheme)は別アプリに横取り登録されうるので足さず、OS がドメインの持ち主を確認する App Links に倒した。③で「custom scheme を足す日が本当の急所」と後回しにした穴は、足さない選択で回収している。
この記事は2026年6月時点の自分の理解で書いています。仕様や認識が変わってる可能性があります。