Initial commit

This commit is contained in:
wgh19
2024-05-13 09:36:23 +08:00
commit b095643cbc
160 changed files with 9956 additions and 0 deletions

140
lib/network/app_dio.dart Normal file
View File

@@ -0,0 +1,140 @@
import 'dart:io';
import 'package:dio/dio.dart';
import 'package:dio/io.dart';
import 'package:pixes/foundation/log.dart';
export 'package:dio/dio.dart';
class MyLogInterceptor implements Interceptor {
@override
void onError(DioException err, ErrorInterceptorHandler handler) {
Log.error("Network",
"${err.requestOptions.method} ${err.requestOptions.path}\n$err\n${err.response?.data.toString()}");
switch (err.type) {
case DioExceptionType.badResponse:
var statusCode = err.response?.statusCode;
if (statusCode != null) {
err = err.copyWith(
message: "Invalid Status Code: $statusCode. "
"${_getStatusCodeInfo(statusCode)}");
}
case DioExceptionType.connectionTimeout:
err = err.copyWith(message: "Connection Timeout");
case DioExceptionType.receiveTimeout:
err = err.copyWith(
message: "Receive Timeout: "
"This indicates that the server is too busy to respond");
case DioExceptionType.unknown:
if (err.toString().contains("Connection terminated during handshake")) {
err = err.copyWith(
message: "Connection terminated during handshake: "
"This may be caused by the firewall blocking the connection "
"or your requests are too frequent.");
} else if (err.toString().contains("Connection reset by peer")) {
err = err.copyWith(
message: "Connection reset by peer: "
"The error is unrelated to app, please check your network.");
}
default:
{}
}
handler.next(err);
}
static const errorMessages = <int, String>{
400: "The Request is invalid.",
401: "The Request is unauthorized.",
403: "No permission to access the resource. Check your account or network.",
404: "Not found.",
429: "Too many requests. Please try again later.",
};
String _getStatusCodeInfo(int? statusCode) {
if (statusCode != null && statusCode >= 500) {
return "This is server-side error, please try again later. "
"Do not report this issue.";
} else {
return errorMessages[statusCode] ?? "";
}
}
@override
void onResponse(
Response<dynamic> response, ResponseInterceptorHandler handler) {
var headers = response.headers.map.map((key, value) => MapEntry(
key.toLowerCase(), value.length == 1 ? value.first : value.toString()));
headers.remove("cookie");
String content;
if (response.data is List<int>) {
content = "<Bytes>\nlength:${response.data.length}";
} else {
content = response.data.toString();
}
Log.addLog(
(response.statusCode != null && response.statusCode! < 400)
? LogLevel.info
: LogLevel.error,
"Network",
"Response ${response.realUri.toString()} ${response.statusCode}\n"
"headers:\n$headers\n$content");
handler.next(response);
}
@override
void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
options.connectTimeout = const Duration(seconds: 15);
options.receiveTimeout = const Duration(seconds: 15);
options.sendTimeout = const Duration(seconds: 15);
if (options.headers["Host"] == null && options.headers["host"] == null) {
options.headers["host"] = options.uri.host;
}
Log.info("Network",
"${options.method} ${options.uri}\n${options.headers}\n${options.data}");
handler.next(options);
}
}
class AppDio extends DioForNative {
bool isInitialized = false;
@override
Future<Response<T>> request<T>(String path,
{Object? data,
Map<String, dynamic>? queryParameters,
CancelToken? cancelToken,
Options? options,
ProgressCallback? onSendProgress,
ProgressCallback? onReceiveProgress}) {
if (!isInitialized) {
isInitialized = true;
interceptors.add(MyLogInterceptor());
}
return super.request(path,
data: data,
queryParameters: queryParameters,
cancelToken: cancelToken,
options: options,
onSendProgress: onSendProgress,
onReceiveProgress: onReceiveProgress);
}
}
void setSystemProxy() {
HttpOverrides.global = _ProxyHttpOverrides();
}
class _ProxyHttpOverrides extends HttpOverrides {
String findProxy(Uri uri) {
// TODO: proxy
return "DIRECT";
}
@override
HttpClient createHttpClient(SecurityContext? context) {
final client = super.createHttpClient(context);
client.connectionTimeout = const Duration(seconds: 5);
client.findProxy = findProxy;
return client;
}
}

