diff --git a/README.md b/README.md index 4a5454b..e0472a3 100644 --- a/README.md +++ b/README.md @@ -151,7 +151,7 @@ Add to `android/app/src/main/AndroidManifest.xml`: ```xml - + @@ -160,12 +160,17 @@ Add to `android/app/src/main/AndroidManifest.xml`: ``` +Notes: +- The `android:label` attribute on the `` is optional. You can omit it or set any string. +- On Android 12+ (API level 31+), any Activity with an `intent-filter` must declare `android:exported="true"`. +- Use the same callback scheme string on both platforms: iOS (`CFBundleURLSchemes`) and Android (``). They must match exactly. + #### Differences in MiAuth and OAuth Configuration (Key Points for App Integration) - This configuration (registration of the URL scheme) is done on the "app side." It is not included in the library's Manifest. - Both methods require a "custom URL scheme" to return from an external browser to the app. - The difference lies in how to specify "where to return from the browser." - OAuth: Since it needs to return to an HTTPS `redirect_uri` from the authorization server, `redirect.html` placed there ultimately redirects back to `yourscheme://...` for the app. -- MiAuth: The `callback` query of the authentication start URL specifies `yourscheme://...` from the beginning (no need for `https`). +- MiAuth: The `callback` query of the authentication start URL points to the app via the custom scheme only (e.g., `yourscheme://`). No `https` is needed. ##### Example of MiAuth (no persistence) @@ -180,7 +185,7 @@ final miConfig = MisskeyMiAuthConfig( permissions: ['read:account', 'write:notes'], iconUrl: 'https://example.com/icon.png', // Optional ); -final miRes = await miClient.authenticate(miConfig); // returns token only +final miRes = await miClient.authenticate(miConfig); // returns token (and user if available) ``` ##### Example of MiAuth (with persistence via MisskeyAuthManager) @@ -241,12 +246,12 @@ final current = await auth.currentToken(); ##### How to Support Both Methods in the Same App - By registering the same `scheme` (e.g., `yourscheme`) in iOS's `Info.plist` and Android's `AndroidManifest.xml`, it can be shared between OAuth and MiAuth. -- If you implement the OAuth `redirect.html` to redirect to `yourscheme://oauth/callback?...`, you can reuse the same path expression (`yourscheme://oauth/callback`) for MiAuth's `callback`. +- This library uses a scheme-only callback for MiAuth (e.g., `yourscheme://`). You do not need to reuse a path like `yourscheme://oauth/callback` for MiAuth. - For Android, matching only on the `scheme` is sufficient as shown below (the `host` and `path` are optional). ```xml - + @@ -296,6 +301,7 @@ class MisskeyMiAuthClient { /// Authenticate with Misskey server using MiAuth (no persistence) Future authenticate(MisskeyMiAuthConfig config); } +``` #### MisskeyAuthManager @@ -358,7 +364,6 @@ class AccountEntry { final DateTime? createdAt; } ``` -``` ### Error Handling @@ -379,7 +384,7 @@ The library includes exception classes for: ### License -This project is licensed under the 3-Clause BSD License - see the [LICENSE](LICENSE) file for details. +This project is published by 司書 (LibraryLibrarian) under the 3-Clause BSD License. For details, please see the [LICENSE](LICENSE) file. ### Related Links @@ -416,26 +421,6 @@ dependencies: ### クイックスタート -#### かんたん例(MisskeyAuthManager) - -```dart -import 'package:misskey_auth/misskey_auth.dart'; - -final auth = MisskeyAuthManager.defaultInstance(); - -// 認証後にトークンを自動保存 -final key = await auth.loginWithOAuth( - MisskeyOAuthConfig( - host: 'misskey.io', - clientId: 'https://yourpage/yourapp/', - redirectUri: 'https://yourpage/yourapp/redirect.html', - scope: 'read:account write:notes', - callbackScheme: 'yourscheme', - ), - setActive: true, -); -``` - #### 1. client_idページの設定 MisskeyのOAuth 2.0はIndieAuth仕様に準拠しています。以下が必要です: @@ -549,7 +534,7 @@ await auth.signOutAll(); ```xml - + @@ -558,13 +543,18 @@ await auth.signOutAll(); ``` +補足: +- `` の `android:label` は省略可能です(省略しても動作します)。 +- Android 12+(API 31 以降)では、`intent-filter` を持つ Activity に `android:exported="true"` の指定が必須です。 +- iOS(`CFBundleURLSchemes`)と Android(``)で登録するカスタムスキーム名は同一にしてください(完全一致が必要)。 + #### MiAuth と OAuth の設定の違い(アプリ組み込み時のポイント) - この設定(URLスキームの登録)は「アプリ側」で行います。ライブラリ内のManifestには含めません。 - 両方式とも、外部ブラウザからアプリへ戻すために「カスタムURLスキーム」が必要です。 - 相違点は「ブラウザからどこに戻すか」の指定方法です。 - OAuth: 認可サーバーからはHTTPSの`redirect_uri`に戻る必要があるため、そこに配置した`redirect.html`が最終的に`yourscheme://...`へリダイレクトしてアプリに戻します。 - - MiAuth: 認証開始URLの`callback`クエリに、最初から`yourscheme://...`を指定します(`https`は不要)。 + - MiAuth: 認証開始URLの`callback`クエリには、アプリのカスタムスキームのみ(例: `yourscheme://`)を指定します(`https`は不要)。 ##### MiAuth の例(保存無し) @@ -579,7 +569,7 @@ final miConfig = MisskeyMiAuthConfig( permissions: ['read:account', 'write:notes'], iconUrl: 'https://example.com/icon.png', // 任意 ); -final miRes = await miClient.authenticate(miConfig); // トークンのみ返します +final miRes = await miClient.authenticate(miConfig); // トークン(必要に応じて user も)を返します ``` ##### MiAuth の例(MisskeyAuthManager による保存あり) @@ -640,13 +630,13 @@ final current = await auth.currentToken(); ##### 両方式を同一アプリでサポートするには -- iOSの`Info.plist`・Androidの`AndroidManifest.xml`で同じ`sheme`(例: `yourscheme`)を1つ登録すれば、OAuth/MiAuthで共用可能です。 -- OAuth用の`redirect.html`は、`yourscheme://oauth/callback?...`へ飛ばす実装にしておくと、MiAuthの`callback`でも同じパス表現(`yourscheme://oauth/callback`)を使い回せます。 +- iOSの`Info.plist`・Androidの`AndroidManifest.xml`で同じ`scheme`(例: `yourscheme`)を1つ登録すれば、OAuth/MiAuthで共用可能です。 +- 本ライブラリの MiAuth は scheme のみ(`yourscheme://`)を callback に使います。`yourscheme://oauth/callback` のようなパス付きに揃える必要はありません。 - Androidは以下のように`scheme`のみのマッチで十分です(`host`や`path`は任意)。 ```xml - + @@ -777,7 +767,7 @@ class AccountEntry { ### ライセンス -このプロジェクトは3-Clause BSD Licenseの下で公開されています。詳細は[LICENSE](LICENSE)ファイルをご覧ください。 +このプロジェクトは司書(LibraryLibrarian)によって、3-Clause BSD Licenseの下で公開されています。詳細は[LICENSE](LICENSE)ファイルをご覧ください。 ### リンク diff --git a/assets/demo_thumb.gif b/assets/demo_thumb.gif index 5e3e955..2c02333 100644 Binary files a/assets/demo_thumb.gif and b/assets/demo_thumb.gif differ diff --git a/example/lib/main.dart b/example/lib/main.dart index 377160f..fa6e4ae 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -3,6 +3,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:misskey_auth/misskey_auth.dart'; import 'package:loader_overlay/loader_overlay.dart'; +import 'package:flutter/services.dart'; void main() { runApp(const MyApp()); @@ -53,6 +54,38 @@ class _AuthExamplePageState extends State { // 状態 OAuthServerInfo? _serverInfo; + // スコープ入力(カスタムのみを採用) + final TextEditingController _oauthCustomScopesController = + TextEditingController(); + final TextEditingController _miCustomScopesController = + TextEditingController(); + + void _addOAuthCustomScopesFromInput() { + final List items = _oauthCustomScopesController.text + .split(',') + .map((e) => e.trim()) + .where((e) => e.isNotEmpty) + .toList(); + if (items.isEmpty) return; + _scopeController.text = items.join(' '); + setState(() { + _oauthCustomScopesController.clear(); + }); + } + + void _addMiCustomScopesFromInput() { + final List items = _miCustomScopesController.text + .split(',') + .map((e) => e.trim()) + .where((e) => e.isNotEmpty) + .toList(); + if (items.isEmpty) return; + _miPermissionsController.text = items.join(' '); + setState(() { + _miCustomScopesController.clear(); + }); + } + String _mapErrorToMessage(Object error) { // MisskeyAuth のカスタム例外をユーザー向け日本語に整形 if (error is MisskeyAuthException) { @@ -145,6 +178,8 @@ class _AuthExamplePageState extends State { _miAppNameController.text = 'Misskey Auth Example'; _miPermissionsController.text = 'read:account write:notes'; _miIconUrlController.text = ''; + + // 候補配列は廃止(カスタム欄から確定時にTextControllerへ反映) } Future _checkServerInfo() async { @@ -169,22 +204,24 @@ class _AuthExamplePageState extends State { }); if (serverInfo == null && mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('OAuth認証はサポートされていません(MiAuth認証を使用してください)')), - ); + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar( + const SnackBar( + content: Text('OAuth認証はサポートされていません(MiAuth認証を使用してください)')), + ); } } on MisskeyAuthException catch (e) { if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(_mapErrorToMessage(e))), - ); + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar(SnackBar(content: Text(_mapErrorToMessage(e)))); } } catch (e) { if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(e.toString())), - ); + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar(SnackBar(content: Text(e.toString()))); } } finally { if (mounted) { @@ -198,6 +235,8 @@ class _AuthExamplePageState extends State { context.loaderOverlay.show(); try { + // 未確定のカスタムスコープ入力を確定して反映 + _addOAuthCustomScopesFromInput(); final config = MisskeyOAuthConfig( host: _hostController.text.trim(), clientId: _clientIdController.text.trim(), @@ -214,24 +253,24 @@ class _AuthExamplePageState extends State { } if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('認証に成功しました!')), - ); + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar(const SnackBar(content: Text('認証に成功しました!'))); setState(() { _currentIndex = 3; // アカウント一覧タブへ }); } } on MisskeyAuthException catch (e) { if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(_mapErrorToMessage(e))), - ); + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar(SnackBar(content: Text(_mapErrorToMessage(e)))); } } catch (e) { if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('認証エラー: $e')), - ); + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar(SnackBar(content: Text('認証エラー: $e'))); } } finally { if (mounted) { @@ -245,6 +284,8 @@ class _AuthExamplePageState extends State { context.loaderOverlay.show(); try { + // 未確定のカスタムスコープ入力を確定して反映 + _addMiCustomScopesFromInput(); final host = _hostController.text.trim(); if (host.isEmpty) { throw Exception('ホストを入力してください'); @@ -278,24 +319,24 @@ class _AuthExamplePageState extends State { } if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('MiAuth に成功しました!')), - ); + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar(const SnackBar(content: Text('MiAuth に成功しました!'))); setState(() { _currentIndex = 3; // アカウント一覧タブへ }); } } on MisskeyAuthException catch (e) { if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(_mapErrorToMessage(e))), - ); + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar(SnackBar(content: Text(_mapErrorToMessage(e)))); } } catch (e) { if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('MiAuth エラー: $e')), - ); + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar(SnackBar(content: Text('MiAuth エラー: $e'))); } } finally { if (mounted) { @@ -364,7 +405,7 @@ class _AuthExamplePageState extends State { hintText: '例: misskeyauth', ), ), - const SizedBox(height: 16), + const SizedBox(height: 8), TextField( controller: _hostController, decoration: const InputDecoration( @@ -390,25 +431,31 @@ class _AuthExamplePageState extends State { ), ), const SizedBox(height: 8), + const Text( + 'カスタムスコープ(カンマ区切り)', + style: TextStyle(fontWeight: FontWeight.w600), + ), + const SizedBox(height: 8), TextField( - controller: _scopeController, + controller: _oauthCustomScopesController, decoration: const InputDecoration( - labelText: 'スコープ', - hintText: '例: read:account write:notes', + labelText: '例: write:drive, read:favorites', ), + keyboardType: TextInputType.text, + textInputAction: TextInputAction.done, + onSubmitted: (_) => _addOAuthCustomScopesFromInput(), ), const SizedBox(height: 16), - Row( - children: [ - ElevatedButton( - onPressed: _startAuth, - style: ElevatedButton.styleFrom( - backgroundColor: Theme.of(context).colorScheme.primary, - foregroundColor: Colors.white, - ), - child: const Text('認証を開始'), + SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: _startAuth, + style: ElevatedButton.styleFrom( + backgroundColor: Theme.of(context).colorScheme.primary, + foregroundColor: Colors.white, ), - ], + child: const Text('OAuthで認証'), + ), ), ], ), @@ -452,12 +499,19 @@ class _AuthExamplePageState extends State { ), ), const SizedBox(height: 8), + const Text( + 'カスタムスコープ(カンマ区切り)', + style: TextStyle(fontWeight: FontWeight.w600), + ), + const SizedBox(height: 8), TextField( - controller: _miPermissionsController, + controller: _miCustomScopesController, decoration: const InputDecoration( - labelText: '権限(空白/カンマ区切り)', - hintText: '例: read:account write:notes', + labelText: '例: write:drive, read:favorites', ), + keyboardType: TextInputType.text, + textInputAction: TextInputAction.done, + onSubmitted: (_) => _addMiCustomScopesFromInput(), ), const SizedBox(height: 8), TextField( @@ -467,17 +521,16 @@ class _AuthExamplePageState extends State { ), ), const SizedBox(height: 16), - Row( - children: [ - ElevatedButton( - onPressed: _startMiAuth, - style: ElevatedButton.styleFrom( - backgroundColor: Theme.of(context).colorScheme.primary, - foregroundColor: Colors.white, - ), - child: const Text('MiAuthで認証'), + SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: _startMiAuth, + style: ElevatedButton.styleFrom( + backgroundColor: Theme.of(context).colorScheme.primary, + foregroundColor: Colors.white, ), - ], + child: const Text('MiAuthで認証'), + ), ), ], ), @@ -497,13 +550,45 @@ class _AuthExamplePageState extends State { style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), ), const SizedBox(height: 8), - Text('認証エンドポイント:\n${_serverInfo!.authorizationEndpoint}'), + const Text('認証エンドポイント'), const SizedBox(height: 4), - Text('トークンエンドポイント:\n${_serverInfo!.tokenEndpoint}'), - if (_serverInfo!.scopesSupported != null) ...[ + SelectableText(_serverInfo!.authorizationEndpoint), + const SizedBox(height: 8), + const Text('トークンエンドポイント'), + const SizedBox(height: 4), + SelectableText(_serverInfo!.tokenEndpoint), + if (_serverInfo!.scopesSupported != null && + _serverInfo!.scopesSupported!.isNotEmpty) ...[ + const SizedBox(height: 12), + const Text('サポートされているスコープ(タップでコピー)'), const SizedBox(height: 4), - Text( - 'サポートされているスコープ:\n${_serverInfo!.scopesSupported!.join(', ')}'), + ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 200), + child: Scrollbar( + child: ListView.separated( + itemCount: _serverInfo!.scopesSupported!.length, + separatorBuilder: (_, __) => const Divider(height: 1), + itemBuilder: (context, index) { + final scope = _serverInfo!.scopesSupported![index]; + return InkWell( + onTap: () async { + await Clipboard.setData(ClipboardData(text: scope)); + if (!context.mounted) return; + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar( + SnackBar(content: Text('コピーしました: $scope')), + ); + }, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 10.0), + child: Text(scope), + ), + ); + }, + ), + ), + ), ], ], ), @@ -534,9 +619,12 @@ class _AuthExamplePageState extends State { ), ), const SizedBox(height: 16), - ElevatedButton( - onPressed: _checkServerInfo, - child: const Text('サーバー情報を確認'), + SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: _checkServerInfo, + child: const Text('サーバー情報を確認'), + ), ), ], ), @@ -585,9 +673,11 @@ class _AuthExamplePageState extends State { '[Dump] ${key.host}/${key.accountId} token=${t?.accessToken}'); } if (!context.mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('デバッグログにトークンを出力しました')), - ); + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar( + const SnackBar(content: Text('デバッグログにトークンを出力しました')), + ); }, ) ], @@ -647,9 +737,12 @@ class _AuthExamplePageState extends State { await _auth.setActive(key); if (mounted) setState(() {}); if (!context.mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('デフォルトを変更: ${key.accountId}')), - ); + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar( + SnackBar( + content: Text('デフォルトを変更: ${key.accountId}')), + ); }, ); }, diff --git a/lib/src/api/misskey_miauth_client.dart b/lib/src/api/misskey_miauth_client.dart index 695a590..6c3276b 100644 --- a/lib/src/api/misskey_miauth_client.dart +++ b/lib/src/api/misskey_miauth_client.dart @@ -7,6 +7,7 @@ import 'package:flutter_web_auth_2/flutter_web_auth_2.dart'; import '../models/miauth_models.dart'; import '../exceptions/misskey_auth_exception.dart'; +import '../net/retry.dart'; /// Misskey の MiAuth 認証を扱うクライアント class MisskeyMiAuthClient { @@ -110,10 +111,13 @@ class MisskeyMiAuthClient { path: '/api/miauth/$sessionId/check', ); - final response = await _dio.post( - checkUrl.toString(), - options: Options(contentType: 'application/json'), - data: {}, + final response = await retry( + () => _dio.post( + checkUrl.toString(), + options: Options(contentType: 'application/json'), + data: {}, + ), + const RetryPolicy(maxAttempts: 3), ); if (response.statusCode != 200) { diff --git a/lib/src/api/misskey_oauth_client.dart b/lib/src/api/misskey_oauth_client.dart index 8a93755..dfad0ea 100644 --- a/lib/src/api/misskey_oauth_client.dart +++ b/lib/src/api/misskey_oauth_client.dart @@ -7,6 +7,7 @@ import 'package:flutter/services.dart'; import 'package:flutter_web_auth_2/flutter_web_auth_2.dart'; import '../models/oauth_models.dart'; import '../exceptions/misskey_auth_exception.dart'; +import '../net/retry.dart'; /// MisskeyのOAuth認証を管理するクライアント class MisskeyOAuthClient { @@ -41,8 +42,9 @@ class MisskeyOAuthClient { /// OAuth認証サーバー情報を取得 Future getOAuthServerInfo(String host) async { try { - final response = await _dio.get( - 'https://$host/.well-known/oauth-authorization-server', + final response = await retry( + () => _dio.get('https://$host/.well-known/oauth-authorization-server'), + const RetryPolicy(maxAttempts: 3), ); if (response.statusCode == 200) { @@ -253,19 +255,22 @@ class MisskeyOAuthClient { required String codeVerifier, }) async { try { - final response = await _dio.post( - tokenEndpoint, - options: Options( - contentType: 'application/x-www-form-urlencoded', + final response = await retry( + () => _dio.post( + tokenEndpoint, + options: Options( + contentType: 'application/x-www-form-urlencoded', + ), + data: { + 'grant_type': 'authorization_code', + 'client_id': clientId, + 'redirect_uri': redirectUri, + 'scope': scope, + 'code': code, + 'code_verifier': codeVerifier, + }, ), - data: { - 'grant_type': 'authorization_code', - 'client_id': clientId, - 'redirect_uri': redirectUri, - 'scope': scope, - 'code': code, - 'code_verifier': codeVerifier, - }, + const RetryPolicy(maxAttempts: 3), ); if (response.statusCode == 200) { diff --git a/lib/src/auth/misskey_auth_manager.dart b/lib/src/auth/misskey_auth_manager.dart index d69d863..8a2b4ac 100644 --- a/lib/src/auth/misskey_auth_manager.dart +++ b/lib/src/auth/misskey_auth_manager.dart @@ -9,6 +9,7 @@ import '../store/account_key.dart'; import '../store/secure_token_store.dart'; import '../store/stored_token.dart'; import '../store/token_store.dart'; +import '../net/retry.dart'; /// Misskey 認証の高レベル管理クラス /// @@ -102,10 +103,13 @@ class MisskeyAuthManager { String host, String accessToken) async { try { final url = 'https://$host/api/i'; - final response = await dio.post( - url, - // Misskeyの一般的な仕様に従い、リクエストボディに `i` でトークンを渡す - data: {'i': accessToken}, + final response = await retry( + () => dio.post( + url, + // Misskeyの一般的な仕様に従い、リクエストボディに `i` でトークンを渡す + data: {'i': accessToken}, + ), + const RetryPolicy(maxAttempts: 3), ); if (response.statusCode == 200 && response.data is Map) { return response.data as Map; diff --git a/lib/src/net/retry.dart b/lib/src/net/retry.dart new file mode 100644 index 0000000..afc6e68 --- /dev/null +++ b/lib/src/net/retry.dart @@ -0,0 +1,83 @@ +import 'dart:math'; + +import 'package:dio/dio.dart'; + +/// ネットワークリクエストのリトライポリシー。 +class RetryPolicy { + /// 最大試行回数(初回含む)。 + final int maxAttempts; + + /// 初回遅延。 + final Duration initialDelay; + + /// 指数バックオフの倍率。 + final double backoffFactor; + + /// 遅延の上限。 + final Duration maxDelay; + + /// リトライ対象の Dio 例外タイプ。 + final Set retryOnTypes; + + /// リトライ対象のHTTPステータス。 + final Set retryOnStatusCodes; + + const RetryPolicy({ + this.maxAttempts = 3, + this.initialDelay = const Duration(milliseconds: 500), + this.backoffFactor = 2.0, + this.maxDelay = const Duration(seconds: 5), + this.retryOnTypes = const { + DioExceptionType.connectionTimeout, + DioExceptionType.sendTimeout, + DioExceptionType.receiveTimeout, + DioExceptionType.connectionError, + DioExceptionType.unknown, + }, + this.retryOnStatusCodes = const {429, 500, 502, 503, 504}, + }); + + /// 応答/例外からリトライすべきかを判定。 + bool shouldRetry(DioException e) { + if (retryOnTypes.contains(e.type)) { + return true; + } + final status = e.response?.statusCode; + if (status != null && retryOnStatusCodes.contains(status)) { + return true; + } + return false; + } + + /// 指数バックオフで次の遅延を計算(上限あり、軽いジッター)。 + Duration nextDelay(int attemptIndexZeroBased) { + final baseMs = + initialDelay.inMilliseconds * pow(backoffFactor, attemptIndexZeroBased); + final cappedMs = min(baseMs.round(), maxDelay.inMilliseconds); + final jitterMs = Random().nextInt(100); // 0-99ms の軽いジッター + return Duration( + milliseconds: min(cappedMs + jitterMs, maxDelay.inMilliseconds)); + } +} + +/// 与えられた非同期処理をリトライ付きで実行します。 +Future retry(Future Function() action, RetryPolicy policy) async { + DioException? last; + for (int attempt = 0; attempt < policy.maxAttempts; attempt++) { + try { + return await action(); + } on DioException catch (e) { + last = e; + final should = policy.shouldRetry(e); + final isLast = attempt == policy.maxAttempts - 1; + if (!should || isLast) { + rethrow; + } + await Future.delayed(policy.nextDelay(attempt)); + continue; + } + } + // 通常ここには到達しない + if (last != null) throw last; + throw StateError('Retry failed with unknown error'); +}