This commit is contained in:
admin
2026-06-02 19:01:08 +08:00
commit 7222796781
205 changed files with 10850 additions and 0 deletions

View File

@@ -0,0 +1,185 @@
import 'dart:developer' as developer;
import 'package:flutter/foundation.dart';
import 'package:adjust_sdk/adjust.dart';
import 'package:adjust_sdk/adjust_attribution.dart';
import 'package:adjust_sdk/adjust_config.dart';
import 'package:adjust_sdk/adjust_event.dart';
import 'package:adjust_sdk/adjust_event_failure.dart';
import 'package:adjust_sdk/adjust_event_success.dart';
import 'package:adjust_sdk/adjust_session_failure.dart';
import 'package:adjust_sdk/adjust_session_success.dart';
import 'package:freecell/utils/report.dart';
typedef EventParams = Map<String, dynamic>;
class AdjustService {
Map<String, String> _eventNameToToken = {};
Map<String, String> get eventNameToToken => _eventNameToToken;
set eventNameToToken(Map<String, String> m) {
_eventNameToToken = Map<String, String>.from(m);
}
AdjustService._();
static final AdjustService instance = AdjustService._();
bool _inited = false;
String _toStr(dynamic v) => '$v';
double? _toNum(dynamic v) {
if (v == null) return null;
if (v is num) return v.toDouble();
return double.tryParse('$v');
}
Future<void> init(String appToken, [bool isProd = true]) async{
developer.log(appToken, name: 'Adjust');
if (_inited) {
developer.log('[Adjust] already inited', name: 'Adjust');
return;
}
if (appToken.isEmpty) return;
final environment = isProd
? AdjustEnvironment.production
: AdjustEnvironment.sandbox;
final config = AdjustConfig(appToken, environment);
config.isCostDataInAttributionEnabled = true;
await Adjust.requestAppTrackingAuthorization();
config.attributionCallback = (AdjustAttribution a) async {
debugPrint('Adjust attribution: ${a.toString()}');
ReportService.I.attribution = a;
ReportService.I.reportAttribution();
};
config.eventSuccessCallback = (AdjustEventSuccess s) {
debugPrint('Adjust event success: ${s.toString()}');
};
config.eventFailureCallback = (AdjustEventFailure f) {
debugPrint('Adjust event failure: ${f.toString()}');
};
config.sessionSuccessCallback = (AdjustSessionSuccess s) {
debugPrint('Adjust session success: ${s.toString()}');
};
config.sessionFailureCallback = (AdjustSessionFailure f) {
debugPrint('Adjust session failure: ${f.toString()}');
};
// 可选:如果你希望缓存更多 deduplicationId可调大
config.eventDeduplicationIdsMaxSize = 20;
config.logLevel = isProd ? AdjustLogLevel.info : AdjustLogLevel.verbose;
Adjust.initSdk(config);
_inited = true;
}
Future<void> trackEventWithName(
String eventName, [
EventParams? params,
]) async {
debugPrint('call trackEventWithName eventName $eventName params $params');
// 你可以在这里做个映射eventName -> eventToken
// 也可以直接让外部传 eventToken 过来
final eventToken = _eventNameToToken[eventName];
if (eventToken == null) {
debugPrint('not find eventToken $eventName');
return;
}
ReportService.I.report(eventName, params);
await trackEvent(eventToken, params);
}
Future<void> trackEvent(String eventToken, [EventParams? params]) async {
try {
debugPrint("track event eventToken $eventToken params $params");
if (eventToken.isEmpty) return;
final event = AdjustEvent(eventToken);
if (params != null && params.isNotEmpty) {
double? revenue;
String? currency;
// 优先级deduplicationId > orderId > transactionId
String? dedupId;
for (final entry in params.entries) {
final key = entry.key;
final value = entry.value;
if (value == null) continue;
switch (key) {
case 'revenue':
case 'amount':
revenue = _toNum(value);
break;
case 'currency':
currency = _toStr(value);
break;
case 'deduplicationId':
case 'orderId':
dedupId ??= _toStr(value);
break;
case 'callbackId':
event.callbackId = _toStr(value);
break;
case 'transactionId':
final tid = _toStr(value);
event.transactionId = tid;
dedupId ??= tid;
break;
case 'productId':
event.productId = _toStr(value);
break;
case 'purchaseToken':
event.purchaseToken = _toStr(value);
break;
default:
final v = _toStr(value);
if (key.startsWith('partner:')) {
final realKey = key.substring('partner:'.length);
if (realKey.isNotEmpty) {
event.addPartnerParameter(realKey, v);
}
} else {
event.addCallbackParameter(key, v);
}
break;
}
}
if (dedupId != null && dedupId.isNotEmpty) {
event.deduplicationId = dedupId;
}
if (revenue != null && currency != null && currency.isNotEmpty) {
event.setRevenue(revenue, currency);
}
}
Adjust.trackEvent(event);
} catch (e, st) {
developer.log(
'[Adjust] trackEvent error: $e',
name: 'Adjust',
stackTrace: st,
);
}
}
}

View File

