644 lines
21 KiB
Dart
644 lines
21 KiB
Dart
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.');
|
|
}
|
|
}
|
|
}
|
|
}
|