[verified] Publish ReelBox source under GPLv3
This commit is contained in:
commit
db2b505ca3
445 files changed
+105274
No files matched your search
@@ -0,0 +1,643 @@
|
||||
import 'dart:collection';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import 'models.dart';
|
||||
import 'content_policy.dart';
|
||||
|
||||
List<dynamic> _array(dynamic raw) {
|
||||
if (raw == null) return [];
|
||||
if (raw is List) return raw;
|
||||
if (raw is Map) {
|
||||
for (final key in ['posters', 'data', 'result', 'items']) {
|
||||
if (raw[key] is List) return raw[key] as List;
|
||||
}
|
||||
}
|
||||
throw const ApiException(
|
||||
'The service returned an unsupported response shape.',
|
||||
);
|
||||
}
|
||||
|
||||
List<Map<String, dynamic>> _maps(dynamic raw) =>
|
||||
_array(raw)
|
||||
.whereType<Map>()
|
||||
.map((item) => Map<String, dynamic>.from(item))
|
||||
.toList();
|
||||
Map<String, dynamic> _object(dynamic raw) {
|
||||
if (raw is Map) return Map<String, dynamic>.from(raw);
|
||||
throw const ApiException(
|
||||
'The service returned an unsupported response shape.',
|
||||
);
|
||||
}
|
||||
|
||||
List<TitleRecord> _titles(dynamic raw) =>
|
||||
_maps(raw)
|
||||
.where(ContentPolicy.allowsTitleJson)
|
||||
.map(TitleRecord.fromJson)
|
||||
.where((title) => title.id > 0 && title.title.isNotEmpty)
|
||||
.toList();
|
||||
|
||||
/// Safe error suitable for presentation. Never holds request URLs or form data.
|
||||
class ApiException implements Exception {
|
||||
final String message;
|
||||
final int? statusCode;
|
||||
const ApiException(this.message, {this.statusCode});
|
||||
@override
|
||||
String toString() => message;
|
||||
}
|
||||
|
||||
/// A single raw server page, compatible with existing List-based callers.
|
||||
/// Visible items may be empty even when the raw page has a continuation.
|
||||
class CatalogPage extends ListBase<TitleRecord> {
|
||||
CatalogPage(Iterable<TitleRecord> items, {required this.nextPage})
|
||||
: _items = List.unmodifiable(items);
|
||||
final List<TitleRecord> _items;
|
||||
final int? nextPage;
|
||||
@override
|
||||
int get length => _items.length;
|
||||
@override
|
||||
set length(int value) => throw UnsupportedError('Read-only page');
|
||||
@override
|
||||
TitleRecord operator [](int index) => _items[index];
|
||||
@override
|
||||
void operator []=(int index, TitleRecord value) =>
|
||||
throw UnsupportedError('Read-only page');
|
||||
}
|
||||
|
||||
class ApiService {
|
||||
// Iran accepts a public route marker, not an upstream application secret.
|
||||
static const sharedKey = 'public';
|
||||
// Compatibility only; actor queries no longer send a key.
|
||||
static const actorKey = 'public';
|
||||
// Release AOT also uses --obfuscate. This removes an obvious literal from
|
||||
// binary string scans, not a secret: network destinations remain observable.
|
||||
static final String iranBaseUrl = String.fromCharCodes(
|
||||
[
|
||||
50,
|
||||
46,
|
||||
46,
|
||||
42,
|
||||
41,
|
||||
96,
|
||||
117,
|
||||
117,
|
||||
40,
|
||||
63,
|
||||
63,
|
||||
54,
|
||||
56,
|
||||
53,
|
||||
34,
|
||||
116,
|
||||
62,
|
||||
59,
|
||||
40,
|
||||
46,
|
||||
46,
|
||||
35,
|
||||
116,
|
||||
46,
|
||||
53,
|
||||
42,
|
||||
117,
|
||||
59,
|
||||
42,
|
||||
51,
|
||||
117,
|
||||
].map((value) => value ^ 0x5a),
|
||||
);
|
||||
static final String defaultBaseUrl = iranBaseUrl;
|
||||
static const _selectionKey = 'reelbox.api-selection.v1';
|
||||
// One production metadata origin; loopback remains an explicit test seam.
|
||||
static final _serviceOrigins = {Uri.parse(iranBaseUrl).origin};
|
||||
static const _legacyHosts = {
|
||||
'simbaapp.info',
|
||||
'winbedrives.com',
|
||||
'boxlimon.com',
|
||||
'gitcss.info',
|
||||
};
|
||||
final Dio _dio;
|
||||
// Only pending idempotent metadata reads are shared. Never retain detail,
|
||||
// source or catalog responses after completion: existing refreshes stay fresh.
|
||||
final _pendingMetadata = <String, Future<dynamic>>{};
|
||||
static const _maxPendingMetadata = 64;
|
||||
|
||||
static bool _shareableMetadata(Uri uri) {
|
||||
if (RegExp(
|
||||
r'^/api/(first/public/|(genre|country)/all/public/|movie/by/[0-9]+/public/|movie/by/actor/[0-9]+/public/|season/by/serie/[0-9]+/public/|role/by/poster/[0-9]+/public/|subtitles/by/(movie|episode)/[0-9]+/public/|(movie|serie|poster)/by/filtres/[^?]+/public/)$',
|
||||
).hasMatch(uri.path)) {
|
||||
// Unknown query actions can change the semantics of an otherwise known
|
||||
// route. Keep them outside the sharing allowlist.
|
||||
return !uri.queryParametersAll.containsKey('action');
|
||||
}
|
||||
return false;
|
||||
}
|
||||
final Future<SharedPreferences> Function() _preferences;
|
||||
bool _cacheRead = false;
|
||||
Future<void>? _cacheRestore;
|
||||
static const _cacheKey = 'reelbox.trusted-api.v1';
|
||||
String _baseUrl;
|
||||
int _selectionGeneration = 0;
|
||||
Future<void> _selectionWrite = Future<void>.value();
|
||||
ApiService({
|
||||
Dio? dio,
|
||||
String? baseUrl,
|
||||
Future<SharedPreferences> Function()? preferences,
|
||||
}) : _dio = dio ?? Dio(),
|
||||
_preferences = preferences ?? SharedPreferences.getInstance,
|
||||
_baseUrl = _validatedBase(baseUrl ?? defaultBaseUrl) {
|
||||
// Do not inherit upstream credentials or query defaults from injected Dio.
|
||||
_dio.options = BaseOptions(
|
||||
connectTimeout: const Duration(seconds: 12),
|
||||
receiveTimeout: const Duration(seconds: 25),
|
||||
sendTimeout: const Duration(seconds: 12),
|
||||
followRedirects: false,
|
||||
maxRedirects: 0,
|
||||
responseType: ResponseType.plain,
|
||||
headers: {'Accept': 'application/json'},
|
||||
);
|
||||
}
|
||||
String get baseUrl => _baseUrl;
|
||||
String get hostRoot => Uri.parse(_baseUrl).origin;
|
||||
static bool _loopback(Uri uri) =>
|
||||
{'localhost', '127.0.0.1', '::1', '[::1]'}.contains(uri.host);
|
||||
static String _validatedBase(String value) {
|
||||
try {
|
||||
if (value != value.trim() ||
|
||||
!RegExp(r'^https?://[^/]+/api/?$').hasMatch(value)) {
|
||||
throw const FormatException();
|
||||
}
|
||||
final uri = Uri.parse(value);
|
||||
final trusted =
|
||||
uri.scheme == 'https' && _serviceOrigins.contains(uri.origin);
|
||||
final local =
|
||||
_loopback(uri) && (uri.scheme == 'http' || uri.scheme == 'https');
|
||||
if ((!trusted && !local) ||
|
||||
uri.userInfo.isNotEmpty ||
|
||||
uri.hasQuery ||
|
||||
uri.hasFragment ||
|
||||
!{'/api', '/api/'}.contains(uri.path)) {
|
||||
throw const FormatException();
|
||||
}
|
||||
return uri.replace(path: '/api/').toString();
|
||||
} on FormatException {
|
||||
throw const ApiException('Use a trusted HTTPS API server.');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> setBaseUrl(String url) async {
|
||||
final validated = _validatedBase(url);
|
||||
++_selectionGeneration;
|
||||
final write = _selectionWrite.then((_) async {
|
||||
final local = _loopback(Uri.parse(validated));
|
||||
if (!local) {
|
||||
final stored = await (await _preferences()).setString(
|
||||
_selectionKey,
|
||||
'primary',
|
||||
);
|
||||
if (!stored) {
|
||||
throw const ApiException('Could not save the API selection.');
|
||||
}
|
||||
}
|
||||
_cacheRead = true;
|
||||
_baseUrl = validated;
|
||||
});
|
||||
_selectionWrite = write.catchError((Object _) {});
|
||||
return write;
|
||||
}
|
||||
|
||||
// `discover` remains source-compatible; remote provider discovery is retired.
|
||||
Future<void> initialize({bool discover = true}) async {
|
||||
await _restoreCachedBase();
|
||||
}
|
||||
|
||||
Future<void> _restoreCachedBase() {
|
||||
if (_cacheRead || _loopback(Uri.parse(_baseUrl))) {
|
||||
return Future<void>.value();
|
||||
}
|
||||
final running = _cacheRestore;
|
||||
if (running != null) return running;
|
||||
late final Future<void> work;
|
||||
work =
|
||||
(() async {
|
||||
final generation = _selectionGeneration;
|
||||
try {
|
||||
final prefs = await _preferences();
|
||||
if (_selectionGeneration != generation) return;
|
||||
// Stored provider choices and old discovery caches are migration
|
||||
// inputs, never permission to contact another production origin.
|
||||
await prefs.setString(_selectionKey, 'primary');
|
||||
if (prefs.containsKey(_cacheKey)) {
|
||||
await prefs.remove(_cacheKey);
|
||||
}
|
||||
} catch (_) {
|
||||
// Storage failure must not change the Iran-only in-memory default.
|
||||
}
|
||||
})().whenComplete(() {
|
||||
_cacheRead = true;
|
||||
if (identical(_cacheRestore, work)) _cacheRestore = null;
|
||||
});
|
||||
_cacheRestore = work;
|
||||
return work;
|
||||
}
|
||||
|
||||
Uri _resolve(String path, {bool allowLegacy = true}) {
|
||||
try {
|
||||
if (path != path.trim() || path.startsWith('//')) {
|
||||
throw const FormatException();
|
||||
}
|
||||
var rawPath = path.split('?').first;
|
||||
for (var i = 0; i < 3; i++) {
|
||||
if (RegExp(r'[\\\x00-\x20\x7f]').hasMatch(rawPath) ||
|
||||
RegExp(r'(^|/)\.{1,2}(/|$)').hasMatch(rawPath)) {
|
||||
throw const FormatException();
|
||||
}
|
||||
final decoded = Uri.decodeComponent(rawPath);
|
||||
if (decoded == rawPath) break;
|
||||
rawPath = decoded;
|
||||
}
|
||||
var uri = Uri.parse(path);
|
||||
if (uri.hasFragment || uri.userInfo.isNotEmpty) {
|
||||
throw const FormatException();
|
||||
}
|
||||
final base = Uri.parse(_baseUrl);
|
||||
if (uri.hasScheme || uri.hasAuthority) {
|
||||
if (!{'http', 'https'}.contains(uri.scheme)) {
|
||||
throw const FormatException();
|
||||
}
|
||||
if (uri.origin == base.origin) {
|
||||
// Current-host requests never downgrade HTTPS.
|
||||
} else if (allowLegacy &&
|
||||
_legacyHosts.contains(uri.host) &&
|
||||
(uri.scheme == 'http' || uri.scheme == 'https') &&
|
||||
((!uri.hasPort) || uri.port == 80 || uri.port == 443) &&
|
||||
(uri.path.startsWith('/api-user/newapi/') ||
|
||||
uri.path.startsWith('/api/'))) {
|
||||
uri = Uri.parse(
|
||||
'$hostRoot${uri.path}${uri.hasQuery ? '?${uri.query}' : ''}',
|
||||
);
|
||||
} else {
|
||||
throw const FormatException();
|
||||
}
|
||||
} else {
|
||||
uri = base.resolve(path);
|
||||
}
|
||||
if (uri.origin != base.origin ||
|
||||
!(uri.path.startsWith('/api/') ||
|
||||
uri.path.startsWith('/api-user/'))) {
|
||||
throw const FormatException();
|
||||
}
|
||||
return uri;
|
||||
} on FormatException {
|
||||
throw const ApiException('The service link is not trusted.');
|
||||
}
|
||||
}
|
||||
|
||||
List<TitleRecord> decodeTitles(dynamic raw) => _titles(raw);
|
||||
Future<dynamic> _legacy(String file, Map<String, dynamic> query) async {
|
||||
await _restoreCachedBase();
|
||||
return _request(
|
||||
'GET',
|
||||
_resolve('$hostRoot/api-user/newapi/$file'),
|
||||
query: query,
|
||||
);
|
||||
}
|
||||
|
||||
Future<List<TitleRecord>> _titlePage(
|
||||
Future<dynamic> request,
|
||||
int page,
|
||||
) async {
|
||||
final raw = _array(await request);
|
||||
// Hidden announcements must not terminate upstream catalog pagination.
|
||||
return CatalogPage(_titles(raw), nextPage: raw.isEmpty ? null : page + 1);
|
||||
}
|
||||
|
||||
Future<List<TitleRecord>> search(
|
||||
String query, {
|
||||
int page = 1,
|
||||
bool english = false,
|
||||
}) => _titlePage(
|
||||
_legacy('like.php', {
|
||||
'action': english ? 'search-movie-en' : 'search-movie',
|
||||
'q': query,
|
||||
'pageno': page,
|
||||
}),
|
||||
page,
|
||||
);
|
||||
Future<List<TitleRecord>> recommended(int genre, {int page = 1}) async =>
|
||||
_titlePage(
|
||||
_legacy('filter.php', {
|
||||
'action': 'pishnahad',
|
||||
'garid': genre,
|
||||
'pageno': page,
|
||||
}),
|
||||
page,
|
||||
);
|
||||
Future<List<TitleRecord>> bestRandom({bool byYear = false, int page = 1}) =>
|
||||
_titlePage(
|
||||
_legacy('like.php', {
|
||||
'action': byYear ? 'best-rand-year' : 'best-rand-top',
|
||||
'pageno': page,
|
||||
}),
|
||||
page,
|
||||
);
|
||||
Future<List<String>> trending() async {
|
||||
final raw = _array(
|
||||
await _legacy('like.php', {'action': 'search-trand', 'pageno': 1}),
|
||||
);
|
||||
return raw
|
||||
.map(
|
||||
(item) => item is String
|
||||
? item
|
||||
: item is Map
|
||||
? item['title'] ?? item['q']
|
||||
: null,
|
||||
)
|
||||
.whereType<String>()
|
||||
.where(ContentPolicy.allowsText)
|
||||
.where((title) => title.trim().isNotEmpty)
|
||||
.take(14)
|
||||
.toList();
|
||||
}
|
||||
|
||||
Future<List<Map<String, dynamic>>> actors({
|
||||
int page = 1,
|
||||
String search = '',
|
||||
}) async {
|
||||
await _restoreCachedBase();
|
||||
if (search.trim().isEmpty) {
|
||||
return _maps(
|
||||
ContentPolicy.sanitizeJson(
|
||||
await _legacy('like.php', {'action': 'all-actor', 'pageno': page}),
|
||||
),
|
||||
);
|
||||
}
|
||||
// Native Retrofit evidence; the desktop like.php starsearch route currently
|
||||
// returns an empty non-JSON body rather than actor results.
|
||||
return _maps(
|
||||
await get(
|
||||
'$hostRoot/api-user/filmahang/star.php',
|
||||
query: {'action': 'starsearch', 'q': search.trim(), 'pageno': page},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<List<Map<String, dynamic>>> playlists({
|
||||
bool mine = false,
|
||||
String userId = '',
|
||||
int page = 1,
|
||||
}) async {
|
||||
// No-network compatibility seam for older callers. Public lists are removed.
|
||||
if (mine) {
|
||||
throw const ApiException('Server playlists are not part of Reelbox.');
|
||||
}
|
||||
return const [];
|
||||
}
|
||||
|
||||
Future<List<Map<String, dynamic>>> playlistItems(
|
||||
int listId, {
|
||||
int page = 1,
|
||||
}) async => const [];
|
||||
Future<List<Map<String, dynamic>>> curatedLinks({bool best = false}) async {
|
||||
await _restoreCachedBase();
|
||||
final groups = _maps(
|
||||
await _legacy(best ? 'bartar.php' : 'pishnahad.php', {}),
|
||||
);
|
||||
final result = <Map<String, dynamic>>[];
|
||||
for (final group in groups) {
|
||||
final titles = group['title'], links = group['link'];
|
||||
if (titles is! List || links is! List) continue;
|
||||
for (var i = 0; i < titles.length && i < links.length; i++) {
|
||||
if (titles[i] is! String || links[i] is! String) continue;
|
||||
if (!ContentPolicy.allowsText(titles[i] as String) ||
|
||||
!ContentPolicy.allowsUrl(links[i] as String)) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
result.add({
|
||||
'title': titles[i],
|
||||
'link': _resolve(links[i] as String).toString(),
|
||||
});
|
||||
} on ApiException {
|
||||
// Never expose a server-supplied arbitrary destination as API content.
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
Future<List<TitleRecord>> curatedContent(String link, {int page = 1}) async {
|
||||
await _restoreCachedBase();
|
||||
final uri = _resolve(link);
|
||||
final params = {...uri.queryParameters, 'pageno': '$page'};
|
||||
return _titles(await get(uri.replace(queryParameters: params).toString()));
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> home() async {
|
||||
final data = _object(await get('first/$sharedKey/'));
|
||||
// Unknown future server fields cannot silently introduce vendor feeds.
|
||||
const keys = {
|
||||
'posters',
|
||||
'genres',
|
||||
'slides',
|
||||
'actors',
|
||||
'countries',
|
||||
'movies',
|
||||
'series',
|
||||
'featured',
|
||||
'popular',
|
||||
'trending',
|
||||
};
|
||||
return {
|
||||
for (final entry in data.entries)
|
||||
if (keys.contains(entry.key)) entry.key: entry.value,
|
||||
};
|
||||
}
|
||||
|
||||
Future<List<Tag>> _tags(String name) async =>
|
||||
_maps(await get('$name/all/$sharedKey/')).map(Tag.fromJson).toList();
|
||||
Future<List<Tag>> genres() => _tags('genre');
|
||||
Future<List<Tag>> countries() => _tags('country');
|
||||
Future<List<Tag>> categories() async => const [];
|
||||
Future<List<TitleRecord>> catalog({
|
||||
String kind = 'movie',
|
||||
int genre = 0,
|
||||
int country = 0,
|
||||
String order = 'created',
|
||||
int page = 0,
|
||||
}) async {
|
||||
if (!{'movie', 'serie', 'series', 'mixed', 'poster'}.contains(kind) ||
|
||||
page < 0) {
|
||||
throw const ApiException('Unsupported catalog filter.');
|
||||
}
|
||||
await _restoreCachedBase();
|
||||
final normalized = kind == 'series' ? 'serie' : kind;
|
||||
final mixed =
|
||||
normalized == 'mixed' || normalized == 'poster' || country != 0;
|
||||
final route = mixed
|
||||
? 'poster/by/filtres/$genre/$country/${Uri.encodeComponent(order)}/$page'
|
||||
: '$normalized/by/filtres/$genre/${Uri.encodeComponent(order)}/$page';
|
||||
final raw = _array(await _request('GET', _resolve('$route/$sharedKey/')));
|
||||
final titles = _titles(raw);
|
||||
return CatalogPage(
|
||||
country != 0 && {'movie', 'serie'}.contains(normalized)
|
||||
? titles.where((title) => title.type == normalized)
|
||||
: titles,
|
||||
nextPage: raw.isEmpty ? null : page + 1,
|
||||
);
|
||||
}
|
||||
|
||||
Future<List<TitleRecord>> channels({
|
||||
int category = 0,
|
||||
int country = 0,
|
||||
int page = 0,
|
||||
}) async => const [];
|
||||
Future<TitleRecord> poster(int id) async =>
|
||||
TitleRecord.fromJson(_object(await get('movie/by/$id/$sharedKey/')));
|
||||
Future<TitleRecord> channel(int id) async =>
|
||||
throw const ApiException('This content is not part of Reelbox.');
|
||||
Future<List<SeasonRecord>> seasons(int id) async =>
|
||||
_maps(await get('season/by/serie/$id/$sharedKey/'))
|
||||
.map(SeasonRecord.fromJson)
|
||||
.toList();
|
||||
Future<List<TitleRecord>> actorTitles(int id) async =>
|
||||
_titles(await get('movie/by/actor/$id/$sharedKey/'));
|
||||
Future<List<Map<String, dynamic>>> roles(int posterId) async =>
|
||||
_maps(await get('role/by/poster/$posterId/$sharedKey/'));
|
||||
Future<List<Map<String, dynamic>>> subtitles(String kind, int id) async {
|
||||
if (!{'movie', 'episode'}.contains(kind)) {
|
||||
throw const ApiException('Unsupported subtitle kind.');
|
||||
}
|
||||
return _maps(await get('subtitles/by/$kind/$id/$sharedKey/'));
|
||||
}
|
||||
|
||||
Future<List<Map<String, dynamic>>> comments(int posterId) async => const [];
|
||||
|
||||
Future<dynamic> get(String path, {Map<String, dynamic>? query}) async {
|
||||
await _restoreCachedBase();
|
||||
return ContentPolicy.sanitizeJson(
|
||||
await _request('GET', _resolve(path), query: query),
|
||||
);
|
||||
}
|
||||
|
||||
Future<dynamic> post(String path, Map<String, dynamic> form) async {
|
||||
await _restoreCachedBase();
|
||||
return _request('POST', _resolve(path, allowLegacy: false), form: form);
|
||||
}
|
||||
|
||||
Future<dynamic> _request(
|
||||
String method,
|
||||
Uri uri, {
|
||||
Map<String, dynamic>? query,
|
||||
Map<String, dynamic>? form,
|
||||
bool retryTransport = true,
|
||||
}) async {
|
||||
final policyUris = [
|
||||
uri,
|
||||
for (final action in [query?['action'], form?['action']])
|
||||
if (action != null) uri.replace(queryParameters: {'action': '$action'}),
|
||||
];
|
||||
if (policyUris.any((value) => !ContentPolicy.allowsApiPath(value)) ||
|
||||
!ContentPolicy.allowsUrl(uri.toString())) {
|
||||
throw const ApiException('This content is not part of Reelbox.');
|
||||
}
|
||||
// Key exactly the URI Dio will send, including origin, page and filters.
|
||||
// Validation above always runs, including for a potential cache hit.
|
||||
final requestUri = Options(method: method)
|
||||
.compose(_dio.options, uri.toString(), queryParameters: query)
|
||||
.uri;
|
||||
if (method != 'GET' || !_shareableMetadata(requestUri)) {
|
||||
return _sendRequest(method, requestUri, form: form,
|
||||
retryTransport: retryTransport);
|
||||
}
|
||||
final key = requestUri.toString();
|
||||
final pending = _pendingMetadata[key];
|
||||
if (pending != null) return pending;
|
||||
if (_pendingMetadata.length >= _maxPendingMetadata) {
|
||||
return _sendRequest(method, requestUri,
|
||||
retryTransport: retryTransport);
|
||||
}
|
||||
late final Future<dynamic> work;
|
||||
work = _sendRequest(method, requestUri,
|
||||
retryTransport: retryTransport).whenComplete(() {
|
||||
if (identical(_pendingMetadata[key], work)) {
|
||||
_pendingMetadata.remove(key);
|
||||
}
|
||||
});
|
||||
_pendingMetadata[key] = work;
|
||||
return work;
|
||||
}
|
||||
|
||||
Future<dynamic> _sendRequest(
|
||||
String method,
|
||||
Uri uri, {
|
||||
Map<String, dynamic>? form,
|
||||
required bool retryTransport,
|
||||
}) async {
|
||||
for (var attempt = 0; ; attempt++) {
|
||||
try {
|
||||
final response = await _dio.request<dynamic>(
|
||||
uri.toString(),
|
||||
data: form,
|
||||
options: Options(
|
||||
method: method,
|
||||
followRedirects: false,
|
||||
maxRedirects: 0,
|
||||
responseType: ResponseType.plain,
|
||||
contentType: form == null
|
||||
? null
|
||||
: Headers.formUrlEncodedContentType,
|
||||
),
|
||||
);
|
||||
final status = response.statusCode ?? 0;
|
||||
if (status < 200 || status >= 300) {
|
||||
throw ApiException(
|
||||
'The service rejected the request.',
|
||||
statusCode: status,
|
||||
);
|
||||
}
|
||||
if (response.data is String) {
|
||||
final text = (response.data as String)
|
||||
.replaceFirst('\uFEFF', '')
|
||||
.trim();
|
||||
return jsonDecode(text);
|
||||
}
|
||||
return response.data;
|
||||
} on DioException catch (error) {
|
||||
if (attempt == 0 &&
|
||||
retryTransport &&
|
||||
method == 'GET' &&
|
||||
error.response == null &&
|
||||
const {
|
||||
DioExceptionType.connectionError,
|
||||
DioExceptionType.connectionTimeout,
|
||||
DioExceptionType.sendTimeout,
|
||||
DioExceptionType.receiveTimeout,
|
||||
}.contains(error.type)) {
|
||||
// One replay of an idempotent read only. No discovery request storm,
|
||||
// alternate untrusted destination, denial retry or mutation replay.
|
||||
await _restoreCachedBase();
|
||||
continue;
|
||||
}
|
||||
final code = error.response?.statusCode;
|
||||
final message = switch (error.type) {
|
||||
DioExceptionType.connectionTimeout ||
|
||||
DioExceptionType.sendTimeout ||
|
||||
DioExceptionType.receiveTimeout =>
|
||||
'The service timed out. Please retry.',
|
||||
DioExceptionType.connectionError =>
|
||||
'Unable to connect. Check your connection and retry.',
|
||||
DioExceptionType.cancel => 'The request was cancelled.',
|
||||
_ when code == 401 || code == 403 =>
|
||||
'The service denied access to this content.',
|
||||
_ => 'The service request failed. Please retry.',
|
||||
};
|
||||
throw ApiException(message, statusCode: code);
|
||||
} on FormatException {
|
||||
throw const ApiException('The service returned an invalid response.');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user