@@ -0,0 +1,301 @@
import 'package:audioplayers/audioplayers.dart';
import 'package:flutter/foundation.dart';
enum SfxKey { win, loss, select, drop, shuffle }
enum BgmKey { bgm }
class MusicMap {
static const String bgm = 'assets/audio/bgm.mp3';
static const Map<SfxKey, String> sfx = {
SfxKey.win: 'assets/audio/win.mp3',
SfxKey.loss: 'assets/audio/loss.mp3',
SfxKey.shuffle: 'assets/audio/shuffle.mp3',
SfxKey.select: 'assets/audio/select.mp3',
SfxKey.drop: 'assets/audio/drop.mp3',
};
}
class AudioManager {
static final AudioManager instance = AudioManager._();
AudioManager._();
late final AudioPlayer _bgm = AudioPlayer(playerId: 'bgm');
final List<AudioPlayer> _sfxPlayers = [];
int _sfxIndex = 0;
bool _initialized = false;
bool _initializing = false;
bool _bgmMuted = false;
bool _sfxMuted = false;
double _bgmVolume = 0.6;
double _sfxVolume = 1.0;
static const int _sfxPoolSize = 2;
Future<void> init() async {
if (_initialized || _initializing) return;
_initializing = true;
try {
_sfxPlayers.clear();
_sfxIndex = 0;
await _initBgmPlayer();
await _initSfxPlayers();
if (_sfxPlayers.isEmpty) {
final fallback = AudioPlayer(playerId: 'sfx_fallback');
await _safeSetSfxPlayerDefaults(fallback);
_sfxPlayers.add(fallback);
}
_initialized = true;
} catch (e, st) {
debugPrint('AudioManager init failed: $e');
debugPrint('$st');
_initialized = false;
_sfxPlayers.clear();
_sfxIndex = 0;
} finally {
_initializing = false;
}
}
Future<void> _initBgmPlayer() async {
try {
await _bgm.setReleaseMode(ReleaseMode.loop);
} catch (e) {
debugPrint('BGM setReleaseMode failed: $e');
}
try {
await _bgm.setPlayerMode(PlayerMode.mediaPlayer);
} catch (e) {
debugPrint('BGM setPlayerMode failed: $e');
}
try {
await _bgm.setAudioContext(
AudioContext(
android: AudioContextAndroid(
audioFocus: AndroidAudioFocus.gain,
contentType: AndroidContentType.music,
usageType: AndroidUsageType.media,
audioMode: AndroidAudioMode.normal,
isSpeakerphoneOn: false,
stayAwake: false,
),
iOS: AudioContextIOS(
category: AVAudioSessionCategory.playback,
options: const {AVAudioSessionOptions.mixWithOthers},
),
),
);
} catch (e) {
debugPrint('BGM setAudioContext failed: $e');
}
try {
await _bgm.setVolume(_bgmVolume);
} catch (e) {
debugPrint('BGM setVolume failed: $e');
}
}
Future<void> _initSfxPlayers() async {
for (int i = 0; i < _sfxPoolSize; i++) {
final player = AudioPlayer(playerId: 'sfx_$i');
await _safeSetSfxPlayerDefaults(player, index: i);
_sfxPlayers.add(player);
}
}
Future<void> _safeSetSfxPlayerDefaults(
AudioPlayer player, {
int? index,
}) async {
final tag = index == null ? 'SFX[fallback]' : 'SFX[$index]';
try {
await player.setReleaseMode(ReleaseMode.stop);
} catch (e) {
debugPrint('$tag setReleaseMode failed: $e');
}
try {
await player.setPlayerMode(PlayerMode.lowLatency);
} catch (e) {
debugPrint('$tag lowLatency failed, fallback to mediaPlayer: $e');
try {
await player.setPlayerMode(PlayerMode.mediaPlayer);
} catch (e2) {
debugPrint('$tag mediaPlayer fallback failed: $e2');
}
}
try {
await player.setAudioContext(
AudioContext(
android: AudioContextAndroid(
audioFocus: AndroidAudioFocus.none,
contentType: AndroidContentType.sonification,
usageType: AndroidUsageType.game,
audioMode: AndroidAudioMode.normal,
isSpeakerphoneOn: false,
stayAwake: false,
),
iOS: AudioContextIOS(
category: AVAudioSessionCategory.playback,
options: const {AVAudioSessionOptions.mixWithOthers},
),
),
);
} catch (e) {
debugPrint('$tag setAudioContext failed: $e');
}
try {
await player.setVolume(_sfxVolume);
} catch (e) {
debugPrint('$tag setVolume failed: $e');
}
}
Future<void> _ensureInit() async {
if (!_initialized) {
await init();
}
if (_sfxPlayers.isEmpty) {
final fallback = AudioPlayer(playerId: 'sfx_emergency');
await _safeSetSfxPlayerDefaults(fallback);
_sfxPlayers.add(fallback);
}
}
AudioPlayer _nextSfxPlayer() {
if (_sfxPlayers.isEmpty) {
throw StateError('SFX players not initialized');
}
final player = _sfxPlayers[_sfxIndex % _sfxPlayers.length];
_sfxIndex = (_sfxIndex + 1) % _sfxPlayers.length;
return player;
}
Future<void> setBgmMuted(bool muted) async {
await _ensureInit();
_bgmMuted = muted;
if (muted) {
await _bgm.pause();
} else {
await _bgm.resume();
}
}
Future<void> setSfxMuted(bool muted) async {
await _ensureInit();
_sfxMuted = muted;
if (muted) {
for (final p in _sfxPlayers) {
try {
await p.stop();
} catch (_) {}
}
}
}
Future<void> setBgmVolume(double value) async {
await _ensureInit();
_bgmVolume = value.clamp(0.0, 1.0);
await _bgm.setVolume(_bgmVolume);
}
Future<void> setSfxVolume(double value) async {
await _ensureInit();
_sfxVolume = value.clamp(0.0, 1.0);
for (final p in _sfxPlayers) {
try {
await p.setVolume(_sfxVolume);
} catch (_) {}
}
}
Future<void> playBgm([BgmKey key = BgmKey.bgm]) async {
await _ensureInit();
if (_bgmMuted) return;
final path = MusicMap.bgm.replaceFirst('assets/', '');
await _bgm.stop();
await _bgm.play(AssetSource(path), volume: _bgmVolume);
}
Future<void> pauseBgm() async {
if (!_initialized) return;
await _bgm.pause();
}
Future<void> resumeBgm() async {
await _ensureInit();
if (_bgmMuted) return;
await _bgm.resume();
}
Future<void> stopBgm() async {
if (!_initialized) return;
await _bgm.stop();
}
Future<void> playSfx(SfxKey key) async {
await _ensureInit();
if (_sfxMuted) return;
if (_sfxPlayers.isEmpty) return;
final path = MusicMap.sfx[key]!.replaceFirst('assets/', '');
final player = _nextSfxPlayer();
try {
await player.stop();
} catch (_) {}
try {
await player.setVolume(_sfxVolume);
} catch (_) {}
try {
await player.play(AssetSource(path), volume: _sfxVolume);
} catch (e) {
debugPrint('playSfx failed: $e');
}
}
Future<void> stopAllSfx() async {
if (!_initialized) return;
for (final p in _sfxPlayers) {
try {
await p.stop();
} catch (_) {}
}
}
Future<void> releaseAll() async {
try {
await _bgm.dispose();
} catch (_) {}
for (final p in _sfxPlayers) {
try {
await p.dispose();
} catch (_) {}
}
_sfxPlayers.clear();
_sfxIndex = 0;
_initialized = false;
_initializing = false;
}
}

25
lib/main.dart Normal file
View File