View File

@@ -0,0 +1,5 @@
import 'package:pixes/network/network.dart';
extension IllustExt on Illust {
bool get downloaded => false;
}

215
lib/network/models.dart Normal file
View File

@@ -0,0 +1,215 @@
class Account {
String accessToken;
String refreshToken;
final User user;
Account(this.accessToken, this.refreshToken, this.user);
Account.fromJson(Map<String, dynamic> json)
: accessToken = json['access_token'],
refreshToken = json['refresh_token'],
user = User.fromJson(json['user']);
Map<String, dynamic> toJson() => {
'access_token': accessToken,
'refresh_token': refreshToken,
'user': user.toJson()
};
}
class User {
String profile;
final String id;
String name;
String account;
String email;
bool isPremium;
User(this.profile, this.id, this.name, this.account, this.email,
this.isPremium);
User.fromJson(Map<String, dynamic> json)
: profile = json['profile_image_urls']['px_170x170'],
id = json['id'],
name = json['name'],
account = json['account'],
email = json['mail_address'],
isPremium = json['is_premium'];
Map<String, dynamic> toJson() => {
'profile_image_urls': {'px_170x170': profile},
'id': id,
'name': name,
'account': account,
'mail_address': email,
'is_premium': isPremium
};
}
class UserDetails {
final int id;
final String name;
final String account;
final String avatar;
final String comment;
final bool isFollowed;
final bool isBlocking;
final String? webpage;
final String gender;
final String birth;
final String region;
final String job;
final int totalFollowUsers;
final int myPixivUsers;
final int totalIllusts;
final int totalMangas;
final int totalNovels;
final int totalIllustBookmarks;
final String? backgroundImage;
final String? twitterUrl;
final bool isPremium;
final String? pawooUrl;
UserDetails(
this.id,
this.name,
this.account,
this.avatar,
this.comment,
this.isFollowed,
this.isBlocking,
this.webpage,
this.gender,
this.birth,
this.region,
this.job,
this.totalFollowUsers,
this.myPixivUsers,
this.totalIllusts,
this.totalMangas,
this.totalNovels,
this.totalIllustBookmarks,
this.backgroundImage,
this.twitterUrl,
this.isPremium,
this.pawooUrl);
UserDetails.fromJson(Map<String, dynamic> json)
: id = json['user']['id'],
name = json['user']['name'],
account = json['user']['account'],
avatar = json['user']['profile_image_urls']['medium'],
comment = json['user']['comment'],
isFollowed = json['user']['is_followed'],
isBlocking = json['user']['is_access_blocking_user'],
webpage = json['profile']['webpage'],
gender = json['profile']['gender'],
birth = json['profile']['birth'],
region = json['profile']['region'],
job = json['profile']['job'],
totalFollowUsers = json['profile']['total_follow_users'],
myPixivUsers = json['profile']['total_mypixiv_users'],
totalIllusts = json['profile']['total_illusts'],
totalMangas = json['profile']['total_manga'],
totalNovels = json['profile']['total_novels'],
totalIllustBookmarks = json['profile']['total_illust_bookmarks_public'],
backgroundImage = json['profile']['background_image_url'],
twitterUrl = json['profile']['twitter_url'],
isPremium = json['profile']['is_premium'],
pawooUrl = json['profile']['pawoo_url'];
}
class IllustAuthor {
final int id;
final String name;
final String account;
final String avatar;
bool isFollowed;
IllustAuthor(
this.id, this.name, this.account, this.avatar, this.isFollowed);
}
class Tag {
final String name;
final String? translatedName;
const Tag(this.name, this.translatedName);
}
class IllustImage {
final String squareMedium;
final String medium;
final String large;
final String original;
const IllustImage(this.squareMedium, this.medium, this.large, this.original);
}
class Illust {
final int id;
final String title;
final String type;
final List<IllustImage> images;
final String caption;
final int restrict;
final IllustAuthor author;
final List<Tag> tags;
final String createDate;
final int pageCount;
final int width;
final int height;
final int totalView;
final int totalBookmarks;
bool isBookmarked;
final bool isAi;
Illust.fromJson(Map<String, dynamic> json)
: id = json['id'],
title = json['title'],
type = json['type'],
images = (() {
List<IllustImage> images = [];
for (var i in json['meta_pages']) {
images.add(IllustImage(
i['image_urls']['square_medium'],
i['image_urls']['medium'],
i['image_urls']['large'],
i['image_urls']['original']));
}
if (images.isEmpty) {
images.add(IllustImage(
json['image_urls']['square_medium'],
json['image_urls']['medium'],
json['image_urls']['large'],
json['meta_single_page']['original_image_url']));
}
return images;
}()),
caption = json['caption'],
restrict = json['restrict'],
author = IllustAuthor(
json['user']['id'],
json['user']['name'],
json['user']['account'],
json['user']['profile_image_urls']['medium'],
json['user']['is_followed'] ?? false),
tags = (json['tags'] as List)
.map((e) => Tag(e['name'], e['translated_name']))
.toList(),
createDate = json['create_date'],
pageCount = json['page_count'],
width = json['width'],
height = json['height'],
totalView = json['total_view'],
totalBookmarks = json['total_bookmarks'],
isBookmarked = json['is_bookmarked'],
isAi = json['is_ai'] != 1;
}
class TrendingTag {
final Tag tag;
final Illust illust;
TrendingTag(this.tag, this.illust);
}

