mirror of
https://github.com/GopeedLab/gopeed.git
synced 2026-11-08 01:20:27 +00:00
feat: move native communication to FFI (#1457)
This commit is contained in:
86 files changed
+4558
-886
No files matched your search
+45
-105
@@ -1,8 +1,6 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:dio/io.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../core/network/gopeed/gopeed_transport.dart';
|
||||
|
||||
import 'model/create_task.dart';
|
||||
import 'model/create_task_batch.dart';
|
||||
@@ -27,77 +25,35 @@ class ApiTimeoutException implements Exception {
|
||||
String toString() => message;
|
||||
}
|
||||
|
||||
class _Client {
|
||||
_Client._();
|
||||
GopeedTransport? _transport;
|
||||
|
||||
static final _Client instance = _Client._();
|
||||
|
||||
late Dio dio;
|
||||
bool initialized = false;
|
||||
|
||||
void init(String network, String address, String apiToken, {VoidCallback? onUnauthorized}) {
|
||||
final isUnixSocket = network == 'unix';
|
||||
final baseUrl = isUnixSocket
|
||||
? 'http://127.0.0.1/'
|
||||
: kIsWeb
|
||||
? kDebugMode
|
||||
? 'http://127.0.0.1:9999/'
|
||||
: ''
|
||||
: 'http://$address/';
|
||||
dio = Dio(
|
||||
BaseOptions(
|
||||
baseUrl: baseUrl,
|
||||
contentType: Headers.jsonContentType,
|
||||
sendTimeout: const Duration(seconds: 5),
|
||||
connectTimeout: const Duration(seconds: 5),
|
||||
receiveTimeout: const Duration(seconds: 120),
|
||||
),
|
||||
);
|
||||
dio.interceptors.add(
|
||||
InterceptorsWrapper(
|
||||
onRequest: (options, handler) {
|
||||
if (apiToken.isNotEmpty) {
|
||||
options.headers['X-Api-Token'] = apiToken;
|
||||
}
|
||||
handler.next(options);
|
||||
},
|
||||
onError: (error, handler) {
|
||||
if (kIsWeb && error.response?.statusCode == 401) {
|
||||
onUnauthorized?.call();
|
||||
}
|
||||
handler.next(error);
|
||||
},
|
||||
),
|
||||
);
|
||||
if (isUnixSocket) {
|
||||
(dio.httpClientAdapter as IOHttpClientAdapter).createHttpClient = () {
|
||||
final client = HttpClient();
|
||||
client.connectionFactory = (Uri uri, String? proxyHost, int? proxyPort) {
|
||||
return Socket.startConnect(InternetAddress(address, type: InternetAddressType.unix), 0);
|
||||
};
|
||||
return client;
|
||||
};
|
||||
}
|
||||
initialized = true;
|
||||
}
|
||||
void setTransportForTesting(GopeedTransport transport) {
|
||||
_transport = transport;
|
||||
}
|
||||
|
||||
void init(String network, String address, String apiToken, {VoidCallback? onUnauthorized}) {
|
||||
_Client.instance.init(network, address, apiToken, onUnauthorized: onUnauthorized);
|
||||
void init(String network, String address, String apiToken, {void Function()? onUnauthorized}) {
|
||||
_transport = createGopeedTransport(
|
||||
GopeedTransportConfig(network: network, address: address, apiToken: apiToken, onUnauthorized: onUnauthorized),
|
||||
);
|
||||
}
|
||||
|
||||
void initDefault({String address = '127.0.0.1:9999', String apiToken = ''}) {
|
||||
if (!_Client.instance.initialized) {
|
||||
if (_transport == null) {
|
||||
init('tcp', address, apiToken);
|
||||
}
|
||||
}
|
||||
|
||||
Future<T> _parse<T>(Future<Response<dynamic>> Function() fetch, T Function(dynamic json)? fromJsonT) async {
|
||||
Future<dynamic> _request(String path, {String method = 'GET', dynamic data, Map<String, dynamic>? queryParameters}) {
|
||||
initDefault();
|
||||
return _transport!.request(path, method: method, data: data, queryParameters: queryParameters);
|
||||
}
|
||||
|
||||
Future<T> _parse<T>(Future<dynamic> Function() fetch, T Function(dynamic json)? fromJsonT) async {
|
||||
initDefault();
|
||||
try {
|
||||
final resp = await fetch();
|
||||
fromJsonT ??= (json) => null as T;
|
||||
final result = Result<T>.fromJson(resp.data, fromJsonT);
|
||||
final result = Result<T>.fromJson(resp as Map<String, dynamic>, fromJsonT);
|
||||
if (result.code == 0) {
|
||||
return result.data as T;
|
||||
}
|
||||
@@ -119,169 +75,153 @@ Future<ResolveResult> resolve(ResolveTask resolveTask) {
|
||||
throw ArgumentError('resolve request is required');
|
||||
}
|
||||
return _parse<ResolveResult>(
|
||||
() => _Client.instance.dio.post('api/v1/resolve', data: resolveTask),
|
||||
() => _request('api/v1/resolve', method: 'POST', data: resolveTask),
|
||||
(data) => ResolveResult.fromJson(data as Map<String, dynamic>),
|
||||
);
|
||||
}
|
||||
|
||||
Future<String> createTask(CreateTask createTask) {
|
||||
return _parse<String>(() => _Client.instance.dio.post('api/v1/tasks', data: createTask), (data) => data as String);
|
||||
return _parse<String>(() => _request('api/v1/tasks', method: 'POST', data: createTask), (data) => data as String);
|
||||
}
|
||||
|
||||
Future<List<String>> createTaskBatch(CreateTaskBatch createTaskBatch) {
|
||||
return _parse<List<String>>(
|
||||
() => _Client.instance.dio.post('api/v1/tasks/batch', data: createTaskBatch),
|
||||
() => _request('api/v1/tasks/batch', method: 'POST', data: createTaskBatch),
|
||||
(data) => (data as List<dynamic>).map((e) => e as String).toList(),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> patchTask(String id, ResolveTask patchTask) {
|
||||
return _parse<void>(() => _Client.instance.dio.patch('api/v1/tasks/$id', data: patchTask), null);
|
||||
return _parse<void>(() => _request('api/v1/tasks/$id', method: 'PATCH', data: patchTask), null);
|
||||
}
|
||||
|
||||
Future<List<Task>> getTasks(List<Status> statuses) {
|
||||
final query = statuses.map((e) => 'status=${e.name}').join('&');
|
||||
return _parse<List<Task>>(
|
||||
() => _Client.instance.dio.get('/api/v1/tasks?$query'),
|
||||
() => _request('/api/v1/tasks?$query'),
|
||||
(data) => (data as List<dynamic>).map((e) => Task.fromJson(e as Map<String, dynamic>)).toList(),
|
||||
);
|
||||
}
|
||||
|
||||
Future<TaskRuntimeStatus> getTaskStatus(String id) {
|
||||
return _parse<TaskRuntimeStatus>(
|
||||
() => _Client.instance.dio.get('/api/v1/tasks/$id/status'),
|
||||
() => _request('/api/v1/tasks/$id/status'),
|
||||
(data) => TaskRuntimeStatus.fromJson(data as Map<String, dynamic>),
|
||||
);
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> getTaskStats(String id) {
|
||||
return _parse<Map<String, dynamic>>(
|
||||
() => _Client.instance.dio.get('/api/v1/tasks/$id/stats'),
|
||||
() => _request('/api/v1/tasks/$id/stats'),
|
||||
(data) =>
|
||||
data is Map ? {for (final entry in data.entries) entry.key.toString(): entry.value} : const <String, dynamic>{},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> pauseTask(String id) {
|
||||
return _parse<void>(() => _Client.instance.dio.put('api/v1/tasks/$id/pause'), null);
|
||||
return _parse<void>(() => _request('api/v1/tasks/$id/pause', method: 'PUT'), null);
|
||||
}
|
||||
|
||||
Future<void> continueTask(String id) {
|
||||
return _parse<void>(() => _Client.instance.dio.put('api/v1/tasks/$id/continue'), null);
|
||||
return _parse<void>(() => _request('api/v1/tasks/$id/continue', method: 'PUT'), null);
|
||||
}
|
||||
|
||||
Future<void> pauseAllTasks(List<String>? ids) {
|
||||
return _parse<void>(() => _Client.instance.dio.put('api/v1/tasks/pause', queryParameters: {'id': ids}), null);
|
||||
return _parse<void>(() => _request('api/v1/tasks/pause', method: 'PUT', queryParameters: {'id': ids}), null);
|
||||
}
|
||||
|
||||
Future<void> continueAllTasks(List<String>? ids) {
|
||||
return _parse<void>(() => _Client.instance.dio.put('api/v1/tasks/continue', queryParameters: {'id': ids}), null);
|
||||
return _parse<void>(() => _request('api/v1/tasks/continue', method: 'PUT', queryParameters: {'id': ids}), null);
|
||||
}
|
||||
|
||||
Future<void> deleteTask(String id, bool force) {
|
||||
return _parse<void>(() => _Client.instance.dio.delete('api/v1/tasks/$id?force=$force'), null);
|
||||
return _parse<void>(() => _request('api/v1/tasks/$id', method: 'DELETE', queryParameters: {'force': force}), null);
|
||||
}
|
||||
|
||||
Future<void> deleteTasks(List<String>? ids, bool force) {
|
||||
return _parse<void>(
|
||||
() => _Client.instance.dio.delete('api/v1/tasks', queryParameters: {'id': ids, 'force': force}),
|
||||
() => _request('api/v1/tasks', method: 'DELETE', queryParameters: {'id': ids, 'force': force}),
|
||||
null,
|
||||
);
|
||||
}
|
||||
|
||||
Future<DownloaderConfig> getConfig() {
|
||||
return _parse<DownloaderConfig>(
|
||||
() => _Client.instance.dio.get('api/v1/config'),
|
||||
() => _request('api/v1/config'),
|
||||
(data) => DownloaderConfig.fromJson(data as Map<String, dynamic>),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> putConfig(DownloaderConfig config) {
|
||||
return _parse<void>(() => _Client.instance.dio.put('api/v1/config', data: config.toJson()), null);
|
||||
return _parse<void>(() => _request('api/v1/config', method: 'PUT', data: config.toJson()), null);
|
||||
}
|
||||
|
||||
Future<String> installExtension(InstallExtension installExtension) {
|
||||
return _parse<String>(
|
||||
() => _Client.instance.dio.post('api/v1/extensions', data: installExtension.toJson()),
|
||||
() => _request('api/v1/extensions', method: 'POST', data: installExtension.toJson()),
|
||||
(data) => data as String,
|
||||
);
|
||||
}
|
||||
|
||||
Future<List<Extension>> getExtensions() {
|
||||
return _parse<List<Extension>>(
|
||||
() => _Client.instance.dio.get('api/v1/extensions'),
|
||||
() => _request('api/v1/extensions'),
|
||||
(data) => (data as List<dynamic>).map((e) => Extension.fromJson(e as Map<String, dynamic>)).toList(),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> updateExtensionSettings(String identity, UpdateExtensionSettings updateExtensionSettings) {
|
||||
return _parse<void>(
|
||||
() => _Client.instance.dio.put('api/v1/extensions/$identity/settings', data: updateExtensionSettings.toJson()),
|
||||
() => _request('api/v1/extensions/$identity/settings', method: 'PUT', data: updateExtensionSettings.toJson()),
|
||||
null,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> switchExtension(String identity, SwitchExtension switchExtension) {
|
||||
return _parse<void>(
|
||||
() => _Client.instance.dio.put('api/v1/extensions/$identity/switch', data: switchExtension.toJson()),
|
||||
() => _request('api/v1/extensions/$identity/switch', method: 'PUT', data: switchExtension.toJson()),
|
||||
null,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> deleteExtension(String identity) {
|
||||
return _parse<void>(() => _Client.instance.dio.delete('api/v1/extensions/$identity'), null);
|
||||
return _parse<void>(() => _request('api/v1/extensions/$identity', method: 'DELETE'), null);
|
||||
}
|
||||
|
||||
Future<UpdateCheckExtensionResp> upgradeCheckExtension(String identity) {
|
||||
return _parse<UpdateCheckExtensionResp>(
|
||||
() => _Client.instance.dio.get('api/v1/extensions/$identity/update'),
|
||||
() => _request('api/v1/extensions/$identity/update'),
|
||||
(data) => UpdateCheckExtensionResp.fromJson(data as Map<String, dynamic>),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> updateExtension(String identity) {
|
||||
return _parse<void>(() => _Client.instance.dio.post('api/v1/extensions/$identity/update'), null);
|
||||
return _parse<void>(() => _request('api/v1/extensions/$identity/update', method: 'POST'), null);
|
||||
}
|
||||
|
||||
Future<void> testWebhook(String url) {
|
||||
return _parse<void>(() => _Client.instance.dio.post('api/v1/webhook/test', data: {'url': url}), null);
|
||||
return _parse<void>(() => _request('api/v1/webhook/test', method: 'POST', data: {'url': url}), null);
|
||||
}
|
||||
|
||||
Future<void> login(LoginReq loginReq) {
|
||||
return _parse<void>(() => _Client.instance.dio.post('api/web/login', data: loginReq.toJson()), null);
|
||||
return _parse<void>(() => _request('api/web/login', method: 'POST', data: loginReq.toJson()), null);
|
||||
}
|
||||
|
||||
Future<Response<String>> proxyRequest<T>(String uri, {dynamic data, Options? options}) async {
|
||||
initDefault();
|
||||
options ??= Options();
|
||||
options.headers ??= {};
|
||||
options.headers!['X-Target-Uri'] = uri;
|
||||
return _Client.instance.dio.request<String>(
|
||||
'/api/v1/proxy?t=${DateTime.now().millisecondsSinceEpoch}',
|
||||
data: data,
|
||||
options: options,
|
||||
);
|
||||
return _transport!.proxyRequest(uri, data: data, options: options);
|
||||
}
|
||||
|
||||
String join(String path) {
|
||||
initDefault();
|
||||
final baseUrl = _Client.instance.dio.options.baseUrl;
|
||||
final cleanBaseUrl = baseUrl.endsWith('/') ? baseUrl.substring(0, baseUrl.length - 1) : baseUrl;
|
||||
final cleanPath = path.startsWith('/') ? path.substring(1) : path;
|
||||
return '$cleanBaseUrl/$cleanPath';
|
||||
return _transport!.join(path);
|
||||
}
|
||||
|
||||
Future<Response<dynamic>> forward(
|
||||
Future<dynamic> forward(
|
||||
String path, {
|
||||
String method = 'GET',
|
||||
dynamic data,
|
||||
Map<String, dynamic>? queryParameters,
|
||||
}) async {
|
||||
initDefault();
|
||||
return _Client.instance.dio.request<dynamic>(
|
||||
path,
|
||||
data: data,
|
||||
queryParameters: queryParameters,
|
||||
options: Options(method: method),
|
||||
);
|
||||
return _request(path, method: method, data: data, queryParameters: queryParameters);
|
||||
}
|
||||
@@ -13,6 +13,7 @@ class DownloaderConfig {
|
||||
ScriptConfig script = ScriptConfig();
|
||||
AutoTorrentConfig autoTorrent = AutoTorrentConfig();
|
||||
ArchiveConfig archive = ArchiveConfig();
|
||||
ApiServerConfig api = ApiServerConfig();
|
||||
bool autoStartTasks;
|
||||
bool autoDeleteMissingFileTasks;
|
||||
|
||||
@@ -28,6 +29,21 @@ class DownloaderConfig {
|
||||
Map<String, dynamic> toJson() => _$DownloaderConfigToJson(this);
|
||||
}
|
||||
|
||||
@JsonSerializable()
|
||||
class ApiServerConfig {
|
||||
bool enable;
|
||||
String network;
|
||||
String address;
|
||||
String token;
|
||||
|
||||
ApiServerConfig({this.enable = false, this.network = 'tcp', this.address = '127.0.0.1:9999', this.token = ''});
|
||||
|
||||
factory ApiServerConfig.fromJson(Map<String, dynamic>? json) =>
|
||||
json == null ? ApiServerConfig() : _$ApiServerConfigFromJson(json);
|
||||
|
||||
Map<String, dynamic> toJson() => _$ApiServerConfigToJson(this);
|
||||
}
|
||||
|
||||
@JsonSerializable(explicitToJson: true)
|
||||
class ProtocolConfig {
|
||||
HttpConfig http = HttpConfig();
|
||||
@@ -102,6 +118,12 @@ class ExtraConfig {
|
||||
bool notifyWhenNewVersion;
|
||||
bool desktopNotification;
|
||||
bool backgroundLocationKeepAlive;
|
||||
WindowStateConfig windowState;
|
||||
Map<String, String> bookmarks;
|
||||
List<String> createHistory;
|
||||
bool runAsMenubarApp;
|
||||
bool analyticsEnabled;
|
||||
String analyticsClientId;
|
||||
List<DownloadCategory> downloadCategories;
|
||||
|
||||
ExtraConfigBt bt = ExtraConfigBt();
|
||||
@@ -117,8 +139,14 @@ class ExtraConfig {
|
||||
this.notifyWhenNewVersion = true,
|
||||
this.desktopNotification = true,
|
||||
this.backgroundLocationKeepAlive = false,
|
||||
WindowStateConfig? windowState,
|
||||
this.bookmarks = const {},
|
||||
this.createHistory = const [],
|
||||
this.runAsMenubarApp = false,
|
||||
this.analyticsEnabled = true,
|
||||
this.analyticsClientId = '',
|
||||
this.downloadCategories = const [],
|
||||
});
|
||||
}) : windowState = windowState ?? WindowStateConfig();
|
||||
|
||||
factory ExtraConfig.fromJson(Map<String, dynamic>? json) =>
|
||||
json == null ? ExtraConfig() : _$ExtraConfigFromJson(json);
|
||||
@@ -126,6 +154,20 @@ class ExtraConfig {
|
||||
Map<String, dynamic> toJson() => _$ExtraConfigToJson(this);
|
||||
}
|
||||
|
||||
@JsonSerializable()
|
||||
class WindowStateConfig {
|
||||
bool isMaximized;
|
||||
double? width;
|
||||
double? height;
|
||||
|
||||
WindowStateConfig({this.isMaximized = false, this.width, this.height});
|
||||
|
||||
factory WindowStateConfig.fromJson(Map<String, dynamic>? json) =>
|
||||
json == null ? WindowStateConfig() : _$WindowStateConfigFromJson(json);
|
||||
|
||||
Map<String, dynamic> toJson() => _$WindowStateConfigToJson(this);
|
||||
}
|
||||
|
||||
@JsonSerializable()
|
||||
class DownloadCategory {
|
||||
String name;
|
||||
|
||||
@@ -19,7 +19,8 @@ DownloaderConfig _$DownloaderConfigFromJson(Map<String, dynamic> json) =>
|
||||
..webhook = WebhookConfig.fromJson(json['webhook'] as Map<String, dynamic>?)
|
||||
..script = ScriptConfig.fromJson(json['script'] as Map<String, dynamic>?)
|
||||
..autoTorrent = AutoTorrentConfig.fromJson(json['autoTorrent'] as Map<String, dynamic>?)
|
||||
..archive = ArchiveConfig.fromJson(json['archive'] as Map<String, dynamic>?);
|
||||
..archive = ArchiveConfig.fromJson(json['archive'] as Map<String, dynamic>?)
|
||||
..api = ApiServerConfig.fromJson(json['api'] as Map<String, dynamic>?);
|
||||
|
||||
Map<String, dynamic> _$DownloaderConfigToJson(DownloaderConfig instance) => <String, dynamic>{
|
||||
'downloadDir': instance.downloadDir,
|
||||
@@ -31,10 +32,25 @@ Map<String, dynamic> _$DownloaderConfigToJson(DownloaderConfig instance) => <Str
|
||||
'script': instance.script.toJson(),
|
||||
'autoTorrent': instance.autoTorrent.toJson(),
|
||||
'archive': instance.archive.toJson(),
|
||||
'api': instance.api.toJson(),
|
||||
'autoStartTasks': instance.autoStartTasks,
|
||||
'autoDeleteMissingFileTasks': instance.autoDeleteMissingFileTasks,
|
||||
};
|
||||
|
||||
ApiServerConfig _$ApiServerConfigFromJson(Map<String, dynamic> json) => ApiServerConfig(
|
||||
enable: json['enable'] as bool? ?? false,
|
||||
network: json['network'] as String? ?? 'tcp',
|
||||
address: json['address'] as String? ?? '127.0.0.1:9999',
|
||||
token: json['token'] as String? ?? '',
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$ApiServerConfigToJson(ApiServerConfig instance) => <String, dynamic>{
|
||||
'enable': instance.enable,
|
||||
'network': instance.network,
|
||||
'address': instance.address,
|
||||
'token': instance.token,
|
||||
};
|
||||
|
||||
ProtocolConfig _$ProtocolConfigFromJson(Map<String, dynamic> json) => ProtocolConfig()
|
||||
..http = HttpConfig.fromJson(json['http'] as Map<String, dynamic>)
|
||||
..bt = BtConfig.fromJson(json['bt'] as Map<String, dynamic>)
|
||||
@@ -101,6 +117,14 @@ ExtraConfig _$ExtraConfigFromJson(Map<String, dynamic> json) =>
|
||||
notifyWhenNewVersion: json['notifyWhenNewVersion'] as bool? ?? true,
|
||||
desktopNotification: json['desktopNotification'] as bool? ?? true,
|
||||
backgroundLocationKeepAlive: json['backgroundLocationKeepAlive'] as bool? ?? false,
|
||||
windowState: json['windowState'] == null
|
||||
? null
|
||||
: WindowStateConfig.fromJson(json['windowState'] as Map<String, dynamic>?),
|
||||
bookmarks: (json['bookmarks'] as Map<String, dynamic>?)?.map((k, e) => MapEntry(k, e as String)) ?? const {},
|
||||
createHistory: (json['createHistory'] as List<dynamic>?)?.map((e) => e as String).toList() ?? const [],
|
||||
runAsMenubarApp: json['runAsMenubarApp'] as bool? ?? false,
|
||||
analyticsEnabled: json['analyticsEnabled'] as bool? ?? true,
|
||||
analyticsClientId: json['analyticsClientId'] as String? ?? '',
|
||||
downloadCategories:
|
||||
(json['downloadCategories'] as List<dynamic>?)
|
||||
?.map((e) => DownloadCategory.fromJson(e as Map<String, dynamic>))
|
||||
@@ -120,11 +144,29 @@ Map<String, dynamic> _$ExtraConfigToJson(ExtraConfig instance) => <String, dynam
|
||||
'notifyWhenNewVersion': instance.notifyWhenNewVersion,
|
||||
'desktopNotification': instance.desktopNotification,
|
||||
'backgroundLocationKeepAlive': instance.backgroundLocationKeepAlive,
|
||||
'windowState': instance.windowState.toJson(),
|
||||
'bookmarks': instance.bookmarks,
|
||||
'createHistory': instance.createHistory,
|
||||
'runAsMenubarApp': instance.runAsMenubarApp,
|
||||
'analyticsEnabled': instance.analyticsEnabled,
|
||||
'analyticsClientId': instance.analyticsClientId,
|
||||
'downloadCategories': instance.downloadCategories.map((e) => e.toJson()).toList(),
|
||||
'bt': instance.bt.toJson(),
|
||||
'githubMirror': instance.githubMirror.toJson(),
|
||||
};
|
||||
|
||||
WindowStateConfig _$WindowStateConfigFromJson(Map<String, dynamic> json) => WindowStateConfig(
|
||||
isMaximized: json['isMaximized'] as bool? ?? false,
|
||||
width: (json['width'] as num?)?.toDouble(),
|
||||
height: (json['height'] as num?)?.toDouble(),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$WindowStateConfigToJson(WindowStateConfig instance) => <String, dynamic>{
|
||||
'isMaximized': instance.isMaximized,
|
||||
'width': ?instance.width,
|
||||
'height': ?instance.height,
|
||||
};
|
||||
|
||||
DownloadCategory _$DownloadCategoryFromJson(Map<String, dynamic> json) => DownloadCategory(
|
||||
name: json['name'] as String,
|
||||
path: json['path'] as String,
|
||||
|
||||
Reference in new issue
Block a user