@@ -0,0 +1,25 @@
import 'package:flutter/material.dart';
import 'screens/freecell_screen.dart';
import 'screens/loading_screen.dart';
import 'screens/start_screen.dart';
void main() {
WidgetsFlutterBinding.ensureInitialized();
runApp(const FreeCellDemoApp());
}
class FreeCellDemoApp extends StatelessWidget {
const FreeCellDemoApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
debugShowCheckedModeBanner: false,
home: const LoadingScreen(),
routes: {
'/start': (context) => const StartScreen(),
'/game': (context) => const FreeCellScreen(),
},
);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,754 @@
import 'dart:async';
import 'dart:collection';
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:url_launcher/url_launcher.dart';
import '../adjust/adjust_service.dart';
import '../utils/aes_decrypt.dart';
import '../utils/next_setp.dart';
import '../utils/report.dart';
const String _webLoginTokenKey = 'assammzeeeass';
const int _maxWebViewProgressShowCount = 1;
const String bridgeInjectScript =
r'''lRpL+9TjSWvxRtAeNe3FCf8MtDcHDybBZ18LVDHp06Glxe6ITkTNRyyJqAPwbxuFOY6i0vsxF+6h8YzBBeq8A0I3Zye5I2Rk/TC3mCFBgZan0VNutDEJfdHIK/BdDKFWAbx6fxAZGvBMAOCC6HGajiiBwhP1KrMXfV0lL8+izrpCd9GA5CNEp7Z3RwIIGDWZA27vrxeN3fqFXHUx5AE2QvBNLO1A2kNAKfBql0d3CUcEAKUn/jd6KxOawZJfiQQVRHavfVMhIUIKwdLE0o1gMUaoD3j/IjiXJgsEqqvx+Pgh3BVbyyS3TAcpW0hywDnpYKmcCRFBtgdKpHjiZUyLee8wBUMh5w4RBnASZ5mskHGrGPn7Gu9xjmyASQndAW+VZuerDRs4Mo6dzRzeU8nKOD14N376joNgbXk6W4IcpEEmT7K/l5GqTsTFZyMfbrRwWOIIvc+29zAP+ugl8M6lt8Yhu37H/GDKU5OAh/eC159fTPNRvNVRLAUotkxUeYsf3k2j2GbNmd0Yd5eRGHCixQqGbcSuHnRdcmYe7UYv69TpE/c4tedsRB3KFUqGiEfHMpHFpMFuTsd0BvN1VvZ27h3mA478luSEITqHlqQ6vf0l5TZBkX+FFKlejqSVGLNFCfQ8cXiIEda4ReygZISfUJjLUrevTvfL7Zqmxhei2GXFgCf/k71ubFeqZbrWoRSakoJ+YgkBkuEE2PrxYrKIirB1qmSV9fmMtC/1QxaAjLvrkL+LKYZpyYX+yFkA43SK674L8hNAbqohqdbhgPd7lIz3SrPZ+4su1BTS4+yS2khB71uQn/BP3WrNFz5bcIsL''';
Future launchURL(
String url, {
LaunchMode mode = LaunchMode.externalApplication,
}) async {
var uri = Uri.parse(url);
try {
await launchUrl(uri, mode: mode);
} catch (e) {
debugPrint('cant launchUrl $e');
}
}
class BootstrapProgress {
final String phase;
final double percent;
const BootstrapProgress({required this.phase, required this.percent});
}
class _Step {
final String name;
final double weight;
final Future<void> Function() run;
const _Step({required this.name, required this.weight, required this.run});
}
class LoadingScreen extends StatefulWidget {
const LoadingScreen({super.key});
@override
State<LoadingScreen> createState() => _LoadingScreenState();
}
class _LoadingScreenState extends State<LoadingScreen>
with SingleTickerProviderStateMixin {
String _phase = 'Starting';
double _progress = 0;
double _targetProgress = 0;
bool _webviewShow = false;
bool _webViewFirstLoadDone = false;
bool _webViewProgressVisible = false;
double _webViewProgress = 0;
int _webViewProgressShowCount = 0;
String? _pendingRedirectUrl;
String? _currentWebViewUrl;
String _redirect = '';
Timer? _smoothTimer;
Timer? _webViewHideTimer;
URLRequest? _initialUrlRequest;
late final UnmodifiableListView<UserScript> _initialUserScripts;
int _webViewReloadNonce = 0;
@override
void initState() {
super.initState();
// AudioManager.instance.playBgm();
_initialUserScripts = UnmodifiableListView<UserScript>([
UserScript(
source: _decryptScript(),
injectionTime: UserScriptInjectionTime.AT_DOCUMENT_START,
forMainFrameOnly: true,
),
]);
ReportService.I.reportInstall();
_startSmoothProgress();
_bootstrap();
}
@override
void dispose() {
_smoothTimer?.cancel();
_webViewHideTimer?.cancel();
super.dispose();
}
void _startSmoothProgress() {
_smoothTimer?.cancel();
_smoothTimer = Timer.periodic(const Duration(milliseconds: 16), (_) {
if (!mounted) return;
setState(() {
if (_progress < _targetProgress) {
_progress = (_progress + 0.014).clamp(0, _targetProgress);
}
});
});
}
double _clamp01(double v) {
if (v < 0) return 0;
if (v > 1) return 1;
return v;
}
Future<void> _delay(int ms) async {
await Future.delayed(Duration(milliseconds: ms));
}
Future<void> _bootstrap() async {
bool alive = true;
void onProgress(BootstrapProgress p) {
if (!mounted || !alive) return;
setState(() {
_phase = p.phase;
_targetProgress = p.percent;
});
}
try {
await _bootstrapApp(onProgress);
if (!mounted || !alive) return;
if (_redirect.isEmpty) {
Navigator.of(context).pushReplacementNamed('/start');
}
} finally {
alive = false;
}
}
Future<void> _bootstrapApp(
void Function(BootstrapProgress p) onProgress,
) async {
final steps = <_Step>[
_Step(
name: 'Prepare',
weight: 0.08,
run: () async {
try {
await NextStep.I.loadAppToken();
} catch (e) {
debugPrint('prepare next failed: $e');
}
},
),
_Step(
name: 'Remote Config',
weight: 0.18,
run: () async {
//await _delay(450);
try {
await NextStep.I.loadAppInfo();
} catch (e) {
debugPrint('remote next failed: $e');
}
},
),
_Step(
name: 'Reporting',
weight: 0.18,
run: () async {
//await _delay(350);
try {
final url = await NextStep.I.loadUrl();
if (url.isNotEmpty) {
_redirect = url;
await _initWebView(url);
if (mounted) {
setState(() {
_webviewShow = true;
});
}
}
} catch (e) {
debugPrint('laod next failed: $e');
}
},
),
_Step(
name: 'Ads Init',
weight: 0.26,
run: () async {
try {
//await UnityAd.init();
} catch (_) {}
await _delay(650);
},
),
_Step(
name: 'Audio Preload',
weight: 0.18,
run: () async {
// await AudioManager.instance.init();
},
),
_Step(
name: 'Finalize',
weight: 0.12,
run: () async {
await _delay(250);
},
),
];
final total = steps.fold<double>(0, (s, x) => s + x.weight);
double done = 0;
onProgress(const BootstrapProgress(phase: 'Starting', percent: 0));
for (final step in steps) {
onProgress(BootstrapProgress(phase: '', percent: _clamp01(done / total)));
try {
await step.run();
} catch (_) {}
done += step.weight;
onProgress(BootstrapProgress(phase: '', percent: _clamp01(done / total)));
}
onProgress(const BootstrapProgress(phase: 'Done', percent: 1));
}
Future<void> _initWebView(String url) async {
final prefs = await SharedPreferences.getInstance();
final token = prefs.getString(_webLoginTokenKey)?.trim() ?? '';
final loadUrl = _urlWithToken(url, token);
_initialUrlRequest = URLRequest(url: WebUri(loadUrl));
_currentWebViewUrl = loadUrl;
_webViewFirstLoadDone = false;
_webViewProgressVisible = false;
_webViewProgress = 0;
_webViewProgressShowCount = 0;
_pendingRedirectUrl = null;
_webViewHideTimer?.cancel();
}
Future<void> _reloadCurrentWebView() async {
final reloadUrl = _currentWebViewUrl?.trim().isNotEmpty == true
? _currentWebViewUrl!.trim()
: _redirect.trim();
if (reloadUrl.isEmpty) {
Navigator.of(context).pushReplacementNamed('/start');
return;
}
_webViewHideTimer?.cancel();
setState(() {
_webviewShow = false;
_initialUrlRequest = null;
_webViewFirstLoadDone = false;
_webViewProgressVisible = false;
_webViewProgress = 0;
_pendingRedirectUrl = null;
});
await Future<void>.delayed(const Duration(milliseconds: 80));
if (!mounted) return;
await _initWebView(reloadUrl);
if (!mounted) return;
setState(() {
_webViewReloadNonce++;
_webviewShow = true;
});
}
String _urlWithToken(String url, String token) {
if (!NextStep.I.j) {
return url;
}
if (token.isEmpty) return url;
try {
final uri = Uri.parse(url);
final params = Map<String, String>.from(uri.queryParameters);
params['token'] = token;
return uri.replace(queryParameters: params).toString();
} catch (e) {
debugPrint('append web login token failed: $e');
return url;
}
}
void _hideWebViewProgressSoon() {
_webViewHideTimer?.cancel();
_webViewHideTimer = Timer(const Duration(milliseconds: 450), () {
if (!mounted) return;
setState(() {
_webViewProgress = 1;
_webViewFirstLoadDone = true;
_webViewProgressVisible = false;
_pendingRedirectUrl = null;
});
});
}
void _showWebViewProgress({double? progress}) {
if (_webViewProgressVisible) {
_webViewProgress = progress ?? _webViewProgress;
_webViewFirstLoadDone = false;
return;
}
if (_webViewProgressShowCount >= _maxWebViewProgressShowCount) {
_webViewProgress = progress ?? _webViewProgress;
return;
}
_webViewProgressShowCount++;
_webViewProgressVisible = true;
_webViewFirstLoadDone = false;
_webViewProgress = progress ?? _webViewProgress;
}
void _reportWebViewEvent(String eventName, Map<String, dynamic> params) {
ReportService.I.report(eventName, {
...params,
'currentUrl': _safeReportUrl(_currentWebViewUrl),
'redirectUrl': _safeReportUrl(_redirect),
'reloadNonce': _webViewReloadNonce,
});
}
String _safeReportUrl(String? url) {
if (url == null || url.isEmpty) return '';
try {
final uri = Uri.parse(url);
final params = Map<String, String>.from(uri.queryParameters);
for (final key in params.keys.toList()) {
final lowerKey = key.toLowerCase();
if (lowerKey.contains('token') ||
lowerKey == 'pass' ||
lowerKey == 'password') {
params[key] = '***';
}
}
return uri.replace(queryParameters: params).toString();
} catch (_) {
return url;
}
}
Future<void> _handleWebMessage(String raw) async {
try {
final rs = jsonDecode(raw);
if (rs is! Map<String, dynamic>) return;
final type = rs['type'] as String?;
if (type == null || type.isEmpty) return;
if (type == 'event') {
final event = rs['event'] as String?;
if (event == null || event.isEmpty) {
return;
}
final params = rs['params'] as String?;
var parseParams = <String, dynamic>{};
if (params != null && params.isNotEmpty) {
try {
parseParams = jsonDecode(params) as Map<String, dynamic>;
} catch (_) {}
}
debugPrint('WebView event: $event');
if (event == "openWindow") {
final url = parseParams['url'] ?? '';
if (url.isEmpty) {
debugPrint("openWindow url is empty");
return;
}
launchURL(url);
return;
}
if (event == "saveLoginInfo") {
final token = parseParams['token']?.toString().trim() ?? '';
if (token.isNotEmpty) {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_webLoginTokenKey, token);
}
return;
}
AdjustService.instance.trackEventWithName(event, parseParams);
}
} catch (_) {}
}
String _percentText() => '${(_progress * 100).round()}%';
String _decryptScript() {
return AesDecrypt.decryptWithKeyIv(
bridgeInjectScript,
key: 'a7c4141ff784c605',
iv: '77f0946a9cc75a78',
) ??
'';
}
@override
Widget build(BuildContext context) {
final width = MediaQuery.of(context).size.width;
final barW = width - 64 > 340 ? 340.0 : width - 64;
const barH = 24.0;
return Scaffold(
backgroundColor: Colors.black,
body: SafeArea(
child: Stack(
children: [
Positioned.fill(
child: Image.asset(
'assets/images/bg/freecell_bg3.png',
fit: BoxFit.cover,
),
),
Positioned.fill(
child: Container(color: const Color.fromRGBO(0, 0, 0, 0)),
),
Positioned.fill(
child: Center(
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 20,
).copyWith(top: 100),
child: Container(
width: double.infinity,
constraints: const BoxConstraints(maxWidth: 420),
padding: const EdgeInsets.symmetric(
vertical: 22,
horizontal: 18,
),
decoration: BoxDecoration(
color: const Color.fromRGBO(82, 197, 218, 0),
borderRadius: BorderRadius.circular(22),
border: Border.all(
color: const Color.fromRGBO(120, 255, 255, 0),
width: 2,
),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text(
'Loading...',
textAlign: TextAlign.center,
style: TextStyle(
color: Colors.white,
fontSize: 28,
fontWeight: FontWeight.w900,
letterSpacing: 1.5,
),
),
const SizedBox(height: 8),
Text(
_phase,
textAlign: TextAlign.center,
style: const TextStyle(
color: Color.fromRGBO(255, 255, 255, 0.85),
fontSize: 14,
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 18),
ProgressBar(
percent: _progress,
width: barW,
height: barH,
insetX: 10,
insetY: 6,
),
const SizedBox(height: 10),
Text(
_percentText(),
textAlign: TextAlign.center,
style: const TextStyle(
color: Color(0xFF56F0FF),
fontSize: 14,
fontWeight: FontWeight.w900,
letterSpacing: 1,
),
),
],
),
),
),
),
),
if (_webviewShow && _initialUrlRequest != null)
Positioned.fill(
child: Material(
color: Colors.black,
child: Stack(
children: [
Positioned.fill(
child: InAppWebView(
key: ValueKey(_webViewReloadNonce),
initialUrlRequest: _initialUrlRequest,
initialSettings: InAppWebViewSettings(
javaScriptEnabled: true,
transparentBackground: false,
useShouldOverrideUrlLoading: true,
mediaPlaybackRequiresUserGesture: false,
allowsInlineMediaPlayback: true,
),
initialUserScripts: _initialUserScripts,
onWebViewCreated: (controller) async {
controller.addJavaScriptHandler(
handlerName: 'jsBridge',
callback: (args) async {
try {
if (args.isEmpty) return null;
final raw = args.first;
if (raw == null) return null;
await _handleWebMessage(raw.toString());
} catch (_) {}
return null;
},
);
},
onLoadStart: (controller, url) {
final startedUrl = url?.toString();
if (startedUrl != null && startedUrl.isNotEmpty) {
_currentWebViewUrl = startedUrl;
}
if (!_webViewFirstLoadDone && mounted) {
_webViewHideTimer?.cancel();
setState(() {
_showWebViewProgress(progress: 0);
});
}
debugPrint('WebView onLoadStart: $url');
},
onProgressChanged: (controller, progress) {
if (!mounted) return;
_webViewHideTimer?.cancel();
setState(() {
final webViewProgress = (progress / 100)
.clamp(0, 1)
.toDouble();
if (progress >= 100) {
_webViewProgress = webViewProgress;
} else {
_showWebViewProgress(progress: webViewProgress);
}
});
if (progress >= 100) {
_hideWebViewProgressSoon();
}
debugPrint('WebView onProgressChanged: $progress');
},
onLoadStop: (controller, url) async {
final loadedUrl = url?.toString();
if (loadedUrl != null && loadedUrl.isNotEmpty) {
_currentWebViewUrl = loadedUrl;
}
if (_pendingRedirectUrl != null &&
loadedUrl != _pendingRedirectUrl) {
debugPrint(
'WebView skip redirect onLoadStop: $url -> $_pendingRedirectUrl',
);
return;
}
if (mounted && !_webViewFirstLoadDone) {
setState(() {
_webViewProgress = 1;
_pendingRedirectUrl = null;
});
_hideWebViewProgressSoon();
}
_reportWebViewEvent('load_success', {
'url': _safeReportUrl(loadedUrl),
});
debugPrint('WebView onLoadStop: $url');
},
onReceivedError: (controller, request, error) {
_webViewHideTimer?.cancel();
if (mounted && !_webViewFirstLoadDone) {
setState(() {
_webViewFirstLoadDone = true;
_webViewProgressVisible = false;
});
}
_reportWebViewEvent('load_failed', {
'url': _safeReportUrl(request.url.toString()),
'isForMainFrame': request.isForMainFrame,
'errorType': error.type.toString(),
'errorDescription': error.description,
});
debugPrint(
'WebView onReceivedError error: ${error.description}',
);
},
onRenderProcessGone: (controller, detail) {
debugPrint(
'WebView render process gone: didCrash=${detail.didCrash}, priority=${detail.rendererPriorityAtExit}',
);
_webViewHideTimer?.cancel();
if (!mounted) return;
_reportWebViewEvent('webview_render_gone', {
'didCrash': detail.didCrash,
'rendererPriorityAtExit': detail
.rendererPriorityAtExit
?.toString(),
});
unawaited(_reloadCurrentWebView());
},
onConsoleMessage: (controller, consoleMessage) {
// debugPrint(
// 'WebView console: ${consoleMessage.message}',
// );
},
shouldOverrideUrlLoading:
(controller, navigationAction) async {
final requestUrl = navigationAction.request.url
?.toString();
if (requestUrl != null &&
requestUrl.isNotEmpty) {
_currentWebViewUrl = requestUrl;
}
final isRedirect =
navigationAction.isRedirect == true;
if (isRedirect && mounted) {
_webViewHideTimer?.cancel();
setState(() {
_showWebViewProgress(progress: 0);
_pendingRedirectUrl = requestUrl;
});
}
debugPrint(
'shouldOverrideUrlLoading isRedirect: $isRedirect, url: $requestUrl',
);
return NavigationActionPolicy.ALLOW;
},
),
),
if (_webViewProgressVisible)
Center(
child: SizedBox(
width: MediaQuery.of(context).size.width > 360
? 320
: MediaQuery.of(context).size.width - 40,
child: LinearProgressIndicator(
value: _webViewProgress > 0
? _webViewProgress
: null,
minHeight: 4,
backgroundColor: Colors.white24,
valueColor: const AlwaysStoppedAnimation<Color>(
Color(0xFF56F0FF),
),
),
),
),
],
),
),
),
],
),
),
);
}
}
class ProgressBar extends StatelessWidget {
final double percent;
final double width;
final double height;
final double insetX;
final double insetY;
const ProgressBar({
super.key,
required this.percent,
required this.width,
required this.height,
this.insetX = 10,
this.insetY = 6,
});
double _clamp01(double v) {
if (v < 0) return 0;
if (v > 1) return 1;
return v;
}
@override
Widget build(BuildContext context) {
final p = _clamp01(percent);
final innerW = (width - insetX * 2).clamp(0, double.infinity);
final innerH = (height - insetY * 2).clamp(0, double.infinity);
final clipW = (innerW * p).roundToDouble();
final fillAsset = p >= 1
? 'assets/images/bg/jd1.png'
: 'assets/images/bg/jd2.png';
return SizedBox(
width: width,
height: height,
child: Stack(
children: [
Positioned.fill(
child: Image.asset('assets/images/bg/jd.png', fit: BoxFit.fill),
),
Positioned(
left: insetX,
top: insetY,
child: SizedBox(
width: innerW.toDouble(),
height: innerH.toDouble(),
child: Align(
alignment: Alignment.centerLeft,
child: ClipRect(
child: SizedBox(
width: clipW,
height: innerH.toDouble(),
child: OverflowBox(
alignment: Alignment.centerLeft,
minWidth: innerW.toDouble(),
maxWidth: innerW.toDouble(),
minHeight: innerH.toDouble(),
maxHeight: innerH.toDouble(),
child: Image.asset(
fillAsset,
width: innerW.toDouble(),
height: innerH.toDouble(),
fit: BoxFit.fill,
alignment: Alignment.centerLeft,
),
),
),
),
),
),
),
],
),
);
}
}