258
lib/network/network.dart Normal file
View File

@@ -0,0 +1,258 @@
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';
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<String, String> 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<String> 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<Res<bool>> loginWithCode(String code) async {
try {
var res = await dio.post<String>("$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<Res<bool>> refreshToken() async {
try {
var res = await dio.post<String>("$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,
headers: _headers));
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<Res<Map<String, dynamic>>> apiGet(String path, {Map<String, dynamic>? query}) async {
try {
if(!path.startsWith("http")) {
path = "$baseUrl$path";
}
final res = await dio.get<Map<String, dynamic>>(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<Res<Map<String, dynamic>>> apiPost(String path, {Map<String, dynamic>? query, Map<String, dynamic>? data}) async {
try {
if(!path.startsWith("http")) {
path = "$baseUrl$path";
}
final res = await dio.post<Map<String, dynamic>>(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<Res<UserDetails>> 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);
}
}
Future<Res<List<Illust>>> getRecommendedIllusts() async {
var res = await apiGet("/v1/illust/recommended?include_privacy_policy=true&filter=for_android&include_ranking_illusts=true");
if (res.success) {
return Res((res.data["illusts"] as List).map((e) => Illust.fromJson(e)).toList());
} else {
return Res.error(res.errorMessage);
}
}
Future<Res<List<Illust>>> getBookmarkedIllusts(String restrict, [String? nextUrl]) async {
var res = await apiGet(nextUrl ?? "/v1/user/bookmarks/illust?user_id=49258688&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<Res<bool>> 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<Res<bool>> 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<Res<List<TrendingTag>>> 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"])
))));
}
}
}

39
lib/network/res.dart Normal file
View File

@@ -0,0 +1,39 @@
import 'package:flutter/cupertino.dart';
@immutable
class Res<T>{
///error info
final String? errorMessage;
String get errorMessageWithoutNull => errorMessage??"Unknown Error";
/// data
final T? _data;
/// is there an error
bool get error => errorMessage!=null || _data==null;
/// whether succeed
bool get success => !error;
/// data
///
/// must be called when no error happened, or it will throw error
T get data => _data ?? (throw Exception(errorMessage));
/// get data, or null if there is an error
T? get dataOrNull => _data;
final dynamic subData;
@override
String toString() => _data.toString();
Res.fromErrorRes(Res another, {this.subData}):
_data=null,errorMessage=another.errorMessageWithoutNull;
/// network result
const Res(this._data,{this.errorMessage, this.subData});
Res.error(dynamic e):errorMessage=e.toString(), _data=null, subData=null;
}