优化:根据新APP优化框架代码

This commit is contained in:
ivan 2026-04-13 18:56:55 +08:00
parent bdab80b2bd
commit b55faf0db5
46 changed files with 4633 additions and 352 deletions

View File

@ -7,6 +7,12 @@
"packageUri": "lib/",
"languageVersion": "2.12"
},
{
"name": "archive",
"rootUri": "file:///Users/sven/.pub-cache/hosted/pub.dev/archive-4.0.9",
"packageUri": "lib/",
"languageVersion": "3.0"
},
{
"name": "args",
"rootUri": "file:///Users/sven/.pub-cache/hosted/pub.dev/args-2.7.0",
@ -37,6 +43,12 @@
"packageUri": "lib/",
"languageVersion": "3.4"
},
{
"name": "code_assets",
"rootUri": "file:///Users/sven/.pub-cache/hosted/pub.dev/code_assets-1.0.0",
"packageUri": "lib/",
"languageVersion": "3.9"
},
{
"name": "collection",
"rootUri": "file:///Users/sven/.pub-cache/hosted/pub.dev/collection-1.19.1",
@ -91,6 +103,18 @@
"packageUri": "lib/",
"languageVersion": "3.9"
},
{
"name": "glob",
"rootUri": "file:///Users/sven/.pub-cache/hosted/pub.dev/glob-2.1.3",
"packageUri": "lib/",
"languageVersion": "3.3"
},
{
"name": "hooks",
"rootUri": "file:///Users/sven/.pub-cache/hosted/pub.dev/hooks-1.0.2",
"packageUri": "lib/",
"languageVersion": "3.10"
},
{
"name": "http",
"rootUri": "file:///Users/sven/.pub-cache/hosted/pub.dev/http-1.6.0",
@ -103,6 +127,12 @@
"packageUri": "lib/",
"languageVersion": "3.4"
},
{
"name": "image",
"rootUri": "file:///Users/sven/.pub-cache/hosted/pub.dev/image-4.8.0",
"packageUri": "lib/",
"languageVersion": "3.0"
},
{
"name": "in_app_purchase",
"rootUri": "file:///Users/sven/.pub-cache/hosted/pub.dev/in_app_purchase-3.2.3",
@ -127,6 +157,18 @@
"packageUri": "lib/",
"languageVersion": "3.9"
},
{
"name": "jni",
"rootUri": "file:///Users/sven/.pub-cache/hosted/pub.dev/jni-1.0.0",
"packageUri": "lib/",
"languageVersion": "3.3"
},
{
"name": "jni_flutter",
"rootUri": "file:///Users/sven/.pub-cache/hosted/pub.dev/jni_flutter-1.0.1",
"packageUri": "lib/",
"languageVersion": "3.3"
},
{
"name": "js",
"rootUri": "file:///Users/sven/.pub-cache/hosted/pub.dev/js-0.7.2",
@ -145,6 +187,12 @@
"packageUri": "lib/",
"languageVersion": "2.17"
},
{
"name": "logging",
"rootUri": "file:///Users/sven/.pub-cache/hosted/pub.dev/logging-1.3.0",
"packageUri": "lib/",
"languageVersion": "3.4"
},
{
"name": "material_color_utilities",
"rootUri": "file:///Users/sven/.pub-cache/hosted/pub.dev/material_color_utilities-0.13.0",
@ -157,12 +205,48 @@
"packageUri": "lib/",
"languageVersion": "3.5"
},
{
"name": "native_toolchain_c",
"rootUri": "file:///Users/sven/.pub-cache/hosted/pub.dev/native_toolchain_c-0.17.6",
"packageUri": "lib/",
"languageVersion": "3.10"
},
{
"name": "objective_c",
"rootUri": "file:///Users/sven/.pub-cache/hosted/pub.dev/objective_c-9.3.0",
"packageUri": "lib/",
"languageVersion": "3.10"
},
{
"name": "package_config",
"rootUri": "file:///Users/sven/.pub-cache/hosted/pub.dev/package_config-2.2.0",
"packageUri": "lib/",
"languageVersion": "3.4"
},
{
"name": "path",
"rootUri": "file:///Users/sven/.pub-cache/hosted/pub.dev/path-1.9.1",
"packageUri": "lib/",
"languageVersion": "3.4"
},
{
"name": "path_provider",
"rootUri": "file:///Users/sven/.pub-cache/hosted/pub.dev/path_provider-2.1.5",
"packageUri": "lib/",
"languageVersion": "3.4"
},
{
"name": "path_provider_android",
"rootUri": "file:///Users/sven/.pub-cache/hosted/pub.dev/path_provider_android-2.3.0",
"packageUri": "lib/",
"languageVersion": "3.9"
},
{
"name": "path_provider_foundation",
"rootUri": "file:///Users/sven/.pub-cache/hosted/pub.dev/path_provider_foundation-2.6.0",
"packageUri": "lib/",
"languageVersion": "3.10"
},
{
"name": "path_provider_linux",
"rootUri": "file:///Users/sven/.pub-cache/hosted/pub.dev/path_provider_linux-2.2.1",
@ -181,6 +265,12 @@
"packageUri": "lib/",
"languageVersion": "3.2"
},
{
"name": "petitparser",
"rootUri": "file:///Users/sven/.pub-cache/hosted/pub.dev/petitparser-7.0.2",
"packageUri": "lib/",
"languageVersion": "3.8"
},
{
"name": "platform",
"rootUri": "file:///Users/sven/.pub-cache/hosted/pub.dev/platform-3.1.6",
@ -205,6 +295,18 @@
"packageUri": "lib/",
"languageVersion": "3.2"
},
{
"name": "posix",
"rootUri": "file:///Users/sven/.pub-cache/hosted/pub.dev/posix-6.5.0",
"packageUri": "lib/",
"languageVersion": "3.0"
},
{
"name": "pub_semver",
"rootUri": "file:///Users/sven/.pub-cache/hosted/pub.dev/pub_semver-2.2.0",
"packageUri": "lib/",
"languageVersion": "3.4"
},
{
"name": "shared_preferences",
"rootUri": "file:///Users/sven/.pub-cache/hosted/pub.dev/shared_preferences-2.5.4",
@ -283,6 +385,12 @@
"packageUri": "lib/",
"languageVersion": "3.1"
},
{
"name": "video_thumbnail",
"rootUri": "file:///Users/sven/.pub-cache/hosted/pub.dev/video_thumbnail-0.5.6",
"packageUri": "lib/",
"languageVersion": "2.16"
},
{
"name": "web",
"rootUri": "file:///Users/sven/.pub-cache/hosted/pub.dev/web-1.1.1",
@ -295,6 +403,18 @@
"packageUri": "lib/",
"languageVersion": "3.3"
},
{
"name": "xml",
"rootUri": "file:///Users/sven/.pub-cache/hosted/pub.dev/xml-6.6.1",
"packageUri": "lib/",
"languageVersion": "3.8"
},
{
"name": "yaml",
"rootUri": "file:///Users/sven/.pub-cache/hosted/pub.dev/yaml-3.1.3",
"packageUri": "lib/",
"languageVersion": "3.4"
},
{
"name": "client_proxy_framework",
"rootUri": "../",

View File

@ -13,14 +13,36 @@
"facebook_app_events",
"flutter",
"http",
"image",
"in_app_purchase",
"in_app_purchase_android",
"logger",
"path_provider",
"play_install_referrer",
"shared_preferences"
"shared_preferences",
"video_thumbnail"
],
"devDependencies": []
},
{
"name": "video_thumbnail",
"version": "0.5.6",
"dependencies": [
"flutter"
]
},
{
"name": "path_provider",
"version": "2.1.5",
"dependencies": [
"flutter",
"path_provider_android",
"path_provider_foundation",
"path_provider_linux",
"path_provider_platform_interface",
"path_provider_windows"
]
},
{
"name": "shared_preferences",
"version": "2.5.4",
@ -124,6 +146,56 @@
"vector_math"
]
},
{
"name": "path_provider_windows",
"version": "2.3.0",
"dependencies": [
"ffi",
"flutter",
"path",
"path_provider_platform_interface"
]
},
{
"name": "path_provider_platform_interface",
"version": "2.1.2",
"dependencies": [
"flutter",
"platform",
"plugin_platform_interface"
]
},
{
"name": "path_provider_linux",
"version": "2.2.1",
"dependencies": [
"ffi",
"flutter",
"path",
"path_provider_platform_interface",
"xdg_directories"
]
},
{
"name": "path_provider_foundation",
"version": "2.6.0",
"dependencies": [
"ffi",
"flutter",
"objective_c",
"path_provider_platform_interface"
]
},
{
"name": "path_provider_android",
"version": "2.3.0",
"dependencies": [
"flutter",
"jni",
"jni_flutter",
"path_provider_platform_interface"
]
},
{
"name": "shared_preferences_windows",
"version": "2.4.1",
@ -286,30 +358,70 @@
"version": "1.4.1",
"dependencies": []
},
{
"name": "path_provider_windows",
"version": "2.3.0",
"dependencies": [
"ffi",
"flutter",
"path",
"path_provider_platform_interface"
]
},
{
"name": "path_provider_platform_interface",
"version": "2.1.2",
"dependencies": [
"flutter",
"platform",
"plugin_platform_interface"
]
},
{
"name": "path",
"version": "1.9.1",
"dependencies": []
},
{
"name": "ffi",
"version": "2.2.0",
"dependencies": []
},
{
"name": "plugin_platform_interface",
"version": "2.1.8",
"dependencies": [
"meta"
]
},
{
"name": "platform",
"version": "3.1.6",
"dependencies": []
},
{
"name": "xdg_directories",
"version": "1.1.0",
"dependencies": [
"meta",
"path"
]
},
{
"name": "objective_c",
"version": "9.3.0",
"dependencies": [
"code_assets",
"collection",
"ffi",
"hooks",
"logging",
"native_toolchain_c",
"pub_semver"
]
},
{
"name": "jni_flutter",
"version": "1.0.1",
"dependencies": [
"flutter",
"jni"
]
},
{
"name": "jni",
"version": "1.0.0",
"dependencies": [
"args",
"collection",
"ffi",
"meta",
"package_config",
"path",
"plugin_platform_interface"
]
},
{
"name": "file",
"version": "7.0.1",
@ -325,24 +437,6 @@
"flutter"
]
},
{
"name": "plugin_platform_interface",
"version": "2.1.8",
"dependencies": [
"meta"
]
},
{
"name": "path_provider_linux",
"version": "2.2.1",
"dependencies": [
"ffi",
"flutter",
"path",
"path_provider_platform_interface",
"xdg_directories"
]
},
{
"name": "json_annotation",
"version": "4.11.0",
@ -379,20 +473,53 @@
]
},
{
"name": "ffi",
"name": "pub_semver",
"version": "2.2.0",
"dependencies": []
},
{
"name": "platform",
"version": "3.1.6",
"dependencies": []
},
{
"name": "xdg_directories",
"version": "1.1.0",
"dependencies": [
"collection"
]
},
{
"name": "native_toolchain_c",
"version": "0.17.6",
"dependencies": [
"code_assets",
"glob",
"hooks",
"logging",
"meta",
"pub_semver"
]
},
{
"name": "logging",
"version": "1.3.0",
"dependencies": []
},
{
"name": "hooks",
"version": "1.0.2",
"dependencies": [
"collection",
"crypto",
"logging",
"meta",
"pub_semver",
"yaml"
]
},
{
"name": "code_assets",
"version": "1.0.0",
"dependencies": [
"collection",
"hooks"
]
},
{
"name": "package_config",
"version": "2.2.0",
"dependencies": [
"path"
]
},
@ -400,6 +527,69 @@
"name": "term_glyph",
"version": "1.2.2",
"dependencies": []
},
{
"name": "glob",
"version": "2.1.3",
"dependencies": [
"async",
"collection",
"file",
"path",
"string_scanner"
]
},
{
"name": "yaml",
"version": "3.1.3",
"dependencies": [
"collection",
"source_span",
"string_scanner"
]
},
{
"name": "image",
"version": "4.8.0",
"dependencies": [
"archive",
"meta",
"xml"
]
},
{
"name": "archive",
"version": "4.0.9",
"dependencies": [
"path",
"posix"
]
},
{
"name": "posix",
"version": "6.5.0",
"dependencies": [
"ffi",
"meta",
"path"
]
},
{
"name": "xml",
"version": "6.6.1",
"dependencies": [
"collection",
"meta",
"petitparser"
]
},
{
"name": "petitparser",
"version": "7.0.2",
"dependencies": [
"collection",
"meta"
]
}
],
"configVersion": 1

View File

@ -1,6 +1,8 @@
package com.funymee.client_proxy_framework
import android.app.ActivityManager
import android.app.Application
import android.content.Context
import com.facebook.appevents.AppEventsLogger
import io.flutter.embedding.engine.plugins.FlutterPlugin
import io.flutter.plugin.common.MethodCall
@ -9,6 +11,7 @@ import io.flutter.plugin.common.MethodChannel
/** Facebook App Events在引擎侧注册固定 Channel供 Dart 触发 activateApp。 */
class ClientProxyFrameworkPlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
private var channel: MethodChannel? = null
private var deviceMemoryChannel: MethodChannel? = null
private var applicationContext: android.content.Context? = null
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
@ -16,11 +19,35 @@ class ClientProxyFrameworkPlugin : FlutterPlugin, MethodChannel.MethodCallHandle
val ch = MethodChannel(binding.binaryMessenger, CHANNEL_NAME)
channel = ch
ch.setMethodCallHandler(this)
val memCh = MethodChannel(binding.binaryMessenger, DEVICE_MEMORY_CHANNEL_NAME)
deviceMemoryChannel = memCh
memCh.setMethodCallHandler { call, memResult ->
when (call.method) {
"getTotalPhysicalMemoryBytes" -> {
try {
val ctx = applicationContext ?: run {
memResult.error("NO_CONTEXT", "applicationContext null", null)
return@setMethodCallHandler
}
val am = ctx.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager
val mi = ActivityManager.MemoryInfo()
am.getMemoryInfo(mi)
memResult.success(mi.totalMem.toString())
} catch (e: Exception) {
memResult.error("MEMORY", e.message, null)
}
}
else -> memResult.notImplemented()
}
}
}
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
channel?.setMethodCallHandler(null)
channel = null
deviceMemoryChannel?.setMethodCallHandler(null)
deviceMemoryChannel = null
applicationContext = null
}
@ -45,5 +72,6 @@ class ClientProxyFrameworkPlugin : FlutterPlugin, MethodChannel.MethodCallHandle
companion object {
const val CHANNEL_NAME = "client_proxy_framework/facebook_sdk"
const val DEVICE_MEMORY_CHANNEL_NAME = "client_proxy_framework/device_memory"
}
}

View File

@ -16,12 +16,17 @@ export 'src/api/proxy_client.dart';
export 'src/bootstrap/client_bootstrap.dart';
export 'src/config/app_config.dart';
export 'src/config/attribution_config.dart';
export 'src/config/ext_config_key_schema.dart';
export 'src/config/ext_config_models.dart';
export 'src/config/ext_config_runtime.dart';
export 'src/config/video_home_runtime.dart';
export 'src/config/skin_config.dart';
export 'src/config/field_mapping.dart';
export 'src/config/default_field_mapping.dart';
export 'src/entities/entities.dart';
export 'src/log/app_logger.dart';
export 'src/log/sdk_reminder_log.dart';
export 'src/media/video_thumbnail_cache.dart';
export 'src/services/adjust_service.dart';
export 'src/services/analytics_attribution_callbacks.dart';
export 'src/services/analytics_service.dart';
@ -29,6 +34,14 @@ export 'src/services/auth_service.dart';
export 'src/services/facebook_service.dart';
export 'src/services/feedback_api.dart';
export 'src/services/image_api.dart';
export 'src/services/image_progress_poll.dart';
export 'src/services/image_compress.dart';
export 'src/services/image_presigned_upload_create_flow.dart';
export 'src/services/image_task_history.dart';
export 'src/services/task_upload_cover_store.dart';
export 'src/services/user_account_refresh.dart';
export 'src/services/payment_api.dart';
export 'src/services/payment_flow/payment_flow.dart';
export 'src/services/payment_service.dart';
export 'src/services/user_api.dart';
export 'src/util/device_memory_profile.dart';

View File