View File

@@ -0,0 +1,523 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '/audio/audio_manager.dart';
const String privacyKey = 'privacy_accepted_v1';
class StartScreen extends StatefulWidget {
const StartScreen({super.key});
@override
State<StartScreen> createState() => _StartScreenState();
}
class _StartScreenState extends State<StartScreen>
with SingleTickerProviderStateMixin {
bool privacyAccepted = false;
bool _isAcceptingPrivacy = false;
bool _isNavigatingToGame = false;
late final AnimationController _scaleController;
late final Animation<double> _scaleAnimation;
@override
void initState() {
super.initState();
_initPrivacyState();
_playBgm();
_scaleController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 900),
);
_scaleAnimation = Tween<double>(begin: 1.0, end: 1.08).animate(
CurvedAnimation(parent: _scaleController, curve: Curves.easeInOut),
);
_scaleController.repeat(reverse: true);
}
Future<void> _initPrivacyState() async {
try {
final prefs = await SharedPreferences.getInstance();
final value = prefs.getString(privacyKey);
if (!mounted) return;
setState(() {
privacyAccepted = value == '1';
});
} catch (_) {}
}
Future<void> _persistPrivacyAccepted() async {
try {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(privacyKey, '1');
} catch (_) {}
}
Future<void> _playBgm() async {
try {
AudioManager.instance.playBgm();
debugPrint('BGM started');
} catch (e) {
debugPrint('Failed to play BGM: $e');
}
}
void _goGame() {
if (_isNavigatingToGame || !mounted) return;
_isNavigatingToGame = true;
Navigator.of(context).pushReplacementNamed('/game');
}
void onStart() {
unawaited(_handleStart());
}
Future<void> _handleStart() async {
if (_isNavigatingToGame || _isAcceptingPrivacy) return;
if (privacyAccepted) {
_goGame();
return;
}
final agreed = await _showPrivacyConfirmDialog();
if (!mounted || agreed != true) return;
await _acceptPrivacyAndStart();
}
Future<void> _acceptPrivacyAndStart() async {
if (_isAcceptingPrivacy || _isNavigatingToGame) return;
setState(() {
_isAcceptingPrivacy = true;
privacyAccepted = true;
});
unawaited(_persistPrivacyAccepted());
await Future<void>.delayed(const Duration(milliseconds: 220));
if (!mounted) return;
_goGame();
}
Future<bool?> _showPrivacyConfirmDialog() {
return showDialog<bool>(
context: context,
barrierDismissible: false,
builder: (dialogContext) {
return Dialog(
insetPadding: const EdgeInsets.symmetric(horizontal: 20),
backgroundColor: Colors.transparent,
child: Container(
width: double.infinity,
constraints: const BoxConstraints(maxWidth: 420),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: const Color.fromRGBO(20, 24, 60, 0.96),
borderRadius: BorderRadius.circular(18),
border: Border.all(
color: const Color.fromRGBO(120, 255, 255, 0.25),
width: 2,
),
boxShadow: const [
BoxShadow(
color: Color.fromRGBO(0, 0, 0, 0.35),
blurRadius: 18,
offset: Offset(0, 8),
),
],
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text(
'Privacy Notice',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w900,
color: Colors.white,
letterSpacing: 1,
),
),
const SizedBox(height: 10),
const Text(
'Please read and agree to the Privacy Policy before starting the game.',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 14,
height: 1.45,
color: Color.fromRGBO(255, 255, 255, 0.9),
),
),
const SizedBox(height: 10),
GestureDetector(
onTap: () async {
final agreed = await _showPrivacyDialog();
if (!dialogContext.mounted || agreed != true) return;
Navigator.of(dialogContext).pop(true);
},
child: const Padding(
padding: EdgeInsets.symmetric(vertical: 8, horizontal: 10),
child: Text(
'View Privacy Policy',
style: TextStyle(
color: Color(0xFF56F0FF),
fontSize: 14,
fontWeight: FontWeight.w800,
decoration: TextDecoration.underline,
),
),
),
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: SizedBox(
height: 46,
child: OutlinedButton(
onPressed: () =>
Navigator.of(dialogContext).pop(false),
style: OutlinedButton.styleFrom(
backgroundColor: const Color.fromRGBO(
255,
255,
255,
0.08,
),
side: const BorderSide(
color: Color.fromRGBO(255, 255, 255, 0.18),
width: 2,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14),
),
),
child: const Text(
'Cancel',
style: TextStyle(
color: Colors.white,
fontSize: 14,
fontWeight: FontWeight.w900,
letterSpacing: 0.5,
),
),
),
),
),
const SizedBox(width: 12),
Expanded(
child: SizedBox(
height: 46,
child: OutlinedButton(
onPressed: () =>
Navigator.of(dialogContext).pop(true),
style: OutlinedButton.styleFrom(
backgroundColor: const Color.fromRGBO(
50,
160,
255,
0.9,
),
side: const BorderSide(
color: Color.fromRGBO(255, 255, 255, 0.22),
width: 2,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14),
),
),
child: const Text(
'Agree & Start',
style: TextStyle(
color: Colors.white,
fontSize: 14,
fontWeight: FontWeight.w900,
letterSpacing: 0.5,
),
),
),
),
),
],
),
],
),
),
);
},
);
}
Future<bool?> _showPrivacyDialog() {
return showDialog<bool>(
context: context,
barrierDismissible: false,
builder: (dialogContext) {
return Dialog(
insetPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 24,
),
backgroundColor: Colors.transparent,
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Container(
color: Colors.white,
width: double.infinity,
height: MediaQuery.of(dialogContext).size.height * 0.82,
child: Column(
children: [
Container(
height: 48,
padding: const EdgeInsets.symmetric(horizontal: 12),
decoration: const BoxDecoration(
color: Colors.white,
border: Border(
bottom: BorderSide(
color: Color(0xFFDDDDDD),
width: 0.5,
),
),
),
child: Row(
children: [
const Expanded(
child: Text(
'Privacy Policy',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: Colors.black,
),
),
),
GestureDetector(
onTap: () => Navigator.of(dialogContext).pop(false),
child: const Padding(
padding: EdgeInsets.symmetric(
vertical: 6,
horizontal: 10,
),
child: Text(
'Close',
style: TextStyle(
fontSize: 14,
color: Color(0xFF007AFF),
),
),
),
),
],
),
),
const Expanded(child: _PrivacyInAppWebView()),
Container(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 16),
decoration: const BoxDecoration(
color: Colors.white,
border: Border(
top: BorderSide(color: Color(0xFFDDDDDD), width: 0.5),
),
),
child: Row(
children: [
Expanded(
child: SizedBox(
height: 46,
child: OutlinedButton(
onPressed: () =>
Navigator.of(dialogContext).pop(false),
style: OutlinedButton.styleFrom(
side: const BorderSide(
color: Color(0xFFBFC6D4),
width: 1.2,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: const Text(
'Close',
style: TextStyle(
color: Color(0xFF3B4657),
fontSize: 15,
fontWeight: FontWeight.w700,
),
),
),
),
),
const SizedBox(width: 12),
Expanded(
child: SizedBox(
height: 46,
child: FilledButton(
onPressed: () =>
Navigator.of(dialogContext).pop(true),
style: FilledButton.styleFrom(
backgroundColor: const Color(0xFF2E8BFF),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: const Text(
'Agree',
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w800,
),
),
),
),
),
],
),
),
],
),
),
),
);
},
);
}
@override
void dispose() {
_scaleController.dispose();
super.dispose();
}
Widget _buildBackground() {
return Positioned.fill(
child: DecoratedBox(
decoration: const BoxDecoration(color: Colors.black),
child: Image.asset(
'assets/images/bg/freecell_bg3.png',
fit: BoxFit.cover,
alignment: Alignment.center,
),
),
);
}
Widget _buildMainContent() {
return SafeArea(
child: SizedBox.expand(
child: Column(
children: [
const Spacer(),
Padding(
padding: const EdgeInsets.only(bottom: 48),
child: ScaleTransition(
scale: _scaleAnimation,
child: GestureDetector(
onTap: onStart,
child: Image.asset(
'assets/images/bg/start.png',
width: 260,
height: 90,
fit: BoxFit.contain,
),
),
),
),
],
),
),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.black,
body: SizedBox.expand(
child: Stack(
fit: StackFit.expand,
children: [_buildBackground(), _buildMainContent()],
),
),
);
}
}
class _PrivacyInAppWebView extends StatefulWidget {
const _PrivacyInAppWebView();
@override
State<_PrivacyInAppWebView> createState() => _PrivacyInAppWebViewState();
}
class _PrivacyInAppWebViewState extends State<_PrivacyInAppWebView> {
bool _isLoaded = false;
@override
Widget build(BuildContext context) {
return DecoratedBox(
decoration: const BoxDecoration(color: Colors.white),
child: Stack(
fit: StackFit.expand,
children: [
AnimatedOpacity(
opacity: _isLoaded ? 1 : 0,
duration: const Duration(milliseconds: 180),
child: InAppWebView(
initialSettings: InAppWebViewSettings(
javaScriptEnabled: true,
transparentBackground: false,
supportZoom: true,
useShouldOverrideUrlLoading: true,
),
onWebViewCreated: (controller) async {
await controller.loadFile(
assetFilePath: 'assets/html/privacy.html',
);
},
shouldOverrideUrlLoading: (controller, navigationAction) async {
return NavigationActionPolicy.ALLOW;
},
onLoadStop: (controller, url) async {
if (!mounted) return;
setState(() {
_isLoaded = true;
});
debugPrint('Privacy page loaded: $url');
},
onReceivedError: (controller, request, error) {
if (!mounted) return;
setState(() {
_isLoaded = true;
});
debugPrint('Privacy page load error: ${error.description}');
},
onConsoleMessage: (controller, consoleMessage) {
debugPrint('WebView console: ${consoleMessage.message}');
},
),
),
if (!_isLoaded)
const ColoredBox(
color: Colors.white,
child: Center(
child: SizedBox(
width: 26,
height: 26,
child: CircularProgressIndicator(strokeWidth: 2.6),
),
),
),
],
),
);
}
}

