ver1.00.00
bindQR fix
This commit is contained in:
@@ -3,6 +3,7 @@ import 'package:unionapp/pages/music/music_page.dart';
|
|||||||
import '../../service/recommendation_helper.dart';
|
import '../../service/recommendation_helper.dart';
|
||||||
import '../../service/song_service.dart';
|
import '../../service/song_service.dart';
|
||||||
import '../../tool/gradientText.dart';
|
import '../../tool/gradientText.dart';
|
||||||
|
import '../score/updateScorePage.dart';
|
||||||
import '../user/userpage.dart';
|
import '../user/userpage.dart';
|
||||||
import '../scorelist.dart';
|
import '../scorelist.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
@@ -182,7 +183,7 @@ class HomePage extends StatelessWidget {
|
|||||||
const SizedBox(width: 4),
|
const SizedBox(width: 4),
|
||||||
Expanded(
|
Expanded(
|
||||||
flex: 6,
|
flex: 6,
|
||||||
child: _buildQuickActionButtons(), // 快捷按钮组件
|
child: _buildQuickActionButtons(context), // 快捷按钮组件
|
||||||
),
|
),
|
||||||
const SizedBox(width: 10), // 左右间距
|
const SizedBox(width: 10), // 左右间距
|
||||||
],
|
],
|
||||||
@@ -195,7 +196,7 @@ class HomePage extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
// 右侧快捷按钮区域(你可以自由修改图标、文字、点击事件)
|
// 右侧快捷按钮区域(你可以自由修改图标、文字、点击事件)
|
||||||
Widget _buildQuickActionButtons() {
|
Widget _buildQuickActionButtons(BuildContext context) {
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
@@ -225,7 +226,12 @@ class HomePage extends StatelessWidget {
|
|||||||
end: Alignment.bottomRight,
|
end: Alignment.bottomRight,
|
||||||
),
|
),
|
||||||
color: Colors.pinkAccent.shade100,
|
color: Colors.pinkAccent.shade100,
|
||||||
onTap: () {},
|
onTap: () {
|
||||||
|
Navigator.push(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(builder: (context) => const BindAccountPage()),
|
||||||
|
);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
_quickActionItem(
|
_quickActionItem(
|
||||||
icon: Icons.stars_outlined,
|
icon: Icons.stars_outlined,
|
||||||
|
|||||||
@@ -7,29 +7,29 @@ import 'package:provider/provider.dart';
|
|||||||
|
|
||||||
import '../../providers/user_provider.dart';
|
import '../../providers/user_provider.dart';
|
||||||
|
|
||||||
class UpdateScorePage extends StatefulWidget {
|
class BindAccountPage extends StatefulWidget {
|
||||||
const UpdateScorePage({super.key});
|
const BindAccountPage({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<UpdateScorePage> createState() => _UpdateScorePageState();
|
State<BindAccountPage> createState() => _BindAccountPageState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _UpdateScorePageState extends State<UpdateScorePage> {
|
class _BindAccountPageState extends State<BindAccountPage> {
|
||||||
final TextEditingController _segaIdController = TextEditingController();
|
final TextEditingController _qrContentController = TextEditingController();
|
||||||
final ImagePicker _picker = ImagePicker();
|
final ImagePicker _picker = ImagePicker();
|
||||||
|
|
||||||
bool _isProcessing = false;
|
bool _isProcessing = false;
|
||||||
String? _statusMessage;
|
String? _statusMessage;
|
||||||
File? _selectedImageFile; // 用于预览
|
File? _selectedImageFile; // 用户选择的原始二维码截图
|
||||||
|
|
||||||
// 条码扫描器 (支持 QR Code, Data Matrix, Aztec 等)
|
// 条码扫描器
|
||||||
final BarcodeScanner _barcodeScanner = BarcodeScanner(
|
final BarcodeScanner _barcodeScanner = BarcodeScanner(
|
||||||
formats: [BarcodeFormat.qrCode, BarcodeFormat.dataMatrix, BarcodeFormat.aztec],
|
formats: [BarcodeFormat.qrCode],
|
||||||
);
|
);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_segaIdController.dispose();
|
_qrContentController.dispose();
|
||||||
_barcodeScanner.close();
|
_barcodeScanner.close();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
@@ -46,7 +46,6 @@ class _UpdateScorePageState extends State<UpdateScorePage> {
|
|||||||
_selectedImageFile = File(pickedFile.path);
|
_selectedImageFile = File(pickedFile.path);
|
||||||
});
|
});
|
||||||
|
|
||||||
// 执行二维码识别
|
|
||||||
final inputImage = InputImage.fromFilePath(pickedFile.path);
|
final inputImage = InputImage.fromFilePath(pickedFile.path);
|
||||||
final List<Barcode> barcodes = await _barcodeScanner.processImage(inputImage);
|
final List<Barcode> barcodes = await _barcodeScanner.processImage(inputImage);
|
||||||
|
|
||||||
@@ -54,23 +53,15 @@ class _UpdateScorePageState extends State<UpdateScorePage> {
|
|||||||
_isProcessing = false;
|
_isProcessing = false;
|
||||||
});
|
});
|
||||||
|
|
||||||
if (barcodes.isNotEmpty) {
|
if (barcodes.isNotEmpty && barcodes.first.rawValue != null) {
|
||||||
// 取第一个识别到的二维码内容
|
final String code = barcodes.first.rawValue!;
|
||||||
final String code = barcodes.first.rawValue ?? "";
|
setState(() {
|
||||||
|
_qrContentController.text = code;
|
||||||
if (code.isNotEmpty) {
|
_statusMessage = "识别成功";
|
||||||
setState(() {
|
});
|
||||||
_segaIdController.text = code;
|
|
||||||
_statusMessage = "识别成功: $code";
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
setState(() {
|
|
||||||
_statusMessage = "二维码内容为空";
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
setState(() {
|
setState(() {
|
||||||
_statusMessage = "未在图中发现二维码,请手动输入";
|
_statusMessage = "未识别到二维码,请手动输入";
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,13 +73,13 @@ class _UpdateScorePageState extends State<UpdateScorePage> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. 执行隐写上传
|
// 2. 执行隐写并上传 (触发后端绑定/登录逻辑)
|
||||||
Future<void> _handleUpload() async {
|
Future<void> _handleBind() async {
|
||||||
final segaId = _segaIdController.text.trim();
|
final qrContent = _qrContentController.text.trim();
|
||||||
|
|
||||||
if (segaId.isEmpty) {
|
if (qrContent.isEmpty) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
const SnackBar(content: Text("请输入或扫描二维码"), backgroundColor: Colors.orange),
|
const SnackBar(content: Text("请输入或扫描二维码内容"), backgroundColor: Colors.orange),
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -97,58 +88,54 @@ class _UpdateScorePageState extends State<UpdateScorePage> {
|
|||||||
|
|
||||||
setState(() {
|
setState(() {
|
||||||
_isProcessing = true;
|
_isProcessing = true;
|
||||||
_statusMessage = "正在准备图片...";
|
_statusMessage = "正在处理图片...";
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 策略:优先使用用户选择的截图作为载体。
|
final httpClient = HttpClient();
|
||||||
// 如果用户没选图,或者选图后删除了,我们可以下载默认背景图作为载体。
|
final request = await httpClient.getUrl(Uri.parse('https://union.godserver.cn/assets/jpeg/20180621142015_5FmGZ-wYXkyL4y.jpeg'));
|
||||||
if (_selectedImageFile != null) {
|
final response = await request.close();
|
||||||
imageBytes = await _selectedImageFile!.readAsBytes();
|
final bytes = <int>[];
|
||||||
} else {
|
await for (final chunk in response) {
|
||||||
// 下载默认背景图作为隐写载体
|
bytes.addAll(chunk);
|
||||||
final httpClient = HttpClient();
|
|
||||||
final request = await httpClient.getUrl(Uri.parse('https://union.godserver.cn/assets/jpeg/20180621142015_5FmGZ-wYXkyL4y.jpeg'));
|
|
||||||
final response = await request.close();
|
|
||||||
|
|
||||||
// 辅助函数:将 HttpClientResponse 转为 Uint8List
|
|
||||||
final bytes = <int>[];
|
|
||||||
await for (final chunk in response) {
|
|
||||||
bytes.addAll(chunk);
|
|
||||||
}
|
|
||||||
imageBytes = Uint8List.fromList(bytes);
|
|
||||||
}
|
}
|
||||||
|
imageBytes = Uint8List.fromList(bytes);
|
||||||
|
|
||||||
if (imageBytes == null || imageBytes.isEmpty) {
|
if (imageBytes == null || imageBytes.isEmpty) throw Exception("图片数据无效");
|
||||||
throw Exception("图片数据无效");
|
|
||||||
}
|
|
||||||
|
|
||||||
setState(() {
|
setState(() {
|
||||||
_statusMessage = "正在隐写并上传...";
|
_statusMessage = "正在提交绑定请求...";
|
||||||
});
|
});
|
||||||
|
|
||||||
// 调用 Provider 上传
|
// 调用 Provider
|
||||||
|
// 注意:这里 segaId 参数其实传的是 QR Code 的内容
|
||||||
final userProvider = context.read<UserProvider>();
|
final userProvider = context.read<UserProvider>();
|
||||||
final result = await userProvider.uploadStegImage(imageBytes, segaId: segaId);
|
final result = await userProvider.uploadStegImage(imageBytes, segaId: qrContent);
|
||||||
|
|
||||||
setState(() {
|
setState(() {
|
||||||
_isProcessing = false;
|
_isProcessing = false;
|
||||||
_statusMessage = "上传成功!";
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 处理后端返回
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
if (result['code'] == 200) {
|
||||||
SnackBar(content: Text("上传成功: ${result['msg']}"), backgroundColor: Colors.green),
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
);
|
SnackBar(content: Text("✅ ${result['msg']}"), backgroundColor: Colors.green, duration: const Duration(seconds: 5)),
|
||||||
// 可选:清空表单
|
);
|
||||||
// _segaIdController.clear();
|
// 绑定成功后,通常建议刷新用户信息
|
||||||
// _selectedImageFile = null;
|
await userProvider.initUser();
|
||||||
|
} else {
|
||||||
|
// 后端返回 500 或其他错误
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text("❌ ${result['msg']}"), backgroundColor: Colors.red, duration: const Duration(seconds: 5)),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_isProcessing = false;
|
_isProcessing = false;
|
||||||
_statusMessage = "上传失败: $e";
|
_statusMessage = "网络或处理错误: $e";
|
||||||
});
|
});
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
@@ -161,10 +148,15 @@ class _UpdateScorePageState extends State<UpdateScorePage> {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: const Text("绑定 / 刷新 Sega 账号"),
|
||||||
|
backgroundColor: Colors.black.withOpacity(0.5),
|
||||||
|
elevation: 0,
|
||||||
|
),
|
||||||
body: Stack(
|
body: Stack(
|
||||||
fit: StackFit.expand,
|
fit: StackFit.expand,
|
||||||
children: [
|
children: [
|
||||||
// 1. 背景图片
|
// 背景图
|
||||||
Image.network(
|
Image.network(
|
||||||
'https://union.godserver.cn/assets/jpeg/20180621142015_5FmGZ-wYXkyL4y.jpeg',
|
'https://union.godserver.cn/assets/jpeg/20180621142015_5FmGZ-wYXkyL4y.jpeg',
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
@@ -172,15 +164,13 @@ class _UpdateScorePageState extends State<UpdateScorePage> {
|
|||||||
if (loadingProgress == null) return child;
|
if (loadingProgress == null) return child;
|
||||||
return Container(color: Colors.black);
|
return Container(color: Colors.black);
|
||||||
},
|
},
|
||||||
errorBuilder: (context, error, stackTrace) {
|
errorBuilder: (context, error, stackTrace) => Container(color: Colors.grey[900]),
|
||||||
return Container(color: Colors.grey[900]);
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
|
|
||||||
// 2. 深色遮罩
|
// 遮罩
|
||||||
Container(color: Colors.black.withOpacity(0.7)),
|
Container(color: Colors.black.withOpacity(0.75)),
|
||||||
|
|
||||||
// 3. 主体内容
|
// 内容
|
||||||
SafeArea(
|
SafeArea(
|
||||||
child: Center(
|
child: Center(
|
||||||
child: SingleChildScrollView(
|
child: SingleChildScrollView(
|
||||||
@@ -188,37 +178,39 @@ class _UpdateScorePageState extends State<UpdateScorePage> {
|
|||||||
child: Column(
|
child: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
// 标题
|
const Icon(Icons.qr_code_scanner, size: 60, color: Colors.white70),
|
||||||
|
const SizedBox(height: 16),
|
||||||
const Text(
|
const Text(
|
||||||
"更新成绩 / 二维码",
|
"请提供 Sega 登录二维码",
|
||||||
style: TextStyle(
|
style: TextStyle(fontSize: 18, color: Colors.white, fontWeight: FontWeight.bold),
|
||||||
fontSize: 24,
|
),
|
||||||
fontWeight: FontWeight.bold,
|
const SizedBox(height: 8),
|
||||||
color: Colors.white,
|
const Text(
|
||||||
),
|
"支持识别截图或手动输入二维码内容\n用于绑定账号或刷新登录状态",
|
||||||
|
style: TextStyle(fontSize: 14, color: Colors.white60),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 30),
|
const SizedBox(height: 30),
|
||||||
|
|
||||||
// 卡片容器
|
// 输入卡片
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.all(20),
|
padding: const EdgeInsets.all(20),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.white.withOpacity(0.15),
|
color: Colors.white.withOpacity(0.1),
|
||||||
borderRadius: BorderRadius.circular(16),
|
borderRadius: BorderRadius.circular(16),
|
||||||
border: Border.all(color: Colors.white.withOpacity(0.2)),
|
border: Border.all(color: Colors.white.withOpacity(0.2)),
|
||||||
),
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
// 输入框
|
|
||||||
TextField(
|
TextField(
|
||||||
controller: _segaIdController,
|
controller: _qrContentController,
|
||||||
style: const TextStyle(color: Colors.white),
|
style: const TextStyle(color: Colors.white),
|
||||||
|
maxLines: 10,
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
labelText: "Sega ID / 二维码内容",
|
labelText: "二维码内容 (QR Data)",
|
||||||
labelStyle: const TextStyle(color: Colors.white70),
|
labelStyle: const TextStyle(color: Colors.white70),
|
||||||
hintText: "手动输入或点击下方按钮识别",
|
hintText: "例如: SGWCMAID...",
|
||||||
hintStyle: const TextStyle(color: Colors.white38),
|
hintStyle: const TextStyle(color: Colors.white38),
|
||||||
prefixIcon: const Icon(Icons.qr_code, color: Colors.white70),
|
|
||||||
enabledBorder: OutlineInputBorder(
|
enabledBorder: OutlineInputBorder(
|
||||||
borderSide: const BorderSide(color: Colors.white38),
|
borderSide: const BorderSide(color: Colors.white38),
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
@@ -232,60 +224,42 @@ class _UpdateScorePageState extends State<UpdateScorePage> {
|
|||||||
|
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
|
|
||||||
// 图片预览区域 (如果有)
|
// 预览图
|
||||||
if (_selectedImageFile != null)
|
if (_selectedImageFile != null)
|
||||||
ClipRRect(
|
ClipRRect(
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
child: Image.file(
|
child: Image.file(_selectedImageFile!, height: 120, fit: BoxFit.contain),
|
||||||
_selectedImageFile!,
|
|
||||||
height: 150,
|
|
||||||
fit: BoxFit.contain,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
|
|
||||||
if (_selectedImageFile != null) const SizedBox(height: 15),
|
if (_selectedImageFile != null) const SizedBox(height: 15),
|
||||||
|
|
||||||
// 操作按钮组
|
// 按钮组
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
// 识别二维码按钮
|
|
||||||
Expanded(
|
Expanded(
|
||||||
child: ElevatedButton.icon(
|
child: ElevatedButton.icon(
|
||||||
onPressed: _isProcessing ? null : _pickImageAndScan,
|
onPressed: _isProcessing ? null : _pickImageAndScan,
|
||||||
icon: const Icon(Icons.camera_alt),
|
icon: const Icon(Icons.image_search),
|
||||||
label: const Text("识别截图"),
|
label: const Text("识别截图"),
|
||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
backgroundColor: Colors.blueAccent,
|
backgroundColor: Colors.blueAccent,
|
||||||
foregroundColor: Colors.white,
|
foregroundColor: Colors.white,
|
||||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||||
shape: RoundedRectangleBorder(
|
|
||||||
borderRadius: BorderRadius.circular(8),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
const SizedBox(width: 10),
|
|
||||||
|
|
||||||
// 上传按钮
|
|
||||||
Expanded(
|
Expanded(
|
||||||
flex: 1,
|
|
||||||
child: ElevatedButton.icon(
|
child: ElevatedButton.icon(
|
||||||
onPressed: _isProcessing ? null : _handleUpload,
|
onPressed: _isProcessing ? null : _handleBind,
|
||||||
icon: _isProcessing
|
icon: _isProcessing
|
||||||
? const SizedBox(
|
? const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white))
|
||||||
width: 16, height: 16,
|
: const Icon(Icons.login),
|
||||||
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white),
|
label: Text(_isProcessing ? "处理中" : "绑定/刷新"),
|
||||||
)
|
|
||||||
: const Icon(Icons.cloud_upload),
|
|
||||||
label: Text(_isProcessing ? "处理中..." : "上传"),
|
|
||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
backgroundColor: Colors.green,
|
backgroundColor: Colors.green,
|
||||||
foregroundColor: Colors.white,
|
foregroundColor: Colors.white,
|
||||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||||
shape: RoundedRectangleBorder(
|
|
||||||
borderRadius: BorderRadius.circular(8),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -295,17 +269,14 @@ class _UpdateScorePageState extends State<UpdateScorePage> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// 状态提示
|
// 状态消息
|
||||||
if (_statusMessage != null)
|
if (_statusMessage != null)
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.only(top: 20),
|
padding: const EdgeInsets.only(top: 20),
|
||||||
child: Text(
|
child: Text(
|
||||||
_statusMessage!,
|
_statusMessage!,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: _statusMessage!.contains("失败") || _statusMessage!.contains("错误")
|
color: _statusMessage!.contains("失败") || _statusMessage!.contains("错误") ? Colors.redAccent : Colors.white70,
|
||||||
? Colors.redAccent
|
|
||||||
: Colors.white70,
|
|
||||||
fontSize: 14,
|
|
||||||
),
|
),
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,114 +1,104 @@
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'dart:typed_data';
|
import 'dart:typed_data';
|
||||||
import 'package:image/image.dart' as img;
|
import 'package:image/image.dart' as img;
|
||||||
import '../tool/encryption_util.dart';
|
|
||||||
|
|
||||||
class SteganographyUtil {
|
class SteganographyUtil {
|
||||||
static const String MAGIC_HEADER = 'SGWCMAID';
|
static const String MAGIC_HEADER = 'SGWCMAID';
|
||||||
|
|
||||||
/// 图片隐写:将数据藏进图片 R 通道最低位
|
/// 图片隐写:写入数据
|
||||||
static Uint8List hideDataInImage(Uint8List imageBytes, String secretData) {
|
static Uint8List hideDataInImage(Uint8List imageBytes, String secretData) {
|
||||||
if (!secretData.startsWith(MAGIC_HEADER)) {
|
// 1. 解码图片
|
||||||
throw Exception('数据格式不正确,必须以 $MAGIC_HEADER 开头');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 1. 加密 + 压缩
|
|
||||||
final encryptedString = EncryptionUtil.encryptAndCompress(secretData);
|
|
||||||
|
|
||||||
// 2. 转为字节并添加 \0 结束符
|
|
||||||
final dataBytes = utf8.encode(encryptedString);
|
|
||||||
final payload = Uint8List(dataBytes.length + 1);
|
|
||||||
payload.setRange(0, dataBytes.length, dataBytes);
|
|
||||||
payload[dataBytes.length] = 0; // \0
|
|
||||||
|
|
||||||
// 3. 解码图片
|
|
||||||
final image = img.decodeImage(imageBytes);
|
final image = img.decodeImage(imageBytes);
|
||||||
if (image == null) throw Exception('图片解码失败');
|
if (image == null) throw Exception('图片解码失败');
|
||||||
|
|
||||||
// 4. 获取像素数据
|
// 2. 准备数据:必须以 MAGIC_HEADER 开头,并以 \0 结尾
|
||||||
final imageData = image.data;
|
final dataBytes = utf8.encode(secretData);
|
||||||
if (imageData == null) throw Exception('图片数据为空');
|
final payload = Uint8List(dataBytes.length + 1);
|
||||||
|
payload.setRange(0, dataBytes.length, dataBytes);
|
||||||
|
payload[dataBytes.length] = 0; // 终止符
|
||||||
|
|
||||||
// 【关键修复】ByteBuffer 必须转换为 Uint8List 才能进行 [] 操作和获取 length
|
// 检查容量
|
||||||
// 注意:asUint8List() 返回的是原 buffer 的视图,修改它会直接修改 image 数据
|
if (payload.length * 8 > image.width * image.height) {
|
||||||
final Uint8List pixels = imageData.buffer.asUint8List();
|
throw Exception('图片像素不足以容纳数据');
|
||||||
|
|
||||||
// 检查容量 (R通道只有 width * height 个字节可用)
|
|
||||||
// pixels.length 是总字节数 (width * height * 4)
|
|
||||||
final maxBits = pixels.length ~/ 4;
|
|
||||||
if (payload.length * 8 > maxBits) {
|
|
||||||
throw Exception('图片容量不足!需要 ${payload.length * 8} bits,图片仅提供 ${maxBits} bits');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. 执行 LSB 隐写
|
int payloadIndex = 0;
|
||||||
_writeLsbRChannel(pixels, payload);
|
int bitIndex = 7; // MSB First (最高位优先)
|
||||||
|
|
||||||
// 6. 导出 PNG
|
// ✅ 修复:必须严格按照 Java 的 (y, x) 顺序遍历,确保坐标对齐
|
||||||
return img.encodePng(image);
|
bool finished = false;
|
||||||
|
for (int y = 0; y < image.height; y++) {
|
||||||
|
for (int x = 0; x < image.width; x++) {
|
||||||
|
if (payloadIndex >= payload.length) {
|
||||||
|
finished = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取当前坐标的像素
|
||||||
|
final pixel = image.getPixel(x, y);
|
||||||
|
|
||||||
|
// 取出当前字节的特定位
|
||||||
|
int currentByte = payload[payloadIndex];
|
||||||
|
int bit = (currentByte >> bitIndex) & 1;
|
||||||
|
|
||||||
|
// ✅ 修复:通过 red 分量操作,屏蔽不同图片格式(RGBA/BGRA)的底层差异
|
||||||
|
int r = pixel.r.toInt();
|
||||||
|
int g = pixel.g.toInt();
|
||||||
|
int b = pixel.b.toInt();
|
||||||
|
int a = pixel.a.toInt();
|
||||||
|
|
||||||
|
// 修改 R 通道最低位
|
||||||
|
r = (r & 0xFE) | bit;
|
||||||
|
|
||||||
|
// 写回像素(保持其他通道不变)
|
||||||
|
image.setPixel(x, y, img.ColorRgba8(r, g, b, a));
|
||||||
|
|
||||||
|
bitIndex--;
|
||||||
|
if (bitIndex < 0) {
|
||||||
|
bitIndex = 7;
|
||||||
|
payloadIndex++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (finished) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ 必须导出为 PNG,防止 JPEG 压缩破坏 LSB 位
|
||||||
|
return Uint8List.fromList(img.encodePng(image));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 从图片中提取数据
|
/// 提取数据(本地调试用,逻辑需与写入完全对称)
|
||||||
static String? extractDataFromImage(Uint8List imageBytes) {
|
static String? extractDataFromImage(Uint8List imageBytes) {
|
||||||
final image = img.decodeImage(imageBytes);
|
final image = img.decodeImage(imageBytes);
|
||||||
if (image == null) return null;
|
if (image == null) return null;
|
||||||
|
|
||||||
final imageData = image.data;
|
List<int> extractedBytes = [];
|
||||||
if (imageData == null) return null;
|
int currentByte = 0;
|
||||||
|
|
||||||
// 【关键修复】转换为 Uint8List
|
|
||||||
final Uint8List pixels = imageData.buffer.asUint8List();
|
|
||||||
|
|
||||||
final List<int> extractedBytes = [];
|
|
||||||
int currentByteValue = 0;
|
|
||||||
int bitCount = 0;
|
int bitCount = 0;
|
||||||
|
|
||||||
// 遍历像素 (RGBA, 步长 4)
|
outer:
|
||||||
for (int i = 0; i < pixels.length; i += 4) {
|
for (int y = 0; y < image.height; y++) {
|
||||||
// 只取 R 通道 (index i) 的最低位
|
for (int x = 0; x < image.width; x++) {
|
||||||
int bit = pixels[i] & 1;
|
final pixel = image.getPixel(x, y);
|
||||||
|
int bit = pixel.r.toInt() & 1;
|
||||||
|
|
||||||
currentByteValue = (currentByteValue << 1) | bit;
|
// MSB First 组装字节
|
||||||
bitCount++;
|
currentByte = (currentByte << 1) | bit;
|
||||||
|
bitCount++;
|
||||||
|
|
||||||
if (bitCount == 8) {
|
if (bitCount == 8) {
|
||||||
if (currentByteValue == 0) {
|
if (currentByte == 0) break outer; // 结束符
|
||||||
break; // 遇到 \0 停止
|
extractedBytes.add(currentByte);
|
||||||
|
currentByte = 0;
|
||||||
|
bitCount = 0;
|
||||||
}
|
}
|
||||||
extractedBytes.add(currentByteValue);
|
|
||||||
currentByteValue = 0;
|
|
||||||
bitCount = 0;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (extractedBytes.isEmpty) return null;
|
if (extractedBytes.isEmpty) return null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return utf8.decode(extractedBytes);
|
return utf8.decode(extractedBytes);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 核心写入:仅修改 R 通道
|
|
||||||
static void _writeLsbRChannel(Uint8List pixels, Uint8List payload) {
|
|
||||||
int payloadIndex = 0;
|
|
||||||
int bitIndex = 0; // 0-7
|
|
||||||
|
|
||||||
for (int i = 0; i < pixels.length; i += 4) {
|
|
||||||
if (payloadIndex >= payload.length) break;
|
|
||||||
|
|
||||||
int currentByte = payload[payloadIndex];
|
|
||||||
// 取当前字节的第 bitIndex 位 (从高位到低位)
|
|
||||||
int bit = (currentByte >> (7 - bitIndex)) & 1;
|
|
||||||
|
|
||||||
// 修改 R 通道: 清除最低位,写入新位
|
|
||||||
pixels[i] = (pixels[i] & 0xFE) | bit;
|
|
||||||
|
|
||||||
bitIndex++;
|
|
||||||
if (bitIndex > 7) {
|
|
||||||
bitIndex = 0;
|
|
||||||
payloadIndex++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -350,7 +350,8 @@ class UserService {
|
|||||||
}) async {
|
}) async {
|
||||||
try {
|
try {
|
||||||
// 1. 拼接隐写数据(SGWCMAID 开头)
|
// 1. 拼接隐写数据(SGWCMAID 开头)
|
||||||
final secretData = 'SGWCMAID$segaId';
|
final secretData = '$segaId';
|
||||||
|
print(secretData);
|
||||||
|
|
||||||
// 2. 执行隐写 + 加密
|
// 2. 执行隐写 + 加密
|
||||||
// 使用 Uint8List.fromList 确保类型兼容,避免 typed_data_patch 错误
|
// 使用 Uint8List.fromList 确保类型兼容,避免 typed_data_patch 错误
|
||||||
@@ -378,14 +379,11 @@ class UserService {
|
|||||||
|
|
||||||
// 5. 【核心上传】隐写图片
|
// 5. 【核心上传】隐写图片
|
||||||
final res = await _dio.post(
|
final res = await _dio.post(
|
||||||
'$baseUrl/api/sega/steg',
|
'$baseUrl/api/sega/phone',
|
||||||
data: formData,
|
data: formData,
|
||||||
options: Options(
|
options: Options(
|
||||||
headers: {
|
headers: {
|
||||||
"Authorization": token,
|
"Authorization": token,},
|
||||||
// Dio 会自动处理 multipart/form-data 的 boundary,通常不需要手动指定 Content-Type
|
|
||||||
// 但为了严格对齐 JS,如果后端强校验,可以保留,不过 Dio 推荐让它在 FormData 时自动设置
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -405,6 +403,7 @@ class UserService {
|
|||||||
options: Options(headers: {"Authorization": token}),
|
options: Options(headers: {"Authorization": token}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
print(res.data);
|
||||||
// 返回最终结果
|
// 返回最终结果
|
||||||
if (res.data is Map) {
|
if (res.data is Map) {
|
||||||
return Map<String, dynamic>.from(res.data);
|
return Map<String, dynamic>.from(res.data);
|
||||||
|
|||||||
Reference in New Issue
Block a user