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');
+}