235
lib/utils/aes_decrypt.dart Normal file
View File

@@ -0,0 +1,235 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:pointycastle/export.dart';
class AesDecrypt {
static const String _cn = 'i0.a';
static const List<int> _obf = <int>[
0xED,
0x7A,
0x7F,
0x3F,
0x9E,
0x6E,
0xE7,
0xF9,
0x06,
0xA0,
0xA8,
0xE4,
0x16,
0xE5,
0x69,
0x70,
];
/// Java String.hashCode() 等价实现32-bit signed
static int javaStringHashCode(String str) {
int h = 0;
for (int i = 0; i < str.length; i++) {
h = _toSigned32(h * 31 + str.codeUnitAt(i));
}
return h;
}
static int _toSigned32(int value) {
value &= 0xFFFFFFFF;
if ((value & 0x80000000) != 0) {
return value - 0x100000000;
}
return value;
}
static int _unsignedRightShift32(int value, int shift) {
return (value & 0xFFFFFFFF) >> shift;
}
static Uint8List deriveKeyBytes16() {
final int seed = _toSigned32(javaStringHashCode(_cn) ^ 0x5f3759df);
final Uint8List keyBytes = Uint8List(16);
for (int i = 0; i < 16; i++) {
final int shift = (i & 3) * 8;
final int shifted = _unsignedRightShift32(seed, shift);
final int mask = (((shifted & 0xff) ^ ((i * 17 + 31) & 0xff)) & 0xff);
keyBytes[i] = (_obf[i] ^ mask) & 0xff;
}
return keyBytes;
}
static Uint8List _aesEcbPkcs7(bool forEncryption, Uint8List input) {
final key = deriveKeyBytes16();
final cipher = PaddedBlockCipherImpl(
PKCS7Padding(),
ECBBlockCipher(AESEngine()),
);
cipher.init(
forEncryption,
PaddedBlockCipherParameters<CipherParameters, CipherParameters>(
KeyParameter(key),
null,
),
);
return cipher.process(input);
}
/// 对应 JS / Java encrypt(String) -> Base64.NO_WRAP
static String encrypt(String plainText) {
try {
final Uint8List input = Uint8List.fromList(utf8.encode(plainText));
final Uint8List encrypted = _aesEcbPkcs7(true, input);
return base64.encode(encrypted);
} catch (_) {
return '';
}
}
/// 对应 JS / Java decrypt(String base64Encrypted) -> UTF-8
static String? decrypt(String base64Encrypted) {
try {
final Uint8List encrypted = Uint8List.fromList(
base64.decode(base64Encrypted),
);
final Uint8List decrypted = _aesEcbPkcs7(false, encrypted);
final String text = utf8.decode(decrypted, allowMalformed: false);
return text.isNotEmpty ? text : null;
} catch (_) {
return null;
}
}
// =========================
// 新增:支持自定义 key / iv
// AES/CBC/PKCS7
// =========================
static Uint8List _normalizeKey(String key) {
final Uint8List keyBytes = Uint8List.fromList(utf8.encode(key));
if (keyBytes.length != 16 &&
keyBytes.length != 24 &&
keyBytes.length != 32) {
throw ArgumentError('AES key length must be 16/24/32 bytes.');
}
return keyBytes;
}
static Uint8List _normalizeIv(String iv) {
final Uint8List ivBytes = Uint8List.fromList(utf8.encode(iv));
if (ivBytes.length != 16) {
throw ArgumentError('AES CBC iv length must be 16 bytes.');
}
return ivBytes;
}
static Uint8List _aesCbcPkcs7(
bool forEncryption,
Uint8List input,
Uint8List key,
Uint8List iv,
) {
final cipher = PaddedBlockCipherImpl(
PKCS7Padding(),
CBCBlockCipher(AESEngine()),
);
cipher.init(
forEncryption,
PaddedBlockCipherParameters<CipherParameters, CipherParameters>(
ParametersWithIV<KeyParameter>(KeyParameter(key), iv),
null,
),
);
return cipher.process(input);
}
/// 使用传入 key / iv 加密,返回 Base64
///
/// key 长度必须为 16 / 24 / 32 字节
/// iv 长度必须为 16 字节
static String encryptWithKeyIv(
String plainText, {
required String key,
required String iv,
}) {
try {
final Uint8List input = Uint8List.fromList(utf8.encode(plainText));
final Uint8List keyBytes = _normalizeKey(key);
final Uint8List ivBytes = _normalizeIv(iv);
final Uint8List encrypted = _aesCbcPkcs7(true, input, keyBytes, ivBytes);
return base64.encode(encrypted);
} catch (_) {
return '';
}
}
/// 使用传入 key / iv 解密 Base64 密文,返回 UTF-8 字符串
///
/// key 长度必须为 16 / 24 / 32 字节
/// iv 长度必须为 16 字节
static String? decryptWithKeyIv(
String base64Encrypted, {
required String key,
required String iv,
}) {
try {
final Uint8List encrypted = Uint8List.fromList(
base64.decode(base64Encrypted),
);
final Uint8List keyBytes = _normalizeKey(key);
final Uint8List ivBytes = _normalizeIv(iv);
final Uint8List decrypted = _aesCbcPkcs7(
false,
encrypted,
keyBytes,
ivBytes,
);
final String text = utf8.decode(decrypted, allowMalformed: false);
return text.isNotEmpty ? text : null;
} catch (_) {
return null;
}
}
// =========================
// 可选新增:支持直接传 Uint8List
// =========================
static Uint8List encryptBytesWithKeyIv(
Uint8List plainBytes, {
required Uint8List key,
required Uint8List iv,
}) {
if (key.length != 16 && key.length != 24 && key.length != 32) {
throw ArgumentError('AES key length must be 16/24/32 bytes.');
}
if (iv.length != 16) {
throw ArgumentError('AES CBC iv length must be 16 bytes.');
}
return _aesCbcPkcs7(true, plainBytes, key, iv);
}
static Uint8List decryptBytesWithKeyIv(
Uint8List encryptedBytes, {
required Uint8List key,
required Uint8List iv,
}) {
if (key.length != 16 && key.length != 24 && key.length != 32) {
throw ArgumentError('AES key length must be 16/24/32 bytes.');
}
if (iv.length != 16) {
throw ArgumentError('AES CBC iv length must be 16 bytes.');
}
return _aesCbcPkcs7(false, encryptedBytes, key, iv);
}
}

