import 'dart:convert'; import 'dart:math'; import 'package:crypto/crypto.dart'; import 'package:intl/intl.dart'; import 'package:pixes/appdata.dart'; import 'package:pixes/foundation/app.dart'; import 'package:pixes/foundation/log.dart'; import 'package:pixes/network/app_dio.dart'; import 'package:pixes/network/res.dart'; import 'models.dart'; export 'models.dart'; export 'res.dart'; part 'novel.dart'; class Network { static const hashSalt = "28c1fdd170a5204386cb1313c7077b34f83e4aaf4aa829ce78c231e05b0bae2c"; static const baseUrl = 'https://app-api.pixiv.net'; static const oauthUrl = 'https://oauth.secure.pixiv.net'; static const String clientID = "MOBrBDS8blbauoSck0ZfDbtuzpyT"; static const String clientSecret = "lsACyCD94FhDUtGTXi3QzcFE2uU1hqtDaKeqrdwj"; static const String refreshClientID = "KzEZED7aC0vird8jWyHM38mXjNTY"; static const String refreshClientSecret = "W9JZoJe00qPvJsiyCGT3CCtC6ZUtdpKpzMbNlUGP"; static Network? instance; factory Network() => instance ?? (instance = Network._create()); Network._create(); String? codeVerifier; String? get token => appdata.account?.accessToken; final dio = AppDio(); Map get headers { final time = DateFormat("yyyy-MM-dd'T'HH:mm:ss'+00:00'").format(DateTime.now()); final hash = md5.convert(utf8.encode(time + hashSalt)).toString(); return { "X-Client-Time": time, "X-Client-Hash": hash, "User-Agent": "PixivAndroidApp/5.0.234 (Android 14.0; Pixes)", "accept-language": App.locale.toLanguageTag(), "Accept-Encoding": "gzip", if (token != null) "Authorization": "Bearer $token" }; } Future generateWebviewUrl() async { const String chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~'; codeVerifier = List.generate(128, (i) => chars[Random.secure().nextInt(chars.length)]) .join(); final codeChallenge = base64Url .encode(sha256.convert(ascii.encode(codeVerifier!)).bytes) .replaceAll('=', ''); return "https://app-api.pixiv.net/web/v1/login?code_challenge=$codeChallenge&code_challenge_method=S256&client=pixiv-android"; } Future> loginWithCode(String code) async { try { var res = await dio.post("$oauthUrl/auth/token", data: { "client_id": clientID, "client_secret": clientSecret, "code": code, "code_verifier": codeVerifier, "grant_type": "authorization_code", "include_policy": "true", "redirect_uri": "https://app-api.pixiv.net/web/v1/users/auth/pixiv/callback", }, options: Options( contentType: Headers.formUrlEncodedContentType, headers: headers)); if (res.statusCode != 200) { throw "Invalid Status code ${res.statusCode}"; } final data = json.decode(res.data!); appdata.account = Account.fromJson(data); appdata.writeData(); return const Res(true); } catch (e, s) { Log.error("Network", "$e\n$s"); return Res.error(e); } } Future> refreshToken() async { try { var res = await dio.post("$oauthUrl/auth/token", data: { "client_id": clientID, "client_secret": clientSecret, "grant_type": "refresh_token", "refresh_token": appdata.account?.refreshToken, "include_policy": "true", }, options: Options( contentType: Headers.formUrlEncodedContentType, validateStatus: (i) => true, headers: headers)); if (res.statusCode != 200) { var data = res.data ?? ""; if (data.contains("Invalid refresh token")) { throw "Failed to refresh token. Please log out."; } } var account = Account.fromJson(json.decode(res.data!)); appdata.account = account; appdata.writeData(); return const Res(true); } catch (e, s) { Log.error("Network", "$e\n$s"); return Res.error(e); } } Future>> apiGet(String path, {Map? query}) async { try { if (!path.startsWith("http")) { path = "$baseUrl$path"; } final res = await dio.get>(path, queryParameters: query, options: Options(headers: headers, validateStatus: (status) => true)); if (res.statusCode == 200) { return Res(res.data!); } else if (res.statusCode == 400) { if (res.data.toString().contains("Access Token")) { var refresh = await refreshToken(); if (refresh.success) { return apiGet(path, query: query); } else { return Res.error(refresh.errorMessage); } } else { return Res.error("Invalid Status Code: ${res.statusCode}"); } } else if ((res.statusCode ?? 500) < 500) { return Res.error(res.data?["error"]?["message"] ?? "Invalid Status code ${res.statusCode}"); } else { return Res.error("Invalid Status Code: ${res.statusCode}"); } } catch (e, s) { Log.error("Network", "$e\n$s"); return Res.error(e); } } Future> apiGetPlain(String path, {Map? query}) async { try { if (!path.startsWith("http")) { path = "$baseUrl$path"; } final res = await dio.get(path, queryParameters: query, options: Options(headers: headers, validateStatus: (status) => true)); if (res.statusCode == 200) { return Res(res.data!); } else if (res.statusCode == 400) { if (res.data.toString().contains("Access Token")) { var refresh = await refreshToken(); if (refresh.success) { return apiGetPlain(path, query: query); } else { return Res.error(refresh.errorMessage); } } else { return Res.error("Invalid Status Code: ${res.statusCode}"); } } else { return Res.error("Invalid Status Code: ${res.statusCode}"); } } catch (e, s) { Log.error("Network", "$e\n$s"); return Res.error(e); } } Future>> apiPost(String path, {Map? query, Map? data}) async { try { if (!path.startsWith("http")) { path = "$baseUrl$path"; } final res = await dio.post>(path, queryParameters: query, data: data, options: Options( headers: headers, validateStatus: (status) => true, contentType: Headers.formUrlEncodedContentType)); if (res.statusCode == 200) { return Res(res.data!); } else if (res.statusCode == 400) { if (res.data.toString().contains("Access Token")) { var refresh = await refreshToken(); if (refresh.success) { return apiGet(path, query: query); } else { return Res.error(refresh.errorMessage); } } else { return Res.error("Invalid Status Code: ${res.statusCode}"); } } else if ((res.statusCode ?? 500) < 500) { return Res.error(res.data?["error"]?["message"] ?? "Invalid Status code ${res.statusCode}"); } else { return Res.error("Invalid Status Code: ${res.statusCode}"); } } catch (e, s) { Log.error("Network", "$e\n$s"); return Res.error(e); } } /// get user details Future> getUserDetails(Object userId) async { var res = await apiGet("/v1/user/detail", query: {"user_id": userId, "filter": "for_android"}); if (res.success) { return Res(UserDetails.fromJson(res.data)); } else { return Res.error(res.errorMessage); } } static const recommendationUrl = "/v1/illust/recommended?include_privacy_policy=true&filter=for_android&include_ranking_illusts=true"; Future>> getRecommendedIllusts() async { var res = await apiGet(recommendationUrl); if (res.success) { return Res( (res.data["illusts"] as List).map((e) => Illust.fromJson(e)).toList(), subData: recommendationUrl); } else { return Res.error(res.errorMessage); } } Future>> getBookmarkedIllusts(String restrict, [String? nextUrl]) async { var res = await apiGet(nextUrl ?? "/v1/user/bookmarks/illust?user_id=${appdata.account?.user.id}&restrict=$restrict"); if (res.success) { return Res( (res.data["illusts"] as List).map((e) => Illust.fromJson(e)).toList(), subData: res.data["next_url"]); } else { return Res.error(res.errorMessage); } } Future>> getUserBookmarks(String uid, [String? nextUrl]) async { var res = await apiGet( nextUrl ?? "/v1/user/bookmarks/illust?user_id=$uid&restrict=public"); if (res.success) { return Res( (res.data["illusts"] as List).map((e) => Illust.fromJson(e)).toList(), subData: res.data["next_url"]); } else { return Res.error(res.errorMessage); } } Future> addBookmark(String id, String method, [String type = "public"]) async { var res = method == "add" ? await apiPost("/v2/illust/bookmark/$method", data: {"illust_id": id, "restrict": type}) : await apiPost("/v1/illust/bookmark/$method", data: { "illust_id": id, }); if (!res.error) { return const Res(true); } else { return Res.fromErrorRes(res); } } Future> follow(String uid, String method, [String type = "public"]) async { var res = method == "add" ? await apiPost("/v1/user/follow/add", data: {"user_id": uid, "restrict": type}) : await apiPost("/v1/user/follow/delete", data: { "user_id": uid, }); if (!res.error) { return const Res(true); } else { return Res.fromErrorRes(res); } } Future>> getHotTags() async { var res = await apiGet( "/v1/trending-tags/illust?filter=for_android&include_translated_tag_results=true"); if (res.error) { return Res.fromErrorRes(res); } else { return Res(List.from(res.data["trend_tags"].map((e) => TrendingTag( Tag(e["tag"], e["translated_name"]), Illust.fromJson(e["illust"]))))); } } Future>> search( String keyword, SearchOptions options) async { String path = ""; final encodedKeyword = Uri.encodeComponent(keyword + options.favoriteNumber.toParam() + options.ageLimit.toParam()); if (options.sort == SearchSort.popular && !options.sort.isPremium) { path = "/v1/search/popular-preview/illust?filter=for_android&include_translated_tag_results=true&merge_plain_keyword_results=true&word=$encodedKeyword&search_target=${options.matchType.toParam()}"; } else { path = "/v1/search/illust?filter=for_android&include_translated_tag_results=true&merge_plain_keyword_results=true&word=$encodedKeyword&sort=${options.sort.toParam()}&search_target=${options.matchType.toParam()}"; } var res = await apiGet(path); if (res.success) { return Res( (res.data["illusts"] as List).map((e) => Illust.fromJson(e)).toList(), subData: res.data["next_url"]); } else { return Res.error(res.errorMessage); } } Future>> getIllustsWithNextUrl(String nextUrl) async { var res = await apiGet(nextUrl); if (res.success) { return Res( (res.data["illusts"] as List).map((e) => Illust.fromJson(e)).toList(), subData: res.data["next_url"]); } else { return Res.error(res.errorMessage); } } Future>> searchUsers(String keyword, [String? nextUrl]) async { var path = nextUrl ?? "/v1/search/user?filter=for_android&word=${Uri.encodeComponent(keyword)}"; var res = await apiGet(path); if (res.success) { return Res( (res.data["user_previews"] as List) .map((e) => UserPreview.fromJson(e)) .toList(), subData: res.data["next_url"]); } else { return Res.error(res.errorMessage); } } Future>> getUserIllusts(String uid) async { var res = await apiGet( "/v1/user/illusts?filter=for_android&user_id=$uid&type=illust"); if (res.success) { return Res( (res.data["illusts"] as List).map((e) => Illust.fromJson(e)).toList(), subData: res.data["next_url"]); } else { return Res.error(res.errorMessage); } } Future>> getFollowing(String uid, String type, [String? nextUrl]) async { var path = nextUrl ?? "/v1/user/following?filter=for_android&user_id=$uid&restrict=$type"; var res = await apiGet(path); if (res.success) { return Res( (res.data["user_previews"] as List) .map((e) => UserPreview.fromJson(e)) .toList(), subData: res.data["next_url"]); } else { return Res.error(res.errorMessage); } } Future>> getFollowingArtworks(String restrict, [String? nextUrl]) async { var res = await apiGet(nextUrl ?? "/v2/illust/follow?restrict=$restrict"); if (res.success) { return Res( (res.data["illusts"] as List).map((e) => Illust.fromJson(e)).toList(), subData: res.data["next_url"]); } else { return Res.error(res.errorMessage); } } Future>> getRecommendationUsers() async { var res = await apiGet("/v1/user/recommended?filter=for_android"); if (res.success) { return Res( (res.data["user_previews"] as List) .map((e) => UserPreview.fromJson(e)) .toList(), subData: res.data["next_url"]); } else { return Res.error(res.errorMessage); } } /// mode: day, week, month, day_male, day_female, week_original, week_rookie, day_manga, week_manga, month_manga, day_r18_manga, day_r18 Future>> getRanking(String mode, [String? nextUrl]) async { var res = await apiGet( nextUrl ?? "/v1/illust/ranking?filter=for_android&mode=$mode"); if (res.success) { return Res( (res.data["illusts"] as List).map((e) => Illust.fromJson(e)).toList(), subData: res.data["next_url"]); } else { return Res.error(res.errorMessage); } } Future>> getComments(String id, [String? nextUrl]) async { var res = await apiGet(nextUrl ?? "/v3/illust/comments?illust_id=$id"); if (res.success) { return Res( (res.data["comments"] as List) .map((e) => Comment.fromJson(e)) .toList(), subData: res.data["next_url"]); } else { return Res.error(res.errorMessage); } } Future> comment(String id, String content) async { var res = await apiPost("/v1/illust/comment/add", data: {"illust_id": id, "comment": content}); if (res.success) { return const Res(true); } else { return Res.fromErrorRes(res); } } Future> getIllustByID(String id) async { var res = await apiGet("/v1/illust/detail?illust_id=$id"); if (res.success) { return Res(Illust.fromJson(res.data["illust"])); } else { return Res.error(res.errorMessage); } } Future>> getRecommendedMangas() async { var res = await apiGet( "/v1/manga/recommended?filter=for_android&include_ranking_illusts=true&include_privacy_policy=true"); if (res.success) { return Res( (res.data["illusts"] as List).map((e) => Illust.fromJson(e)).toList(), subData: res.data["next_url"]); } else { return Res.error(res.errorMessage); } } Future>> getHistory(int page) async { String param = ""; if (page > 1) { param = "?offset=${30 * (page - 1)}"; } var res = await apiGet("/v1/user/browsing-history/illusts$param"); if (res.success) { return Res((res.data["illusts"] as List) .map((e) => Illust.fromJson(e)) .toList()); } else { return Res.error(res.errorMessage); } } Future> getMutedTags() async { var res = await apiGet("/v1/mute/list"); if (res.success) { return res.data["mute_tags"] .map((e) => Tag(e["tag"]["name"], e["tag"]["translated_name"])) .toList(); } else { return []; } } Future> muteTags( List muteTags, List unmuteTags) async { var res = await apiPost("/v1/mute/edit", data: {"add_tags": muteTags, "delete_tags": unmuteTags}); if (res.success) { return const Res(true); } else { return Res.fromErrorRes(res); } } Future>> relatedUsers(String id) async { var res = await apiGet("/v1/user/related?filter=for_android&seed_user_id=$id"); if (res.success) { return Res((res.data["user_previews"] as List) .map((e) => UserPreview.fromJson(e)) .toList()); } else { return Res.error(res.errorMessage); } } Future>> relatedIllusts(String id) async { var res = await apiGet("/v2/illust/related?filter=for_android&illust_id=$id"); if (res.success) { return Res((res.data["illusts"] as List) .map((e) => Illust.fromJson(e)) .toList()); } else { return Res.error(res.errorMessage); } } Future> getNovelImage(String novelId, String imageId) async { var res = await apiGetPlain( "/web/v1/novel/image?novel_id=$novelId&uploaded_image_id=$imageId"); if (res.success) { var html = res.data; int start = html.indexOf('