@ -97,14 +97,14 @@ class ProxyClient {
return result;
}
/// Map
/// Map
///
/// [headers][queryParams][body] 使****
/// [AppConfig.fieldMapping] V2
/// data V2
/// [headers][queryParams][body] 使****
/// [AppConfig.fieldMapping] 线
/// data 线
///
/// ****
/// - [AppConfig.packageName] `pkg` `stakeholder`
/// - [AppConfig.packageName] `pkg`线
/// - [userToken] [includeUserTokenInHeader] true `User_token`
///
/// **** `includeUserTokenInHeader: false`
@ -176,7 +176,7 @@ class ProxyClient {
///
///
/// [headers][queryParams][body] 使****
/// [headers][queryParams][body] 使****
/// [entityFactory] data
///
/// [request] [includeUserTokenInHeader]
@ -198,8 +198,18 @@ class ProxyClient {
includeUserTokenInHeader: includeUserTokenInHeader,
);
if (response.isSuccess && response.data is Map<String, dynamic>) {
final entity = entityFactory(response.data as Map<String, dynamic>);
if (response.isSuccess) {
final raw = response.data;
if (raw is Map<String, dynamic>) {
final entity = entityFactory(raw);
return EntityResponse<T>(
code: response.code,
msg: response.msg,
data: entity,
);
}
// `data` null `true`
final entity = entityFactory(<String, dynamic>{});
return EntityResponse<T>(
code: response.code,
msg: response.msg,

View File

@ -1,59 +1,32 @@
import 'package:flutter/foundation.dart';
import 'default_field_mapping.dart';
import 'ext_config_key_schema.dart';
import 'field_mapping.dart';
///
/// JSON ****线 `skin_config.json` `proxyKeys`
class ProxyKeysConfig {
const ProxyKeysConfig({
/// **** [AppConfig.appName]
this.appIdField = 'hero_class',
/// path
this.pathField = 'pet_species',
/// "POST" "GET"
this.methodField = 'power_level',
/// Header JSON
this.headerField = 'quest_rank',
/// URL JSON
this.paramsField = 'battle_score',
/// V2
this.bodyField = 'loyalty_index',
///
/// [AppConfig.appName]
this.appIdField = 'appId',
this.pathField = 'path',
this.methodField = 'method',
this.headerField = 'headers',
this.paramsField = 'params',
this.bodyField = 'body',
this.noiseKeys = const [
'billing_addr',
'utm_term',
'cluster_id',
'lsn_value',
'accuracy_val',
'dir_path'
'noise1',
'noise2',
'noise3',
'noise4',
],
});
/// = appName
final String appIdField;
/// path
final String pathField;
/// "POST" "GET"
final String methodField;
/// Header JSON
final String headerField;
/// URL JSON
final String paramsField;
/// V2
final String bodyField;
///
final List<String> noiseKeys;
}
@ -104,26 +77,37 @@ abstract class AppConfig {
///
ProxyKeysConfig get proxyKeys => const ProxyKeysConfig();
/// V2
/// V2 `skin_config.json` `v2.sanctumPath`
List<String> get v2SanctumPath =>
const ['vault', 'tome', 'codex', 'grimoire', 'sanctum'];
const ['wrapper', 'layer', 'payload'];
/// V2
List<String> get v2NoiseKeys =>
const ['roar', 'clash', 'thunder', 'rumble', 'howl', 'growl'];
const ['n1', 'n2', 'n3', 'n4', 'n5', 'n6'];
/// V2
String get v2LevelField => 'arsenal';
/// V2 [v2LevelFixedValue]
String get v2LevelField => 'level';
/// V2
int get v2LevelFixedValue => 4;
int get v2LevelFixedValue => 1;
/// V2
Map<String, dynamic> get v2FixedValues {
return {v2LevelField: v2LevelFixedValue};
}
/// V2
/// V2
FieldMapping get fieldMapping => petsHeroAIFieldMapping;
/// 线 [kIdentityFieldMapping]** `skin_config.json` **
FieldMapping get fieldMapping => kIdentityFieldMapping;
/// `common_info.extConfig` JSON `skin_config.extConfig`
ExtConfigKeySchema get extConfigKeySchema => ExtConfigKeySchema.defaults();
/// extConfig null
Map<String, dynamic>? get extConfigDefaults => null;
/// [ExtConfigData.items] Tab `skin_config.videoHome`
String get videoHomeImagesTabLabel => 'Images';
/// true imagesTab `false`images
bool get videoHomeImagesTabFirst => false;
}

View File

@ -1,105 +1,5 @@
import 'field_mapping.dart';
/// petsHeroAI
///
///
/// [FieldMapping]
const FieldMapping petsHeroAIFieldMapping = FieldMapping({
// === ===
'pkg': 'portal',
'User_token': 'knight',
// === ===
'code': 'helm',
'msg': 'rampart',
'data': 'sidekick',
// === query ===
'app': 'sentinel',
'userId': 'asset',
'ch': 'crest',
'type': 'accolade',
// === fast_login body ===
'referer': 'digest',
'sign': 'resolution',
'deviceId': 'origin',
// === ===
'activityId': 'warrior',
'country': 'vambrace',
'client': 'filter',
'id': 'timing',
'filterStatus': 'quest',
'orderId': 'federation',
'payUrl': 'convert',
'productId': 'helm',
'paymentMethod': 'resource',
'paymentType': 'ceremony',
'signature': 'sample',
'purchaseData': 'merchant',
// === / ===
'categoryId': 'insignia',
'taskId': 'tree',
'prompt': 'ledger',
'resolution': 'guild',
'srcImgUrls': 'commission',
'fileName1': 'gateway',
'fileName2': 'action',
'contentType': 'pauldron',
'expectedSize': 'stronghold',
'page': 'trophy',
'pageSize': 'heatmap',
'cursor': 'platoon',
'declaration': 'declaration',
'quest': 'quest',
'imgCount': 'indicator',
'aspectRatio': 'caption',
'ext': 'nexus',
'imgUrl': 'congregation',
'poseId': 'profit',
'templateId': 'compendium',
'notification': 'notification',
'allowance': 'allowance',
'cosmos': 'cosmos',
// === data ===
'userToken': 'reevaluate',
'credits': 'reveal',
'avatar': 'realm',
'userName': 'terminal',
'countryCode': 'navigate',
'extConfig': 'surge',
'appFbConfig': 'evolve',
'usign': 'retrospect',
'creditsRecordUrl': 'conquer',
'tgId': 'concession',
'tgName': 'defer',
'email': 'galaxy',
'forcePayCenter': 'upgrade',
't2IConfig': 'regulate',
'h5UrlConfig': 'pursue',
'payCenterUrl': 'switch',
'freeBlurTimes': 'vow',
'firstRegister': 'equip',
'isVip': 'generate',
'tags': 'rally',
'freeTimes': 'decree',
'subScribeValidTime': 'tokenize',
'status': 'line',
'productList': 'summon',
'paymentMethods': 'renew',
'guardian': 'guardian',
'curriculum': 'curriculum',
'forge': 'forge',
'tag': 'constrain',
'img': 'revenue',
'url': 'digitize',
'launchImgUrl': 'launchImgUrl',
// === ===
'fileName': 'layer',
'fileUrls': 'inventory',
'content': 'cloak',
});
/// petsHero **线 `skin_config.json` `fieldMapping` **
@Deprecated('Use kIdentityFieldMapping; configure skin_config.json fieldMapping.')
const FieldMapping petsHeroAIFieldMapping = kIdentityFieldMapping;

View File

@ -0,0 +1,295 @@
import 'dart:convert';
/// `skin_config.json` `extConfig.keys` / `extConfig.itemKeys`
/// **** /common_info **JSON **便
///
/// [itemKeysHome] / [itemKeysTask]******/**线 [itemKeys]
/// 退 [itemKeys]
///
/// [taskItemMapping]`extConfig.items` /**** [GET /v1/image/img2video/tasks] [FieldMapping.mapResponse] [TaskItem.fromJson]
/// ******** `previewVideo.url``resolution720p.credits`********
/// mapResponse Task [ExtConfigItem.fromJson]
///
/// [defaultItemTitleWhenEmpty]`extConfig.items` [ExtConfigItem.title] `extConfig.defaultItemTitle`
///
/// [imageFixKeys] `image_fix` [itemImageFixKeys] `["image_fix"]` JSON
class ExtConfigKeySchema {
const ExtConfigKeySchema({
required this.showVideoMenuKeys,
required this.allowScreenshotKeys,
required this.blockScreenshotKeys,
required this.allowThirdPartyPaymentKeys,
required this.privacyUrlKeys,
required this.agreementUrlKeys,
required this.itemsKeys,
required this.itemImageKeys,
required this.itemImageFixKeys,
required this.itemImgNeedKeys,
required this.itemCostKeys,
required this.itemCost480pKeys,
required this.itemCost720pKeys,
required this.itemTitleKeys,
required this.itemTemplateNameKeys,
required this.itemTaskTypeKeys,
required this.itemParamsKeys,
required this.itemDetailKeys,
required this.itemVideoUrlKeys,
this.itemKeysHome,
this.itemKeysTask,
this.taskItemMapping,
this.itemsApplyFieldMappingBeforeTaskMapping = true,
this.defaultItemTitleWhenEmpty = '-',
});
/// Video/ Tab items Tab
final List<String> showVideoMenuKeys;
/// `true` ****
final List<String> allowScreenshotKeys;
/// `true` **** [allowScreenshotKeys] true
final List<String> blockScreenshotKeys;
///
final List<String> allowThirdPartyPaymentKeys;
final List<String> privacyUrlKeys;
final List<String> agreementUrlKeys;
/// items
final List<String> itemsKeys;
final List<String> itemImageKeys;
final List<String> itemImageFixKeys;
final List<String> itemImgNeedKeys;
final List<String> itemCostKeys;
/// 480p [itemCostKeys]
final List<String> itemCost480pKeys;
/// 720p [itemCostKeys] / `cost`
final List<String> itemCost720pKeys;
final List<String> itemTitleKeys;
/// `POST /v1/image/create-task` `templateName` / `itinerary` [itemTitleKeys]
final List<String> itemTemplateNameKeys;
/// `POST /v1/image/create-task` `cipher` / `liaison` `taskType`
final List<String> itemTaskTypeKeys;
/// animal_expression [itemDetailKeys] [ExtConfigItem.taskExt]
final List<String> itemParamsKeys;
final List<String> itemDetailKeys;
/// / [ExtConfigItem.isVideoItem] true [itemImageKeys]
final List<String> itemVideoUrlKeys;
/// /线 [itemKeys]
final Map<String, List<String>>? itemKeysHome;
/// /params/detail线
final Map<String, List<String>>? itemKeysTask;
/// `extConfig.items` **** [TaskItem.fromJson] `/v1/image/img2video/tasks` mapResponse
/// **** [itemKeys]
final Map<String, List<String>>? taskItemMapping;
/// `true` [FieldMapping.mapResponse] [taskItemMapping] **/** `false` ****线
final bool itemsApplyFieldMappingBeforeTaskMapping;
/// `extConfig.items` [ExtConfigItem] [ExtConfigItem.title] `extConfig.defaultItemTitle`
final String defaultItemTitleWhenEmpty;
/// 线[itemKeysHome] [fallbackFromItemKeys] [itemKeys]
List<String> homeWireKeysFor(
String logical,
List<String> fallbackFromItemKeys,
) {
final o = itemKeysHome?[logical];
if (o != null && o.isNotEmpty) return o;
return fallbackFromItemKeys;
}
/// /线[itemKeysTask] [fallbackFromItemKeys]
List<String> taskWireKeysFor(
String logical,
List<String> fallbackFromItemKeys,
) {
final o = itemKeysTask?[logical];
if (o != null && o.isNotEmpty) return o;
return fallbackFromItemKeys;
}
/// go_run / need_waitscreensafe_areasan_fanglucky
factory ExtConfigKeySchema.defaults() {
return const ExtConfigKeySchema(
showVideoMenuKeys: ['go_run', 'need_wait'],
allowScreenshotKeys: ['screen'],
blockScreenshotKeys: ['safe_area'],
allowThirdPartyPaymentKeys: ['san_fang', 'lucky'],
privacyUrlKeys: ['privacy'],
agreementUrlKeys: ['agreement'],
itemsKeys: ['items'],
itemImageKeys: ['image'],
itemImageFixKeys: ['image_fix'],
itemImgNeedKeys: ['img_need'],
itemCostKeys: ['cost'],
itemCost480pKeys: ['cost_480p', 'cost480p', 'cost_480'],
itemCost720pKeys: ['cost_720p', 'cost720p', 'cost_720'],
itemTitleKeys: ['title'],
itemTemplateNameKeys: ['templateName', 'template_name'],
itemTaskTypeKeys: ['taskType'],
itemParamsKeys: ['params'],
itemDetailKeys: ['detail'],
itemVideoUrlKeys: ['video', 'video_url', 'videoUrl', 'preview_video'],
itemKeysHome: null,
itemKeysTask: null,
taskItemMapping: null,
itemsApplyFieldMappingBeforeTaskMapping: true,
defaultItemTitleWhenEmpty: '-',
);
}
/// `skin_config` `extConfig` [defaults]
factory ExtConfigKeySchema.fromSkinExtConfigJson(Map<String, dynamic>? ext) {
if (ext == null) return ExtConfigKeySchema.defaults();
final d = ExtConfigKeySchema.defaults();
final keys = ext['keys'];
final itemKeys = ext['itemKeys'] as Map<String, dynamic>?;
List<String> rootList(Map<String, dynamic>? m, String k, List<String> fallback) {
if (m == null) return fallback;
final raw = m[k];
final parsed = _stringList(raw);
return parsed.isEmpty ? fallback : parsed;
}
Map<String, dynamic>? keyMap;
if (keys is Map<String, dynamic>) {
keyMap = keys;
}
List<String> itemField(
String k,
List<String> fallback,
) {
if (itemKeys == null) return fallback;
return rootList(itemKeys, k, fallback);
}
return ExtConfigKeySchema(
showVideoMenuKeys: rootList(keyMap, 'showVideoMenu', d.showVideoMenuKeys),
allowScreenshotKeys:
rootList(keyMap, 'allowScreenshot', d.allowScreenshotKeys),
blockScreenshotKeys:
rootList(keyMap, 'blockScreenshot', d.blockScreenshotKeys),
allowThirdPartyPaymentKeys: rootList(
keyMap,
'allowThirdPartyPayment',
d.allowThirdPartyPaymentKeys,
),
privacyUrlKeys: rootList(keyMap, 'privacyUrl', d.privacyUrlKeys),
agreementUrlKeys: rootList(keyMap, 'agreementUrl', d.agreementUrlKeys),
itemsKeys: rootList(keyMap, 'items', d.itemsKeys),
itemImageKeys: itemField('image', d.itemImageKeys),
itemImageFixKeys: itemField('imageFix', d.itemImageFixKeys),
itemImgNeedKeys: itemField('imgNeed', d.itemImgNeedKeys),
itemCostKeys: itemField('cost', d.itemCostKeys),
itemCost480pKeys: itemField('cost480p', d.itemCost480pKeys),
itemCost720pKeys: itemField('cost720p', d.itemCost720pKeys),
itemTitleKeys: itemField('title', d.itemTitleKeys),
itemTemplateNameKeys:
itemField('templateName', d.itemTemplateNameKeys),
itemTaskTypeKeys: itemField('taskType', d.itemTaskTypeKeys),
itemParamsKeys: itemField('params', d.itemParamsKeys),
itemDetailKeys: itemField('detail', d.itemDetailKeys),
itemVideoUrlKeys: itemField('videoUrl', d.itemVideoUrlKeys),
itemKeysHome: _optionalLogicalWireMap(ext['itemKeysHome']),
itemKeysTask: _optionalLogicalWireMap(ext['itemKeysTask']),
taskItemMapping: _optionalLogicalWireMap(ext['taskItemMapping']),
itemsApplyFieldMappingBeforeTaskMapping:
ext['itemsApplyFieldMappingBeforeTaskMapping'] is bool
? ext['itemsApplyFieldMappingBeforeTaskMapping'] as bool
: true,
defaultItemTitleWhenEmpty: _parseDefaultItemTitle(
ext['defaultItemTitle'],
d.defaultItemTitleWhenEmpty,
),
);
}
static String _parseDefaultItemTitle(dynamic raw, String fallback) {
if (raw is String && raw.trim().isNotEmpty) return raw.trim();
return fallback;
}
/// Map null
static Map<String, List<String>>? _optionalLogicalWireMap(dynamic raw) {
if (raw is! Map) return null;
final out = <String, List<String>>{};
for (final e in raw.entries) {
final k = e.key.toString();
final list = _stringList(e.value);
if (list.isNotEmpty) out[k] = list;
}
return out.isEmpty ? null : out;
}
static List<String> _stringList(dynamic raw) {
if (raw == null) return [];
if (raw is String) return raw.isEmpty ? [] : [raw];
if (raw is List) {
return raw.map((e) => e.toString()).where((s) => s.isNotEmpty).toList();
}
return [];
}
}
/// `skin_config` `extConfig` + JSONwire common_info
class SkinExtConfigSection {
SkinExtConfigSection({
required this.keySchema,
this.defaults,
});
final ExtConfigKeySchema keySchema;
/// extConfig 线 ****`common_info` ****
final Map<String, dynamic>? defaults;
static SkinExtConfigSection? fromRootJson(Map<String, dynamic>? ext) {
if (ext == null) return null;
final schema = ExtConfigKeySchema.fromSkinExtConfigJson(ext);
Map<String, dynamic>? def;
final raw = ext['defaults'];
if (raw is Map<String, dynamic>) {
def = Map<String, dynamic>.from(raw);
} else if (raw is String && raw.trim().isNotEmpty) {
try {
final dec = json.decode(raw);
if (dec is Map<String, dynamic>) def = dec;
} catch (_) {}
}
return SkinExtConfigSection(keySchema: schema, defaults: def);
}
/// [server] null [base]
static Map<String, dynamic>? mergeDefaults({
Map<String, dynamic>? base,
Map<String, dynamic>? server,
}) {
if (base == null || base.isEmpty) {
if (server == null || server.isEmpty) return null;
return Map<String, dynamic>.from(server);
}
if (server == null || server.isEmpty) {
return Map<String, dynamic>.from(base);
}
final out = Map<String, dynamic>.from(base);
server.forEach((k, v) {
out[k] = v;
});
return out;
}
}

View File

@ -0,0 +1,521 @@
import 'dart:convert';
import '../entities/image_entities.dart';
import 'ext_config_key_schema.dart';
import 'field_mapping.dart';
/// `common_info.extConfig` JSON****
///
/// JSON **** [ExtConfigKeySchema] `skin_config.extConfig`使 [ExtConfigKeySchema.defaults]
///
/// `items`
/// - [ExtConfigKeySchema.taskItemMapping]**** [GET /v1/image/img2video/tasks] [FieldMapping.mapResponse] [TaskItem.fromJson] [ExtConfigItem.fromTaskItem]
/// - [FieldMapping.mapResponse] [TaskItem] [ExtConfigItem.fromTaskItem] [ExtConfigItem.fromJson] `image`/`title`
class ExtConfigData {
const ExtConfigData({
this.showVideoMenu,
this.allowScreenshot,
this.allowThirdPartyPayment,
this.privacyUrl,
this.agreementUrl,
this.items = const [],
});
final bool? showVideoMenu;
final bool? allowScreenshot;
final bool? allowThirdPartyPayment;
final String? privacyUrl;
final String? agreementUrl;
final List<ExtConfigItem> items;
bool? get shouldPreventCapture {
final a = allowScreenshot;
if (a == null) return null;
return !a;
}
static ExtConfigData empty() => const ExtConfigData();
static ExtConfigData parse(
String? extConfigJson, {
ExtConfigKeySchema? schema,
FieldMapping fieldMapping = kIdentityFieldMapping,
}) {
final map = parseRawMap(extConfigJson);
if (map == null) return empty();
return fromJson(
map,
schema: schema ?? ExtConfigKeySchema.defaults(),
fieldMapping: fieldMapping,
);
}
static Map<String, dynamic>? parseRawMap(String? extConfigJson) {
if (extConfigJson == null || extConfigJson.isEmpty) return null;
try {
final decoded = json.decode(extConfigJson);
if (decoded is Map<String, dynamic>) return decoded;
return null;
} catch (_) {
return null;
}
}
static ExtConfigData fromJson(
Map<String, dynamic> map, {
required ExtConfigKeySchema schema,
FieldMapping fieldMapping = kIdentityFieldMapping,
}) {
final showVideo = _readBoolFromKeys(map, schema.showVideoMenuKeys);
bool? allowShot;
allowShot = _readBoolFromKeys(map, schema.allowScreenshotKeys);
if (allowShot == null && schema.blockScreenshotKeys.isNotEmpty) {
for (final k in schema.blockScreenshotKeys) {
if (!map.containsKey(k)) continue;
final block = _readBool(map, k) == true;
allowShot = !block;
break;
}
}
final third =
_readBoolFromKeys(map, schema.allowThirdPartyPaymentKeys);
final privacy = _readStringFromKeys(map, schema.privacyUrlKeys);
final agreement = _readStringFromKeys(map, schema.agreementUrlKeys);
final rawItems = _firstListFromKeys(map, schema.itemsKeys);
final List<ExtConfigItem> items = [];
if (rawItems != null) {
for (final e in rawItems) {
if (e is! Map) continue;
final rawItem = Map<String, dynamic>.from(e);
final mappedItem = fieldMapping.mapResponse(rawItem);
final imgNeedFromMap = ExtConfigItem._readIntFromKeys(
mappedItem,
schema.taskWireKeysFor('imgNeed', schema.itemImgNeedKeys),
);
final taskMap = schema.taskItemMapping;
if (taskMap != null && taskMap.isNotEmpty) {
final src = schema.itemsApplyFieldMappingBeforeTaskMapping
? mappedItem
: rawItem;
final taskJson =
buildTaskItemShapeFromExtItemMap(src, taskMap);
final task = TaskItem.fromJson(taskJson);
items.add(
ExtConfigItem.fromTaskItem(
task,
categoryLabel: '',
imgNeedOverride: imgNeedFromMap,
).withDefaultTitleIfEmpty(schema),
);
} else if (_itemMapLooksLikeTaskPayload(mappedItem)) {
final task = TaskItem.fromJson(mappedItem);
items.add(
ExtConfigItem.fromTaskItem(
task,
categoryLabel: '',
imgNeedOverride: imgNeedFromMap,
).withDefaultTitleIfEmpty(schema),
);
} else {
items.add(
ExtConfigItem.fromJson(mappedItem, schema: schema)
.withDefaultTitleIfEmpty(schema),
);
}
}
}
return ExtConfigData(
showVideoMenu: showVideo,
allowScreenshot: allowShot,
allowThirdPartyPayment: third,
privacyUrl: privacy,
agreementUrl: agreement,
items: items,
);
}
/// [ImageApi._parseTasksPayload] 线anchor/slice/factor/span
static bool _itemMapLooksLikeTaskPayload(Map<String, dynamic> m) {
return m.containsKey('previewVideo') ||
m.containsKey('previewImage') ||
m.containsKey('resolution480p') ||
m.containsKey('resolution720p') ||
m.containsKey('imageUrl') ||
m.containsKey('templateUrl');
}
static bool? _readBoolFromKeys(Map<String, dynamic> map, List<String> keys) {
for (final k in keys) {
if (!map.containsKey(k)) continue;
return _readBool(map, k);
}
return null;
}
static String? _readStringFromKeys(
Map<String, dynamic> map,
List<String> keys,
) {
for (final k in keys) {
if (!map.containsKey(k)) continue;
final s = _readString(map, k);
if (s != null && s.isNotEmpty) return s;
}
return null;
}
static List<dynamic>? _firstListFromKeys(
Map<String, dynamic> map,
List<String> keys,
) {
for (final k in keys) {
if (!map.containsKey(k)) continue;
final v = map[k];
if (v is List) return v;
}
return null;
}
static bool? _readBool(Map<String, dynamic> map, String key) {
final v = map[key];
if (v is bool) return v;
if (v is String) {
if (v == 'true') return true;
if (v == 'false') return false;
}
return null;
}
static String? _readString(Map<String, dynamic> map, String key) {
final v = map[key];
if (v == null) return null;
if (v is String) return v.isEmpty ? null : v;
return v.toString();
}
}
/// `extConfig.items`
class ExtConfigItem {
const ExtConfigItem({
required this.image,
this.imageFix,
this.imgNeed,
required this.cost,
this.cost480p,
this.cost720p,
required this.title,
this.templateName,
this.taskType,
this.params,
this.detail,
this.videoUrl,
});
final String image;
final String? imageFix;
final int? imgNeed;
final int cost;
/// 480p [cost720p] [cost]
final int? cost480p;
/// 720p [cost] 720p
final int? cost720p;
final String title;
/// [app_client] `TaskItem.templateName` `congregation` 退 [title]
final String? templateName;
/// `cipher` `liaison` `taskType`
final String? taskType;
final String? params;
final String? detail;
/// common_info [ExtConfigKeySchema.itemVideoUrlKeys]
/// [image]
final String? videoUrl;
/// `videoUrl` [image]/[imageFix] URL
bool get isVideoItem {
final v = videoUrl?.trim();
if (v != null && v.isNotEmpty) return true;
if (_urlLooksLikeVideo(image)) return true;
final fix = imageFix?.trim();
if (fix != null && fix.isNotEmpty && _urlLooksLikeVideo(fix)) return true;
return false;
}
/// / images Tab [TaskItem]
/// //params
bool get isUsableOnHome {
if (title.trim().isNotEmpty) return true;
if (image.trim().isNotEmpty) return true;
final v = videoUrl?.trim();
if (v != null && v.isNotEmpty) return true;
final f = imageFix?.trim();
if (f != null && f.isNotEmpty) return true;
if (templateName?.trim().isNotEmpty ?? false) return true;
if (taskType?.trim().isNotEmpty ?? false) return true;
if (params?.trim().isNotEmpty ?? false) return true;
return false;
}
/// [ExtConfigData.fromJson] `items` [ExtConfigKeySchema.defaultItemTitleWhenEmpty]
ExtConfigItem withDefaultTitleIfEmpty(ExtConfigKeySchema schema) {
if (title.trim().isNotEmpty) return this;
final fill = schema.defaultItemTitleWhenEmpty;
if (fill.isEmpty) return this;
return ExtConfigItem(
image: image,
imageFix: imageFix,
imgNeed: imgNeed,
cost: cost,
cost480p: cost480p,
cost720p: cost720p,
title: fill,
templateName: templateName,
taskType: taskType,
params: params,
detail: detail,
videoUrl: videoUrl,
);
}
static bool _urlLooksLikeVideo(String url) {
final u = url.trim().toLowerCase();
if (u.isEmpty) return false;
const hints = ['.mp4', '.m3u8', '.webm', '.mov', '.mkv'];
return hints.any((h) => u.contains(h));
}
String get taskExt => (params != null && params!.isNotEmpty)
? params!
: (detail ?? '');
/// [VideoHomeRuntime] [ExtConfigItem] 便 `extConfig.defaults.items`
factory ExtConfigItem.fromTaskItem(
TaskItem t, {
String categoryLabel = '',
int? imgNeedOverride,
}) {
final name = (t.title ?? t.templateName ?? t.name ?? '').trim();
final cat = categoryLabel.trim();
final display = cat.isEmpty
? (name.isNotEmpty ? name : cat)
: (name.isNotEmpty ? '$cat · $name' : cat);
final img = (t.imageUrl ?? t.templateUrl ?? '').trim();
final v = t.previewVideoUrl?.trim();
final backendTemplate = (t.templateName ?? t.name ?? '').trim();
final tt = t.taskType?.trim();
final extOnly = t.ext?.trim();
final c480 = t.credits480p;
final c720 = t.credits720p;
return ExtConfigItem(
image: img,
imageFix: null,
imgNeed: imgNeedOverride ?? 1,
cost: c720 ?? c480 ?? 0,
cost480p: c480,
cost720p: c720,
title: display,
templateName:
backendTemplate.isNotEmpty ? backendTemplate : null,
taskType: tt != null && tt.isNotEmpty ? tt : null,
params: extOnly != null && extOnly.isNotEmpty ? extOnly : null,
detail: null,
videoUrl: v != null && v.isNotEmpty ? v : null,
);
}
/// 线[ExtConfigKeySchema.homeWireKeysFor] / [ExtConfigKeySchema.taskWireKeysFor]
/// task home [itemKeys] 退
factory ExtConfigItem.fromJson(
Map<String, dynamic> json, {
required ExtConfigKeySchema schema,
}) {
final costKeys = schema.taskWireKeysFor(
'cost',
schema.homeWireKeysFor('cost', schema.itemCostKeys),
);
final cost480Keys = schema.taskWireKeysFor(
'cost480p',
schema.homeWireKeysFor('cost480p', schema.itemCost480pKeys),
);
final cost720Keys = schema.taskWireKeysFor(
'cost720p',
schema.homeWireKeysFor('cost720p', schema.itemCost720pKeys),
);
return ExtConfigItem(
image: _readStringFromKeys(
json,
schema.homeWireKeysFor('image', schema.itemImageKeys),
) ??
'',
imageFix: _readStringFromKeysOptional(
json,
schema.homeWireKeysFor('imageFix', schema.itemImageFixKeys),
),
imgNeed: _readIntFromKeys(
json,
schema.taskWireKeysFor('imgNeed', schema.itemImgNeedKeys),
),
cost: _readIntFromKeys(json, costKeys) ?? 0,
cost480p: _readIntFromKeys(json, cost480Keys),
cost720p: _readIntFromKeys(json, cost720Keys),
title: _readStringFromKeys(
json,
schema.homeWireKeysFor('title', schema.itemTitleKeys),
) ??
'',
templateName: _readStringFromKeysOptional(
json,
schema.taskWireKeysFor('templateName', schema.itemTemplateNameKeys),
),
taskType: _readStringFromKeysOptional(
json,
schema.taskWireKeysFor('taskType', schema.itemTaskTypeKeys),
),
params: _readStringFromKeysOptional(
json,
schema.taskWireKeysFor('params', schema.itemParamsKeys),
),
detail: _readStringFromKeysOptional(
json,
schema.taskWireKeysFor('detail', schema.itemDetailKeys),
),
videoUrl: _readStringFromKeysOptional(
json,
schema.homeWireKeysFor('videoUrl', schema.itemVideoUrlKeys),
),
);
}
static String? _readStringFromKeys(
Map<String, dynamic> map,
List<String> keys,
) {
for (final k in keys) {
if (!map.containsKey(k)) continue;
final v = map[k];
if (v == null) continue;
if (v is String) return v.isEmpty ? null : v;
return v.toString();
}
return null;
}
static String? _readStringFromKeysOptional(
Map<String, dynamic> map,
List<String> keys,
) {
if (keys.isEmpty) return null;
for (final k in keys) {
if (!map.containsKey(k)) continue;
final v = map[k];
if (v == null) continue;
if (v is String) return v.isEmpty ? null : v;
return v.toString();
}
return null;
}
static int? _readIntFromKeys(Map<String, dynamic> map, List<String> keys) {
for (final k in keys) {
if (!map.containsKey(k)) continue;
return _toInt(map[k]);
}
return null;
}
static int? _toInt(dynamic v) {
if (v == null) return null;
if (v is int) return v;
if (v is num) return v.toInt();
if (v is String && v.isNotEmpty) return int.tryParse(v);
return null;
}
}
/// `skin_config.extConfig.taskItemMapping` `ext` [TaskItem.fromJson] Map `/v1/image/img2video/tasks` mapResponse
Map<String, dynamic> buildTaskItemShapeFromExtItemMap(
Map<String, dynamic> src,
Map<String, List<String>> mapping,
) {
final out = <String, dynamic>{};
for (final e in mapping.entries) {
final targetPath = e.key.trim();
if (targetPath.isEmpty) continue;
final sources = e.value
.map((s) => s.toString().trim())
.where((s) => s.isNotEmpty)
.toList();
if (sources.isEmpty) continue;
dynamic chosen;
for (final p in sources) {
final v = _mapReadDotPath(src, p);
if (v == null) continue;
if (v is String && v.trim().isEmpty) continue;
chosen = v;
break;
}
if (chosen == null) continue;
final coerced = targetPath.endsWith('.credits')
? _coerceCreditsValue(chosen)
: chosen;
if (coerced == null) continue;
_mapSetAtDotPath(out, targetPath, coerced);
}
return out;
}
int? _coerceCreditsValue(dynamic v) {
if (v is int) return v;
if (v is num) return v.toInt();
if (v is String && v.isNotEmpty) return int.tryParse(v.trim());
return null;
}
dynamic _mapReadDotPath(Map<String, dynamic> root, String dotPath) {
final parts = dotPath.split('.').where((s) => s.isNotEmpty).toList();
if (parts.isEmpty) return null;
dynamic cur = root;
for (final p in parts) {
if (cur is! Map) return null;
final m = Map<String, dynamic>.from(cur);
cur = m[p];
if (cur == null) return null;
}
return cur;
}
void _mapSetAtDotPath(Map<String, dynamic> root, String dotPath, dynamic value) {
final parts = dotPath.split('.').where((s) => s.isNotEmpty).toList();
if (parts.isEmpty) return;
Map<String, dynamic> cur = root;
for (var i = 0; i < parts.length - 1; i++) {
final p = parts[i];
final next = cur[p];
if (next is! Map<String, dynamic>) {
cur[p] = <String, dynamic>{};
}
cur = cur[p]! as Map<String, dynamic>;
}
cur[parts.last] = value;
}
const int kExtConfigItemsCategoryId = -1;
List<T> mergeHomeTabsWithExtConfigItems<T>({
required List<T> apiTabs,
required ExtConfigData? ext,
required T staticTab,
}) {
if (ext?.showVideoMenu != true) return apiTabs;
return [...apiTabs, staticTab];
}

View File

@ -0,0 +1,63 @@
import 'package:flutter/foundation.dart';
import '../api/api_client.dart';
import '../entities/user_entities.dart';
import 'ext_config_key_schema.dart';
import 'ext_config_models.dart';
/// [common_info] `extConfig`
///
/// [FrameworkAuthService] [CommonInfoResponse]
/// 宿 [data][commonInfoSucceeded] Tab
abstract final class ExtConfigRuntime {
/// `extConfig` common_info `null`
static final ValueNotifier<ExtConfigData?> data = ValueNotifier<ExtConfigData?>(null);
/// `null` common_info `true` extConfig `false`
///
/// **** `FrameworkAuthService.isLoginComplete` true `true`
/// common_info
static final ValueNotifier<bool?> commonInfoSucceeded =
ValueNotifier<bool?>(null);
/// [FrameworkAuthService] common_info
static void applyCommonInfoSuccess(CommonInfoResponse response) {
commonInfoSucceeded.value = true;
final cfg = ApiClient.instance.config;
final schema = cfg.extConfigKeySchema;
final defaults = cfg.extConfigDefaults;
final serverMap = ExtConfigData.parseRawMap(response.extConfig);
final merged = SkinExtConfigSection.mergeDefaults(
base: defaults,
server: serverMap,
);
if (merged == null || merged.isEmpty) {
data.value = ExtConfigData.empty();
} else {
try {
data.value = ExtConfigData.fromJson(
merged,
schema: schema,
fieldMapping: cfg.fieldMapping,
);
} catch (e, st) {
if (kDebugMode) {
debugPrint('[ExtConfigRuntime] fromJson failed: $e\n$st');
}
data.value = ExtConfigData.empty();
}
}
}
/// [FrameworkAuthService] common_info
static void applyCommonInfoFailure() {
commonInfoSucceeded.value = false;
data.value = null;
}
/// 宿
static void resetForTest() {
data.value = null;
commonInfoSucceeded.value = null;
}
}

View File

@ -1,13 +1,12 @@
///
///
/// ** **canonical V2
/// V2
///
/// ** 线V2**
/// [mapRequest] 线 [mapResponse]线
/// 宿 `skin_config.json` `fieldMapping` 使 [kIdentityFieldMapping]
class FieldMapping {
const FieldMapping(this.mapping);
///
/// {'deviceId': 'origin', 'userId': 'asset', 'userToken': 'reevaluate'}
/// 线
final Map<String, String> mapping;
Map<String, String> get _inverse {
@ -16,10 +15,10 @@ class FieldMapping {
);
}
///
/// 线
String mapField(String original) => mapping[original] ?? original;
/// V2
/// 线
String reverseMapField(String v2) => _inverse[v2] ?? v2;
/// Map key
@ -81,3 +80,6 @@ class FieldMapping {
/// token
String get headerUserTokenField => mapField('User_token');
}
/// `skin_config.json` `fieldMapping` 使线
const FieldMapping kIdentityFieldMapping = FieldMapping({});

View File

@ -6,7 +6,7 @@ import '../log/sdk_reminder_log.dart';
import '../services/analytics_service.dart';
import 'app_config.dart';
import 'attribution_config.dart';
import 'default_field_mapping.dart';
import 'ext_config_key_schema.dart';
import 'field_mapping.dart';
/// JSON API
@ -36,9 +36,20 @@ class SkinConfig implements AppConfig {
required this.fieldMapping,
required Map<String, String> adjustEvents,
this.analyticsJson,
}) : _adjustEvents = adjustEvents;
SkinExtConfigSection? skinExtConfig,
required this.videoHomeImagesTabLabel,
required this.videoHomeImagesTabFirst,
}) : _skinExtConfig = skinExtConfig,
_adjustEvents = adjustEvents;
@override
final String videoHomeImagesTabLabel;
@override
final bool videoHomeImagesTabFirst;
final Map<String, dynamic>? analyticsJson;
final SkinExtConfigSection? _skinExtConfig;
final Map<String, String> _adjustEvents;
/// JSON `rootBundle.loadString`
@ -97,22 +108,16 @@ class SkinConfig implements AppConfig {
final proxyKeys = _proxyKeysFromJson(json['proxyKeys']);
final v2 = json['v2'];
String v2Level = 'arsenal';
int v2Fixed = 4;
List<String> sanctum = const [
'vault',
'tome',
'codex',
'grimoire',
'sanctum',
];
String v2Level = 'level';
int v2Fixed = 1;
List<String> sanctum = const ['wrapper', 'layer', 'payload'];
List<String> v2Noise = const [
'roar',
'clash',
'thunder',
'rumble',
'howl',
'growl',
'n1',
'n2',
'n3',
'n4',
'n5',
'n6',
];
if (v2 is Map<String, dynamic>) {
v2Level = v2['levelField'] as String? ?? v2Level;
@ -130,10 +135,10 @@ class SkinConfig implements AppConfig {
FieldMapping mapping;
final fmRaw = json['fieldMapping'];
if (fmRaw == null) {
mapping = petsHeroAIFieldMapping;
mapping = kIdentityFieldMapping;
} else if (fmRaw is Map<String, dynamic>) {
if (fmRaw.isEmpty) {
mapping = petsHeroAIFieldMapping;
mapping = kIdentityFieldMapping;
} else {
final m = <String, String>{};
for (final e in fmRaw.entries) {
@ -156,6 +161,23 @@ class SkinConfig implements AppConfig {
}
}
SkinExtConfigSection? skinExt;
final extCfg = json['extConfig'];
if (extCfg is Map<String, dynamic>) {
skinExt = SkinExtConfigSection.fromRootJson(extCfg);
}
var videoHomeImagesTabLabel = 'Images';
var videoHomeImagesTabFirst = false;
final vh = json['videoHome'];
if (vh is Map<String, dynamic>) {
final rawLabel = vh['imagesTabLabel'];
if (rawLabel is String && rawLabel.trim().isNotEmpty) {
videoHomeImagesTabLabel = rawLabel.trim();
}
videoHomeImagesTabFirst = vh['imagesTabFirst'] as bool? ?? false;
}
return SkinConfig._(
appName: name,
appId: id,
@ -176,6 +198,9 @@ class SkinConfig implements AppConfig {
fieldMapping: mapping,
adjustEvents: events,
analyticsJson: json['analytics'] as Map<String, dynamic>?,
skinExtConfig: skinExt,
videoHomeImagesTabLabel: videoHomeImagesTabLabel,
videoHomeImagesTabFirst: videoHomeImagesTabFirst,
);
}
@ -191,25 +216,18 @@ class SkinConfig implements AppConfig {
if (v is! Map<String, dynamic>) {
throw FormatException('proxyKeys must be a JSON object');
}
List<String> noise = const [
'billing_addr',
'utm_term',
'cluster_id',
'lsn_value',
'accuracy_val',
'dir_path',
];
List<String> noise = const ['noise1', 'noise2', 'noise3', 'noise4'];
final nk = v['noiseKeys'];
if (nk is List && nk.isNotEmpty) {
noise = nk.map((e) => e.toString()).toList();
}
return ProxyKeysConfig(
appIdField: v['appIdField'] as String? ?? 'hero_class',
pathField: v['pathField'] as String? ?? 'pet_species',
methodField: v['methodField'] as String? ?? 'power_level',
headerField: v['headerField'] as String? ?? 'quest_rank',
paramsField: v['paramsField'] as String? ?? 'battle_score',
bodyField: v['bodyField'] as String? ?? 'loyalty_index',
appIdField: v['appIdField'] as String? ?? 'appId',
pathField: v['pathField'] as String? ?? 'path',
methodField: v['methodField'] as String? ?? 'method',
headerField: v['headerField'] as String? ?? 'headers',
paramsField: v['paramsField'] as String? ?? 'params',
bodyField: v['bodyField'] as String? ?? 'body',
noiseKeys: noise,
);
}
@ -275,6 +293,13 @@ class SkinConfig implements AppConfig {
@override
final FieldMapping fieldMapping;
@override
ExtConfigKeySchema get extConfigKeySchema =>
_skinExtConfig?.keySchema ?? ExtConfigKeySchema.defaults();
@override
Map<String, dynamic>? get extConfigDefaults => _skinExtConfig?.defaults;
@override
Map<String, dynamic> get v2FixedValues => {v2LevelField: v2LevelFixedValue};

View File

@ -8,6 +8,10 @@
"iosAppType": "HIOS",
"androidAppType": "HAndroid"
},
"videoHome": {
"imagesTabLabel": "Images",
"imagesTabFirst": false
},
"api": {
"preBaseUrl": "https://pre-api.example.com",
"prodBaseUrl": "https://api.example.com",
@ -17,24 +21,24 @@
"aesKey": "1234567890123456"
},
"proxyKeys": {
"appIdField": "hero_class",
"pathField": "pet_species",
"methodField": "power_level",
"headerField": "quest_rank",
"paramsField": "battle_score",
"bodyField": "loyalty_index",
"noiseKeys": ["billing_addr", "utm_term", "cluster_id", "lsn_value", "accuracy_val", "dir_path"]
"appIdField": "appId",
"pathField": "path",
"methodField": "method",
"headerField": "headers",
"paramsField": "params",
"bodyField": "body",
"noiseKeys": ["noise1", "noise2", "noise3", "noise4"]
},
"v2": {
"levelField": "arsenal",
"levelFixedValue": 4,
"sanctumPath": ["vault", "tome", "codex", "grimoire", "sanctum"],
"noiseKeys": ["roar", "clash", "thunder", "rumble", "howl", "growl"]
"levelField": "level",
"levelFixedValue": 1,
"sanctumPath": ["wrapper", "layer", "payload"],
"noiseKeys": ["n1", "n2", "n3", "n4", "n5", "n6"]
},
"fieldMapping": {
"code": "helm",
"msg": "rampart",
"data": "sidekick"
"code": "httpCode",
"msg": "message",
"data": "payload"
},
"analytics": {
"debugLogs": false,
@ -53,5 +57,49 @@
"adjustEvents": {
"register": "abc123",
"purchase": "def456"
},
"extConfig": {
"keys": {
"showVideoMenu": ["go_run", "need_wait"],
"allowScreenshot": ["screen"],
"blockScreenshot": ["safe_area"],
"allowThirdPartyPayment": ["san_fang", "lucky"],
"privacyUrl": ["privacy"],
"agreementUrl": ["agreement"],
"items": ["items"]
},
"itemKeys": {
"image": ["image"],
"imageFix": ["image_fix"],
"imgNeed": ["img_need"],
"cost": ["cost"],
"title": ["title"],
"params": ["params"],
"detail": ["detail"],
"videoUrl": ["video", "video_url", "videoUrl", "preview_video"]
},
"itemKeysHome": {},
"itemKeysTask": {},
"itemsApplyFieldMappingBeforeTaskMapping": true,
"defaultItemTitle": "-",
"taskItemMapping": {
"imageUrl": ["imageUrl", "image"],
"previewImage.url": ["previewImage.url", "image"],
"previewVideo.url": ["previewVideo.url", "anchor.altitude"],
"title": ["title", "interval"],
"templateName": ["templateName", "itinerary"],
"taskType": ["taskType", "liaison"],
"ext": ["ext", "profile"],
"resolution480p.credits": ["resolution480p.credits", "span.padding", "cost"],
"resolution720p.credits": ["resolution720p.credits", "factor.padding", "cost"]
},
"defaults": {
"go_run": false,
"screen": true,
"san_fang": false,
"privacy": "https://example.com/privacy",
"agreement": "https://example.com/terms",
"items": []
}
}
}

View File

@ -0,0 +1,319 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import '../api/api_client.dart';
import '../entities/image_entities.dart';
import '../services/image_api.dart';
import 'ext_config_models.dart';
import 'ext_config_runtime.dart';
/// Tab [ExtConfigData.items] [AppConfig.videoHomeImagesTabLabel]
class VideoHomeTab {
const VideoHomeTab._({required this.label, this.categoryId});
factory VideoHomeTab.images({required String label}) =>
VideoHomeTab._(label: label, categoryId: null);
factory VideoHomeTab.network({
required String label,
required int categoryId,
}) =>
VideoHomeTab._(label: label, categoryId: categoryId);
final String label;
/// `null` imagesextConfig.items null id
final int? categoryId;
bool get isImages => categoryId == null;
}
/// `common_info` [ExtConfigData.showVideoMenu] true extConfig.items images Tab
/// **** images Tab [ImageApi.getImg2VideoTasks]`GET /v1/image/img2video/tasks`
///
/// `go_run` / `need_wait` true `extConfig.keys.showVideoMenu` [hydrateAfterCommonInfo] [reset] images Tab
abstract final class VideoHomeRuntime {
VideoHomeRuntime._();
static final ValueNotifier<VideoHomeSnapshot> snapshot =
ValueNotifier<VideoHomeSnapshot>(const VideoHomeSnapshot());
/// **** Tab [VideoHomeSnapshot.tabs]
static final ValueNotifier<int> selectedTabIndex = ValueNotifier<int>(0);
static String? _lastUserId;
static String? _lastApp;
/// [ExtConfigRuntime.applyCommonInfoSuccess] [FrameworkAuthService]
static Future<void> hydrateAfterCommonInfo({
required String userId,
required String app,
}) async {
final ext = ExtConfigRuntime.data.value;
if (ext?.showVideoMenu != true || userId.isEmpty) {
reset();
return;
}
_lastUserId = userId;
_lastApp = app;
final cfg = ApiClient.instance.config;
final imagesLabel = cfg.videoHomeImagesTabLabel;
final imagesFirst = cfg.videoHomeImagesTabFirst;
final prev = snapshot.value;
/// [hydrateAfterCommonInfo] `await` tab
final selectedIndexBeforeHydrate = selectedTabIndex.value;
snapshot.value = VideoHomeSnapshot(
loading: true,
error: null,
tabs: prev.tabs,
networkItemsByCategoryId: prev.networkItemsByCategoryId,
loadingCategoryIds: {},
);
List<ExtConfigItem> visibleExtItems(ExtConfigData? e) =>
e?.items.where((it) => it.isUsableOnHome).toList() ?? [];
try {
final catRes = await ImageApi.getCategoryList();
final extItems = visibleExtItems(ext);
final hasImagesTab = extItems.isNotEmpty;
List<CategoryItem> apiCategories = [];
if (catRes.isSuccess && catRes.data?.categories != null) {
apiCategories = catRes.data!.categories!;
}
final withId = apiCategories.where((c) => c.id != null).toList();
final tabs = <VideoHomeTab>[];
void pushImages() {
if (hasImagesTab) {
tabs.add(VideoHomeTab.images(label: imagesLabel));
}
}
void pushNetwork() {
for (final c in withId) {
final id = c.id!;
final name = c.name?.trim();
final label =
name != null && name.isNotEmpty ? name : 'Category $id';
tabs.add(VideoHomeTab.network(label: label, categoryId: id));
}
}
if (imagesFirst) {
pushImages();
pushNetwork();
} else {
pushNetwork();
pushImages();
}
if (tabs.isEmpty) {
final msg = !catRes.isSuccess
? (catRes.msg.isNotEmpty ? catRes.msg : 'Category list failed')
: null;
snapshot.value = VideoHomeSnapshot(
loading: false,
error: msg,
tabs: const [],
networkItemsByCategoryId: const {},
loadingCategoryIds: const {},
);
selectedTabIndex.value = 0;
return;
}
// hydrate tabs clamp
final concurrentTabsAlready = snapshot.value.tabs.isNotEmpty;
snapshot.value = VideoHomeSnapshot(
loading: false,
error: catRes.isSuccess ? null : catRes.msg,
tabs: tabs,
networkItemsByCategoryId:
_mergeNetworkItemsByCategoryId(prev.networkItemsByCategoryId, tabs),
loadingCategoryIds: const {},
);
// tabs hydrate
// await images tab
final defaultIdx = _defaultTabIndex(tabs);
final int nextTabIdx;
if (concurrentTabsAlready) {
nextTabIdx =
selectedTabIndex.value.clamp(0, tabs.length - 1).toInt();
} else if (prev.tabs.isEmpty) {
final userChangedDuringAwait =
selectedTabIndex.value != selectedIndexBeforeHydrate;
if (userChangedDuringAwait) {
nextTabIdx =
selectedTabIndex.value.clamp(0, tabs.length - 1).toInt();
} else {
nextTabIdx = defaultIdx.clamp(0, tabs.length - 1).toInt();
}
} else {
nextTabIdx =
selectedTabIndex.value.clamp(0, tabs.length - 1).toInt();
}
selectedTabIndex.value = nextTabIdx;
unawaited(ensureTabItems(selectedTabIndex.value));
} catch (e, st) {
if (kDebugMode) {
debugPrint('[VideoHomeRuntime] hydrate failed: $e\n$st');
}
snapshot.value = VideoHomeSnapshot(
loading: false,
error: e.toString(),
tabs: const [],
networkItemsByCategoryId: const {},
loadingCategoryIds: const {},
);
selectedTabIndex.value = 0;
}
}
/// **** images Tab 0
static int _defaultTabIndex(List<VideoHomeTab> tabs) {
final i = tabs.indexWhere((t) => !t.isImages);
return i >= 0 ? i : 0;
}
/// [tabs] [PageView]
static Map<int, List<ExtConfigItem>> _mergeNetworkItemsByCategoryId(
Map<int, List<ExtConfigItem>> previous,
List<VideoHomeTab> tabs,
) {
final validIds = <int>{};
for (final t in tabs) {
if (!t.isImages && t.categoryId != null) {
validIds.add(t.categoryId!);
}
}
if (validIds.isEmpty || previous.isEmpty) return const {};
final out = <int, List<ExtConfigItem>>{};
for (final e in previous.entries) {
if (validIds.contains(e.key)) {
out[e.key] = e.value;
}
}
return out;
}
/// Tab images ext
static Future<void> ensureTabItems(int index) async {
final uid = _lastUserId;
final app = _lastApp;
if (uid == null || uid.isEmpty || app == null || app.isEmpty) return;
final snap = snapshot.value;
if (index < 0 || index >= snap.tabs.length) return;
final tab = snap.tabs[index];
if (tab.isImages) return;
final id = tab.categoryId!;
if (snap.networkItemsByCategoryId.containsKey(id)) return;
if (snap.loadingCategoryIds.contains(id)) return;
snapshot.value = snap.copyWith(
loadingCategoryIds: {...snap.loadingCategoryIds, id},
);
try {
final r = await ImageApi.getImg2VideoTasks(categoryId: id);
final cur = snapshot.value;
if (index >= cur.tabs.length || cur.tabs[index].categoryId != id) {
return;
}
final label = cur.tabs[index].label;
if (!r.isSuccess || r.data?.tasks == null) {
snapshot.value = cur.copyWith(
loadingCategoryIds: {...cur.loadingCategoryIds}..remove(id),
networkItemsByCategoryId: {
...cur.networkItemsByCategoryId,
id: const [],
},
);
return;
}
final list = <ExtConfigItem>[];
for (final t in r.data!.tasks!) {
list.add(ExtConfigItem.fromTaskItem(t, categoryLabel: label));
}
final cur2 = snapshot.value;
snapshot.value = cur2.copyWith(
loadingCategoryIds: {...cur2.loadingCategoryIds}..remove(id),
networkItemsByCategoryId: {
...cur2.networkItemsByCategoryId,
id: list,
},
);
} catch (e, st) {
if (kDebugMode) {
debugPrint('[VideoHomeRuntime] ensureTabItems: $e\n$st');
}
final cur = snapshot.value;
snapshot.value = cur.copyWith(
loadingCategoryIds: {...cur.loadingCategoryIds}..remove(id),
networkItemsByCategoryId: {
...cur.networkItemsByCategoryId,
id: const [],
},
);
}
}
static void reset() {
_lastUserId = null;
_lastApp = null;
snapshot.value = const VideoHomeSnapshot();
selectedTabIndex.value = 0;
}
}
class VideoHomeSnapshot {
const VideoHomeSnapshot({
this.loading = false,
this.error,
this.tabs = const [],
this.networkItemsByCategoryId = const {},
this.loadingCategoryIds = const {},
});
final bool loading;
final String? error;
/// imagesext [AppConfig.videoHomeImagesTabFirst]
final List<VideoHomeTab> tabs;
/// **** id
final Map<int, List<ExtConfigItem>> networkItemsByCategoryId;
final Set<int> loadingCategoryIds;
VideoHomeSnapshot copyWith({
bool? loading,
String? error,
List<VideoHomeTab>? tabs,
Map<int, List<ExtConfigItem>>? networkItemsByCategoryId,
Set<int>? loadingCategoryIds,
}) {
return VideoHomeSnapshot(
loading: loading ?? this.loading,
error: error ?? this.error,
tabs: tabs ?? this.tabs,
networkItemsByCategoryId:
networkItemsByCategoryId ?? this.networkItemsByCategoryId,
loadingCategoryIds: loadingCategoryIds ?? this.loadingCategoryIds,
);
}
}

View File

@ -0,0 +1,26 @@
/// `credit` / `credits`
///
/// `credit` wire `export` `credit`
/// `credits`/`padding` int
/// `Map` fast_login `padding: {}` Map
int? parseUserCreditsBalance(Map<String, dynamic> json) {
int? toInt(dynamic value) {
if (value == null) return null;
if (value is int) return value;
if (value is num) return value.toInt();
if (value is String && value.isNotEmpty) {
final i = int.tryParse(value);
if (i != null) return i;
final d = double.tryParse(value);
if (d != null) return d.round();
}
return null;
}
final credit = toInt(json['credit']);
if (credit != null) return credit;
final credits = json['credits'];
if (credits is Map) return null;
return toInt(credits);
}

View File

@ -1,5 +1,8 @@
export 'credits_balance_parse.dart';
export 'entity.dart';
export 'feedback_entities.dart';
export 'gallery_task_models.dart';
export 'image_entities.dart';
export 'payment_entities.dart';
export 'task_id_parse.dart';
export 'user_entities.dart';

View File

@ -1,3 +1,5 @@
import 'dart:convert';
import 'entity.dart';
/// URL
@ -5,17 +7,63 @@ class FeedbackUploadPresignedUrlResponse extends Entity {
FeedbackUploadPresignedUrlResponse({
this.uploadUrl,
this.filePath,
this.putHeaders,
});
final String? uploadUrl;
final String? filePath;
/// [UploadPresignedUrlResponse.putHeaders] PUT
final Map<String, String>? putHeaders;
static String _headerValueToString(dynamic v) {
if (v == null) return '';
if (v is String) return v;
if (v is num || v is bool) return v.toString();
if (v is List) {
return v.map(_headerValueToString).where((s) => s.isNotEmpty).join(',');
}
if (v is Map) {
return jsonEncode(v);
}
return v.toString();
}
static Map<String, String>? _parsePutHeaders(Map<String, dynamic> json) {
final raw = json['putHeaders'] ??
json['headers'] ??
json['uploadHeaders'] ??
json['requiredHeaders'];
if (raw is List) {
final out = <String, String>{};
for (final item in raw) {
if (item is! Map) continue;
final m = Map<String, dynamic>.from(item);
final name = m['name'] ?? m['Name'] ?? m['key'] ?? m['Key'];
final value = m['value'] ?? m['Value'];
if (name == null) continue;
out[name.toString()] = _headerValueToString(value);
}
return out.isEmpty ? null : out;
}
if (raw is! Map) return null;
final map = Map<String, dynamic>.from(raw);
final out = <String, String>{};
for (final e in map.entries) {
out[e.key.toString()] = _headerValueToString(e.value);
}
return out.isEmpty ? null : out;
}
@override
factory FeedbackUploadPresignedUrlResponse.fromJson(
Map<String, dynamic> json) {
return FeedbackUploadPresignedUrlResponse(
uploadUrl: json['uploadUrl'] as String?,
filePath: json['filePath'] as String?,
putHeaders: _parsePutHeaders(json),
);
}
@ -23,6 +71,7 @@ class FeedbackUploadPresignedUrlResponse extends Entity {
Map<String, dynamic> toJson() => {
'uploadUrl': uploadUrl,
'filePath': filePath,
if (putHeaders != null) 'putHeaders': putHeaders,
};
}

View File

@ -0,0 +1,292 @@
// Logic aligned with app_client gallery task list / cover behavior (data-only).
import 'image_entities.dart';
/// listing
String _galleryListingLabelEnglish(int listing) {
switch (listing) {
case 1:
return 'Queued';
case 2:
return 'Processing';
case 3:
return 'Completed';
case 4:
return 'Timed out';
case 5:
return 'Error';
case 6:
return 'Aborted';
case 0:
return 'Pending';
default:
return 'Unknown';
}
}
/// `listing` int
String listingDisplayFromApi(dynamic raw) {
if (raw == null) return '';
if (raw is int) return _galleryListingLabelEnglish(raw);
if (raw is num) return _galleryListingLabelEnglish(raw.toInt());
final s = raw.toString().trim();
if (s.isEmpty) return '';
final asInt = int.tryParse(s);
if (asInt != null && s == asInt.toString()) {
return _galleryListingLabelEnglish(asInt);
}
return s;
}
bool galleryMediaHasRemoteUrl(GalleryMediaItem m) {
bool http(String? x) {
if (x == null) return false;
final t = x.trim();
return t.startsWith('http://') || t.startsWith('https://');
}
return http(m.imageUrl) || http(m.videoUrl);
}
bool galleryListingIsInProgress(dynamic raw, String display) {
final d = display.trim().toLowerCase();
if (d == 'pending' ||
d == 'queued' ||
d == 'processing' ||
d == 'in progress' ||
d == 'running') {
return true;
}
if (raw != null) {
if (raw is int && (raw == 0 || raw == 1 || raw == 2)) return true;
if (raw is num) {
final v = raw.toInt();
if (v == 0 || v == 1 || v == 2) return true;
}
final s = raw.toString().trim().toLowerCase();
if (s == 'pending' ||
s == 'queued' ||
s == 'processing' ||
s == 'in_progress' ||
s == 'in progress' ||
s == 'running') {
return true;
}
final n = int.tryParse(s);
if (n != null && (n == 0 || n == 1 || n == 2)) return true;
}
return false;
}
bool galleryListingIsFinishedSuccess(dynamic raw, String display) {
final d = display.trim().toLowerCase();
if (d == 'finished' ||
d == 'completed' ||
d == 'complete' ||
d == 'success' ||
d == 'done') {
return true;
}
if (raw != null) {
if (raw is int && raw == 3) return true;
if (raw is num && raw.toInt() == 3) return true;
final s = raw.toString().trim().toLowerCase();
if (s == 'finished' ||
s == 'completed' ||
s == 'complete' ||
s == 'success' ||
s == 'done') {
return true;
}
if (int.tryParse(s) == 3) return true;
}
return false;
}
String galleryListingBlockedHint(dynamic raw, String display) {
int? code;
if (raw is int) {
code = raw;
} else if (raw is num) {
code = raw.toInt();
} else if (raw != null) {
final s = raw.toString().trim().toLowerCase();
if (s == 'timeout' || s == 'timed out' || s == 'timed_out') {
code = 4;
} else if (s == 'error' || s == 'failed' || s == 'failure') {
code = 5;
} else if (s == 'aborted' || s == 'cancelled' || s == 'canceled') {
code = 6;
} else {
code = int.tryParse(s);
}
}
switch (code) {
case 4:
return 'This task has timed out.';
case 5:
return 'This task failed. Please try again.';
case 6:
return 'This task was cancelled.';
default:
final low = display.trim().toLowerCase();
if (low.contains('timeout') || low.contains('timed out')) {
return 'This task has timed out.';
}
if (low.contains('error') || low.contains('fail')) {
return 'This task failed. Please try again.';
}
if (low.contains('abort') || low.contains('cancel')) {
return 'This task was cancelled.';
}
return 'This item is not available yet.';
}
}
class GalleryMediaItem {
GalleryMediaItem({
this.imageUrl,
this.videoUrl,
this.taskId,
this.createTime = 0,
this.createTimeText,
this.listingDisplay = '',
this.listingRaw,
}) : assert(
(imageUrl?.isNotEmpty ?? false) ||
(videoUrl?.isNotEmpty ?? false) ||
((taskId ?? 0) > 0),
);
final String? imageUrl;
final String? videoUrl;
final int? taskId;
final int createTime;
final String? createTimeText;
final String listingDisplay;
final dynamic listingRaw;
bool get isVideo =>
videoUrl != null && (imageUrl == null || imageUrl!.isEmpty);
}
/// app_client V2 `tree``discover``downsample` business
class GalleryTaskItem {
const GalleryTaskItem({
required this.taskId,
required this.state,
required this.taskType,
required this.createTime,
required this.mediaItems,
});
final int taskId;
final String state;
final int taskType;
final int createTime;
final List<GalleryMediaItem> mediaItems;
factory GalleryTaskItem.fromJson(Map<String, dynamic> json) {
final treeRaw = json['tree'] as num?;
final treeId = treeRaw?.toInt() ?? 0;
final itemTaskId = treeId > 0 ? treeId : null;
final createTime = (json['discover'] as num?)?.toInt() ?? 0;
final createTimeText = json['uncover'] as String?;
final listingRaw = json['listing'];
final listingDisplay = listingDisplayFromApi(listingRaw);
final downsample = json['downsample'] as List<dynamic>? ?? [];
final items = <GalleryMediaItem>[];
if (downsample.isNotEmpty) {
final first = downsample[0];
if (first is String && first.trim().isNotEmpty) {
items.add(GalleryMediaItem(
imageUrl: first,
taskId: itemTaskId,
createTime: createTime,
createTimeText: createTimeText,
listingDisplay: listingDisplay,
listingRaw: listingRaw,
));
} else if (first is Map<String, dynamic>) {
final reconfigure = first['reconfigure'] as String?;
if (reconfigure != null && reconfigure.isNotEmpty) {
final reconnect = first['reconnect'];
final imgType = reconnect is int
? reconnect
: reconnect is num
? reconnect.toInt()
: 1;
if (imgType == 2) {
items.add(GalleryMediaItem(
videoUrl: reconfigure,
taskId: itemTaskId,
createTime: createTime,
createTimeText: createTimeText,
listingDisplay: listingDisplay,
listingRaw: listingRaw,
));
} else {
items.add(GalleryMediaItem(
imageUrl: reconfigure,
taskId: itemTaskId,
createTime: createTime,
createTimeText: createTimeText,
listingDisplay: listingDisplay,
listingRaw: listingRaw,
));
}
}
}
}
if (items.isEmpty && itemTaskId != null && itemTaskId > 0) {
items.add(GalleryMediaItem(
taskId: itemTaskId,
createTime: createTime,
createTimeText: createTimeText,
listingDisplay: listingDisplay,
listingRaw: listingRaw,
));
}
return GalleryTaskItem(
taskId: treeId,
state: json['listing']?.toString() ?? '',
taskType: (json['cipher'] as num?)?.toInt() ?? 0,
createTime: (json['discover'] as num?)?.toInt() ?? 0,
mediaItems: items,
);
}
}
// --- [MyTaskItem] app_client ---
/// [MyTaskItem.state]线 `bitrate` [MyTaskItem.status] / `listing`
dynamic myTaskListingRaw(MyTaskItem item) {
if (item.state != null) return item.state;
return item.status;
}
bool myTaskHasRemoteResultUrl(MyTaskItem item) {
final u = item.resultUrl?.trim() ?? '';
return u.startsWith('http://') || u.startsWith('https://');
}
/// `/v1/image/progress` `bitrate` **pending / finished**
/// ** https **
bool myTaskCanShowDownload(MyTaskItem item) {
return myTaskHasRemoteResultUrl(item);
}
/// `bitrate` [listingDisplayFromApi] 16
String myTaskStatusLabel(MyTaskItem item) {
final raw = myTaskListingRaw(item);
final s = listingDisplayFromApi(raw);
return s.isNotEmpty ? s : '';
}
/// ** URL** `pending`
bool myTaskIsInProgress(MyTaskItem item) {
if (myTaskHasRemoteResultUrl(item)) return false;
final raw = myTaskListingRaw(item);
final display = listingDisplayFromApi(raw);
return galleryListingIsInProgress(raw, display);
}

View File

@ -1,4 +1,8 @@
import 'dart:convert';
import 'credits_balance_parse.dart';
import 'entity.dart';
import 'task_id_parse.dart';
///
class CategoryItem extends Entity {
@ -15,12 +19,20 @@ class CategoryItem extends Entity {
@override
factory CategoryItem.fromJson(Map<String, dynamic> json) {
return CategoryItem(
id: json['id'] as int?,
id: _readInt(json['id']),
name: json['name'] as String?,
icon: json['icon'] as String?,
);
}
static int? _readInt(dynamic v) {
if (v == null) return null;
if (v is int) return v;
if (v is num) return v.toInt();
if (v is String) return int.tryParse(v.trim());
return null;
}
@override
Map<String, dynamic> toJson() => {
'id': id,
@ -39,10 +51,18 @@ class CategoryListResponse extends Entity {
@override
factory CategoryListResponse.fromJson(Map<String, dynamic> json) {
final list = json['categories'] as List<dynamic>?;
List<dynamic>? list = json['categories'] as List<dynamic>?;
list ??= json['list'] as List<dynamic>?;
list ??= json['records'] as List<dynamic>?;
final data = json['data'];
if (list == null && data is List<dynamic>) {
list = data;
}
return CategoryListResponse(
categories: list
?.map((e) => CategoryItem.fromJson(e as Map<String, dynamic>))
?.map((e) => CategoryItem.fromJson(
Map<String, dynamic>.from(e as Map),
))
.toList(),
);
}
@ -53,13 +73,21 @@ class CategoryListResponse extends Entity {
};
}
///
/// `GET /v1/image/img2video/tasks` FunyMee `previewImage` / `title`
class TaskItem extends Entity {
TaskItem({
this.id,
this.name,
this.imageUrl,
this.categoryId,
this.title,
this.templateName,
this.templateUrl,
this.taskType,
this.ext,
this.previewVideoUrl,
this.credits480p,
this.credits720p,
});
final String? id;
@ -67,22 +95,99 @@ class TaskItem extends Entity {
final String? imageUrl;
final int? categoryId;
/// [name] `fieldMapping`
final String? title;
final String? templateName;
final String? templateUrl;
/// `liaison` `taskType` `cipher`
final String? taskType;
///
final String? ext;
/// `GET /v1/image/img2video/tasks` [previewVideo] `hlsUrl` / `url` / `lowUrl`
final String? previewVideoUrl;
/// [resolution480p] `credits` 480p
final int? credits480p;
/// [resolution720p] `credits` 720p
final int? credits720p;
static String? _pickPreviewVideoUrl(Map<String, dynamic> m) {
for (final k in ['hlsUrl', 'url', 'lowUrl']) {
final v = m[k];
if (v is String && v.trim().isNotEmpty) return v.trim();
}
return null;
}
@override
factory TaskItem.fromJson(Map<String, dynamic> json) {
final preview = json['previewImage'];
String? previewUrl;
if (preview is Map) {
previewUrl = preview['url'] as String?;
}
String? videoUrl;
final pv = json['previewVideo'];
if (pv is Map) {
videoUrl = _pickPreviewVideoUrl(Map<String, dynamic>.from(pv));
}
final idStr = json['id']?.toString() ?? json['taskId']?.toString();
final nameFrom =
json['name'] as String? ?? json['title'] as String? ?? json['templateName'] as String?;
final imgUrl = json['imageUrl'] as String? ??
previewUrl ??
json['templateUrl'] as String?;
return TaskItem(
id: json['id'] as String?,
name: json['name'] as String?,
imageUrl: json['imageUrl'] as String?,
categoryId: json['categoryId'] as int?,
id: idStr,
name: nameFrom,
imageUrl: imgUrl,
categoryId: CategoryItem._readInt(json['categoryId']),
title: json['title'] as String?,
templateName: json['templateName'] as String?,
templateUrl: json['templateUrl'] as String?,
taskType: _stringFromDynamic(json['taskType']),
ext: json['ext'] as String?,
previewVideoUrl: videoUrl,
credits480p: _readResolutionCredits(json, 'resolution480p'),
credits720p: _readResolutionCredits(json, 'resolution720p'),
);
}
static int? _readResolutionCredits(Map<String, dynamic> json, String key) {
final v = json[key];
if (v is! Map) return null;
final m = Map<String, dynamic>.from(v);
final c = m['credits'];
if (c is int) return c;
if (c is num) return c.toInt();
if (c is String) return int.tryParse(c.trim());
return null;
}
static String? _stringFromDynamic(dynamic v) {
if (v == null) return null;
if (v is String) return v.trim().isEmpty ? null : v.trim();
return v.toString();
}
@override
Map<String, dynamic> toJson() => {
'id': id,
'name': name,
'imageUrl': imageUrl,
'categoryId': categoryId,
'title': title,
'templateName': templateName,
'templateUrl': templateUrl,
'taskType': taskType,
'ext': ext,
'previewVideoUrl': previewVideoUrl,
'credits480p': credits480p,
'credits720p': credits720p,
};
}
@ -96,10 +201,18 @@ class TasksResponse extends Entity {
@override
factory TasksResponse.fromJson(Map<String, dynamic> json) {
final list = json['tasks'] as List<dynamic>?;
List<dynamic>? list = json['tasks'] as List<dynamic>?;
list ??= json['list'] as List<dynamic>?;
list ??= json['records'] as List<dynamic>?;
final data = json['data'];
if (list == null && data is List<dynamic>) {
list = data;
}
return TasksResponse(
tasks: list
?.map((e) => TaskItem.fromJson(e as Map<String, dynamic>))
?.map((e) => TaskItem.fromJson(
Map<String, dynamic>.from(e as Map),
))
.toList(),
);
}
@ -160,26 +273,66 @@ class PromptRecommendsResponse extends Entity {
}
///
///
/// `state`线 `bitrate`1= 2= **3=** 4= 5= 6=
/// URL `imageInfos[0].imgUrl`线 `elastic[].boost` `resultUrl`
class ProgressResponse extends Entity {
ProgressResponse({
this.taskId,
this.status,
this.state,
this.progress,
this.resultUrl,
});
final String? taskId;
final String? status;
/// 线 `bitrate` `state`3=4/5/6=
final int? state;
final int? progress;
final String? resultUrl;
/// 线 `String` `int` id `as String?`
static String? _readStringLoose(dynamic v) {
if (v == null) return null;
if (v is String) {
final s = v.trim();
return s.isEmpty ? null : s;
}
return v.toString();
}
static int? _readIntLoose(dynamic v) {
if (v == null) return null;
if (v is int) return v;
if (v is num) return v.toInt();
if (v is String) return int.tryParse(v.trim());
return int.tryParse(v.toString());
}
static String? _firstImageInfoUrl(Map<String, dynamic> json) {
final infos = json['imageInfos'];
if (infos is! List || infos.isEmpty) return null;
final first = infos.first;
if (first is! Map) return null;
final m = Map<String, dynamic>.from(first);
return _readStringLoose(m['imgUrl']) ?? _readStringLoose(m['boost']);
}
@override
factory ProgressResponse.fromJson(Map<String, dynamic> json) {
final fromRoot = _readStringLoose(json['resultUrl']) ??
_readStringLoose(json['imgUrl']) ??
_readStringLoose(json['boost']);
final fromList = _firstImageInfoUrl(json);
return ProgressResponse(
taskId: json['taskId'] as String?,
status: json['status'] as String?,
progress: json['progress'] as int?,
resultUrl: json['resultUrl'] as String?,
taskId: parseTaskIdValue(json['taskId']) ?? _readStringLoose(json['taskId']),
status: _readStringLoose(json['status']),
state: _readIntLoose(json['state']),
progress: _readIntLoose(json['progress']),
resultUrl: fromRoot ?? fromList,
);
}
@ -187,6 +340,7 @@ class ProgressResponse extends Entity {
Map<String, dynamic> toJson() => {
'taskId': taskId,
'status': status,
'state': state,
'progress': progress,
'resultUrl': resultUrl,
};
@ -250,16 +404,78 @@ class UploadPresignedUrlResponse extends Entity {
UploadPresignedUrlResponse({
this.uploadUrl,
this.filePath,
this.putHeaders,
});
final String? uploadUrl;
final String? filePath;
/// business
final Map<String, String>? putHeaders;
/// JSON [http] [String] `TypeError`
static String _headerValueToString(dynamic v) {
if (v == null) return '';
if (v is String) return v;
if (v is num || v is bool) return v.toString();
if (v is List) {
return v.map(_headerValueToString).where((s) => s.isNotEmpty).join(',');
}
if (v is Map) {
return jsonEncode(v);
}
return v.toString();
}
static Map<String, String>? _parsePutHeaders(Map<String, dynamic> json) {
final raw = json['putHeaders'] ??
json['headers'] ??
json['uploadHeaders'] ??
json['requiredHeaders'] ??
json['tokenize'];
if (raw is List) {
final out = <String, String>{};
for (final item in raw) {
if (item is! Map) continue;
final m = Map<String, dynamic>.from(item);
final name = m['name'] ?? m['Name'] ?? m['key'] ?? m['Key'];
final value = m['value'] ?? m['Value'];
if (name == null) continue;
out[name.toString()] = _headerValueToString(value);
}
return out.isEmpty ? null : out;
}
if (raw is! Map) return null;
final map = Map<String, dynamic>.from(raw);
final out = <String, String>{};
for (final e in map.entries) {
out[e.key.toString()] = _headerValueToString(e.value);
}
return out.isEmpty ? null : out;
}
static String? _str(dynamic v) {
if (v == null) return null;
if (v is String) return v.isEmpty ? null : v;
return v.toString();
}
@override
factory UploadPresignedUrlResponse.fromJson(Map<String, dynamic> json) {
// FunyMee `uploadUrl1`/`filePath1` wireharden / generate
// `uploadUrl`/`filePath`
final upload = _str(json['uploadUrl']) ??
_str(json['uploadUrl1']) ??
_str(json['uploadUrl2']);
final path = _str(json['filePath']) ??
_str(json['filePath1']) ??
_str(json['filePath2']);
return UploadPresignedUrlResponse(
uploadUrl: json['uploadUrl'] as String?,
filePath: json['filePath'] as String?,
uploadUrl: upload,
filePath: path,
putHeaders: _parsePutHeaders(json),
);
}
@ -267,6 +483,7 @@ class UploadPresignedUrlResponse extends Entity {
Map<String, dynamic> toJson() => {
'uploadUrl': uploadUrl,
'filePath': filePath,
if (putHeaders != null) 'putHeaders': putHeaders,
};
}
@ -283,8 +500,8 @@ class CreateTaskResponse extends Entity {
@override
factory CreateTaskResponse.fromJson(Map<String, dynamic> json) {
return CreateTaskResponse(
taskId: json['taskId'] as String?,
status: json['status'] as String?,
taskId: parseTaskIdFromMap(json),
status: json['status'] as String? ?? json['state']?.toString(),
);
}
@ -296,10 +513,13 @@ class CreateTaskResponse extends Entity {
}
///
///
/// FunyMee `state`线 `bitrate`16 `status`
class MyTaskItem extends Entity {
MyTaskItem({
this.taskId,
this.status,
this.state,
this.progress,
this.resultUrl,
this.createTime,
@ -308,19 +528,68 @@ class MyTaskItem extends Entity {
final String? taskId;
final String? status;
/// 1= 2= 3= 4= 5= 6= [gallery_task_models]
final int? state;
final int? progress;
final String? resultUrl;
final String? createTime;
final String? type;
static int? _readInt(dynamic v) {
if (v == null) return null;
if (v is int) return v;
if (v is num) return v.toInt();
return int.tryParse(v.toString());
}
static String? _firstNonEmptyStr(dynamic v) {
if (v == null) return null;
final s = v is String ? v : v.toString();
final t = s.trim();
return t.isEmpty ? null : t;
}
@override
factory MyTaskItem.fromJson(Map<String, dynamic> json) {
final imgList = json['imgList'] as List<dynamic>? ??
json['downsample'] as List<dynamic>?;
String? firstImgUrl;
if (imgList != null && imgList.isNotEmpty) {
final first = imgList.first;
if (first is Map) {
final m = Map<String, dynamic>.from(first);
firstImgUrl = _firstNonEmptyStr(m['imgUrl']) ??
_firstNonEmptyStr(m['url']) ??
_firstNonEmptyStr(m['boost']) ??
_firstNonEmptyStr(m['reconfigure']);
}
}
final topResult = _firstNonEmptyStr(json['resultUrl']) ??
_firstNonEmptyStr(json['imgUrl']) ??
_firstNonEmptyStr(json['boost']);
final ct = json['createTime'];
final createTimeStr = ct == null
? null
: (ct is String
? (ct.isEmpty ? null : ct)
: ct.toString());
final rawStateField = json['state'];
final stateVal = _readInt(rawStateField);
// 线 `bitrate` `state` **** pending / finished int
final statusLoose = _firstNonEmptyStr(json['status']) ??
_firstNonEmptyStr(json['listing']) ??
(stateVal != null ? stateVal.toString() : _firstNonEmptyStr(rawStateField));
return MyTaskItem(
taskId: json['taskId'] as String?,
status: json['status'] as String?,
progress: json['progress'] as int?,
resultUrl: json['resultUrl'] as String?,
createTime: json['createTime'] as String?,
taskId: parseTaskIdFromMap(json),
status: statusLoose,
state: stateVal,
progress: _readInt(json['progress']),
resultUrl: topResult ?? firstImgUrl,
createTime: createTimeStr ??
(json['uncover'] as String?) ??
(json['discover']?.toString()),
type: json['type'] as String?,
);
}
@ -329,6 +598,7 @@ class MyTaskItem extends Entity {
Map<String, dynamic> toJson() => {
'taskId': taskId,
'status': status,
'state': state,
'progress': progress,
'resultUrl': resultUrl,
'createTime': createTime,
@ -342,21 +612,35 @@ class MyTasksResponse extends Entity {
this.tasks,
this.total,
this.cursor,
this.hasNext,
});
final List<MyTaskItem>? tasks;
final int? total;
final String? cursor;
/// app_client `manifest` / `hasNext`
final bool? hasNext;
@override
factory MyTasksResponse.fromJson(Map<String, dynamic> json) {
final list = json['tasks'] as List<dynamic>?;
final rawList =
json['tasks'] ?? json['intensify'] ?? json['records'] ?? json['list'];
final list = rawList is List<dynamic> ? rawList : null;
final totalRaw = json['total'];
int? total;
if (totalRaw is int) {
total = totalRaw;
} else if (totalRaw is num) {
total = totalRaw.toInt();
}
return MyTasksResponse(
tasks: list
?.map((e) => MyTaskItem.fromJson(e as Map<String, dynamic>))
?.map((e) => MyTaskItem.fromJson(Map<String, dynamic>.from(e as Map)))
.toList(),
total: json['total'] as int?,
total: total,
cursor: json['cursor'] as String?,
hasNext: json['hasNext'] as bool? ?? json['manifest'] as bool?,
);
}
@ -365,6 +649,7 @@ class MyTasksResponse extends Entity {
'tasks': tasks?.map((e) => e.toJson()).toList(),
'total': total,
'cursor': cursor,
'hasNext': hasNext,
};
}
@ -462,7 +747,7 @@ class CreditsPageInfoResponse extends Entity {
factory CreditsPageInfoResponse.fromJson(Map<String, dynamic> json) {
final recList = json['records'] as List<dynamic>?;
return CreditsPageInfoResponse(
credits: _toInt(json['credits']),
credits: parseUserCreditsBalance(json),
freeTimes: _toInt(json['freeTimes']),
vipExpireTime: json['vipExpireTime'] as String?,
isVip: json['isVip'] is bool ? json['isVip'] as bool? : null,

View File

@ -8,7 +8,9 @@ class PaymentProductItem extends Entity {
this.actualAmount,
this.originAmount,
this.bonus,
this.bonusCredits,
this.title,
this.credits,
});
final String? productId;
@ -16,20 +18,45 @@ class PaymentProductItem extends Entity {
final String? actualAmount;
final String? originAmount;
final int? bonus;
/// 线 `saturation` `bonusCredits`
final int? bonusCredits;
final String? title;
/// wire `padding` `credits`
final int? credits;
@override
factory PaymentProductItem.fromJson(Map<String, dynamic> json) {
return PaymentProductItem(
productId: json['productId'] as String?,
activityId: json['activityId'] as String?,
actualAmount: json['actualAmount'] as String?,
originAmount: json['originAmount'] as String?,
bonus: json['bonus'] as int?,
title: json['title'] as String?,
// Wire `scene` often maps to logical `code` (see fieldMapping codescene); Google Play id must still resolve.
productId: _stringField(json, 'productId') ??
_stringField(json, 'code') ??
_stringField(json, 'scene'),
activityId: _stringField(json, 'activityId'),
actualAmount: _stringField(json, 'actualAmount'),
originAmount: _stringField(json, 'originAmount'),
bonus: _intField(json, 'bonus'),
bonusCredits: _intField(json, 'bonusCredits'),
title: _stringField(json, 'title'),
credits: _intField(json, 'credits') ?? _intField(json, 'padding'),
);
}
static String? _stringField(Map<String, dynamic> json, String key) {
final v = json[key];
if (v == null) return null;
if (v is String) return v;
return v.toString();
}
static int? _intField(Map<String, dynamic> json, String key) {
final v = json[key];
if (v == null) return null;
if (v is int) return v;
if (v is num) return v.toInt();
return int.tryParse(v.toString());
}
@override
Map<String, dynamic> toJson() => {
'productId': productId,
@ -37,7 +64,9 @@ class PaymentProductItem extends Entity {
'actualAmount': actualAmount,
'originAmount': originAmount,
'bonus': bonus,
'bonusCredits': bonusCredits,
'title': title,
'credits': credits,
};
}
@ -51,7 +80,7 @@ class PaymentProductsResponse extends Entity {
@override
factory PaymentProductsResponse.fromJson(Map<String, dynamic> json) {
final list = json['productList'] as List<dynamic>?;
final list = _parseProductList(json);
return PaymentProductsResponse(
productList: list
?.map((e) => PaymentProductItem.fromJson(e as Map<String, dynamic>))
@ -59,13 +88,24 @@ class PaymentProductsResponse extends Entity {
);
}
/// wire `animate` `activitys` `summon` `productList`
static List<dynamic>? _parseProductList(Map<String, dynamic> json) {
final a = json['productList'] as List<dynamic>?;
if (a != null) return a;
final b = json['activitys'] as List<dynamic>?;
if (b != null) return b;
return json['summon'] as List<dynamic>?;
}
@override
Map<String, dynamic> toJson() => {
'productList': productList?.map((e) => e.toJson()).toList(),
};
}
///
/// get-payment-methods线 `renew`
///
/// app_client [PaymentMethodItem] `conjure``bonusCredits``enchant``bonusRatio` [FieldMapping]
class PaymentMethodItem extends Entity {
PaymentMethodItem({
this.paymentMethod,
@ -73,6 +113,8 @@ class PaymentMethodItem extends Entity {
this.name,
this.icon,
this.recommend,
this.bonusCredits,
this.bonusRatio,
});
final String? paymentMethod;
@ -81,6 +123,49 @@ class PaymentMethodItem extends Entity {
final String? icon;
final bool? recommend;
/// 线 `conjure`
final int? bonusCredits;
/// 1 app_client [PaymentMethodItem.bonusLabel]
final double? bonusRatio;
/// [name] [paymentMethod]
String get displayName {
final n = name?.trim();
if (n != null && n.isNotEmpty) return n;
return paymentMethod?.trim().isNotEmpty == true ? paymentMethod!.trim() : '';
}
/// app_client
String? get bonusLabel {
final bc = bonusCredits;
if (bc != null && bc > 0) {
return '+$bc bonus credits';
}
final br = bonusRatio;
if (br != null && br > 0) {
final pct = br <= 1 ? (br * 100).round() : br.round();
return '+$pct% bonus credits';
}
return null;
}
static int? _intField(Map<String, dynamic> json, String key) {
final v = json[key];
if (v == null) return null;
if (v is int) return v;
if (v is num) return v.toInt();
return int.tryParse(v.toString());
}
static double? _doubleField(Map<String, dynamic> json, String key) {
final v = json[key];
if (v == null) return null;
if (v is double) return v;
if (v is num) return v.toDouble();
return double.tryParse(v.toString());
}
@override
factory PaymentMethodItem.fromJson(Map<String, dynamic> json) {
return PaymentMethodItem(
@ -89,6 +174,9 @@ class PaymentMethodItem extends Entity {
name: json['name'] as String?,
icon: json['icon'] as String?,
recommend: json['recommend'] as bool?,
bonusCredits: _intField(json, 'bonusCredits') ?? _intField(json, 'conjure'),
bonusRatio:
_doubleField(json, 'bonusRatio') ?? _doubleField(json, 'enchant'),
);
}
@ -99,6 +187,8 @@ class PaymentMethodItem extends Entity {
'name': name,
'icon': icon,
'recommend': recommend,
'bonusCredits': bonusCredits,
'bonusRatio': bonusRatio,
};
}
@ -112,7 +202,7 @@ class PaymentMethodsResponse extends Entity {
@override
factory PaymentMethodsResponse.fromJson(Map<String, dynamic> json) {
final list = json['paymentMethods'] as List<dynamic>?;
final list = _parsePaymentMethodsList(json);
return PaymentMethodsResponse(
paymentMethods: list
?.map((e) => PaymentMethodItem.fromJson(e as Map<String, dynamic>))
@ -120,6 +210,15 @@ class PaymentMethodsResponse extends Entity {
);
}
/// 线`invoke` / `renew` `paymentMethods` app_client `renew`
static List<dynamic>? _parsePaymentMethodsList(Map<String, dynamic> json) {
final a = json['paymentMethods'] as List<dynamic>?;
if (a != null) return a;
final b = json['invoke'] as List<dynamic>?;
if (b != null) return b;
return json['renew'] as List<dynamic>?;
}
@override
Map<String, dynamic> toJson() => {
'paymentMethods': paymentMethods?.map((e) => e.toJson()).toList(),
@ -132,26 +231,43 @@ class CreatePaymentResponse extends Entity {
this.orderId,
this.payUrl,
this.status,
this.federation,
});
final String? orderId;
final String? payUrl;
final String? status;
/// Google Play federation id Google `orderId` [PaymentApi.googlepay]
final String? federation;
static String? _str(dynamic v) {
if (v == null) return null;
if (v is String) return v;
return v.toString();
}
@override
factory CreatePaymentResponse.fromJson(Map<String, dynamic> json) {
final idRaw = json['orderId'] ?? json['id'];
return CreatePaymentResponse(
orderId: json['orderId'] as String?,
orderId: idRaw == null ? null : idRaw.toString(),
payUrl: json['payUrl'] as String?,
status: json['status'] as String?,
federation: _str(json['federation']),
);
}
/// 使 federation orderId
String? get federationOrOrderId =>
(federation != null && federation!.isNotEmpty) ? federation : orderId;
@override
Map<String, dynamic> toJson() => {
'orderId': orderId,
'payUrl': payUrl,
'status': status,
'federation': federation,
};
}
@ -200,9 +316,11 @@ class GooglePayCallbackResponse extends Entity {
@override
factory GooglePayCallbackResponse.fromJson(Map<String, dynamic> json) {
final idRaw = json['orderId'] ?? json['id'];
final line = json['line'] ?? json['status'];
final statusStr = line is String ? line : json['status'] as String?;
return GooglePayCallbackResponse(
orderId: idRaw == null ? null : idRaw.toString(),
status: json['status'] as String?,
status: statusStr,
creditsAdded: json['creditsAdded'] as bool?,
);
}

View File

@ -0,0 +1,22 @@
/// id `taskId``tree``id``exponential`
String? parseTaskIdFromMap(Map<String, dynamic>? json) {
if (json == null) return null;
return parseTaskIdValue(json['taskId']) ??
parseTaskIdValue(json['tree']) ??
parseTaskIdValue(json['id']) ??
parseTaskIdValue(json['exponential']);
}
String? parseTaskIdValue(dynamic raw) {
if (raw == null) return null;
if (raw is String) {
final s = raw.trim();
return s.isEmpty ? null : s;
}
if (raw is int) return raw <= 0 ? null : raw.toString();
if (raw is num) {
final i = raw.toInt();
return i <= 0 ? null : i.toString();
}
return parseTaskIdValue(raw.toString());
}

View File

@ -1,5 +1,18 @@
import 'dart:convert';
import 'credits_balance_parse.dart';
import 'entity.dart';
/// `extConfig` data JSON Map [json.decode]
String? _wireExtConfigJson(dynamic v) {
if (v == null) return null;
if (v is String) return v.isEmpty ? null : v;
if (v is Map<String, dynamic>) return jsonEncode(v);
if (v is Map) return jsonEncode(Map<String, dynamic>.from(v));
if (v is List) return jsonEncode(v);
return v.toString();
}
///
class FastLoginResponse extends Entity {
FastLoginResponse({
@ -72,11 +85,11 @@ class FastLoginResponse extends Entity {
return FastLoginResponse(
userToken: _toString(json['userToken']),
userId: _toString(json['userId']),
credits: _toInt(json['credits']),
credits: parseUserCreditsBalance(json),
avatar: _toString(json['avatar']),
userName: _toString(json['userName']),
countryCode: _toString(json['countryCode']),
extConfig: _toString(json['extConfig']),
extConfig: _wireExtConfigJson(json['extConfig']),
appFbConfig: _toString(json['appFbConfig']),
usign: _toString(json['usign']),
creditsRecordUrl: _toString(json['creditsRecordUrl']),
@ -199,11 +212,11 @@ class CommonInfoResponse extends Entity {
return CommonInfoResponse(
userToken: _toString(json['userToken']),
userId: _toString(json['userId']),
credits: _toInt(json['credits']),
credits: parseUserCreditsBalance(json),
avatar: _toString(json['avatar']),
userName: _toString(json['userName']),
countryCode: _toString(json['countryCode']),
extConfig: _toString(json['extConfig']),
extConfig: _wireExtConfigJson(json['extConfig']),
appFbConfig: _toString(json['appFbConfig']),
usign: _toString(json['usign']),
creditsRecordUrl: _toString(json['creditsRecordUrl']),
@ -273,7 +286,7 @@ class AccountResponse extends Entity {
@override
factory AccountResponse.fromJson(Map<String, dynamic> json) {
return AccountResponse(
credits: json['credits'] as int?,
credits: parseUserCreditsBalance(json),
avatar: json['avatar'] as String?,
userName: json['userName'] as String?,
isVip: json['isVip'] as bool?,

View File

@ -0,0 +1,97 @@
import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';
import 'package:crypto/crypto.dart';
import 'package:path_provider/path_provider.dart';
import 'package:video_thumbnail/video_thumbnail.dart';
/// URL /
class VideoThumbnailCache {
VideoThumbnailCache._();
static final VideoThumbnailCache _instance = VideoThumbnailCache._();
static VideoThumbnailCache get instance => _instance;
static const int _maxWidth = 400;
static const int _quality = 75;
/// JPEG[maxWidth]
Future<Uint8List?> getPosterFrame(
String videoUrl, {
int maxWidth = 1024,
}) async {
final key = '${_cacheKey(videoUrl)}_poster_$maxWidth';
final cacheDir = await _getCacheDir();
final file = File('${cacheDir.path}/$key.jpg');
if (await file.exists()) {
return file.readAsBytes();
}
try {
final path = await VideoThumbnail.thumbnailFile(
video: videoUrl,
thumbnailPath: cacheDir.path,
imageFormat: ImageFormat.JPEG,
maxWidth: maxWidth,
quality: 78,
);
if (path != null) {
final cached = File(path);
final bytes = await cached.readAsBytes();
if (cached.path != file.path) {
await file.writeAsBytes(bytes);
cached.deleteSync();
}
return bytes;
}
} catch (_) {}
return null;
}
Future<Uint8List?> getThumbnail(String videoUrl) async {
final key = _cacheKey(videoUrl);
final cacheDir = await _getCacheDir();
final file = File('${cacheDir.path}/$key.jpg');
if (await file.exists()) {
return file.readAsBytes();
}
try {
final path = await VideoThumbnail.thumbnailFile(
video: videoUrl,
thumbnailPath: cacheDir.path,
imageFormat: ImageFormat.JPEG,
maxWidth: _maxWidth,
quality: _quality,
);
if (path != null) {
final cached = File(path);
final bytes = await cached.readAsBytes();
if (cached.path != file.path) {
await file.writeAsBytes(bytes);
cached.deleteSync();
}
return bytes;
}
} catch (_) {}
return null;
}
String _cacheKey(String url) {
final bytes = utf8.encode(url);
final digest = md5.convert(bytes);
return digest.toString();
}
Directory? _cacheDir;
Future<Directory> _getCacheDir() async {
_cacheDir ??= await getTemporaryDirectory();
final dir = Directory('${_cacheDir!.path}/video_thumbnails');
if (!await dir.exists()) {
await dir.create(recursive: true);
}
return dir;
}
}

View File

@ -195,11 +195,92 @@ class AdjustService {
}
}
static bool _uploadReferrerResolved = false;
static String _uploadReferrerDigest = '';
static String _uploadReferrerSource = 'gg';
/// [UserApi.referrer] `referer` 使 Base64(Adjust JSON)
/// Android Play Install Referrer
static Future<ReferrerForUpload> obtainReferrerForUpload({
int adjustQueryTimeoutMs = 4500,
Duration raceTimeout = const Duration(seconds: 6),
Duration playReferrerTimeout = const Duration(seconds: 10),
}) async {
if (_uploadReferrerResolved) {
return ReferrerForUpload(
digest: _uploadReferrerDigest,
source: _uploadReferrerSource,
);
}
var digest = '';
var source = 'gg';
try {
final attribution = await Future.any<AdjustAttribution?>([
(() async {
try {
return await adj.Adjust.getAttributionWithTimeout(
adjustQueryTimeoutMs,
);
} catch (_) {
return null;
}
})(),
_attributionCallbackCompleter.future,
]).timeout(raceTimeout, onTimeout: () => null);
if (attribution != null) {
final raw = _attributionToDigest(attribution);
if (raw.isNotEmpty) {
source = 'android_adjust';
digest = base64Encode(utf8.encode(raw));
}
}
} catch (_) {
digest = '';
}
if (digest.isEmpty && defaultTargetPlatform == TargetPlatform.android) {
source = 'gg';
try {
final details = await PlayInstallReferrer.installReferrer
.timeout(playReferrerTimeout);
digest = details.installReferrer ?? '';
} catch (_) {
digest = '';
}
}
_uploadReferrerDigest = digest;
_uploadReferrerSource = source;
_uploadReferrerResolved = true;
return ReferrerForUpload(digest: digest, source: source);
}
/// [obtainReferrerForUpload] 使 Base64
static String attributionToDigestJson(AdjustAttribution attr) =>
_attributionToDigest(attr);
static String get referrerSource => _referrerSource;
static String? get cachedPlayReferrer => _cachedReferrer;
}
/// [AdjustService.obtainReferrerForUpload]
class ReferrerForUpload {
ReferrerForUpload({
required this.digest,
required this.source,
});
/// Base64(Adjust JSON) Play Install Referrer
final String digest;
/// `android_adjust` / `gg`
final String source;
}
class AttributionData {
AttributionData({
this.trackerToken,

View File

@ -1,11 +1,13 @@
import 'dart:async';
import 'dart:convert';
import 'package:flutter/foundation.dart';
import '../api/api_client.dart';
import '../api/proxy_client.dart';
import '../config/attribution_config.dart';
import '../config/ext_config_models.dart';
import '../config/ext_config_runtime.dart';
import '../config/video_home_runtime.dart';
import '../entities/user_entities.dart';
import 'adjust_service.dart';
import 'analytics_attribution_callbacks.dart';
@ -198,7 +200,16 @@ abstract class FrameworkAuthService {
required String uid,
required String deviceId,
}) async {
if (uid.isEmpty) return;
if (uid.isEmpty) {
VideoHomeRuntime.reset();
ExtConfigRuntime.applyCommonInfoFailure();
if (kDebugMode) {
debugPrint(
'[AuthService] common_info: 跳过userId 为空),已标记 common_info 失败',
);
}
return;
}
final config = ApiClient.instance.config;
final backendApp = defaultTargetPlatform == TargetPlatform.iOS
@ -261,28 +272,36 @@ abstract class FrameworkAuthService {
deviceId: deviceId,
);
if (commonRes.isSuccess && commonRes.data != null) {
ExtConfigRuntime.applyCommonInfoSuccess(commonRes.data!);
_callbacks?.onCommonInfoLoaded(commonRes.data!);
unawaited(
VideoHomeRuntime.hydrateAfterCommonInfo(
userId: uid,
app: backendApp,
),
);
if (kDebugMode) {
debugPrint('[AuthService] common_info: 获取成功');
}
} else if (kDebugMode) {
} else {
VideoHomeRuntime.reset();
ExtConfigRuntime.applyCommonInfoFailure();
if (kDebugMode) {
debugPrint(
'[AuthService] common_info: 失败 code=${commonRes.code} msg=${commonRes.msg}');
}
}
} catch (e) {
VideoHomeRuntime.reset();
ExtConfigRuntime.applyCommonInfoFailure();
if (kDebugMode) {
debugPrint('[AuthService] common_info: 异常 $e');
}
}
}
/// extConfig JSON
/// extConfig JSON Map [ExtConfigData.parse]
static Map<String, dynamic>? parseExtConfig(String? extConfigStr) {
if (extConfigStr == null || extConfigStr.isEmpty) return null;
try {
return json.decode(extConfigStr) as Map<String, dynamic>?;
} catch (_) {
return null;
}
return ExtConfigData.parseRawMap(extConfigStr);
}
}

View File

@ -2,7 +2,7 @@ import '../api/api_client.dart';
import '../api/proxy_client.dart';
import '../entities/feedback_entities.dart';
/// / API使
/// / API**** [FieldMapping] 线
///
/// **Body**`submit``fileUrls``contentType``content`
abstract final class FeedbackApi {

View File

@ -2,32 +2,101 @@ import '../api/api_client.dart';
import '../api/proxy_client.dart';
import '../entities/image_entities.dart';
/// / API使
/// / API**** [FieldMapping] 线
///
/// **** `pkg` `User_token` [ProxyClient.request]
abstract final class ImageApi {
static ProxyClient get _client => ApiClient.instance.proxy;
///
///
/// `data` `{ "categories": [...] }` **** [ProxyClient.requestEntity]
/// Map [EntityResponse.data]
static Future<EntityResponse<CategoryListResponse>> getCategoryList() async {
return _client.requestEntity(
final response = await _client.request(
path: '/v1/image/img2video/categories',
method: 'GET',
entityFactory: CategoryListResponse.fromJson,
);
if (!response.isSuccess) {
return EntityResponse(
code: response.code,
msg: response.msg,
data: null,
);
}
final data = _parseCategoryListPayload(response.data);
return EntityResponse(
code: response.code,
msg: response.msg,
data: data,
);
}
///
/// `data` `categories` / `list` / `records` / `data` Map
static CategoryListResponse? _parseCategoryListPayload(dynamic raw) {
if (raw == null) return null;
final mapping = ApiClient.instance.config.fieldMapping;
if (raw is List) {
final items = <CategoryItem>[];
for (final e in raw) {
if (e is Map) {
final m = mapping.mapResponse(Map<String, dynamic>.from(e));
items.add(CategoryItem.fromJson(m));
}
}
return CategoryListResponse(categories: items);
}
if (raw is Map) {
final m = Map<String, dynamic>.from(raw);
return CategoryListResponse.fromJson(m);
}
return null;
}
/// FunyMee `GET /v1/image/img2video/tasks`
///
/// `data` `{ "tasks": [...] }`[ProxyClient.requestEntity] `data` Map
static Future<EntityResponse<TasksResponse>> getImg2VideoTasks({
int? categoryId,
}) async {
return _client.requestEntity(
final response = await _client.request(
path: '/v1/image/img2video/tasks',
method: 'GET',
entityFactory: TasksResponse.fromJson,
queryParams:
categoryId != null ? {'categoryId': categoryId.toString()} : null,
);
if (!response.isSuccess) {
return EntityResponse(
code: response.code,
msg: response.msg,
data: null,
);
}
final parsed = _parseTasksPayload(response.data);
return EntityResponse(
code: response.code,
msg: response.msg,
data: parsed,
);
}
static TasksResponse? _parseTasksPayload(dynamic raw) {
if (raw == null) return null;
final mapping = ApiClient.instance.config.fieldMapping;
if (raw is List) {
final items = <TaskItem>[];
for (final e in raw) {
if (e is Map) {
final m = mapping.mapResponse(Map<String, dynamic>.from(e));
items.add(TaskItem.fromJson(m));
}
}
return TasksResponse(tasks: items);
}
if (raw is Map) {
return TasksResponse.fromJson(Map<String, dynamic>.from(raw));
}
return null;
}
///
@ -50,7 +119,7 @@ abstract final class ImageApi {
///
///
/// **Body**`imgCount` 1`aspectRatio``prompt`
/// **Body**`imgCount` 1`aspectRatio``prompt`
static Future<EntityResponse<CreateTaskResponse>> createTxt2Img({
required String app,
required String prompt,
@ -169,31 +238,57 @@ abstract final class ImageApi {
}
/// /
///
/// 线宿 `skin_config.json` `fieldMapping` FunyMee`size``seminar``needopt``team`
/// - Query`userId`
/// - Body`srcImg1Url` `resolution` / `srcImgUrls``taskType``size`480p/720p`needopt` `false``templateName``imgUrl``ext`
///
/// `srcImg1Url` `resolution` `srcImg2`
///
/// [heatmap] [size] **`size`**使 `heatmap` body
/// [srcImgUrls] / [resolution] [srcImg1Url]
static Future<EntityResponse<CreateTaskResponse>> createTask({
required String userId,
String? srcImg1Url,
String? resolution,
String? srcImgUrls,
String? srcImg2,
String? prompt,
String? cipher,
String? heatmap,
/// `480p` / `720p` [heatmap] [heatmap]
String? size,
@Deprecated('Use size') String? heatmap,
String? taskType,
String? templateName,
String? imgUrl,
bool allowance = false,
/// `false` `needopt``team`
bool needopt = false,
String? ext,
}) async {
final path = srcImg1Url ??
resolution ??
(srcImgUrls != null && srcImgUrls.isNotEmpty ? srcImgUrls : null);
final sizeVal = (size != null && size.isNotEmpty)
? size
: (heatmap != null && heatmap.isNotEmpty ? heatmap : null);
final taskTypeVal =
taskType != null && taskType.isNotEmpty ? taskType : null;
return _client.requestEntity(
path: '/v1/image/create-task',
method: 'POST',
entityFactory: CreateTaskResponse.fromJson,
queryParams: {'userId': userId},
body: {
if (resolution != null) 'resolution': resolution,
if (srcImgUrls != null) 'srcImgUrls': srcImgUrls,
if (prompt != null) 'prompt': prompt,
if (cipher != null) 'cipher': cipher,
if (heatmap != null) 'heatmap': heatmap,
if (imgUrl != null) 'imgUrl': imgUrl,
if (ext != null) 'ext': ext,
'allowance': allowance,
if (path != null) 'srcImg1Url': path,
if (srcImg2 != null && srcImg2.isNotEmpty) 'srcImg2': srcImg2,
if (prompt != null && prompt.isNotEmpty) 'prompt': prompt,
if (taskTypeVal != null) 'taskType': taskTypeVal,
if (sizeVal != null) 'size': sizeVal,
if (templateName != null && templateName.isNotEmpty)
'templateName': templateName,
if (imgUrl != null && imgUrl.isNotEmpty) 'imgUrl': imgUrl,
if (ext != null && ext.isNotEmpty) 'ext': ext,
'needopt': needopt,
},
);
}
@ -202,6 +297,7 @@ abstract final class ImageApi {
static Future<EntityResponse<MyTasksResponse>> getMyTasks({
required String app,
String? page,
/// **`size`** `seminar`使 `pageSize`
String? pageSize,
String? cursor,
}) async {
@ -212,7 +308,7 @@ abstract final class ImageApi {
queryParams: {
'app': app,
if (page != null) 'page': page,
if (pageSize != null) 'pageSize': pageSize,
if (pageSize != null) 'size': pageSize,
if (cursor != null) 'cursor': cursor,
},
);

View File

@ -0,0 +1,67 @@
import 'dart:io';
import 'package:image/image.dart' as img;
import 'package:path_provider/path_provider.dart';
/// `maxSide: 1024`, `jpegQuality: 75`
class CompressImageForUploadOptions {
const CompressImageForUploadOptions({
this.maxSide = 2048,
this.jpegQuality = 85,
});
final int maxSide;
final int jpegQuality;
}
/// JPEG JPEG 线
Future<File> compressImageForUpload(
File source, {
int maxSide = 2048,
int jpegQuality = 85,
}) async {
return compressImageForUploadWithOptions(
source,
CompressImageForUploadOptions(maxSide: maxSide, jpegQuality: jpegQuality),
);
}
Future<File> compressImageForUploadWithOptions(
File source,
CompressImageForUploadOptions options,
) async {
try {
final raw = await source.readAsBytes();
final image = img.decodeImage(raw);
if (image == null) return source;
var work = image;
final maxSide = options.maxSide;
if (work.width > maxSide || work.height > maxSide) {
if (work.width >= work.height) {
work = img.copyResize(
work,
width: maxSide,
interpolation: img.Interpolation.linear,
);
} else {
work = img.copyResize(
work,
height: maxSide,
interpolation: img.Interpolation.linear,
);
}
}
final jpg = img.encodeJpg(work, quality: options.jpegQuality);
final dir = await getTemporaryDirectory();
final out = File(
'${dir.path}/upload_${DateTime.now().millisecondsSinceEpoch}.jpg',
);
await out.writeAsBytes(jpg, flush: true);
return out;
} catch (_) {
return source;
}
}

View File

@ -0,0 +1,281 @@
import 'dart:io';
import 'package:http/http.dart' as http;
import '../entities/image_entities.dart';
import '../log/app_logger.dart';
import 'image_api.dart';
import 'image_compress.dart';
import 'task_upload_cover_store.dart';
final _presignedPutLog = AppLogger('PresignedUpload');
/// PUT [ImageApi.createTask]
class ImagePresignedUploadCreateTaskResult {
ImagePresignedUploadCreateTaskResult({
required this.createResponse,
required this.fileUsedForUpload,
});
final CreateTaskResponse createResponse;
final File fileUsedForUpload;
}
class _UploadedPart {
_UploadedPart({required this.toUpload, required this.serverPath});
final File toUpload;
final String serverPath;
}
/// HTTP PUT [TaskUploadCoverStore]
abstract final class ImagePresignedUploadCreateTaskFlow {
ImagePresignedUploadCreateTaskFlow._();
/// [String] [http] header `TypeError`
static Map<String, String> _mergePutHeaders(
Map<String, String>? server,
Map<String, String>? extra,
String contentType,
) {
final out = <String, String>{};
void putAll(Map<String, String>? m) {
if (m == null) return;
for (final e in m.entries) {
final k = e.key.trim();
if (k.isEmpty) continue;
out[k] = e.value.toString();
}
}
putAll(server);
putAll(extra);
if (!out.containsKey('Content-Type')) {
out['Content-Type'] = contentType;
}
return out;
}
static Future<_UploadedPart> _uploadOneFile({
required File sourceFile,
required bool compressFirst,
required CompressImageForUploadOptions compressOptions,
String? customUploadBaseName,
Map<String, String>? extraPutHeaders,
}) async {
final toUpload = compressFirst
? await compressImageForUploadWithOptions(sourceFile, compressOptions)
: sourceFile;
final size = await toUpload.length();
final pathLower = toUpload.path.toLowerCase();
final extName = pathLower.contains('.')
? toUpload.path.split('.').last.toLowerCase()
: 'jpg';
final contentType = extName == 'png'
? 'image/png'
: extName == 'gif'
? 'image/gif'
: 'image/jpeg';
final fileName = customUploadBaseName != null &&
customUploadBaseName.trim().isNotEmpty
? (customUploadBaseName.contains('.')
? customUploadBaseName
: '$customUploadBaseName.$extName')
: 'img_${DateTime.now().millisecondsSinceEpoch}.$extName';
final presignedRes = await ImageApi.getUploadPresignedUrl(
fileName1: fileName,
contentType: contentType,
expectedSize: size,
);
if (!presignedRes.isSuccess || presignedRes.data == null) {
throw StateError(
presignedRes.msg.isNotEmpty
? presignedRes.msg
: 'Failed to get upload URL',
);
}
final presigned = presignedRes.data!;
final uploadUrl = presigned.uploadUrl;
final filePath = presigned.filePath;
if (uploadUrl == null ||
uploadUrl.isEmpty ||
filePath == null ||
filePath.isEmpty) {
throw StateError('Invalid presigned URL response');
}
final headers = _mergePutHeaders(
presigned.putHeaders,
extraPutHeaders,
contentType,
);
final bytes = await toUpload.readAsBytes();
final uri = Uri.parse(uploadUrl);
_presignedPutLog.d(
'PUT begin url=$uploadUrl bytes=${bytes.length} '
'contentType=${headers['Content-Type']}',
);
_presignedPutLog.d(
'PUT header keys: ${headers.keys.join(', ')}',
);
final uploadResponse = await http.put(
uri,
headers: headers,
body: bytes,
);
_presignedPutLog.d(
'PUT done status=${uploadResponse.statusCode} '
'reason=${uploadResponse.reasonPhrase} '
'respBodyLen=${uploadResponse.body.length}',
);
if (uploadResponse.statusCode < 200 || uploadResponse.statusCode >= 300) {
final snippet = uploadResponse.body.length > 800
? '${uploadResponse.body.substring(0, 800)}'
: uploadResponse.body;
_presignedPutLog.e('PUT failed body snippet: $snippet');
throw StateError('Upload failed: ${uploadResponse.statusCode}');
}
return _UploadedPart(toUpload: toUpload, serverPath: filePath);
}
/// [srcImageServerPath] 使 [UploadPresignedUrlResponse.filePath] URL
static Future<ImagePresignedUploadCreateTaskResult> run({
required File sourceFile,
required String userId,
bool compressFirst = true,
CompressImageForUploadOptions compressOptions =
const CompressImageForUploadOptions(maxSide: 1024, jpegQuality: 75),
String? customUploadBaseName,
String? resolution,
String? srcImgUrls,
String? prompt,
String? size,
String? taskType,
String? templateName,
String? imgUrl,
bool needopt = false,
String? ext,
/// `srcImg1Url` `skin_config`
bool createTaskUseImgUrlOnly = false,
/// `true` [CreateTaskResponse.taskId]
bool saveLocalUploadCover = true,
Map<String, String>? extraPutHeaders,
}) async {
final part = await _uploadOneFile(
sourceFile: sourceFile,
compressFirst: compressFirst,
compressOptions: compressOptions,
customUploadBaseName: customUploadBaseName,
extraPutHeaders: extraPutHeaders,
);
final serverPath = srcImgUrls ?? part.serverPath;
final pathForTask = resolution ?? serverPath;
final createRes = await ImageApi.createTask(
userId: userId,
srcImg1Url: pathForTask,
prompt: prompt,
size: size,
taskType: taskType,
templateName: templateName,
imgUrl: imgUrl,
needopt: needopt,
ext: ext,
);
if (!createRes.isSuccess || createRes.data == null) {
throw StateError(
createRes.msg.isNotEmpty ? createRes.msg : 'Failed to create task',
);
}
final cr = createRes.data!;
if (saveLocalUploadCover) {
await TaskUploadCoverStore.saveAfterCreateTaskResponse(
response: cr,
source: part.toUpload,
);
}
return ImagePresignedUploadCreateTaskResult(
createResponse: cr,
fileUsedForUpload: part.toUpload,
);
}
/// `srcImg1Url` `resolution` `srcImg2`
/// [sourceFile1]
static Future<ImagePresignedUploadCreateTaskResult> runTwoSourceFiles({
required File sourceFile1,
required File sourceFile2,
required String userId,
bool compressFirst = true,
CompressImageForUploadOptions compressOptions =
const CompressImageForUploadOptions(maxSide: 1024, jpegQuality: 75),
String? resolution,
String? prompt,
String? size,
String? taskType,
String? templateName,
String? imgUrl,
bool needopt = false,
String? ext,
bool saveLocalUploadCover = true,
Map<String, String>? extraPutHeaders,
}) async {
final part1 = await _uploadOneFile(
sourceFile: sourceFile1,
compressFirst: compressFirst,
compressOptions: compressOptions,
extraPutHeaders: extraPutHeaders,
);
final part2 = await _uploadOneFile(
sourceFile: sourceFile2,
compressFirst: compressFirst,
compressOptions: compressOptions,
extraPutHeaders: extraPutHeaders,
);
final createRes = await ImageApi.createTask(
userId: userId,
srcImg1Url: resolution ?? part1.serverPath,
srcImg2: part2.serverPath,
prompt: prompt,
size: size,
taskType: taskType,
templateName: templateName,
imgUrl: imgUrl,
needopt: needopt,
ext: ext,
);
if (!createRes.isSuccess || createRes.data == null) {
throw StateError(
createRes.msg.isNotEmpty ? createRes.msg : 'Failed to create task',
);
}
final cr = createRes.data!;
if (saveLocalUploadCover) {
await TaskUploadCoverStore.saveAfterCreateTaskResponse(
response: cr,
source: part1.toUpload,
);
}
return ImagePresignedUploadCreateTaskResult(
createResponse: cr,
fileUsedForUpload: part1.toUpload,
);
}
}

View File

@ -0,0 +1,206 @@
import 'dart:async';
import 'dart:io';
import 'package:http/http.dart' show ClientException;
import '../api/proxy_client.dart';
import '../entities/image_entities.dart';
import 'image_api.dart';
/// 线 [EntityResponse]
class ProgressPollTick {
const ProgressPollTick({
required this.response,
this.transientNetworkFailuresBeforeSuccess = 0,
});
final EntityResponse<ProgressResponse> response;
///
final int transientNetworkFailuresBeforeSuccess;
}
/// FunyMee 宿
abstract final class ProgressPollSemantics {
static bool hasUsableResultUrl(String? url) {
if (url == null || url.isEmpty) return false;
final u = url.trim();
return u.startsWith('http://') || u.startsWith('https://');
}
static bool isTerminalStatus(String? status) {
final t = (status ?? '').toLowerCase();
return t == 'success' ||
t == 'completed' ||
t == 'complete' ||
t == 'failed' ||
t == 'failure' ||
t == 'error' ||
t == 'cancelled' ||
t == 'canceled';
}
static bool isSuccessTerminal(String? status) {
final t = (status ?? '').toLowerCase();
if (t == 'success' || t == 'completed' || t == 'complete') return true;
// 线 `status` `state` "3"
if (t == '3') return true;
return false;
}
static bool isFailureTerminal(String? status) {
final t = (status ?? '').toLowerCase();
return t == 'failed' ||
t == 'failure' ||
t == 'error' ||
t == 'cancelled' ||
t == 'canceled';
}
/// URL
static bool shouldStopPolling(ProgressResponse p) {
if (hasUsableResultUrl(p.resultUrl)) return true;
if (isTerminalStatus(p.status)) return true;
final s = p.state;
// 3=4=5=6=FunyMee
if (s != null && s >= 3 && s <= 6) return true;
return false;
}
/// URL`state==3`
static bool isProgressSuccess(ProgressResponse p) {
if (hasUsableResultUrl(p.resultUrl)) return true;
if (p.state == 3) return true;
return isSuccessTerminal(p.status);
}
/// //
static bool isProgressFailure(ProgressResponse p) {
final s = p.state;
if (s == 4 || s == 5 || s == 6) return true;
return isTerminalStatus(p.status) && isFailureTerminal(p.status);
}
}
///
class ImageProgressPollHandle {
ImageProgressPollHandle._(void Function() cancel) : _cancel = cancel;
final void Function() _cancel;
void cancel() => _cancel();
}
/// [ImageApi.getProgress] [interval]
abstract final class ImageProgressPoll {
static const Duration defaultInterval = Duration(seconds: 5);
/// [delayBeforeFirst] 0
///
/// - [onTick] `getProgress`
/// - [onTransientNetworkFailure]`count` 1
/// - [onFatalError] [maxTransientNetworkFailures]
static ImageProgressPollHandle start({
required String app,
required String taskId,
String? userId,
Duration interval = defaultInterval,
Duration delayBeforeFirst = Duration.zero,
int maxTransientNetworkFailures = 12,
required void Function(ProgressPollTick tick) onTick,
void Function(int failureCount, int maxFailures)? onTransientNetworkFailure,
void Function(String message)? onFatalError,
}) {
var cancelled = false;
void cancel() => cancelled = true;
Future<void> loop() async {
if (delayBeforeFirst > Duration.zero) {
await Future<void>.delayed(delayBeforeFirst);
}
while (!cancelled) {
EntityResponse<ProgressResponse>? res;
var transientBeforeSuccess = 0;
while (!cancelled) {
try {
res = await ImageApi.getProgress(
app: app,
taskId: taskId,
userId: userId,
);
break;
} on SocketException catch (_) {
transientBeforeSuccess++;
onTransientNetworkFailure?.call(
transientBeforeSuccess,
maxTransientNetworkFailures,
);
if (transientBeforeSuccess >= maxTransientNetworkFailures) {
onFatalError?.call(
'Network unstable (TLS). Check connection or try again later.',
);
return;
}
} on HandshakeException catch (_) {
transientBeforeSuccess++;
onTransientNetworkFailure?.call(
transientBeforeSuccess,
maxTransientNetworkFailures,
);
if (transientBeforeSuccess >= maxTransientNetworkFailures) {
onFatalError?.call(
'Network unstable (TLS). Check connection or try again later.',
);
return;
}
} on TlsException catch (_) {
transientBeforeSuccess++;
onTransientNetworkFailure?.call(
transientBeforeSuccess,
maxTransientNetworkFailures,
);
if (transientBeforeSuccess >= maxTransientNetworkFailures) {
onFatalError?.call(
'Network unstable (TLS). Check connection or try again later.',
);
return;
}
} on ClientException catch (_) {
transientBeforeSuccess++;
onTransientNetworkFailure?.call(
transientBeforeSuccess,
maxTransientNetworkFailures,
);
if (transientBeforeSuccess >= maxTransientNetworkFailures) {
onFatalError?.call(
'Network unstable. Check connection or try again later.',
);
return;
}
}
}
if (cancelled || res == null) return;
onTick(ProgressPollTick(
response: res,
transientNetworkFailuresBeforeSuccess: transientBeforeSuccess,
));
if (!res.isSuccess || res.data == null) {
return;
}
final p = res.data!;
if (ProgressPollSemantics.shouldStopPolling(p)) {
return;
}
await Future<void>.delayed(interval);
}
}
unawaited(loop());
return ImageProgressPollHandle._(cancel);
}
}

View File

@ -0,0 +1,40 @@
import '../entities/gallery_task_models.dart';
import '../entities/image_entities.dart';
import 'task_upload_cover_store.dart';
/// app_client UI
abstract final class ImageTaskHistory {
ImageTaskHistory._();
/// `EntityResponse.data` `tasks` / `intensify`
static List<GalleryTaskItem> parseGalleryTasksFromData(
Map<String, dynamic>? data,
) {
if (data == null) return [];
final list = (data['tasks'] ?? data['intensify']) as List<dynamic>? ?? [];
return list
.whereType<Map<String, dynamic>>()
.map(GalleryTaskItem.fromJson)
.toList();
}
static bool? parseHasNextFromData(Map<String, dynamic>? data) {
if (data == null) return null;
return data['hasNext'] as bool? ?? data['manifest'] as bool?;
}
/// [GalleryTaskItem.taskId] [TaskUploadCoverStore] 使 id
static Future<Map<int, String>> localCoverPathsForGalleryTasks(
Iterable<GalleryTaskItem> tasks,
) {
final ids = tasks.map((t) => t.taskId).where((id) => id > 0);
return TaskUploadCoverStore.existingPathsForTaskIdsInt(ids);
}
static Future<Map<String, String>> localCoverPathsForMyTaskItems(
Iterable<MyTaskItem> items,
) {
final ids = items.map((e) => e.taskId).whereType<String>();
return TaskUploadCoverStore.existingPathsForTaskIds(ids);
}
}

View File

@ -2,7 +2,7 @@ import '../api/api_client.dart';
import '../api/proxy_client.dart';
import '../entities/payment_entities.dart';
/// API使
/// API**** [FieldMapping] 线
///
/// **** `pkg` `User_token`
abstract final class PaymentApi {
@ -74,18 +74,15 @@ abstract final class PaymentApi {
///
///
/// **Query**`app``userId`
/// **Body** +
/// `lastName``country``expireMonth``accountName``userInfoType`
/// `automaticRenewal``channel``cvcCode``channelType``firstName`
/// `subPaymentMethod``phone``tgOrderId``tgId``name``expireYear`
/// `card``status` `lineage``armor` [FieldMapping]
/// **Body** + 线 `skin_config.fieldMapping`
/// `lastName``country``card``status` `fps` 线 `lineage`
static Future<EntityResponse<CreatePaymentResponse>> createPayment({
required String app,
required String userId,
required String activityId,
required String paymentMethod,
String? paymentType,
String? lineage,
String? fps,
String? armor,
String? lastName,
String? country,
@ -118,7 +115,7 @@ abstract final class PaymentApi {
'paymentMethod': paymentMethod,
if (paymentType != null && paymentType.isNotEmpty)
'paymentType': paymentType,
if (lineage != null && lineage.isNotEmpty) 'lineage': lineage,
if (fps != null && fps.isNotEmpty) 'fps': fps,
if (armor != null && armor.isNotEmpty) 'armor': armor,
if (lastName != null && lastName.isNotEmpty) 'lastName': lastName,
if (country != null && country.isNotEmpty) 'country': country,
@ -183,7 +180,7 @@ abstract final class PaymentApi {
);
}
/// query 使 `id` / ID
/// query 使 `id` / ID
static Future<EntityResponse<OrderDetailResponse>> getOrderDetail({
required String userId,
required String orderId,

View File

@ -0,0 +1,199 @@
import 'package:flutter/foundation.dart';
import '../../api/api_client.dart';
import '../../entities/payment_entities.dart';
import '../payment_api.dart';
import '../payment_service.dart';
import 'payment_flow_models.dart';
import 'payment_settlement_sink.dart';
/// ** Android Google Play**
///
/// **app_client** [RechargeScreen]
/// - [PaymentApi.createPayment]
/// - ** URL**[CreatePaymentResponse.payUrl] 线 `convert` Play + [PaymentApi.googlepay]
/// - [payUrl]宿 H5/****
abstract final class NativeIapCoordinator {
/// app_client [RechargeScreen._shouldUseGooglePay] `payUrl`
static bool shouldLaunchGooglePlayBillingInsteadOfWeb(String? payUrl) {
final p = payUrl?.trim() ?? '';
return p.isEmpty;
}
/// Android Google Play [PaymentApi.createPayment]
///
/// [activityId]/ id `PaymentApi.createPayment.activityId`
/// [storeProductId] `productId` [PaymentProductItem]
static Future<void> purchaseGooglePlay({
required PaymentSettlementSink sink,
required String userId,
required String activityId,
required String storeProductId,
String paymentMethod = 'GooglePay',
String? paymentType,
String? createPaymentApp,
}) async {
if (defaultTargetPlatform != TargetPlatform.android) {
sink.onPaymentSettled(PaymentSettlement.failure(
message:
'NativeIapCoordinator.purchaseGooglePlay only supports Android',
));
return;
}
if (userId.isEmpty) {
sink.onPaymentSettled(
PaymentSettlement.failure(message: 'userId is empty'));
return;
}
final cfg = ApiClient.instance.config;
final app = createPaymentApp ?? cfg.backendAppTypeAndroid;
try {
final createRes = await PaymentApi.createPayment(
app: app,
userId: userId,
activityId: activityId,
paymentMethod: paymentMethod,
paymentType: paymentType ?? paymentMethod,
);
if (!createRes.isSuccess || createRes.data == null) {
sink.onPaymentSettled(PaymentSettlement.failure(
message: createRes.msg.isNotEmpty
? createRes.msg
: 'createPayment failed',
));
return;
}
await _completeGooglePlayIap(
sink: sink,
userId: userId,
storeProductId: storeProductId,
data: createRes.data!,
app: app,
);
} catch (e) {
sink.onPaymentSettled(
PaymentSettlement.failure(message: e.toString()));
}
}
/// **** [PaymentApi.createPayment] [shouldLaunchGooglePlayBillingInsteadOfWeb] true
/// Play + [PaymentApi.googlepay] + consume**** createPayment
///
/// app_client [RechargeScreen._launchGooglePlayPurchase] [serverOrderId]
static Future<void> purchaseGooglePlayAfterCreatePayment({
required PaymentSettlementSink sink,
required String userId,
required String storeProductId,
required CreatePaymentResponse createResponse,
String? createPaymentApp,
}) async {
if (defaultTargetPlatform != TargetPlatform.android) {
sink.onPaymentSettled(PaymentSettlement.failure(
message:
'NativeIapCoordinator.purchaseGooglePlay only supports Android',
));
return;
}
if (userId.isEmpty) {
sink.onPaymentSettled(
PaymentSettlement.failure(message: 'userId is empty'));
return;
}
final cfg = ApiClient.instance.config;
final app = createPaymentApp ?? cfg.backendAppTypeAndroid;
try {
await _completeGooglePlayIap(
sink: sink,
userId: userId,
storeProductId: storeProductId,
data: createResponse,
app: app,
);
} catch (e) {
sink.onPaymentSettled(
PaymentSettlement.failure(message: e.toString()));
}
}
static Future<void> _completeGooglePlayIap({
required PaymentSettlementSink sink,
required String userId,
required String storeProductId,
required CreatePaymentResponse data,
required String app,
}) async {
final serverFederation = data.federationOrOrderId;
final purchase = await PaymentService.launchPurchaseAndReturnData(
storeProductId,
);
if (purchase == null) {
sink.onPaymentSettled(PaymentSettlement.cancelled(
message: 'Purchase cancelled or failed',
));
return;
}
final federation = (serverFederation != null &&
serverFederation.isNotEmpty)
? serverFederation
: purchase.orderId;
if (serverFederation != null && serverFederation.isNotEmpty) {
await PaymentService.saveFederationForGoogleOrderId(
purchase.orderId,
serverFederation,
);
}
final googlepayRes = await PaymentApi.googlepay(
signature: purchase.payload.signature,
purchaseData: purchase.payload.purchaseData,
orderId: federation,
userId: userId,
app: app,
);
if (!googlepayRes.isSuccess || googlepayRes.data == null) {
sink.onPaymentSettled(PaymentSettlement.failure(
orderId: federation,
message: googlepayRes.msg.isNotEmpty
? googlepayRes.msg
: 'googlepay verification failed',
));
return;
}
final body = googlepayRes.data!;
if (!_isGooglePaySuccess(body)) {
sink.onPaymentSettled(PaymentSettlement.failure(
orderId: federation,
message: body.status ?? 'verification not successful',
));
return;
}
await PaymentService.completeAndConsumePurchase(purchase.purchaseDetails);
if (serverFederation != null && serverFederation.isNotEmpty) {
await PaymentService.removeFederationForGoogleOrderId(purchase.orderId);
}
sink.onPaymentSettled(PaymentSettlement.success(
orderId: federation,
thirdParty: false,
));
}
static bool _isGooglePaySuccess(GooglePayCallbackResponse body) {
if (body.creditsAdded == true) return true;
final s = (body.status ?? '').toUpperCase();
return s == 'SUCCESS';
}
}

View File

@ -0,0 +1,2 @@
/// 宿 H5/ WebView / /
typedef PaymentCheckoutUrlLauncher = Future<void> Function(Uri url);

View File

@ -0,0 +1,10 @@
/// 宿 + /Google Play
library payment_flow;
export 'payment_checkout_launcher.dart';
export 'payment_flow_catalog.dart';
export 'payment_flow_models.dart';
export 'payment_settlement_sink.dart';
export 'native_iap_coordinator.dart';
export 'third_party_checkout_coordinator.dart';
export 'third_party_payment_watch.dart';

View File

@ -0,0 +1,28 @@
import 'package:flutter/foundation.dart';
import '../../api/proxy_client.dart';
import '../../entities/payment_entities.dart';
import '../payment_api.dart';
/// /
abstract final class PaymentFlowCatalog {
/// Google / Apple
static Future<EntityResponse<PaymentProductsResponse>> loadStoreActivities({
String? client,
String? country,
String? pkg,
}) async {
if (defaultTargetPlatform == TargetPlatform.iOS) {
return PaymentApi.getApplePayActivities(
client: client,
country: country,
pkg: pkg,
);
}
return PaymentApi.getGooglePayActivities(
client: client,
country: country,
pkg: pkg,
);
}
}

View File

@ -0,0 +1,155 @@
/// / 宿 [PaymentSettlementSink.onPaymentSettled]
enum PaymentFlowOutcomeType {
///
success,
///
failure,
///
cancelled,
///
timeout,
/// 宿
nativePendingHostVerification,
}
/// 宿
class PaymentSettlement {
PaymentSettlement._({
required this.type,
this.orderId,
this.message,
this.thirdParty = false,
this.extra,
});
factory PaymentSettlement.success({
String? orderId,
String? message,
bool thirdParty = false,
Map<String, String>? extra,
}) {
return PaymentSettlement._(
type: PaymentFlowOutcomeType.success,
orderId: orderId,
message: message,
thirdParty: thirdParty,
extra: extra,
);
}
factory PaymentSettlement.failure({
String? orderId,
String? message,
bool thirdParty = false,
}) {
return PaymentSettlement._(
type: PaymentFlowOutcomeType.failure,
orderId: orderId,
message: message,
thirdParty: thirdParty,
);
}
factory PaymentSettlement.cancelled({String? orderId, String? message}) {
return PaymentSettlement._(
type: PaymentFlowOutcomeType.cancelled,
orderId: orderId,
message: message,
);
}
factory PaymentSettlement.timeout({String? orderId, String? message}) {
return PaymentSettlement._(
type: PaymentFlowOutcomeType.timeout,
orderId: orderId,
message: message,
thirdParty: true,
);
}
factory PaymentSettlement.nativePendingHostVerification({
required String message,
String? orderId,
Map<String, String>? extra,
}) {
return PaymentSettlement._(
type: PaymentFlowOutcomeType.nativePendingHostVerification,
orderId: orderId,
message: message,
extra: extra,
);
}
final PaymentFlowOutcomeType type;
final String? orderId;
final String? message;
final bool thirdParty;
final Map<String, String>? extra;
}
/// +
class ThirdPartyCheckoutOutcome {
ThirdPartyCheckoutOutcome._({
required this.isSuccess,
this.message,
this.orderId,
this.payUrl,
this.createResponse,
});
factory ThirdPartyCheckoutOutcome.ok({
required String orderId,
String? payUrl,
dynamic createResponse,
}) {
return ThirdPartyCheckoutOutcome._(
isSuccess: true,
orderId: orderId,
payUrl: payUrl,
createResponse: createResponse,
);
}
factory ThirdPartyCheckoutOutcome.fail(String message) {
return ThirdPartyCheckoutOutcome._(isSuccess: false, message: message);
}
final bool isSuccess;
final String? message;
final String? orderId;
final String? payUrl;
final dynamic createResponse;
}
/// [OrderDetailResponse.status]
class PaymentPollPolicy {
const PaymentPollPolicy({
this.interval = const Duration(seconds: 2),
this.maxDuration = const Duration(minutes: 5),
this.successStatuses = const {
'paid',
'success',
'completed',
'paid_success',
'success_paid',
},
this.failureStatuses = const {
'failed',
'cancel',
'cancelled',
'canceled',
'closed',
'expired',
'fail',
},
});
final Duration interval;
final Duration maxDuration;
final Set<String> successStatuses;
final Set<String> failureStatuses;
}

View File

@ -0,0 +1,6 @@
import 'payment_flow_models.dart';
/// 宿 / common_info / UI
abstract class PaymentSettlementSink {
void onPaymentSettled(PaymentSettlement settlement);
}

View File

@ -0,0 +1,77 @@
import 'package:flutter/foundation.dart';
import '../../api/api_client.dart';
import '../payment_api.dart';
import 'payment_checkout_launcher.dart';
import 'payment_flow_models.dart';
/// [payUrl]
abstract final class ThirdPartyCheckoutCoordinator {
/// [PaymentApi.createPayment] `orderId`entity `payUrl`
static Future<ThirdPartyCheckoutOutcome> createOrder({
required String userId,
required String activityId,
required String paymentMethod,
String? paymentType,
String? app,
String? fps,
String? armor,
String? country,
String? subPaymentMethod,
}) async {
final cfg = ApiClient.instance.config;
final backendApp = app ??
(defaultTargetPlatform == TargetPlatform.iOS
? cfg.backendAppTypeIOS
: cfg.backendAppTypeAndroid);
final res = await PaymentApi.createPayment(
app: backendApp,
userId: userId,
activityId: activityId,
paymentMethod: paymentMethod,
paymentType: paymentType,
fps: fps,
armor: armor,
country: country,
subPaymentMethod: subPaymentMethod,
);
if (!res.isSuccess || res.data == null) {
return ThirdPartyCheckoutOutcome.fail(
res.msg.isNotEmpty ? res.msg : 'createPayment failed',
);
}
final data = res.data!;
final oid = data.orderId ?? data.federation;
if (oid == null || oid.isEmpty) {
return ThirdPartyCheckoutOutcome.fail('Missing order id in response');
}
final url = data.payUrl;
return ThirdPartyCheckoutOutcome.ok(
orderId: oid,
payUrl: (url != null && url.isNotEmpty) ? url : null,
createResponse: data,
);
}
/// [payUrl] [launcher]宿 WebView /
static Future<void> openPayUrlIfPresent(
String? payUrl,
PaymentCheckoutUrlLauncher? launcher,
) async {
if (payUrl == null || payUrl.isEmpty) return;
if (launcher == null) {
throw StateError(
'ThirdPartyCheckoutCoordinator: payUrl is set but launcher is null',
);
}
final uri = Uri.tryParse(payUrl);
if (uri == null) {
throw FormatException('Invalid payUrl: $payUrl');
}
await launcher(uri);
}
}

View File

@ -0,0 +1,101 @@
import 'dart:async';
import '../payment_api.dart';
import 'payment_flow_models.dart';
import 'payment_settlement_sink.dart';
/// [PaymentApi.getOrderDetail] / /
///
/// 宿 App [start] [stop]
class ThirdPartyPaymentWatch {
ThirdPartyPaymentWatch({
required this.userId,
required this.sink,
this.policy = const PaymentPollPolicy(),
});
final String userId;
final PaymentSettlementSink sink;
final PaymentPollPolicy policy;
Timer? _timer;
DateTime? _startedAt;
bool _settled = false;
/// [orderId] [stop]
void start({required String orderId}) {
stop();
_settled = false;
_startedAt = DateTime.now();
_timer = Timer.periodic(policy.interval, (_) {
unawaited(_tick(orderId));
});
unawaited(_tick(orderId));
}
/// sink
void stop() {
_timer?.cancel();
_timer = null;
}
void _complete(PaymentSettlement settlement) {
if (_settled) return;
_settled = true;
stop();
sink.onPaymentSettled(settlement);
}
Future<void> _tick(String orderId) async {
if (_settled) return;
final started = _startedAt;
if (started != null &&
DateTime.now().difference(started) > policy.maxDuration) {
_complete(PaymentSettlement.timeout(
orderId: orderId,
message: 'Payment status polling timed out',
));
return;
}
try {
final res = await PaymentApi.getOrderDetail(
userId: userId,
orderId: orderId,
);
if (_settled) return;
if (!res.isSuccess || res.data == null) {
return;
}
final detail = res.data!;
final raw = detail.status;
if (raw == null || raw.isEmpty) return;
final norm = raw.trim().toLowerCase();
for (final s in policy.successStatuses) {
if (norm == s.toLowerCase()) {
_complete(PaymentSettlement.success(
orderId: detail.orderId ?? orderId,
message: raw,
thirdParty: true,
));
return;
}
}
for (final s in policy.failureStatuses) {
if (norm == s.toLowerCase()) {
_complete(PaymentSettlement.failure(
orderId: detail.orderId ?? orderId,
message: raw,
thirdParty: true,
));
return;
}
}
} catch (_) {
/* 单次失败忽略,依赖超时 */
}
}
void dispose() => stop();
}

View File

@ -0,0 +1,144 @@
import 'dart:io';
import 'package:path_provider/path_provider.dart';
import '../entities/image_entities.dart';
import '../entities/task_id_parse.dart';
/// [taskId] 便使
///
/// app_client [GalleryUploadCoverStore] support `gallery_upload_covers`
/// `{taskId}.jpg` id 25h
abstract final class TaskUploadCoverStore {
TaskUploadCoverStore._();
static const String _subdir = 'gallery_upload_covers';
static const String _fileExt = '.jpg';
static const Duration maxRetention = Duration(hours: 25);
static Future<Directory> _directory() async {
final base = await getApplicationSupportDirectory();
final dir = Directory('${base.path}/$_subdir');
if (!await dir.exists()) {
await dir.create(recursive: true);
}
return dir;
}
static Future<Directory> _directoryAfterPurge() async {
final dir = await _directory();
await _purgeExpired(dir);
return dir;
}
static Future<void> _purgeExpired(Directory dir) async {
if (!await dir.exists()) return;
final now = DateTime.now();
try {
await for (final entity in dir.list(followLinks: false)) {
if (entity is! File) continue;
final name = entity.uri.pathSegments.last;
if (!name.endsWith(_fileExt)) continue;
final stat = await entity.stat();
if (now.difference(stat.modified) >= maxRetention) {
try {
await entity.delete();
} catch (_) {}
}
}
} catch (_) {}
}
/// app_client id `123.jpg`
static String fileBaseNameForTaskId(String taskId) {
final t = taskId.trim();
if (t.isEmpty) return '';
if (RegExp(r'^\d+$').hasMatch(t)) return t;
return t.replaceAll(RegExp(r'[^0-9a-zA-Z_-]+'), '_');
}
static File _fileForTaskId(Directory dir, String taskId) =>
File('${dir.path}/${fileBaseNameForTaskId(taskId)}$_fileExt');
/// [source]
static Future<void> saveForTask(String taskId, File source) async {
final id = taskId.trim();
if (id.isEmpty) return;
if (!await source.exists()) return;
final dir = await _directoryAfterPurge();
final dest = _fileForTaskId(dir, id);
await source.copy(dest.path);
}
static Future<void> saveForTaskInt(int taskId, File source) =>
saveForTask(taskId.toString(), source);
static Future<String?> pathIfExists(String taskId) async {
final id = taskId.trim();
if (id.isEmpty) return null;
final dir = await _directoryAfterPurge();
final f = _fileForTaskId(dir, id);
return await f.exists() ? f.path : null;
}
static Future<String?> pathIfExistsInt(int taskId) async {
if (taskId <= 0) return null;
return pathIfExists(taskId.toString());
}
/// path
static Future<Map<String, String>> existingPathsForTaskIds(
Iterable<String> ids,
) async {
final dir = await _directoryAfterPurge();
final out = <String, String>{};
for (final raw in ids) {
final id = raw.trim();
if (id.isEmpty) continue;
final f = _fileForTaskId(dir, id);
if (await f.exists()) {
out[id] = f.path;
}
}
return out;
}
/// app_client key int id id
static Future<Map<int, String>> existingPathsForTaskIdsInt(
Iterable<int> ids,
) async {
final dir = await _directoryAfterPurge();
final out = <int, String>{};
for (final id in ids) {
if (id <= 0) continue;
final f = _fileForTaskId(dir, id.toString());
if (await f.exists()) {
out[id] = f.path;
}
}
return out;
}
/// data JSON id `unawaited`
static Future<void> saveAfterCreateTaskBody({
required Map<String, dynamic>? body,
required File source,
}) async {
final id = parseTaskIdFromMap(body);
if (id != null && id.isNotEmpty) {
await saveForTask(id, source);
}
}
/// [ImageApi.createTask] [CreateTaskResponse.taskId] [parseTaskIdFromMap] `exponential`
static Future<void> saveAfterCreateTaskResponse({
required CreateTaskResponse? response,
required File source,
}) async {
final id = response?.taskId?.trim();
if (id != null && id.isNotEmpty) {
await saveForTask(id, source);
}
}
}

View File

@ -0,0 +1,26 @@
import '../entities/user_entities.dart';
import 'user_api.dart';
/// 宿/ `ValueNotifier` UI
abstract final class UserAccountRefresh {
UserAccountRefresh._();
/// [onAccount] [AccountResponse] `null` [onFailure]
static Future<AccountResponse?> fetchAndNotify({
required String app,
String? userId,
void Function(AccountResponse account)? onAccount,
void Function(String message)? onFailure,
}) async {
final res = await UserApi.getAccount(app: app, userId: userId);
if (!res.isSuccess || res.data == null) {
final msg =
res.msg.isNotEmpty ? res.msg : 'getAccount failed (code ${res.code})';
onFailure?.call(msg);
return null;
}
final account = res.data!;
onAccount?.call(account);
return account;
}
}

View File

@ -4,12 +4,12 @@ import '../api/proxy_client.dart';
import '../entities/image_entities.dart';
import '../entities/user_entities.dart';
/// API使
/// API**** [FieldMapping] 线
///
/// **** [UserApi.fastLogin] [ProxyClient]
/// `pkg` `User_token` token fast_login `pkg` token
///
/// ****使**** `referer``deviceId`
/// ****使**** `referer``deviceId`
abstract final class UserApi {
static ProxyClient get _client => ApiClient.instance.proxy;
@ -77,7 +77,7 @@ abstract final class UserApi {
///
///
/// query [AppConfig.fieldMapping]
/// query [AppConfig.fieldMapping]
/// - [app] iOS `HIOS` / Android `HAndroid` fast_login
/// - [pkg]
/// - [client][userId][ch][inviteBy][deviceId][clientId]

View File

@ -0,0 +1,160 @@
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
/// [ClientProxyFrameworkPlugin] Android
const String kDefaultDeviceMemoryChannelName =
'client_proxy_framework/device_memory';
/// 3GiB
const int kMemoryTierStaticOnlyBytesThreshold = 3 * 1024 * 1024 * 1024;
/// 6GiB \[3GiB, 6GiB)
const int kMemoryTierFullConcurrentBytesThreshold = 6 * 1024 * 1024 * 1024;
enum GridVideoMemoryPolicy {
staticOnly,
maxConcurrent2,
maxConcurrent4,
}
GridVideoMemoryPolicy? _cachedPolicy;
GridVideoMemoryPolicy get _effectivePolicy =>
_cachedPolicy ?? GridVideoMemoryPolicy.staticOnly;
/// `WidgetsFlutterBinding.ensureInitialized()` `runApp`
Future<void> ensureDeviceMemoryProfileInitialized({
String methodChannelName = kDefaultDeviceMemoryChannelName,
}) async {
if (_cachedPolicy != null) return;
if (!Platform.isAndroid) {
_cachedPolicy = GridVideoMemoryPolicy.maxConcurrent4;
return;
}
final channel = MethodChannel(methodChannelName);
try {
final raw =
await channel.invokeMethod<dynamic>('getTotalPhysicalMemoryBytes');
final bytes = _coerceToPhysicalRamBytes(raw);
if (bytes != null && _isPlausibleDeviceRamBytes(bytes)) {
_cachedPolicy = _policyFromTotalBytes(bytes);
debugPrint(
'DeviceMemory: channel totalMem≈${(bytes / (1024 * 1024)).toStringAsFixed(0)}MiB '
'$_cachedPolicy',
);
return;
}
if (bytes != null) {
debugPrint(
'DeviceMemory: rejected implausible totalMem=$bytes (channel raw=$raw)',
);
}
} on MissingPluginException catch (e, st) {
debugPrint('DeviceMemory: channel missing: $e\n$st');
} on PlatformException catch (e, st) {
debugPrint(
'DeviceMemory: platform ${e.code} ${e.message}\n${e.details}\n$st',
);
} on Object catch (e, st) {
debugPrint('DeviceMemory: channel error: $e\n$st');
}
try {
final kb = _tryParseProcMemTotalKb();
if (kb != null) {
final bytes = kb * 1024;
if (_isPlausibleDeviceRamBytes(bytes)) {
_cachedPolicy = _policyFromTotalBytes(bytes);
debugPrint(
'DeviceMemory: /proc/meminfo MemTotal=${kb}kB → $_cachedPolicy',
);
return;
}
debugPrint(
'DeviceMemory: MemTotal kB=$kb implausible as bytes=$bytes',
);
}
} on Object catch (e, st) {
debugPrint('DeviceMemory: /proc/meminfo fallback error: $e\n$st');
}
debugPrint('DeviceMemory: unreadable → staticOnly (fallback)');
_cachedPolicy = GridVideoMemoryPolicy.staticOnly;
}
GridVideoMemoryPolicy _policyFromTotalBytes(int bytes) {
if (bytes < kMemoryTierStaticOnlyBytesThreshold) {
return GridVideoMemoryPolicy.staticOnly;
}
if (bytes < kMemoryTierFullConcurrentBytesThreshold) {
return GridVideoMemoryPolicy.maxConcurrent2;
}
return GridVideoMemoryPolicy.maxConcurrent4;
}
///
bool get deviceGridStaticPreviewOnly =>
_effectivePolicy == GridVideoMemoryPolicy.staticOnly;
/// /0
int get deviceGridMaxConcurrentVideos {
switch (_effectivePolicy) {
case GridVideoMemoryPolicy.staticOnly:
return 0;
case GridVideoMemoryPolicy.maxConcurrent2:
return 2;
case GridVideoMemoryPolicy.maxConcurrent4:
return 4;
}
}
int? _coerceToPhysicalRamBytes(Object? raw) {
if (raw == null) return null;
if (raw is int) return raw > 0 ? raw : null;
if (raw is num) {
final v = raw.round();
if (v <= 0 || !v.isFinite) return null;
return v.toInt();
}
final s = raw.toString().trim();
if (s.isEmpty) return null;
return int.tryParse(s);
}
bool _isPlausibleDeviceRamBytes(int bytes) {
const minBytes = 128 * 1024 * 1024;
const maxBytes = 32 * 1024 * 1024 * 1024;
return bytes >= minBytes && bytes <= maxBytes;
}
int? _tryParseProcMemTotalKb() {
if (!Platform.isAndroid) return null;
try {
final file = File('/proc/meminfo');
final content = file.readAsStringSync();
if (content.isEmpty) return null;
final match = RegExp(
r'^MemTotal:\s+(\d+)\s+kB',
multiLine: true,
).firstMatch(content);
if (match == null) return null;
final kb = int.tryParse(match.group(1)!);
if (kb == null || kb <= 0) return null;
if (kb > 1 << 28) return null;
return kb;
} on FileSystemException catch (e, st) {
debugPrint('DeviceMemory: meminfo FileSystemException: $e\n$st');
return null;
} on IOException catch (e, st) {
debugPrint('DeviceMemory: meminfo IOException: $e\n$st');
} on FormatException catch (e, st) {
debugPrint('DeviceMemory: meminfo FormatException: $e\n$st');
} on Object catch (e, st) {
debugPrint('DeviceMemory: meminfo: $e\n$st');
}
return null;
}

View File

@ -9,6 +9,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "5.5.1"
archive:
dependency: transitive
description:
name: archive
sha256: a96e8b390886ee8abb49b7bd3ac8df6f451c621619f52a26e815fdcf568959ff
url: "https://pub.dev"
source: hosted
version: "4.0.9"
args:
dependency: transitive
description:
@ -49,6 +57,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.1.2"
code_assets:
dependency: transitive
description:
name: code_assets
sha256: "83ccdaa064c980b5596c35dd64a8d3ecc68620174ab9b90b6343b753aa721687"
url: "https://pub.dev"
source: hosted
version: "1.0.0"
collection:
dependency: transitive
description:
@ -115,6 +131,22 @@ packages:
description: flutter
source: sdk
version: "0.0.0"
glob:
dependency: transitive
description:
name: glob
sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de
url: "https://pub.dev"
source: hosted
version: "2.1.3"
hooks:
dependency: transitive
description:
name: hooks
sha256: e79ed1e8e1929bc6ecb6ec85f0cb519c887aa5b423705ded0d0f2d9226def388
url: "https://pub.dev"
source: hosted
version: "1.0.2"
http:
dependency: "direct main"
description:
@ -131,6 +163,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "4.1.2"
image:
dependency: "direct main"
description:
name: image
sha256: f9881ff4998044947ec38d098bc7c8316ae1186fa786eddffdb867b9bc94dfce
url: "https://pub.dev"
source: hosted
version: "4.8.0"
in_app_purchase:
dependency: "direct main"
description:
@ -163,6 +203,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.4.8+1"
jni:
dependency: transitive
description:
name: jni
sha256: c2230682d5bc2362c1c9e8d3c7f406d9cbba23ab3f2e203a025dd47e0fb2e68f
url: "https://pub.dev"
source: hosted
version: "1.0.0"
jni_flutter:
dependency: transitive
description:
name: jni_flutter
sha256: "8b59e590786050b1cd866677dddaf76b1ade5e7bc751abe04b86e84d379d3ba6"
url: "https://pub.dev"
source: hosted
version: "1.0.1"
js:
dependency: transitive
description:
@ -187,6 +243,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.7.0"
logging:
dependency: transitive
description:
name: logging
sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61
url: "https://pub.dev"
source: hosted
version: "1.3.0"
material_color_utilities:
dependency: transitive
description:
@ -203,6 +267,30 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.17.0"
native_toolchain_c:
dependency: transitive
description:
name: native_toolchain_c
sha256: "6ba77bb18063eebe9de401f5e6437e95e1438af0a87a3a39084fbd37c90df572"
url: "https://pub.dev"
source: hosted
version: "0.17.6"
objective_c:
dependency: transitive
description:
name: objective_c
sha256: "100a1c87616ab6ed41ec263b083c0ef3261ee6cd1dc3b0f35f8ddfa4f996fe52"
url: "https://pub.dev"
source: hosted
version: "9.3.0"
package_config:
dependency: transitive
description:
name: package_config
sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc
url: "https://pub.dev"
source: hosted
version: "2.2.0"
path:
dependency: transitive
description:
@ -211,6 +299,30 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.9.1"
path_provider:
dependency: "direct main"
description:
name: path_provider
sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd"
url: "https://pub.dev"
source: hosted
version: "2.1.5"
path_provider_android:
dependency: transitive
description:
name: path_provider_android
sha256: "914a07484c4380e572998d30486e77e0d9cd2faec72fee268086d07bf7f302c9"
url: "https://pub.dev"
source: hosted
version: "2.3.0"
path_provider_foundation:
dependency: transitive
description:
name: path_provider_foundation
sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699"
url: "https://pub.dev"
source: hosted
version: "2.6.0"
path_provider_linux:
dependency: transitive
description:
@ -235,6 +347,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.3.0"
petitparser:
dependency: transitive
description:
name: petitparser
sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675"
url: "https://pub.dev"
source: hosted
version: "7.0.2"
platform:
dependency: transitive
description:
@ -267,6 +387,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.9.1"
posix:
dependency: transitive
description:
name: posix
sha256: "185ef7606574f789b40f289c233efa52e96dead518aed988e040a10737febb07"
url: "https://pub.dev"
source: hosted
version: "6.5.0"
pub_semver:
dependency: transitive
description:
name: pub_semver
sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585"
url: "https://pub.dev"
source: hosted
version: "2.2.0"
shared_preferences:
dependency: "direct main"
description:
@ -368,6 +504,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.2.0"
video_thumbnail:
dependency: "direct main"
description:
name: video_thumbnail
sha256: "181a0c205b353918954a881f53a3441476b9e301641688a581e0c13f00dc588b"
url: "https://pub.dev"
source: hosted
version: "0.5.6"
web:
dependency: transitive
description:
@ -384,6 +528,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.1.0"
xml:
dependency: transitive
description:
name: xml
sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025"
url: "https://pub.dev"
source: hosted
version: "6.6.1"
yaml:
dependency: transitive
description:
name: yaml
sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce
url: "https://pub.dev"
source: hosted
version: "3.1.3"
sdks:
dart: ">=3.9.0 <4.0.0"
flutter: ">=3.35.0"
dart: ">=3.10.3 <4.0.0"
flutter: ">=3.38.4"

View File

@ -28,3 +28,6 @@ dependencies:
in_app_purchase_android: ^0.4.0+8
play_install_referrer: ^0.5.0
shared_preferences: ^2.2.2
path_provider: ^2.1.2
image: ^4.3.0
video_thumbnail: ^0.5.6