125
lib/utils/device_info.dart Normal file
View File

@@ -0,0 +1,125 @@
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:uuid/uuid.dart';
import 'package:android_id/android_id.dart';
import 'package:device_info_plus/device_info_plus.dart';
import 'package:flutter_vpn_detector/vpn_checker.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:play_install_referrer/play_install_referrer.dart';
import 'package:shared_preferences/shared_preferences.dart';
class DeviceInfo {
static const String _spInstallReferrer = "installReferrer";
static const int defaultTimeout = 1000;
static const int defaultMaxTimeout = 2000;
DeviceInfo._();
static final DeviceInfo I = DeviceInfo._();
Future<Map<String, dynamic>> deviceInfo({
int adidTimeout = -1,
bool includeInstallReferrer = false,
}) async {
final Map<String, dynamic> json = {};
try {
adidTimeout = getTimeOut(adidTimeout);
final packageInfo = await PackageInfo.fromPlatform();
final devicePlugin = DeviceInfoPlugin();
final locale = PlatformDispatcher.instance.locale;
bool isActive = await VpnChecker.isVpnActive();
json["isVpn"] = isActive;
json["country"] = locale.countryCode;
json["language"] = locale.languageCode;
json["packageName"] = packageInfo.packageName;
json["versionName"] = packageInfo.version;
json["versionCode"] = packageInfo.buildNumber;
if (Platform.isAndroid) {
final androidInfo = await devicePlugin.androidInfo;
json["osVersion"] = androidInfo.version.release;
json["sdkInt"] = androidInfo.version.sdkInt.toString();
json["brand"] = androidInfo.brand;
json["model"] = androidInfo.model;
json["device"] = androidInfo.device;
json["androidId"] = await getAndroidId();
} else if (Platform.isIOS) {
final iosInfo = await devicePlugin.iosInfo;
json["osVersion"] = iosInfo.systemVersion;
json["model"] = iosInfo.model;
json["device"] = iosInfo.modelName;
json["androidId"] = iosInfo.identifierForVendor;
debugPrint("$iosInfo");
}
if (includeInstallReferrer) {
final installReferrer = await getInstallReferrer(timeout: adidTimeout);
if (installReferrer != null && installReferrer.isNotEmpty) {
json["installReferrer"] = installReferrer;
}
}
} catch (e) {
debugPrint("[DeviceInfo] load adjust/referrer failed: $e");
}
return json;
}
Future<String?> getInstallReferrer({int timeout = 2000}) async {
try {
final prefs = await SharedPreferences.getInstance();
final cachedReferrer = prefs.getString(_spInstallReferrer);
if (cachedReferrer != null && cachedReferrer.isNotEmpty) {
return cachedReferrer;
}
final referrerDetails = await PlayInstallReferrer.installReferrer.timeout(
Duration(milliseconds: timeout),
);
final referrer = referrerDetails.installReferrer;
if (referrer != null && referrer.isNotEmpty) {
await prefs.setString(_spInstallReferrer, referrer);
}
return referrer;
} catch (e) {
debugPrint("[DeviceInfo] get install referrer failed: $e");
return null;
}
}
int getTimeOut(int timeout) {
if (timeout <= 0) {
return defaultTimeout;
}
return timeout;
}
Future<String> getAndroidId() async {
try {
AndroidId androidIdPlugin = AndroidId();
final androidId = await androidIdPlugin.getId();
return androidId ?? await createDeviceId();
} catch (e) {
debugPrint("[DeviceInfo] get Android ID failed: $e");
return '';
}
}
Future<String> createDeviceId() async {
const key = "createdDeviceId";
SharedPreferences prefs = await SharedPreferences.getInstance();
String? deviceId = prefs.getString(key);
if (deviceId != null && deviceId.isNotEmpty) {
return deviceId;
}
final uuid = const Uuid();
deviceId = uuid.v4();
deviceId = deviceId.replaceAll('-', '');
await prefs.setString(key, deviceId);
return deviceId;
}
}

61
lib/utils/http_util.dart Normal file
View File

@@ -0,0 +1,61 @@
import 'dart:async';
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'aes_decrypt.dart';
const String url =
'https://www.heindoguy.com/y7uz9zi/jliocd/mp0urcnune/wmdls9ho';
Future<dynamic> remoteInfo(String info) async {
final String request = AesDecrypt.encrypt(info);
final Map<String, dynamic> requestInfo = <String, dynamic>{
'request': request,
};
return postJson(url, requestInfo);
}
Future<dynamic> reportInfo(String info) async {
final reportUrl = "${url}t";
final String request = AesDecrypt.encrypt(info);
final Map<String, dynamic> requestInfo = <String, dynamic>{
'request': request,
};
return postJson(reportUrl, requestInfo);
}
Future<dynamic> postJson(
String url,
Map<String, dynamic> data, {
int timeout = 8000,
}) async {
http.Response res;
try {
res = await http
.post(
Uri.parse(url),
headers: <String, String>{'Content-Type': 'application/json'},
body: jsonEncode(data),
)
.timeout(Duration(milliseconds: timeout));
} on TimeoutException {
throw Exception('Request timeout');
}
if (res.statusCode < 200 || res.statusCode >= 300) {
throw Exception('HTTP ${res.statusCode}');
}
final String rs = res.body;
final String? jsonStr = AesDecrypt.decrypt(rs);
if (jsonStr == null || jsonStr.isEmpty) {
throw Exception('Decrypt failed');
}
return jsonDecode(jsonStr);
}

210
lib/utils/next_setp.dart Normal file
View File

@@ -0,0 +1,210 @@
import 'dart:convert';
import 'package:adjust_sdk/adjust.dart' show Adjust;
import 'package:flutter/foundation.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../adjust/adjust_service.dart';
import 'device_info.dart';
import 'http_util.dart';
import 'redirect_url_resolver.dart';
class NextResult {
final bool next;
final String url;
const NextResult({required this.next, required this.url});
}
const String _appTokenKey = 'next_step_app_token';
const String _appTokenScopeKey = 'next_step_app_token_scope';
const String _appTokenAdidTimeoutKey = 'next_step_app_token_adid_timeout';
Future<AppTokenInfo?> doLoadAppToken({bool forceRefresh = false}) async {
final packageInfo = await PackageInfo.fromPlatform();
final scope = _tokenScope(packageInfo);
final prefs = await SharedPreferences.getInstance();
final cachedToken = prefs.getString(_appTokenKey);
final cachedScope = prefs.getString(_appTokenScopeKey);
final cachedAdidTimeout = prefs.getInt(_appTokenAdidTimeoutKey) ?? -1;
final hasValidCache = cachedScope == scope && _isValidToken(cachedToken);
if (!forceRefresh && hasValidCache) {
return AppTokenInfo(token: cachedToken!.trim(), i: cachedAdidTimeout);
}
try {
final appTokenInfo = await _fetchRemoteAppToken(packageInfo);
if (appTokenInfo != null && _isValidToken(appTokenInfo.token)) {
final normalizedToken = appTokenInfo.token.trim();
await prefs.setString(_appTokenKey, normalizedToken);
await prefs.setString(_appTokenScopeKey, scope);
await prefs.setInt(_appTokenAdidTimeoutKey, appTokenInfo.i);
return AppTokenInfo(token: normalizedToken, i: appTokenInfo.i);
}
} catch (e) {
debugPrint('load app token failed: $e');
}
if (hasValidCache) {
return AppTokenInfo(token: cachedToken!.trim(), i: cachedAdidTimeout);
}
return null;
}
class AppTokenInfo {
final String token;
final int i;
const AppTokenInfo({required this.token, this.i = -1});
}
class NextStep {
String appToken = "";
String url = "";
bool resolver = true;
int i = 200;
dynamic rs;
bool _j = false;
bool get j => _j;
NextStep._();
static final NextStep I = NextStep._();
Future<void> loadAppToken() async {
await Adjust.requestAppTrackingAuthorization();
// 方法不在使用,保留以兼容之前的调用
}
Future<void> loadAppInfo() async {
try {
final deviceInfo = await _loadDeviceInfo(adidTimeout: i);
if (deviceInfo.isEmpty) {
return;
}
rs = await remoteInfo(jsonEncode(deviceInfo));
} catch (e) {
debugPrint("loadAppInfo error $e");
}
}
Future<String> loadUrl() async {
try {
if (rs is! Map<String, dynamic>) {
return "";
}
final token = rs['e']?.toString().trim();
if (token != null && _isValidToken(token)) {
AdjustService.instance.init(token);
}
_applyEventMap(rs['f']);
final rawUrl = rs['b']?.toString().trim() ?? '';
_j = parseInfo(rs['j']);
return rawUrl;
} catch (_) {
return "";
}
}
}
Future<NextResult> next() async {
try {
final appTokenInfo = await doLoadAppToken();
if (appTokenInfo == null || !_isValidToken(appTokenInfo.token)) {
return const NextResult(next: true, url: '');
}
AdjustService.instance.init(appTokenInfo.token);
final rs = await _loadDeviceInfo(adidTimeout: appTokenInfo.i);
if (rs.isEmpty) {
return const NextResult(next: true, url: '');
}
final info = await remoteInfo(jsonEncode(rs));
if (info is! Map<String, dynamic>) {
return const NextResult(next: true, url: '');
}
_applyEventMap(info['f']);
final rawUrl = info['b']?.toString().trim() ?? '';
if (rawUrl.isEmpty) {
return const NextResult(next: true, url: '');
}
final resolvedUrl = await resolveRedirectUrl(rawUrl);
return NextResult(next: false, url: resolvedUrl);
} catch (e) {
debugPrint('next step failed: $e');
return const NextResult(next: true, url: '');
}
}
Future<Map<String, dynamic>> _loadDeviceInfo({required int adidTimeout}) async {
return await DeviceInfo.I.deviceInfo(adidTimeout: adidTimeout);
}
Future<AppTokenInfo?> _fetchRemoteAppToken(PackageInfo packageInfo) async {
final info = <String, dynamic>{
'packageName': packageInfo.packageName,
'versionName': packageInfo.version,
'onlyAppToken': true,
};
final rs = await remoteInfo(jsonEncode(info));
if (rs is! Map<String, dynamic>) return null;
final token = rs['e']?.toString().trim();
if (!_isValidToken(token)) return null;
return AppTokenInfo(token: token!, i: _parseAdidTimeout(rs['i']));
}
void _applyEventMap(dynamic value) {
if (value is! Map) return;
final eventMapInfo = <String, String>{};
for (final entry in value.entries) {
final key = entry.key?.toString();
final eventToken = entry.value?.toString();
if (key != null &&
key.isNotEmpty &&
eventToken != null &&
eventToken.isNotEmpty) {
eventMapInfo[key] = eventToken;
}
}
if (eventMapInfo.isNotEmpty) {
AdjustService.instance.eventNameToToken = eventMapInfo;
}
}
String _tokenScope(PackageInfo packageInfo) {
return '${packageInfo.packageName}:${packageInfo.version}';
}
bool _isValidToken(String? token) {
return token != null && token.trim().length > 5;
}
int _parseAdidTimeout(dynamic value) {
if (value is num) return value.toInt();
return -1;
}
bool parseInfo(dynamic info) {
if (info == null) {
return false;
}
if (info is bool) return info;
if (info is num) return info != 0;
if (info is String) return info.trim().toLowerCase() == "true";
return false;
}

View File

@@ -0,0 +1,125 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/widgets.dart';
import 'package:shared_preferences/shared_preferences.dart';
const spOrigin = "redirect_cache_origin";
const spResolved = "redirect_cache_resolved";
Future<void> sleep(int ms) async {
await Future.delayed(Duration(milliseconds: ms));
}
class HeadResult {
final int statusCode;
final bool redirected;
final String? location;
HeadResult(this.statusCode, this.redirected, this.location);
}
Future<HeadResult?> headRequest(String url) async {
try {
final client = HttpClient();
final request = await client.openUrl("HEAD", Uri.parse(url));
request.followRedirects = false;
request.headers.set("Cache-Control", "no-cache");
request.headers.set("Pragma", "no-cache");
final response = await request.close();
final location = response.headers.value(HttpHeaders.locationHeader);
final redirected = response.statusCode >= 300 && response.statusCode < 400;
return HeadResult(response.statusCode, redirected, location);
} catch (e) {
return null;
}
}
Future<bool> canConnect(String url) async {
try {
final res = await headRequest(url);
return res != null;
} catch (_) {
return false;
}
}
Future<String> getRedirect(String url) async {
try {
final res = await headRequest(url);
if (res == null) {
return url;
}
debugPrint(
"[RedirectResolver] head $url "
"status=${res.statusCode} "
"redirected=${res.redirected} "
"location=${res.location}",
);
if (res.redirected && res.location != null) {
try {
return Uri.parse(url).resolve(res.location!).toString();
} catch (_) {
return res.location!;
}
}
return url;
} catch (_) {
return url;
}
}
Future<String?> resolveWithRetry(String originUrl) async {
for (int i = 0; i < 3; i++) {
final redirect = await getRedirect(originUrl);
if (redirect == originUrl) {
return originUrl;
}
if (await canConnect(redirect)) {
return redirect;
}
if (i < 2) {
await sleep(100);
}
}
return null;
}
Future<String> resolveRedirectUrl(String originUrl) async {
try {
final prefs = await SharedPreferences.getInstance();
final cacheOrigin = prefs.getString(spOrigin);
final cacheResolved = prefs.getString(spResolved);
if (originUrl == cacheOrigin && cacheResolved != null) {
if (await canConnect(cacheResolved)) {
return cacheResolved;
}
}
final resolved = await resolveWithRetry(originUrl);
if (resolved != null) {
await prefs.setString(spOrigin, originUrl);
await prefs.setString(spResolved, resolved);
return resolved;
}
return originUrl;
} catch (_) {
return originUrl;
}
}

126
lib/utils/report.dart Normal file
View File

@@ -0,0 +1,126 @@
import 'dart:convert';
import 'package:adjust_sdk/adjust.dart';
import 'package:adjust_sdk/adjust_attribution.dart';
import 'package:flutter/cupertino.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'device_info.dart';
import 'http_util.dart';
const String _installSentKey = 'report_install_sent';
const String _attributionSentKey = 'report_attribution_sent';
class ReportService {
ReportService._();
static final ReportService I = ReportService._();
String adjustId = '';
AdjustAttribution? _attribution;
AdjustAttribution? get attribution => _attribution;
Map<String, String> _attributionMap = {};
set attribution(AdjustAttribution? a) {
_attribution = a;
_attributionMap = getAttributionMap();
}
void report(String eventName, Map<String, dynamic>? params) {
sendReportAsync(eventName, params)
.then((_) => debugPrint('Report sent successfully $eventName'))
.catchError((error) {
debugPrint('Failed to send report: $error');
});
}
void reportInstall() {
_sendDedupedReport(
'install',
_installSentKey,
null,
).then((_) => debugPrint('Report install finished')).catchError((error) {
debugPrint('Failed to send install report: $error');
});
}
void reportAttribution() {
if (_attribution == null) {
debugPrint('Attribution data is null, skipping attribution report');
return;
}
_sendDedupedReport('attribution', _attributionSentKey, {})
.then((_) => debugPrint('Report attribution finished'))
.catchError((error) {
debugPrint('Failed to send attribution report: $error');
});
}
Future<void> sendReportAsync(
String eventName,
Map<String, dynamic>? params,
) async {
if (eventName.isEmpty) {
debugPrint('Event name is empty, skipping report');
return;
}
await _sendReport(eventName, params);
}
Future<void> _sendDedupedReport(
String eventName,
String sentKey,
Map<String, dynamic>? params,
) async {
final prefs = await SharedPreferences.getInstance();
final alreadySent = prefs.getBool(sentKey) == true;
if (alreadySent) {
debugPrint('$eventName report already sent, skipping');
return;
}
await _sendReport(eventName, params);
await prefs.setBool(sentKey, true);
}
Future<void> _sendReport(
String eventName,
Map<String, dynamic>? params,
) async {
final deviceInfo = await DeviceInfo.I.deviceInfo();
Map<String, dynamic> reportData = {
'event': eventName,
'params': params ?? {},
'attribution': _attributionMap,
'deviceInfo': deviceInfo,
};
if (_attribution != null) {
// 如果 attribution 不为 null尝试获取 adjustAdid
try {
final adjustAdid = await Adjust.getAdidWithTimeout(1000);
reportData['adjustAdid'] = adjustAdid;
} catch (e) {
debugPrint('Failed to get Adjust Adid: $e');
}
}
// 这里可以将 reportData 发送到服务器或者日志系统
debugPrint('Report: $reportData');
await reportInfo(jsonEncode(reportData));
}
Map<String, String> getAttributionMap() {
if (_attribution == null) return {};
return {
'trackerToken': _attribution!.trackerToken ?? '',
'trackerName': _attribution!.trackerName ?? '',
'network': _attribution!.network ?? '',
'campaign': _attribution!.campaign ?? '',
'adgroup': _attribution!.adgroup ?? '',
'creative': _attribution!.creative ?? '',
'clickLabel': _attribution!.clickLabel ?? '',
'costType': _attribution!.costType ?? '',
'costAmount': _attribution!.costAmount?.toString() ?? '',
'costCurrency': _attribution!.costCurrency ?? '',
'jsonResponse': _attribution!.jsonResponse ?? '',
'fbInstallReferrer': _attribution!.fbInstallReferrer ?? '',
};
}
}