After Width: | Height: | Size: 868 B |
Before Width: | Height: | Size: 868 B After Width: | Height: | Size: 1.5 KiB |
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 3.3 KiB |
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 3.3 KiB |
After Width: | Height: | Size: 3.7 KiB |
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 2.2 KiB |
After Width: | Height: | Size: 1.2 KiB |
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 2.3 KiB |
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 5.5 KiB |
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 5.5 KiB |
After Width: | Height: | Size: 6.3 KiB |
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 3.5 KiB |
After Width: | Height: | Size: 479 B |
Before Width: | Height: | Size: 479 B After Width: | Height: | Size: 974 B |
Before Width: | Height: | Size: 822 B After Width: | Height: | Size: 1.4 KiB |
Before Width: | Height: | Size: 762 B After Width: | Height: | Size: 1.5 KiB |
After Width: | Height: | Size: 1.7 KiB |
Before Width: | Height: | Size: 806 B After Width: | Height: | Size: 1.1 KiB |
Before Width: | Height: | Size: 3.0 KiB After Width: | Height: | Size: 1.0 KiB |
@ -0,0 +1,88 @@ |
|||||||
|
|
||||||
|
import 'package:flutter/foundation.dart'; |
||||||
|
|
||||||
|
/// 图片消息的图片URL的HOST |
||||||
|
const String chatImageHost = "http://skk8mlm5b.hn-bkt.clouddn.com/"; |
||||||
|
|
||||||
|
/// socket的host和port端口 |
||||||
|
//47.93.216.24:9090 线上 192.168.10.200/192.168.10.129:9090 测试 |
||||||
|
const String socketHost = kDebugMode ? '192.168.10.200' : '47.93.216.24'; |
||||||
|
const num socketPort = 9090; |
||||||
|
|
||||||
|
const ipBaseUrl = "http://whois.pconline.com.cn"; |
||||||
|
|
||||||
|
/// 小程序接口的请求地址 |
||||||
|
const localMiniBaseUrl = "http://192.168.10.54:8765/app/";///本地 |
||||||
|
const serviceMiniBaseUrl = "https://pos.api.lotus-wallet.com/app/";///线上 |
||||||
|
|
||||||
|
/// app接口的请求地址 |
||||||
|
const localBaseUrl = "http://192.168.10.54:8766/app/";///本地 |
||||||
|
const serviceBaseUrl = "https://pos.platform.lotus-wallet.com/app/";///线上 |
||||||
|
|
||||||
|
/// 对list进行分组 |
||||||
|
Map<S, List<T>> groupBy<S, T>(Iterable<T> values, S Function(T) key) { |
||||||
|
var map = <S, List<T>>{}; |
||||||
|
for (var element in values) { |
||||||
|
(map[key(element)] ??= []).add(element); |
||||||
|
} |
||||||
|
return map; |
||||||
|
} |
||||||
|
|
||||||
|
/// 对list进行分组计数 |
||||||
|
Map<String, int> groupCount<S, T>(Map<S, List<T>> values) { |
||||||
|
var map = <String, int>{}; |
||||||
|
for (var element in values.keys) { |
||||||
|
map["$element"] = values[element]?.length ?? 0; |
||||||
|
} |
||||||
|
return map; |
||||||
|
} |
||||||
|
|
||||||
|
/// 对list进行分组并取最大值 |
||||||
|
Map<String, T> groupItem<S, T>(Map<S, List<T>> values, {int Function(T) key}) { |
||||||
|
var map = <String, T>{}; |
||||||
|
for (var element in values.keys) { |
||||||
|
if (values[element] == null) { |
||||||
|
continue; |
||||||
|
} |
||||||
|
map["$element"] = key == null ? values[element].first : values[element].lMax(key); |
||||||
|
} |
||||||
|
return map; |
||||||
|
} |
||||||
|
|
||||||
|
/// 最大值 |
||||||
|
T max<T>(Iterable<T> list, int Function(T) key) { |
||||||
|
T tt; |
||||||
|
for (T t in list) { |
||||||
|
if (tt == null) { |
||||||
|
tt = t; |
||||||
|
} |
||||||
|
if (key(tt) < key(t)) { |
||||||
|
tt = t; |
||||||
|
} |
||||||
|
} |
||||||
|
return tt; |
||||||
|
} |
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
extension ListExtension<S, T> on Iterable<T> { |
||||||
|
|
||||||
|
Map<S, List<T>> lGroupBy(S Function(T) key) { |
||||||
|
return groupBy(this, key); |
||||||
|
} |
||||||
|
|
||||||
|
T lMax(int Function(T) key) { |
||||||
|
return max(this, key); |
||||||
|
} |
||||||
|
|
||||||
|
} |
||||||
|
|
||||||
|
extension MapExtension<S, T> on Map<S, List<T>> { |
||||||
|
|
||||||
|
Map<String, int> get mGroupCount => groupCount(this); |
||||||
|
|
||||||
|
Map<String, T> mGroupItem({int Function(T) key}) { |
||||||
|
return groupItem(this, key: key); |
||||||
|
} |
||||||
|
|
||||||
|
} |
@ -1,103 +1,245 @@ |
|||||||
|
import 'dart:async'; |
||||||
|
|
||||||
|
|
||||||
import 'dart:convert'; |
import 'dart:convert'; |
||||||
|
import 'dart:core'; |
||||||
import 'dart:io'; |
import 'dart:io'; |
||||||
|
import 'package:dio/dio.dart'; |
||||||
import 'package:flutter/foundation.dart'; |
import 'package:flutter/foundation.dart'; |
||||||
|
import 'package:flutter/material.dart'; |
||||||
|
import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; |
||||||
|
import 'package:huixiang/constant.dart'; |
||||||
import 'package:huixiang/im/Proto.dart'; |
import 'package:huixiang/im/Proto.dart'; |
||||||
import 'package:huixiang/im/database/message.dart'; |
import 'package:huixiang/im/database/message.dart'; |
||||||
import 'package:huixiang/im/out/auth.pb.dart'; |
import 'package:huixiang/im/out/auth.pb.dart'; |
||||||
import 'package:huixiang/im/out/message.pb.dart'; |
import 'package:huixiang/im/out/message.pb.dart'; |
||||||
import 'package:huixiang/main.dart'; |
import 'package:huixiang/main.dart'; |
||||||
|
import 'package:image_gallery_saver/image_gallery_saver.dart'; |
||||||
|
import 'package:path_provider/path_provider.dart'; |
||||||
import 'package:shared_preferences/shared_preferences.dart'; |
import 'package:shared_preferences/shared_preferences.dart'; |
||||||
|
|
||||||
class SocketClient { |
class SocketClient { |
||||||
|
|
||||||
Socket _socket; |
Socket _socket; |
||||||
SharedPreferences shared; |
SharedPreferences _shared; |
||||||
|
Timer timer; |
||||||
|
bool get heartbeatActive => timer != null && timer.isActive; |
||||||
|
|
||||||
connect() async { |
connect() async { |
||||||
shared = await SharedPreferences.getInstance(); |
_shared = await SharedPreferences.getInstance(); |
||||||
|
|
||||||
await Socket.connect('192.168.10.129', 9090).then((value) { |
if (_socket != null) { |
||||||
debugPrint("socket-connect"); |
reconnect(); |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
showDebugToast("socket-connect .... "); |
||||||
|
await Socket.connect(socketHost, socketPort).then((value) { |
||||||
|
debugPrint("socket-connect-$socketHost"); |
||||||
_socket = value; |
_socket = value; |
||||||
_socket.listen((data) { |
_socket.listen((data) { |
||||||
print(data); |
print(data); |
||||||
print("socket-listen"); |
print("socket-listen"); |
||||||
Proto proto = Proto.fromBytes(data); |
Proto proto = Proto.fromBytes(data); |
||||||
print("socket-listen: $proto"); |
MsgData dataResult = MsgData.fromBuffer(proto.body); |
||||||
MsgData data1 = MsgData.fromBuffer(proto.body); |
print('收到来自:${dataResult.from},消息内容: ${utf8.decode(dataResult.data)} '); |
||||||
print('收到来自:${data1.from},消息内容: ${utf8.decode(data1.data)} '); |
|
||||||
|
|
||||||
hxDatabase.messageDao.insertMessage(createMessage(mobile, utf8.decode(data1.data), userId: data1.from)); |
|
||||||
|
|
||||||
callbacks.forEach((callback) { |
receiveInsertMessage(dataResult).then((messageMap) { |
||||||
callback.call(data1); |
if (callbacks[dataResult.from] != null) { |
||||||
|
messageMap["state"] = 1; |
||||||
|
} |
||||||
|
hxDatabase.insert(messageMap).then((value) { |
||||||
|
messageMap["id"] = value; |
||||||
|
Message message = Message.fromJson(messageMap); |
||||||
|
if (callbacks[dataResult.from] != null) { |
||||||
|
callbacks[dataResult.from].call(message); /// user conversation callback |
||||||
|
} |
||||||
|
callbacks[userId]?.call(message); /// user self conversation list callback |
||||||
|
}); |
||||||
}); |
}); |
||||||
|
|
||||||
}, onError: (Object error, StackTrace stackTrace) { |
}, onError: (Object error, StackTrace stackTrace) { |
||||||
debugPrint("socket-error: $error, stackTrace: ${stackTrace}"); |
debugPrint("socket-listen-error: $error, stackTrace: $stackTrace"); |
||||||
|
showDebugToast("socket-listen-error: $error, stackTrace: $stackTrace"); |
||||||
|
reconnect(); |
||||||
|
}, onDone: () { |
||||||
|
debugPrint("socket-listen-down: down"); |
||||||
}); |
}); |
||||||
|
|
||||||
authRequest(shared.getString("token")); |
authRequest(_shared.getString("token")); |
||||||
|
|
||||||
|
heartbeat(); |
||||||
}).catchError((error) { |
}).catchError((error) { |
||||||
debugPrint("socket-connect-error: $error"); |
debugPrint("socket-connect-error: $error"); |
||||||
|
showDebugToast("socket-connect-error: $error"); |
||||||
|
_socket = null; |
||||||
|
reconnect(); |
||||||
}); |
}); |
||||||
} |
} |
||||||
|
|
||||||
List<Function> callbacks = []; |
Future<Map<String, dynamic>> receiveInsertMessage(MsgData dataResult) async { |
||||||
|
Uint8List dataBytes = dataResult.data; |
||||||
|
String content = utf8.decode(dataBytes); |
||||||
|
|
||||||
addCallback(Function callback) { |
String attach = ""; |
||||||
callbacks.add(callback); |
MsgType type = MsgType.values[dataResult.type.value]; |
||||||
|
if (type == MsgType.IMAGE || type == MsgType.VIDEO || type == MsgType.AUDIO) { |
||||||
|
|
||||||
|
String filePath = await qiniu.downloadFile(content); |
||||||
|
|
||||||
|
Map<String, dynamic> result = await ImageGallerySaver.saveFile(filePath).catchError((error){}); |
||||||
|
bool isSuccess = result["isSuccess"] != null && result["isSuccess"]; |
||||||
|
attach = filePath; |
||||||
} |
} |
||||||
|
|
||||||
removeCallback(Function callback) { |
Map<String, dynamic> messageMap = createMessage(userId, content, attach: attach, msgType: type.value, fromId: dataResult.from); |
||||||
callbacks.remove(callback); |
|
||||||
|
return messageMap; |
||||||
|
} |
||||||
|
|
||||||
|
showDebugToast(text) { |
||||||
|
if (kDebugMode) { |
||||||
|
SmartDialog.showToast(text, alignment: Alignment.center); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
Proto heartbeatData() { |
||||||
|
DateTime dateTime = DateTime.now(); |
||||||
|
int millisecondsTime = dateTime.millisecondsSinceEpoch; |
||||||
|
Uint8List data = utf8.encode(jsonEncode({"heartbeat": millisecondsTime})); |
||||||
|
MsgData msgData = MsgData(from: userId, type: MsgType.TEXT, data: data); |
||||||
|
final proto2 = Proto(3, 1, msgData.writeToBuffer()); |
||||||
|
debugPrint("heartbeat: ${dateTime.toString()}"); |
||||||
|
return proto2; |
||||||
|
} |
||||||
|
|
||||||
|
heartbeat() { |
||||||
|
cancelTimer(); |
||||||
|
timer = Timer.periodic(const Duration(milliseconds: 30000), (timer) { |
||||||
|
if(!checkSocket()) { |
||||||
|
timer.cancel(); |
||||||
|
return; |
||||||
|
} |
||||||
|
sendHeartBeatAndCheckSocket(); |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
/// send heartBeat and check socket is connected |
||||||
|
/// send error: reconnect, |
||||||
|
/// else check Timer.periodic isActive , |
||||||
|
/// if not active: Timer.periodic send heartBeat |
||||||
|
sendHeartBeatAndCheckSocket() { |
||||||
|
final proto2 = heartbeatData(); |
||||||
|
try { |
||||||
|
_socket.add(proto2.toBytes()); |
||||||
|
if (!socketClient.heartbeatActive) { |
||||||
|
heartbeat(); |
||||||
|
debugPrint("socket-periodic-send-heart-beat"); |
||||||
|
} |
||||||
|
} catch (e) { |
||||||
|
debugPrint("socket-send-error: ${e.toString()}"); |
||||||
|
showDebugToast("socket-send-error: ${e.toString()}"); |
||||||
|
reconnect(); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
cancelTimer() { |
||||||
|
if (timer != null && timer.isActive) { |
||||||
|
timer.cancel(); |
||||||
|
timer = null; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
reconnect() { |
||||||
|
dispose(); |
||||||
|
Future.delayed(Duration(milliseconds: 3000), () { |
||||||
|
connect(); |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
Map<String, Function(Message message)> callbacks = <String, Function(Message message)>{}; |
||||||
|
|
||||||
|
addCallback(String userId, callback) { |
||||||
|
callbacks[userId] = callback; |
||||||
|
} |
||||||
|
|
||||||
|
removeCallback(String userId) { |
||||||
|
callbacks.remove(userId); |
||||||
} |
} |
||||||
|
|
||||||
dispose() { |
dispose() { |
||||||
|
if (_socket != null) { |
||||||
_socket.close(); |
_socket.close(); |
||||||
|
_socket = null; |
||||||
|
} |
||||||
} |
} |
||||||
|
|
||||||
authRequest(String token) { |
authRequest(String token) { |
||||||
if (!checkSocket()) { |
if (!checkSocket()) { |
||||||
return; |
return; |
||||||
} |
} |
||||||
|
debugPrint("socket-authRequest: request"); |
||||||
final authReq = AuthReq() |
final authReq = AuthReq() |
||||||
..uid = mobile |
..uid = userId |
||||||
..token = token; |
..token = token; |
||||||
final authReqBytes = authReq.writeToBuffer(); |
final authReqBytes = authReq.writeToBuffer(); |
||||||
final proto = Proto(1, 1, authReqBytes); // 假设 operation 和 seqId 为 1 |
final proto = Proto(1, 1, authReqBytes); // 假设 operation 和 seqId 为 1 |
||||||
final protoBytes = proto.toBytes(); |
final protoBytes = proto.toBytes(); |
||||||
|
try { |
||||||
_socket.add(protoBytes); |
_socket.add(protoBytes); |
||||||
|
} catch (e) { |
||||||
|
debugPrint("socket-authRequest: $e"); |
||||||
|
Future.delayed(const Duration(milliseconds: 1000), () { |
||||||
|
authRequest(token); |
||||||
|
}); |
||||||
|
} |
||||||
} |
} |
||||||
|
|
||||||
sendMessage(int toId, String content) { |
Future<Message> sendMessage(String toId, String content, {String attach, int msgType = 1, replyId}) async { |
||||||
|
MsgType type = MsgType.values[msgType]; |
||||||
|
Uint8List data = utf8.encode(content); |
||||||
|
if (type == MsgType.IMAGE || type == MsgType.VIDEO || type == MsgType.AUDIO) { |
||||||
|
File file = File(attach); |
||||||
|
Directory dir = await getTemporaryDirectory(); |
||||||
|
File newFile = await file.copy("${dir.path}/hx_${attach.split('/').last}"); |
||||||
|
attach = newFile.path; |
||||||
|
} |
||||||
|
Map<String, dynamic> message = createMessage(toId, content, fromId: userId, attach: attach, msgType: msgType, replyId: replyId); |
||||||
|
message["state"] = 1; |
||||||
|
int id = await hxDatabase.insert(message).catchError((error) { |
||||||
|
debugPrint("insertMessage: $error"); |
||||||
|
}); |
||||||
if (!checkSocket()) { |
if (!checkSocket()) { |
||||||
return; |
hxDatabase.update({"id": id, "state": 3}).catchError((error) { |
||||||
|
debugPrint("insertMessage: ${error.toString()}"); |
||||||
|
}); |
||||||
|
message["id"] = id; |
||||||
|
message["state"] = 3; |
||||||
|
return Message.fromJson(message); |
||||||
} |
} |
||||||
Uint8List data = utf8.encode(content); |
message["id"] = id; |
||||||
MsgData msgData = MsgData(to: toId, from: mobile, type: MsgType.SINGLE_TEXT,data: data); |
|
||||||
|
MsgData msgData = MsgData(to: toId, from: userId, type: type, data: data); |
||||||
final proto2 = Proto(5, 1, msgData.writeToBuffer()); |
final proto2 = Proto(5, 1, msgData.writeToBuffer()); |
||||||
|
try { |
||||||
_socket.add(proto2.toBytes()); |
_socket.add(proto2.toBytes()); |
||||||
hxDatabase.messageDao.insertMessage(createMessage(toId, content, userId: mobile)).catchError((error) { |
debugPrint("socket-send-success:"); |
||||||
debugPrint("insertMessage: $error"); |
} catch (e) { |
||||||
|
hxDatabase.update({"id": id, "state": 3}).catchError((error) { |
||||||
|
debugPrint("insertMessage: ${error.toString()}"); |
||||||
}); |
}); |
||||||
debugPrint("insertMessage: end"); |
debugPrint("socket-send-error: ${e.toString()}"); |
||||||
|
showDebugToast("socket-send-error: ${e.toString()}"); |
||||||
|
reconnect(); |
||||||
|
} |
||||||
|
return Message.fromJson(message); |
||||||
} |
} |
||||||
|
|
||||||
checkSocket() { |
checkSocket() { |
||||||
if (_socket == null) { |
if (_socket == null) { |
||||||
connect(); |
reconnect(); |
||||||
return false; |
return false; |
||||||
} |
} |
||||||
return true; |
return true; |
||||||
} |
} |
||||||
|
|
||||||
get mobile => 123456; |
String get userId => _shared.getString("userId"); |
||||||
|
|
||||||
|
|
||||||
} |
} |
@ -1,18 +1,226 @@ |
|||||||
|
import 'package:flutter/cupertino.dart'; |
||||||
|
import 'package:huixiang/constant.dart'; |
||||||
|
import 'package:huixiang/im/database/message.dart'; |
||||||
|
import 'package:huixiang/im/database/migration.dart'; |
||||||
|
import 'package:shared_preferences/shared_preferences.dart'; |
||||||
|
import 'package:sqflite/sqflite.dart'; |
||||||
|
|
||||||
|
import '../../retrofit/data/im_user.dart'; |
||||||
|
|
||||||
import 'dart:async'; |
class HxDatabase { |
||||||
|
Database db; |
||||||
|
|
||||||
import 'package:floor/floor.dart'; |
void open({String key}) async { |
||||||
import 'package:flutter/cupertino.dart'; |
// _migrations.add(Migration(3, 4, (Database database) async { |
||||||
import 'package:huixiang/im/database/message_dao.dart'; |
// await database.execute('ALTER TABLE ImUser ADD COLUMN IF NOT EXISTS `isTop` INTEGER DEFAULT 0'); |
||||||
import 'package:huixiang/im/database/message.dart'; |
// })); |
||||||
import 'package:sqflite/sqflite.dart' as sqflite; |
|
||||||
|
String databaseName = 'hx.db'; |
||||||
|
if (key?.isNotEmpty ?? false) { |
||||||
|
databaseName = 'hx_$key.db'; |
||||||
|
} |
||||||
|
await openDatabase(databaseName, version: 1, onCreate: (Database db, int version) async { |
||||||
|
db.execute('CREATE TABLE IF NOT EXISTS `Message` (`id` INTEGER, `conversationId` VARCHAR(40), `fromId` VARCHAR(20), `toId` VARCHAR(20), `replyId` VARCHAR(20), `content` TEXT, `attach` TEXT, `msgType` INTEGER, `time` VARCHAR(20), `state` INTEGER, `isDelete` INTEGER, PRIMARY KEY (`id`))'); |
||||||
|
db.execute('CREATE TABLE IF NOT EXISTS `ImUser` (`id` INTEGER, `mid` VARCHAR(20), `nickname` VARCHAR(20), `avatar` VARCHAR(200), `phone` VARCHAR(200), `isDelete` INTEGER, `isTop` INTEGER, PRIMARY KEY (`id`))'); |
||||||
|
}, onConfigure: (database) async { |
||||||
|
await database.execute('PRAGMA foreign_keys = ON'); |
||||||
|
debugPrint("database-version: ${await database.getVersion()}"); |
||||||
|
}, onUpgrade: (database, startVersion, endVersion) async { |
||||||
|
await runMigrations(database, startVersion, endVersion, _migrations); |
||||||
|
}, onOpen: (Database db) { |
||||||
|
this.db = db; |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
void close() { |
||||||
|
db.close(); |
||||||
|
} |
||||||
|
|
||||||
|
_dbIsOpen() async { |
||||||
|
if (db == null || !db.isOpen) { |
||||||
|
var sp = await SharedPreferences.getInstance(); |
||||||
|
open(key: sp.getString("userId")); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
Future<Message> lastMessage(String conversationId) async { |
||||||
|
await _dbIsOpen(); |
||||||
|
String sql = 'SELECT * FROM Message WHERE conversationId = ? ORDER BY time DESC LIMIT 1'; |
||||||
|
List<Message> messages = await db.rawQuery(sql, [conversationId]).then((value) { |
||||||
|
return value.map((e) { |
||||||
|
debugPrint("Message: $e"); |
||||||
|
return Message.fromJson(e); |
||||||
|
}).toList(); |
||||||
|
}, onError: (error) { |
||||||
|
debugPrint("Message_error: $error"); |
||||||
|
}); |
||||||
|
return (messages?.isNotEmpty ?? false) ? messages.first : null; |
||||||
|
} |
||||||
|
|
||||||
|
Future<List<Message>> queryList() async{ |
||||||
|
await _dbIsOpen(); |
||||||
|
String sql = '''SELECT * |
||||||
|
FROM `Message` |
||||||
|
WHERE ROWID IN ( |
||||||
|
SELECT ROWID |
||||||
|
FROM ( |
||||||
|
SELECT ROWID, conversationId, MAX(`time`) AS max_time |
||||||
|
FROM `Message` |
||||||
|
GROUP BY conversationId |
||||||
|
) AS grouped_messages |
||||||
|
WHERE max_time = ( |
||||||
|
SELECT MAX(`time`) |
||||||
|
FROM `Message` |
||||||
|
WHERE conversationId = grouped_messages.conversationId |
||||||
|
) |
||||||
|
) |
||||||
|
ORDER BY `time` DESC;'''; |
||||||
|
return db.rawQuery(sql).then((value) { |
||||||
|
return value.map((e) { |
||||||
|
debugPrint("Message: $e"); |
||||||
|
return Message.fromJson(e); |
||||||
|
}).toList(); |
||||||
|
}, onError: (error) { |
||||||
|
debugPrint("Message-error: $error"); |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
Future<List<Message>> queryUList(conversationId, {int page = 1, int pageSize = 10}) async{ |
||||||
|
await _dbIsOpen(); |
||||||
|
int start = (page - 1) * pageSize; |
||||||
|
String sql = 'SELECT * FROM Message WHERE conversationId = ? ORDER BY time DESC LIMIT ?, ?'; |
||||||
|
return db.rawQuery(sql, [conversationId, start, pageSize]).then((value) { |
||||||
|
return value.map((e) => Message.fromJson(e)).toList(); |
||||||
|
}, onError: (error) { |
||||||
|
debugPrint("Message-error: $error"); |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
Future<List<Message>> queryTList(conversationId) async{ |
||||||
|
await _dbIsOpen(); |
||||||
|
String sql = 'SELECT *, time / 300000 * 300000 AS time_interval FROM Message WHERE conversationId = ? GROUP BY time_interval ORDER BY time DESC'; |
||||||
|
return db.rawQuery(sql, [conversationId]).then((value) { |
||||||
|
return value.map((e) => Message.fromJson(e)).toList(); |
||||||
|
}, onError: (error) { |
||||||
|
debugPrint("Message-error: $error"); |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
Future<Map<String, int>> messageUnreadCount(List<String> conversationIds) async { |
||||||
|
await _dbIsOpen(); |
||||||
|
String userStr = conversationIds.join("','"); |
||||||
|
debugPrint("userStr: $userStr"); |
||||||
|
List<Message> messages = await db.query("Message", |
||||||
|
where: "conversationId IN ('$userStr') AND state = 0 AND isDelete = 0", |
||||||
|
whereArgs: [], |
||||||
|
).then((value) { |
||||||
|
return value.map((e) => Message.fromJson(e)).toList(); |
||||||
|
}, onError: (error) { |
||||||
|
debugPrint("Message-error: $error"); |
||||||
|
}); |
||||||
|
return (messages??[]).lGroupBy((p) => p.conversationId).mGroupCount; |
||||||
|
} |
||||||
|
|
||||||
|
Future<List<Map>> queryListAll() async{ |
||||||
|
await _dbIsOpen(); |
||||||
|
String sql = 'SELECT * FROM Message ORDER BY time DESC'; |
||||||
|
return db.rawQuery(sql); |
||||||
|
} |
||||||
|
|
||||||
|
Future<int> deleteByUser(String conversationId) async { |
||||||
|
return db.delete("Message",where: "conversationId = ?", whereArgs: [conversationId]); |
||||||
|
} |
||||||
|
|
||||||
|
Future<int> deleteByMsgId(String id) async { |
||||||
|
return db.delete("Message",where: "id = ?", whereArgs: [id]); |
||||||
|
} |
||||||
|
|
||||||
|
Future<int> deleteAll() async { |
||||||
|
return db.delete("Message"); |
||||||
|
} |
||||||
|
|
||||||
|
update(Map<dynamic, dynamic> message) async{ |
||||||
|
await _dbIsOpen(); |
||||||
|
debugPrint("Message_insert: $message"); |
||||||
|
return db.update("Message", message, |
||||||
|
where: 'id = ?', whereArgs: [message['id']]); |
||||||
|
} |
||||||
|
|
||||||
|
Future<int> insert(Map message) async { |
||||||
|
await _dbIsOpen(); |
||||||
|
debugPrint("Message_insert: $message"); |
||||||
|
return db.insert("Message", message); |
||||||
|
} |
||||||
|
|
||||||
|
/// update message read state |
||||||
|
readMessage(String conversationId) async{ |
||||||
|
await _dbIsOpen(); |
||||||
|
db.update("Message", {"state": 1}, |
||||||
|
where: "conversationId = ? AND state = 0 AND isDelete = 0", |
||||||
|
whereArgs: [conversationId]); |
||||||
|
} |
||||||
|
|
||||||
|
Future<int> insertOrUpdateImUser(Map imUserMap) async { |
||||||
|
await _dbIsOpen(); |
||||||
|
debugPrint("imUser_insert: $imUserMap"); |
||||||
|
if ((await queryImUserById(imUserMap['mid'])) == null) |
||||||
|
return db.insert("ImUser", imUserMap); |
||||||
|
else |
||||||
|
return db.update("ImUser", imUserMap, |
||||||
|
where: 'mid = ?', whereArgs: [imUserMap['mid']]); |
||||||
|
} |
||||||
|
|
||||||
|
Future<List<ImUser>> queryImUser(List<String> userIds) async { |
||||||
|
await _dbIsOpen(); |
||||||
|
String query = |
||||||
|
'SELECT * FROM ImUser WHERE mid IN (${userIds.map((mid) => "'$mid'").join(',')})'; |
||||||
|
return db.rawQuery(query).then((value) { |
||||||
|
return value.map((e) => ImUser.fromJson(e)).toList(); |
||||||
|
}, onError: (error) { |
||||||
|
debugPrint("ImUser_error: $error"); |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
Future<ImUser> queryImUserById(String userId) async { |
||||||
|
await _dbIsOpen(); |
||||||
|
List<ImUser> imUser = await db.query("ImUser", |
||||||
|
distinct: true, where: "mid = ?", whereArgs: [userId]).then((value) { |
||||||
|
return value.map((e) => ImUser.fromJson(e)).toList(); |
||||||
|
}, onError: (error) { |
||||||
|
debugPrint("ImUser_error: $error"); |
||||||
|
}); |
||||||
|
return (imUser?.isNotEmpty ?? false) ? imUser.first : null; |
||||||
|
} |
||||||
|
|
||||||
|
final List<Migration> _migrations = []; |
||||||
|
|
||||||
part 'hx_database.g.dart'; |
addMigrations(List<Migration> migrations) { |
||||||
|
_migrations.addAll(migrations); |
||||||
|
return this; |
||||||
|
} |
||||||
|
|
||||||
@Database(version: 1, entities: [Message]) |
Future<void> runMigrations( |
||||||
abstract class HxDatabase extends FloorDatabase { |
final Database migrationDatabase, |
||||||
|
final int startVersion, |
||||||
|
final int endVersion, |
||||||
|
final List<Migration> migrations, |
||||||
|
) async { |
||||||
|
final relevantMigrations = migrations |
||||||
|
.where((migration) => migration.startVersion >= startVersion) |
||||||
|
.toList() |
||||||
|
..sort( |
||||||
|
(first, second) => first.startVersion.compareTo(second.startVersion)); |
||||||
|
|
||||||
MessageDao get messageDao; |
if (relevantMigrations.isEmpty || |
||||||
|
relevantMigrations.last.endVersion != endVersion) { |
||||||
|
throw StateError( |
||||||
|
'There is no migration supplied to update the database to the current version.' |
||||||
|
' Aborting the migration.', |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
for (final migration in relevantMigrations) { |
||||||
|
await migration.migrate(migrationDatabase); |
||||||
|
} |
||||||
|
} |
||||||
} |
} |
||||||
|
@ -1,144 +1,146 @@ |
|||||||
// GENERATED CODE - DO NOT MODIFY BY HAND |
// // GENERATED CODE - DO NOT MODIFY BY HAND |
||||||
|
// |
||||||
part of 'hx_database.dart'; |
// part of 'hx_database.dart'; |
||||||
|
// |
||||||
// ************************************************************************** |
// // ************************************************************************** |
||||||
// FloorGenerator |
// // FloorGenerator |
||||||
// ************************************************************************** |
// // ************************************************************************** |
||||||
|
// |
||||||
// ignore: avoid_classes_with_only_static_members |
// // ignore: avoid_classes_with_only_static_members |
||||||
class $FloorHxDatabase { |
// import 'package:floor/floor.dart'; |
||||||
/// Creates a database builder for a persistent database. |
// |
||||||
/// Once a database is built, you should keep a reference to it and re-use it. |
// class $FloorHxDatabase { |
||||||
static _$HxDatabaseBuilder databaseBuilder(String name) => |
// /// Creates a database builder for a persistent database. |
||||||
_$HxDatabaseBuilder(name); |
// /// Once a database is built, you should keep a reference to it and re-use it. |
||||||
|
// static _$HxDatabaseBuilder databaseBuilder(String name) => |
||||||
/// Creates a database builder for an in memory database. |
// _$HxDatabaseBuilder(name); |
||||||
/// Information stored in an in memory database disappears when the process is killed. |
// |
||||||
/// Once a database is built, you should keep a reference to it and re-use it. |
// /// Creates a database builder for an in memory database. |
||||||
static _$HxDatabaseBuilder inMemoryDatabaseBuilder() => |
// /// Information stored in an in memory database disappears when the process is killed. |
||||||
_$HxDatabaseBuilder(null); |
// /// Once a database is built, you should keep a reference to it and re-use it. |
||||||
} |
// static _$HxDatabaseBuilder inMemoryDatabaseBuilder() => |
||||||
|
// _$HxDatabaseBuilder(null); |
||||||
class _$HxDatabaseBuilder { |
// } |
||||||
_$HxDatabaseBuilder(this.name); |
// |
||||||
|
// class _$HxDatabaseBuilder { |
||||||
final String name; |
// _$HxDatabaseBuilder(this.name); |
||||||
|
// |
||||||
final List<Migration> _migrations = []; |
// final String name; |
||||||
|
// |
||||||
Callback _callback; |
// final List<Migration> _migrations = []; |
||||||
|
// |
||||||
/// Adds migrations to the builder. |
// Callback _callback; |
||||||
_$HxDatabaseBuilder addMigrations(List<Migration> migrations) { |
// |
||||||
_migrations.addAll(migrations); |
// /// Adds migrations to the builder. |
||||||
return this; |
// _$HxDatabaseBuilder addMigrations(List<Migration> migrations) { |
||||||
} |
// _migrations.addAll(migrations); |
||||||
|
// return this; |
||||||
/// Adds a database [Callback] to the builder. |
// } |
||||||
_$HxDatabaseBuilder addCallback(Callback callback) { |
// |
||||||
_callback = callback; |
// /// Adds a database [Callback] to the builder. |
||||||
return this; |
// _$HxDatabaseBuilder addCallback(Callback callback) { |
||||||
} |
// _callback = callback; |
||||||
|
// return this; |
||||||
/// Creates the database and initializes it. |
// } |
||||||
Future<HxDatabase> build() async { |
// |
||||||
final path = name != null |
// /// Creates the database and initializes it. |
||||||
? await sqfliteDatabaseFactory.getDatabasePath(name) |
// Future<HxDatabase> build() async { |
||||||
: ':memory:'; |
// final path = name != null |
||||||
final database = _$HxDatabase(); |
// ? await sqfliteDatabaseFactory.getDatabasePath(name) |
||||||
database.database = await database.open( |
// : ':memory:'; |
||||||
path, |
// final database = _$HxDatabase(); |
||||||
_migrations, |
// database.database = await database.open( |
||||||
_callback, |
// path, |
||||||
); |
// _migrations, |
||||||
return database; |
// _callback, |
||||||
} |
// ); |
||||||
} |
// return database; |
||||||
|
// } |
||||||
class _$HxDatabase extends HxDatabase { |
// } |
||||||
_$HxDatabase([StreamController<String> listener]) { |
// |
||||||
changeListener = listener ?? StreamController<String>.broadcast(); |
// class _$HxDatabase extends HxDatabase { |
||||||
} |
// _$HxDatabase([StreamController<String> listener]) { |
||||||
|
// changeListener = listener ?? StreamController<String>.broadcast(); |
||||||
MessageDao _messageDaoInstance; |
// } |
||||||
|
// |
||||||
Future<sqflite.Database> open( |
// MessageDao _messageDaoInstance; |
||||||
String path, |
// |
||||||
List<Migration> migrations, [ |
// Future<sqflite.Database> open( |
||||||
Callback callback, |
// String path, |
||||||
]) async { |
// List<Migration> migrations, [ |
||||||
final databaseOptions = sqflite.OpenDatabaseOptions( |
// Callback callback, |
||||||
version: 1, |
// ]) async { |
||||||
onConfigure: (database) async { |
// final databaseOptions = sqflite.OpenDatabaseOptions( |
||||||
await database.execute('PRAGMA foreign_keys = ON'); |
// version: 1, |
||||||
await callback?.onConfigure?.call(database); |
// onConfigure: (database) async { |
||||||
}, |
// await database.execute('PRAGMA foreign_keys = ON'); |
||||||
onOpen: (database) async { |
// await callback?.onConfigure?.call(database); |
||||||
await callback?.onOpen?.call(database); |
// }, |
||||||
}, |
// onOpen: (database) async { |
||||||
onUpgrade: (database, startVersion, endVersion) async { |
// await callback?.onOpen?.call(database); |
||||||
await MigrationAdapter.runMigrations( |
// }, |
||||||
database, startVersion, endVersion, migrations); |
// onUpgrade: (database, startVersion, endVersion) async { |
||||||
|
// await MigrationAdapter.runMigrations( |
||||||
await callback?.onUpgrade?.call(database, startVersion, endVersion); |
// database, startVersion, endVersion, migrations); |
||||||
}, |
// |
||||||
onCreate: (database, version) async { |
// await callback?.onUpgrade?.call(database, startVersion, endVersion); |
||||||
await database.execute( |
// }, |
||||||
'CREATE TABLE IF NOT EXISTS `Message` (`id` INTEGER, `fromId` INTEGER, `toId` INTEGER, `content` TEXT, `attach` TEXT, `msgType` INTEGER, `time` INTEGER, `state` INTEGER, `isDelete` INTEGER, PRIMARY KEY (`id`))'); |
// onCreate: (database, version) async { |
||||||
|
// await database.execute( |
||||||
await callback?.onCreate?.call(database, version); |
// 'CREATE TABLE IF NOT EXISTS `Message` (`id` INTEGER, `fromId` INTEGER, `toId` INTEGER, `content` TEXT, `attach` TEXT, `msgType` INTEGER, `time` INTEGER, `state` INTEGER, `isDelete` INTEGER, PRIMARY KEY (`id`))'); |
||||||
}, |
// |
||||||
); |
// await callback?.onCreate?.call(database, version); |
||||||
return sqfliteDatabaseFactory.openDatabase(path, options: databaseOptions); |
// }, |
||||||
} |
// ); |
||||||
|
// return sqfliteDatabaseFactory.openDatabase(path, options: databaseOptions); |
||||||
@override |
// } |
||||||
MessageDao get messageDao { |
// |
||||||
return _messageDaoInstance ??= _$MessageDao(database, changeListener); |
// @override |
||||||
} |
// MessageDao get messageDao { |
||||||
} |
// return _messageDaoInstance ??= _$MessageDao(database, changeListener); |
||||||
|
// } |
||||||
class _$MessageDao extends MessageDao { |
// } |
||||||
_$MessageDao( |
// |
||||||
this.database, |
// class _$MessageDao extends MessageDao { |
||||||
this.changeListener, |
// _$MessageDao( |
||||||
) : _queryAdapter = QueryAdapter(database, changeListener), |
// this.database, |
||||||
_messageInsertionAdapter = InsertionAdapter( |
// this.changeListener, |
||||||
database, |
// ) : _queryAdapter = QueryAdapter(database, changeListener), |
||||||
'Message', |
// _messageInsertionAdapter = InsertionAdapter( |
||||||
(Message item) => item.toJson(), |
// database, |
||||||
changeListener); |
// 'Message', |
||||||
|
// (Message item) => item.toJson(), |
||||||
final sqflite.DatabaseExecutor database; |
// changeListener); |
||||||
|
// |
||||||
final StreamController<String> changeListener; |
// final sqflite.DatabaseExecutor database; |
||||||
|
// |
||||||
final QueryAdapter _queryAdapter; |
// final StreamController<String> changeListener; |
||||||
|
// |
||||||
final InsertionAdapter<Message> _messageInsertionAdapter; |
// final QueryAdapter _queryAdapter; |
||||||
|
// |
||||||
@override |
// final InsertionAdapter<Message> _messageInsertionAdapter; |
||||||
Stream<List<Message>> findMessageByToId(int toId) { |
// |
||||||
return _queryAdapter.queryListStream( |
// @override |
||||||
'SELECT * FROM Message WHERE toId = ?1', |
// Stream<List<Message>> findMessageByToId(int toId) { |
||||||
mapper: (Map<String, Object> row) => Message.fromJson(row), |
// return _queryAdapter.queryListStream( |
||||||
arguments: [toId], |
// 'SELECT * FROM Message WHERE toId = ?1', |
||||||
queryableName: 'Message', |
// mapper: (Map<String, Object> row) => Message.fromJson(row), |
||||||
isView: false); |
// arguments: [toId], |
||||||
} |
// queryableName: 'Message', |
||||||
|
// isView: false); |
||||||
@override |
// } |
||||||
Future<List<Message>> findMessageByGroup(int userId) { |
// |
||||||
debugPrint("findMessageByGroup: $userId"); |
// @override |
||||||
return _queryAdapter.queryList( |
// Future<List<Message>> findMessageByGroup(int userId) { |
||||||
'SELECT * FROM Message WHERE toId = ?1 OR fromId = ?2 GROUP BY toId,fromId ORDER BY time DESC', |
// debugPrint("findMessageByGroup: $userId"); |
||||||
mapper: (Map<String, Object> row) => Message.fromJson(row), |
// return _queryAdapter.queryList( |
||||||
arguments: [userId, userId]); |
// 'SELECT * FROM Message WHERE toId = ?1 OR fromId = ?2 GROUP BY toId,fromId ORDER BY time DESC', |
||||||
} |
// mapper: (Map<String, Object> row) => Message.fromJson(row), |
||||||
|
// arguments: [userId, userId]); |
||||||
@override |
// } |
||||||
Future<void> insertMessage(Message message) async { |
// |
||||||
await _messageInsertionAdapter.insert(message, OnConflictStrategy.abort); |
// @override |
||||||
} |
// Future<void> insertMessage(Message message) async { |
||||||
} |
// await _messageInsertionAdapter.insert(message, OnConflictStrategy.abort); |
||||||
|
// } |
||||||
|
// } |
||||||
|
@ -1,17 +1,17 @@ |
|||||||
import 'package:floor/floor.dart'; |
// import 'package:floor/floor.dart'; |
||||||
import 'package:huixiang/im/database/message.dart'; |
// import 'package:huixiang/im/database/message.dart'; |
||||||
|
// |
||||||
|
// |
||||||
@dao |
// @dao |
||||||
abstract class MessageDao { |
// abstract class MessageDao { |
||||||
|
// |
||||||
@Query('SELECT * FROM Message WHERE toId = :toId') |
// @Query('SELECT * FROM Message WHERE toId = :toId') |
||||||
Stream<List<Message>> findMessageByToId(int toId); |
// Stream<List<Message>> findMessageByToId(int toId); |
||||||
|
// |
||||||
@insert |
// @insert |
||||||
Future<void> insertMessage(Message message); |
// Future<void> insertMessage(Message message); |
||||||
|
// |
||||||
@Query('SELECT * FROM Message WHERE toId = :userId OR fromId = :userId GROUP BY toId,fromId ORDER BY time DESC') |
// @Query('SELECT * FROM Message WHERE toId = :userId OR fromId = :userId GROUP BY toId,fromId ORDER BY time DESC') |
||||||
Future<List<Message>> findMessageByGroup(int userId); |
// Future<List<Message>> findMessageByGroup(int userId); |
||||||
|
// |
||||||
} |
// } |
@ -0,0 +1,41 @@ |
|||||||
|
import 'package:sqflite/sqflite.dart' as sqflite; |
||||||
|
|
||||||
|
/// Base class for a database migration. |
||||||
|
/// |
||||||
|
/// Each migration can move between 2 versions that are defined by |
||||||
|
/// [startVersion] and [endVersion]. |
||||||
|
class Migration { |
||||||
|
/// The start version of the database. |
||||||
|
final int startVersion; |
||||||
|
|
||||||
|
/// The start version of the database. |
||||||
|
final int endVersion; |
||||||
|
|
||||||
|
/// Function that performs the migration. |
||||||
|
final Future<void> Function(sqflite.Database database) migrate; |
||||||
|
|
||||||
|
/// Creates a new migration between [startVersion] and [endVersion]. |
||||||
|
/// [migrate] will be called by the database and performs the actual |
||||||
|
/// migration. |
||||||
|
Migration(this.startVersion, this.endVersion, this.migrate) |
||||||
|
: assert(startVersion > 0), |
||||||
|
assert(startVersion < endVersion); |
||||||
|
|
||||||
|
@override |
||||||
|
bool operator ==(Object other) => |
||||||
|
identical(this, other) || |
||||||
|
other is Migration && |
||||||
|
runtimeType == other.runtimeType && |
||||||
|
startVersion == other.startVersion && |
||||||
|
endVersion == other.endVersion && |
||||||
|
migrate == other.migrate; |
||||||
|
|
||||||
|
@override |
||||||
|
int get hashCode => |
||||||
|
startVersion.hashCode ^ endVersion.hashCode ^ migrate.hashCode; |
||||||
|
|
||||||
|
@override |
||||||
|
String toString() { |
||||||
|
return 'Migration{startVersion: $startVersion, endVersion: $endVersion, migrate: $migrate}'; |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,365 @@ |
|||||||
|
import 'dart:ui'; |
||||||
|
|
||||||
|
import 'package:dio/dio.dart'; |
||||||
|
import 'package:flutter/cupertino.dart'; |
||||||
|
import 'package:flutter/material.dart'; |
||||||
|
import 'package:flutter_screenutil/flutter_screenutil.dart'; |
||||||
|
import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; |
||||||
|
import 'package:shared_preferences/shared_preferences.dart'; |
||||||
|
|
||||||
|
import '../generated/l10n.dart'; |
||||||
|
import '../retrofit/data/base_data.dart'; |
||||||
|
import '../retrofit/data/im_user.dart'; |
||||||
|
import '../retrofit/retrofit_api.dart'; |
||||||
|
import '../utils/font_weight.dart'; |
||||||
|
import '../view_widget/custom_image.dart'; |
||||||
|
import '../view_widget/my_appbar.dart'; |
||||||
|
import '../view_widget/settlement_tips_dialog.dart'; |
||||||
|
|
||||||
|
class ImSearch extends StatefulWidget { |
||||||
|
@override |
||||||
|
State<StatefulWidget> createState() { |
||||||
|
return _ImSearch(); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
class _ImSearch extends State<ImSearch> { |
||||||
|
ApiService apiService; |
||||||
|
final TextEditingController editingController = TextEditingController(); |
||||||
|
FocusNode _focusNode = FocusNode(); |
||||||
|
List<ImUser> searchUser = []; |
||||||
|
int searchState = 0; |
||||||
|
int textType = 0; |
||||||
|
int searchUserIndex = 0; |
||||||
|
String selfUserId = ""; |
||||||
|
|
||||||
|
@override |
||||||
|
void initState() { |
||||||
|
super.initState(); |
||||||
|
SharedPreferences.getInstance().then((value) { |
||||||
|
selfUserId = value.getString("userId"); |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
///离开页面记着销毁和清除 |
||||||
|
@override |
||||||
|
void dispose() { |
||||||
|
_focusNode.unfocus(); |
||||||
|
super.dispose(); |
||||||
|
} |
||||||
|
|
||||||
|
///搜索列表 |
||||||
|
queryImSearch(keyword) async { |
||||||
|
if (apiService == null) { |
||||||
|
SharedPreferences value = await SharedPreferences.getInstance(); |
||||||
|
apiService = ApiService(Dio(), |
||||||
|
context: context, |
||||||
|
token: value.getString("token"), |
||||||
|
showLoading:false |
||||||
|
); |
||||||
|
} |
||||||
|
BaseData<List<ImUser>> baseData = |
||||||
|
await apiService.memberSearch(keyword).catchError((onError) {}); |
||||||
|
if (baseData != null && baseData.isSuccess) { |
||||||
|
searchUser.clear(); |
||||||
|
baseData.data.forEach((element) { |
||||||
|
if (element.phone != "" && element.nickname != "") { |
||||||
|
searchUser.add(element); |
||||||
|
} |
||||||
|
}); |
||||||
|
searchState = 1; |
||||||
|
if (baseData.data.length == 0) { |
||||||
|
searchState = 2; |
||||||
|
} |
||||||
|
setState(() {}); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
@override |
||||||
|
Widget build(BuildContext context) { |
||||||
|
return GestureDetector( |
||||||
|
onTap: () { |
||||||
|
FocusScope.of(context).requestFocus(FocusNode()); |
||||||
|
}, |
||||||
|
child: Scaffold( |
||||||
|
backgroundColor: Color(0xFFFFFFFF), |
||||||
|
resizeToAvoidBottomInset: false, |
||||||
|
appBar: MyAppBar( |
||||||
|
title: "搜索", |
||||||
|
leadingColor: Colors.black, |
||||||
|
background: Color(0xFFFFFFFF), |
||||||
|
), |
||||||
|
body: Container( |
||||||
|
child: Column( |
||||||
|
crossAxisAlignment: CrossAxisAlignment.start, |
||||||
|
children: [ |
||||||
|
Row( |
||||||
|
crossAxisAlignment: CrossAxisAlignment.center, |
||||||
|
mainAxisAlignment: MainAxisAlignment.center, |
||||||
|
children: [ |
||||||
|
Expanded(child: imSearch()), |
||||||
|
GestureDetector( |
||||||
|
behavior: HitTestBehavior.opaque, |
||||||
|
onTap: () { |
||||||
|
Navigator.of(context).pop(); |
||||||
|
}, |
||||||
|
child: Container( |
||||||
|
margin: EdgeInsets.only(top: 8.h, bottom: 29.h), |
||||||
|
alignment: Alignment.center, |
||||||
|
padding: EdgeInsets.only(right: 16.w), |
||||||
|
child: Text( |
||||||
|
S.of(context).quxiao, |
||||||
|
textAlign: TextAlign.center, |
||||||
|
style: TextStyle( |
||||||
|
color: Color(0xFFA29E9E), |
||||||
|
fontSize: 14.sp, |
||||||
|
fontWeight: MyFontWeight.medium, |
||||||
|
), |
||||||
|
)), |
||||||
|
) |
||||||
|
], |
||||||
|
), |
||||||
|
searchState == 2 |
||||||
|
? Center( |
||||||
|
child: Text( |
||||||
|
"未找到该用户", |
||||||
|
style: TextStyle( |
||||||
|
fontSize: 14.sp, |
||||||
|
color: Color(0xFFA29E9E), |
||||||
|
), |
||||||
|
), |
||||||
|
) |
||||||
|
: Expanded( |
||||||
|
child: Column( |
||||||
|
children: [ |
||||||
|
Padding( |
||||||
|
padding: EdgeInsets.only(bottom: 19.h), |
||||||
|
child: Row( |
||||||
|
crossAxisAlignment: CrossAxisAlignment.start, |
||||||
|
children: [ |
||||||
|
if (editingController.text != "" && |
||||||
|
searchState == 1) |
||||||
|
Padding( |
||||||
|
padding: EdgeInsets.only(left: 16.w), |
||||||
|
child: Text( |
||||||
|
"搜索用户:", |
||||||
|
textAlign: TextAlign.center, |
||||||
|
style: TextStyle( |
||||||
|
color: Color(0xFF060606), |
||||||
|
fontSize: 16.sp, |
||||||
|
fontWeight: MyFontWeight.medium, |
||||||
|
), |
||||||
|
), |
||||||
|
), |
||||||
|
if (editingController.text != "" && |
||||||
|
searchState == 1) |
||||||
|
Expanded( |
||||||
|
child: Text( |
||||||
|
editingController?.text ?? "", |
||||||
|
style: TextStyle( |
||||||
|
fontSize: 16.sp, |
||||||
|
color: Color(0xFF32A060), |
||||||
|
fontWeight: MyFontWeight.regular), |
||||||
|
)) |
||||||
|
], |
||||||
|
), |
||||||
|
), |
||||||
|
if (editingController.text != "" && searchState == 1) |
||||||
|
Expanded( |
||||||
|
child: ListView.builder( |
||||||
|
itemCount: searchUser?.length ?? 0, |
||||||
|
physics: BouncingScrollPhysics(), |
||||||
|
shrinkWrap: true, |
||||||
|
itemBuilder: (context, position) { |
||||||
|
return GestureDetector( |
||||||
|
behavior: HitTestBehavior.opaque, |
||||||
|
onTap: () { |
||||||
|
setState(() { |
||||||
|
searchUserIndex = position; |
||||||
|
}); |
||||||
|
Navigator.of(context).pushNamed( |
||||||
|
'/router/personal_page', |
||||||
|
arguments: { |
||||||
|
"memberId": (searchUser[searchUserIndex] |
||||||
|
.mid ?? |
||||||
|
"") == |
||||||
|
selfUserId |
||||||
|
? "0" |
||||||
|
: searchUser[searchUserIndex].mid, |
||||||
|
"inletType": 0 |
||||||
|
}); |
||||||
|
FocusScope.of(context) |
||||||
|
.requestFocus(FocusNode()); |
||||||
|
}, |
||||||
|
child: imSearchItem(searchUser[position]), |
||||||
|
); |
||||||
|
}, |
||||||
|
)) |
||||||
|
], |
||||||
|
)), |
||||||
|
], |
||||||
|
), |
||||||
|
), |
||||||
|
), |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
///搜索 |
||||||
|
Widget imSearch() { |
||||||
|
return Container( |
||||||
|
margin: EdgeInsets.fromLTRB(16.w, 8.h, 16.w, 29.h), |
||||||
|
padding: EdgeInsets.symmetric(vertical: 13.h), |
||||||
|
decoration: BoxDecoration( |
||||||
|
color: Color(0xFFFDFCFC), |
||||||
|
borderRadius: BorderRadius.circular(4), |
||||||
|
), |
||||||
|
child: TextField( |
||||||
|
textInputAction: TextInputAction.search, |
||||||
|
onChanged: (value) { |
||||||
|
setState(() { |
||||||
|
searchState = 3; |
||||||
|
}); |
||||||
|
if (isNumeric(value)) { |
||||||
|
textType = 1; |
||||||
|
} else { |
||||||
|
textType = 2; |
||||||
|
} |
||||||
|
if (editingController.text == null || editingController.text == "") { |
||||||
|
return; |
||||||
|
} else { |
||||||
|
queryImSearch(editingController.text ?? ""); |
||||||
|
} |
||||||
|
}, |
||||||
|
onEditingComplete: () { |
||||||
|
FocusScope.of(context).requestFocus(FocusNode()); |
||||||
|
if (editingController.text == null || editingController.text == "") { |
||||||
|
SmartDialog.show( |
||||||
|
widget: SettlementTips( |
||||||
|
() {}, |
||||||
|
text: "请输入姓名或手机号搜索", |
||||||
|
)); |
||||||
|
} else { |
||||||
|
queryImSearch(editingController.text ?? ""); |
||||||
|
} |
||||||
|
}, |
||||||
|
controller: editingController, |
||||||
|
style: TextStyle( |
||||||
|
fontSize: 14.sp, |
||||||
|
), |
||||||
|
decoration: InputDecoration( |
||||||
|
hintText: "输入姓名或手机号搜索", |
||||||
|
hintStyle: TextStyle( |
||||||
|
fontSize: 14.sp, |
||||||
|
color: Color(0xFFA29E9E), |
||||||
|
), |
||||||
|
isCollapsed: true, |
||||||
|
prefixIcon: Padding( |
||||||
|
padding: EdgeInsets.only(left: 15.w, right: 5.w), |
||||||
|
child: Image.asset( |
||||||
|
"assets/image/icon_search.webp", |
||||||
|
width: 14.h, |
||||||
|
height: 14.h, |
||||||
|
color: Color(0xFFB3B3B3), |
||||||
|
), |
||||||
|
), |
||||||
|
prefixIconConstraints: BoxConstraints(), |
||||||
|
border: InputBorder.none, |
||||||
|
), |
||||||
|
), |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
///搜索列表 |
||||||
|
Widget imSearchItem(ImUser searchUser) { |
||||||
|
return Container( |
||||||
|
padding: EdgeInsets.only(left: 10.w, right: 16.w, bottom: 15.h), |
||||||
|
child: Row( |
||||||
|
children: [ |
||||||
|
MImage( |
||||||
|
searchUser?.avatar ?? "", |
||||||
|
isCircle: true, |
||||||
|
height: 54.h, |
||||||
|
width: 54.h, |
||||||
|
fit: BoxFit.cover, |
||||||
|
errorSrc: "assets/image/default_1.webp", |
||||||
|
fadeSrc: "assets/image/default_1.webp", |
||||||
|
), |
||||||
|
SizedBox( |
||||||
|
width: 12.w, |
||||||
|
), |
||||||
|
Expanded( |
||||||
|
child: Column( |
||||||
|
crossAxisAlignment: CrossAxisAlignment.start, |
||||||
|
children: [ |
||||||
|
RichText( |
||||||
|
text: TextSpan( |
||||||
|
style: TextStyle(color: Colors.black), // 默认文本颜色为黑色 |
||||||
|
children: _splitText(searchUser?.nickname ?? "", |
||||||
|
editingController.text) |
||||||
|
.map((part) { |
||||||
|
return TextSpan( |
||||||
|
text: part, |
||||||
|
style: part == editingController.text |
||||||
|
? TextStyle(color: Colors.green) // 匹配部分变为绿色 |
||||||
|
: null, |
||||||
|
); |
||||||
|
}).toList(), |
||||||
|
), |
||||||
|
), |
||||||
|
SizedBox( |
||||||
|
height: 7.h, |
||||||
|
), |
||||||
|
RichText( |
||||||
|
text: TextSpan( |
||||||
|
style: TextStyle(color: Colors.black), // 默认文本颜色为黑色 |
||||||
|
children: _splitText( |
||||||
|
searchUser?.phone ?? "", editingController.text) |
||||||
|
.map((part) { |
||||||
|
return TextSpan( |
||||||
|
text: part, |
||||||
|
style: part == editingController.text |
||||||
|
? TextStyle(color: Colors.green) // 匹配部分变为绿色 |
||||||
|
: null, |
||||||
|
); |
||||||
|
}).toList(), |
||||||
|
), |
||||||
|
), |
||||||
|
], |
||||||
|
), |
||||||
|
), |
||||||
|
], |
||||||
|
)); |
||||||
|
} |
||||||
|
|
||||||
|
/// 搜索结构显示 |
||||||
|
List<String> _splitText(String text, String search) { |
||||||
|
if (text == null || text.isEmpty || search == null || search.isEmpty) { |
||||||
|
throw ArgumentError('text and search must not be null or empty'); |
||||||
|
} |
||||||
|
|
||||||
|
final List<String> parts = []; |
||||||
|
int start = 0; |
||||||
|
int index = text.indexOf(search); |
||||||
|
|
||||||
|
while (index != -1) { |
||||||
|
if (index > start) { |
||||||
|
parts.add(text.substring(start, index)); |
||||||
|
} |
||||||
|
parts.add(text.substring(index, index + search.length)); |
||||||
|
start = index + search.length; |
||||||
|
index = text.indexOf(search, start); |
||||||
|
} |
||||||
|
|
||||||
|
if (start < text.length) { |
||||||
|
parts.add(text.substring(start)); |
||||||
|
} |
||||||
|
|
||||||
|
return parts; |
||||||
|
} |
||||||
|
|
||||||
|
/// 判断给的字符串是否全部由数字组成 |
||||||
|
bool isNumeric(String str) { |
||||||
|
RegExp regExp = RegExp(r'^\d+$'); |
||||||
|
return regExp.hasMatch(str); |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,73 @@ |
|||||||
|
/// mid : "1379254113602109440" |
||||||
|
/// nickname : "哈哈" |
||||||
|
/// avatar : "https://pos.upload.lotus-wallet.com/admin/2022/11/d501d2cd-ffc0-49f2-967c-2e463462f500.jpeg" |
||||||
|
/// phone : "13052919193" |
||||||
|
/// isFollow : null |
||||||
|
/// createTime : null |
||||||
|
|
||||||
|
class ImUser { |
||||||
|
ImUser({ |
||||||
|
String mid, |
||||||
|
String nickname, |
||||||
|
num isDelete, |
||||||
|
num isTop, |
||||||
|
String avatar, |
||||||
|
String phone, }){ |
||||||
|
_mid = mid; |
||||||
|
_nickname = nickname; |
||||||
|
_isDelete = isDelete; |
||||||
|
_isTop = isTop; |
||||||
|
_avatar = avatar; |
||||||
|
_phone = phone; |
||||||
|
} |
||||||
|
|
||||||
|
ImUser.fromJson(dynamic json) { |
||||||
|
_mid = json['mid']; |
||||||
|
_nickname = json['nickname']; |
||||||
|
_isDelete = json['isDelete']; |
||||||
|
_isTop = json['isTop']; |
||||||
|
_avatar = json['avatar']; |
||||||
|
_phone = json['phone']; |
||||||
|
} |
||||||
|
String _mid; |
||||||
|
String _nickname; |
||||||
|
num _isDelete; |
||||||
|
num _isTop; |
||||||
|
String _avatar; |
||||||
|
String _phone; |
||||||
|
ImUser copyWith({ String mid, |
||||||
|
String nickname, |
||||||
|
num isDelete, |
||||||
|
num isTop, |
||||||
|
String avatar, |
||||||
|
String phone, |
||||||
|
}) => ImUser( mid: mid ?? _mid, |
||||||
|
nickname: nickname ?? _nickname, |
||||||
|
isDelete: isDelete ?? _isDelete, |
||||||
|
isTop: isTop ?? _isTop, |
||||||
|
avatar: avatar ?? _avatar, |
||||||
|
phone: phone ?? _phone, |
||||||
|
); |
||||||
|
String get mid => _mid; |
||||||
|
String get nickname => _nickname; |
||||||
|
num get isDelete => _isDelete; |
||||||
|
num get isTop => _isTop; |
||||||
|
String get avatar => _avatar; |
||||||
|
String get phone => _phone; |
||||||
|
|
||||||
|
set isTop(num value) { |
||||||
|
_isTop = value; |
||||||
|
} |
||||||
|
|
||||||
|
Map<String, dynamic> toJson() { |
||||||
|
final map = <String, dynamic>{}; |
||||||
|
map['mid'] = _mid; |
||||||
|
map['nickname'] = _nickname; |
||||||
|
map['isDelete'] = _isDelete; |
||||||
|
map['isTop'] = _isTop; |
||||||
|
map['avatar'] = _avatar; |
||||||
|
map['phone'] = _phone; |
||||||
|
return map; |
||||||
|
} |
||||||
|
|
||||||
|
} |
@ -0,0 +1,85 @@ |
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
import 'dart:io'; |
||||||
|
|
||||||
|
import 'package:dio/dio.dart'; |
||||||
|
import 'package:flutter/cupertino.dart'; |
||||||
|
import 'package:huixiang/constant.dart'; |
||||||
|
import 'package:huixiang/retrofit/data/base_data.dart'; |
||||||
|
import 'package:huixiang/retrofit/retrofit_api.dart'; |
||||||
|
import 'package:path_provider/path_provider.dart'; |
||||||
|
import 'package:qiniu_flutter_sdk/qiniu_flutter_sdk.dart'; |
||||||
|
|
||||||
|
class Qiniu { |
||||||
|
|
||||||
|
|
||||||
|
Storage storage = Storage(config: Config()); |
||||||
|
PutController putController = PutController(); |
||||||
|
|
||||||
|
progressListener() { |
||||||
|
// 添加任务进度监听 |
||||||
|
putController.addProgressListener((double percent) { |
||||||
|
print('任务进度变化:已发送:$percent'); |
||||||
|
}); |
||||||
|
|
||||||
|
// 添加文件发送进度监听 |
||||||
|
putController.addSendProgressListener((double percent) { |
||||||
|
print('已上传进度变化:已发送:$percent'); |
||||||
|
}); |
||||||
|
|
||||||
|
// 添加任务状态监听 |
||||||
|
putController.addStatusListener((StorageStatus status) { |
||||||
|
print('状态变化: 当前任务状态:$status'); |
||||||
|
}); |
||||||
|
|
||||||
|
} |
||||||
|
|
||||||
|
Future<String> uploadFile(ApiService apiService, String filePath) async { |
||||||
|
|
||||||
|
String token = await _getToken(apiService); |
||||||
|
|
||||||
|
File file = File(filePath); |
||||||
|
|
||||||
|
PutResponse putResponse = await storage.putFile(file, token, options: PutOptions( |
||||||
|
controller: putController, |
||||||
|
key: filePath.split('/').last, |
||||||
|
)); |
||||||
|
|
||||||
|
debugPrint("qiniuToken-result: ${putResponse.toJson()}"); |
||||||
|
|
||||||
|
return "$chatImageHost/${putResponse.key}"; |
||||||
|
} |
||||||
|
|
||||||
|
Future<String> _getToken(ApiService apiService) async { |
||||||
|
BaseData<String> baseData = await apiService.getQiniuToken() |
||||||
|
.catchError((error){ |
||||||
|
debugPrint("getQiniuToken: $error"); |
||||||
|
}); |
||||||
|
if (baseData.isSuccess) { |
||||||
|
return baseData.data; |
||||||
|
} else { |
||||||
|
debugPrint("get token fail, check network"); |
||||||
|
throw Error(); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
final Dio dio = Dio(); |
||||||
|
|
||||||
|
Future<String> downloadFile(String urlPath, {String savePath}) async { |
||||||
|
Directory dir = await getTemporaryDirectory(); |
||||||
|
File newFile; |
||||||
|
if (savePath != null && savePath != '') { |
||||||
|
newFile = File(savePath); |
||||||
|
} else { |
||||||
|
newFile = File("${dir.path}/hx_${urlPath.split('/').last}"); |
||||||
|
} |
||||||
|
Response response = await dio.download(urlPath, newFile.path); |
||||||
|
if (response.statusCode == 200) { |
||||||
|
return newFile.path; |
||||||
|
} |
||||||
|
return null; |
||||||
|
} |
||||||
|
|
||||||
|
} |
||||||
|
|
@ -0,0 +1,58 @@ |
|||||||
|
language: dart |
||||||
|
|
||||||
|
dart: |
||||||
|
- stable |
||||||
|
|
||||||
|
install: |
||||||
|
- cd $TRAVIS_BUILD_DIR/base && pub get |
||||||
|
# - cd $$TRAVIS_BUILD_DIR/flutter && pub get |
||||||
|
|
||||||
|
before_script: |
||||||
|
- cd $TRAVIS_BUILD_DIR/base && rm -rf .env |
||||||
|
|
||||||
|
jobs: |
||||||
|
include: |
||||||
|
####################################### |
||||||
|
######### jobs for base ############# |
||||||
|
####################################### |
||||||
|
# 检查 lint 并在 warnings、infos 时报错退出 |
||||||
|
- stage: base(analyze,format,test) |
||||||
|
name: "Analyze" |
||||||
|
os: linux |
||||||
|
script: cd $TRAVIS_BUILD_DIR/base && dartanalyzer --fatal-warnings --fatal-infos . |
||||||
|
# 检查格式并在异常时退出 |
||||||
|
- stage: base(analyze,format,test) |
||||||
|
name: "Format" |
||||||
|
os: linux |
||||||
|
script: cd $TRAVIS_BUILD_DIR/base && dartfmt -n --set-exit-if-changed . |
||||||
|
# 执行测试(已开启 null safe) |
||||||
|
- stage: base(analyze,format,test) |
||||||
|
name: "Vm Tests" |
||||||
|
os: linux |
||||||
|
script: cd $TRAVIS_BUILD_DIR/base && pub run test_coverage --print-test-output && bash <(curl -s https://codecov.io/bash) |
||||||
|
|
||||||
|
####################################### |
||||||
|
####### jobs for flutter_sdk ########## |
||||||
|
####################################### |
||||||
|
# - stage: flutter_sdk(analyze,format,test) |
||||||
|
# name: "Analyze" |
||||||
|
# os: linux |
||||||
|
# script: cd $TRAVIS_BUILD_DIR/flutter |
||||||
|
# - stage: flutter_sdk(analyze,format,test) |
||||||
|
# name: "Format" |
||||||
|
# os: linux |
||||||
|
# script: cd $TRAVIS_BUILD_DIR/flutter |
||||||
|
|
||||||
|
# - stage: flutter_sdk(analyze,format,test) |
||||||
|
# name: "Vm Tests" |
||||||
|
# os: linux |
||||||
|
# script: cd $TRAVIS_BUILD_DIR/flutter |
||||||
|
|
||||||
|
stages: |
||||||
|
- base(analyze,format,test) |
||||||
|
# - flutter_sdk(analyze,format,test) |
||||||
|
# - flutter_sdk_example(analyze,format,test) |
||||||
|
|
||||||
|
cache: |
||||||
|
directories: |
||||||
|
- $HOME/.pub-cache |
@ -0,0 +1,76 @@ |
|||||||
|
# Contributor Covenant Code of Conduct |
||||||
|
|
||||||
|
## Our Pledge |
||||||
|
|
||||||
|
In the interest of fostering an open and welcoming environment, we as |
||||||
|
contributors and maintainers pledge to making participation in our project and |
||||||
|
our community a harassment-free experience for everyone, regardless of age, body |
||||||
|
size, disability, ethnicity, sex characteristics, gender identity and expression, |
||||||
|
level of experience, education, socio-economic status, nationality, personal |
||||||
|
appearance, race, religion, or sexual identity and orientation. |
||||||
|
|
||||||
|
## Our Standards |
||||||
|
|
||||||
|
Examples of behavior that contributes to creating a positive environment |
||||||
|
include: |
||||||
|
|
||||||
|
* Using welcoming and inclusive language |
||||||
|
* Being respectful of differing viewpoints and experiences |
||||||
|
* Gracefully accepting constructive criticism |
||||||
|
* Focusing on what is best for the community |
||||||
|
* Showing empathy towards other community members |
||||||
|
|
||||||
|
Examples of unacceptable behavior by participants include: |
||||||
|
|
||||||
|
* The use of sexualized language or imagery and unwelcome sexual attention or |
||||||
|
advances |
||||||
|
* Trolling, insulting/derogatory comments, and personal or political attacks |
||||||
|
* Public or private harassment |
||||||
|
* Publishing others' private information, such as a physical or electronic |
||||||
|
address, without explicit permission |
||||||
|
* Other conduct which could reasonably be considered inappropriate in a |
||||||
|
professional setting |
||||||
|
|
||||||
|
## Our Responsibilities |
||||||
|
|
||||||
|
Project maintainers are responsible for clarifying the standards of acceptable |
||||||
|
behavior and are expected to take appropriate and fair corrective action in |
||||||
|
response to any instances of unacceptable behavior. |
||||||
|
|
||||||
|
Project maintainers have the right and responsibility to remove, edit, or |
||||||
|
reject comments, commits, code, wiki edits, issues, and other contributions |
||||||
|
that are not aligned to this Code of Conduct, or to ban temporarily or |
||||||
|
permanently any contributor for other behaviors that they deem inappropriate, |
||||||
|
threatening, offensive, or harmful. |
||||||
|
|
||||||
|
## Scope |
||||||
|
|
||||||
|
This Code of Conduct applies both within project spaces and in public spaces |
||||||
|
when an individual is representing the project or its community. Examples of |
||||||
|
representing a project or community include using an official project e-mail |
||||||
|
address, posting via an official social media account, or acting as an appointed |
||||||
|
representative at an online or offline event. Representation of a project may be |
||||||
|
further defined and clarified by project maintainers. |
||||||
|
|
||||||
|
## Enforcement |
||||||
|
|
||||||
|
Instances of abusive, harassing, or otherwise unacceptable behavior may be |
||||||
|
reported by contacting the project team at yinxulai@qiniu.com. All |
||||||
|
complaints will be reviewed and investigated and will result in a response that |
||||||
|
is deemed necessary and appropriate to the circumstances. The project team is |
||||||
|
obligated to maintain confidentiality with regard to the reporter of an incident. |
||||||
|
Further details of specific enforcement policies may be posted separately. |
||||||
|
|
||||||
|
Project maintainers who do not follow or enforce the Code of Conduct in good |
||||||
|
faith may face temporary or permanent repercussions as determined by other |
||||||
|
members of the project's leadership. |
||||||
|
|
||||||
|
## Attribution |
||||||
|
|
||||||
|
This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, |
||||||
|
available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html |
||||||
|
|
||||||
|
[homepage]: https://www.contributor-covenant.org |
||||||
|
|
||||||
|
For answers to common questions about this code of conduct, see |
||||||
|
https://www.contributor-covenant.org/faq |
@ -0,0 +1,15 @@ |
|||||||
|
# Dart SDK |
||||||
|
|
||||||
|
[![codecov](https://codecov.io/gh/qiniu/dart-sdk/branch/master/graph/badge.svg?token=5VOX6NJTKF)](https://codecov.io/gh/qiniu/dart-sdk) |
||||||
|
[![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://opensource.org/licenses/Apache-2.0) |
||||||
|
[![qiniu_sdk_base](https://img.shields.io/pub/v/qiniu_sdk_base.svg?label=qiniu_sdk_base)](https://pub.dev/packages/qiniu_sdk_base) |
||||||
|
[![qiniu_flutter_sdk](https://img.shields.io/pub/v/qiniu_flutter_sdk.svg?label=qiniu_flutter_sdk)](https://pub.dev/packages/qiniu_flutter_sdk) |
||||||
|
|
||||||
|
## 目录说明 |
||||||
|
|
||||||
|
- base 封装了七牛各业务的基础实现 |
||||||
|
- flutter 该目录是 base + Flutter 的绑定实现,同时导出为单独的 package 提供给用户使用 |
||||||
|
|
||||||
|
### [Flutter SDK](https://github.com/qiniu/dart-sdk/tree/master/flutter) |
||||||
|
|
||||||
|
七牛云业务基于 Dart 绑定 Flutter 的实现,为 Flutter 提供简易的使用方式,更多信息查看该目录下的 [README.md](https://github.com/qiniu/dart-sdk/tree/master/flutter/README.md) 文件。 |
@ -0,0 +1,12 @@ |
|||||||
|
## 0.1.0 |
||||||
|
|
||||||
|
- Initial Release. |
||||||
|
|
||||||
|
## 0.2.0 |
||||||
|
|
||||||
|
- 优化了 `StorageError` 输出的调用栈 |
||||||
|
- `CacheProvider` 的方法都改成异步的 |
||||||
|
|
||||||
|
## 0.2.1 |
||||||
|
|
||||||
|
- 修复关闭 App 缓存丢失的问题 |
@ -0,0 +1,201 @@ |
|||||||
|
Apache License |
||||||
|
Version 2.0, January 2004 |
||||||
|
http://www.apache.org/licenses/ |
||||||
|
|
||||||
|
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION |
||||||
|
|
||||||
|
1. Definitions. |
||||||
|
|
||||||
|
"License" shall mean the terms and conditions for use, reproduction, |
||||||
|
and distribution as defined by Sections 1 through 9 of this document. |
||||||
|
|
||||||
|
"Licensor" shall mean the copyright owner or entity authorized by |
||||||
|
the copyright owner that is granting the License. |
||||||
|
|
||||||
|
"Legal Entity" shall mean the union of the acting entity and all |
||||||
|
other entities that control, are controlled by, or are under common |
||||||
|
control with that entity. For the purposes of this definition, |
||||||
|
"control" means (i) the power, direct or indirect, to cause the |
||||||
|
direction or management of such entity, whether by contract or |
||||||
|
otherwise, or (ii) ownership of fifty percent (50%) or more of the |
||||||
|
outstanding shares, or (iii) beneficial ownership of such entity. |
||||||
|
|
||||||
|
"You" (or "Your") shall mean an individual or Legal Entity |
||||||
|
exercising permissions granted by this License. |
||||||
|
|
||||||
|
"Source" form shall mean the preferred form for making modifications, |
||||||
|
including but not limited to software source code, documentation |
||||||
|
source, and configuration files. |
||||||
|
|
||||||
|
"Object" form shall mean any form resulting from mechanical |
||||||
|
transformation or translation of a Source form, including but |
||||||
|
not limited to compiled object code, generated documentation, |
||||||
|
and conversions to other media types. |
||||||
|
|
||||||
|
"Work" shall mean the work of authorship, whether in Source or |
||||||
|
Object form, made available under the License, as indicated by a |
||||||
|
copyright notice that is included in or attached to the work |
||||||
|
(an example is provided in the Appendix below). |
||||||
|
|
||||||
|
"Derivative Works" shall mean any work, whether in Source or Object |
||||||
|
form, that is based on (or derived from) the Work and for which the |
||||||
|
editorial revisions, annotations, elaborations, or other modifications |
||||||
|
represent, as a whole, an original work of authorship. For the purposes |
||||||
|
of this License, Derivative Works shall not include works that remain |
||||||
|
separable from, or merely link (or bind by name) to the interfaces of, |
||||||
|
the Work and Derivative Works thereof. |
||||||
|
|
||||||
|
"Contribution" shall mean any work of authorship, including |
||||||
|
the original version of the Work and any modifications or additions |
||||||
|
to that Work or Derivative Works thereof, that is intentionally |
||||||
|
submitted to Licensor for inclusion in the Work by the copyright owner |
||||||
|
or by an individual or Legal Entity authorized to submit on behalf of |
||||||
|
the copyright owner. For the purposes of this definition, "submitted" |
||||||
|
means any form of electronic, verbal, or written communication sent |
||||||
|
to the Licensor or its representatives, including but not limited to |
||||||
|
communication on electronic mailing lists, source code control systems, |
||||||
|
and issue tracking systems that are managed by, or on behalf of, the |
||||||
|
Licensor for the purpose of discussing and improving the Work, but |
||||||
|
excluding communication that is conspicuously marked or otherwise |
||||||
|
designated in writing by the copyright owner as "Not a Contribution." |
||||||
|
|
||||||
|
"Contributor" shall mean Licensor and any individual or Legal Entity |
||||||
|
on behalf of whom a Contribution has been received by Licensor and |
||||||
|
subsequently incorporated within the Work. |
||||||
|
|
||||||
|
2. Grant of Copyright License. Subject to the terms and conditions of |
||||||
|
this License, each Contributor hereby grants to You a perpetual, |
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable |
||||||
|
copyright license to reproduce, prepare Derivative Works of, |
||||||
|
publicly display, publicly perform, sublicense, and distribute the |
||||||
|
Work and such Derivative Works in Source or Object form. |
||||||
|
|
||||||
|
3. Grant of Patent License. Subject to the terms and conditions of |
||||||
|
this License, each Contributor hereby grants to You a perpetual, |
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable |
||||||
|
(except as stated in this section) patent license to make, have made, |
||||||
|
use, offer to sell, sell, import, and otherwise transfer the Work, |
||||||
|
where such license applies only to those patent claims licensable |
||||||
|
by such Contributor that are necessarily infringed by their |
||||||
|
Contribution(s) alone or by combination of their Contribution(s) |
||||||
|
with the Work to which such Contribution(s) was submitted. If You |
||||||
|
institute patent litigation against any entity (including a |
||||||
|
cross-claim or counterclaim in a lawsuit) alleging that the Work |
||||||
|
or a Contribution incorporated within the Work constitutes direct |
||||||
|
or contributory patent infringement, then any patent licenses |
||||||
|
granted to You under this License for that Work shall terminate |
||||||
|
as of the date such litigation is filed. |
||||||
|
|
||||||
|
4. Redistribution. You may reproduce and distribute copies of the |
||||||
|
Work or Derivative Works thereof in any medium, with or without |
||||||
|
modifications, and in Source or Object form, provided that You |
||||||
|
meet the following conditions: |
||||||
|
|
||||||
|
(a) You must give any other recipients of the Work or |
||||||
|
Derivative Works a copy of this License; and |
||||||
|
|
||||||
|
(b) You must cause any modified files to carry prominent notices |
||||||
|
stating that You changed the files; and |
||||||
|
|
||||||
|
(c) You must retain, in the Source form of any Derivative Works |
||||||
|
that You distribute, all copyright, patent, trademark, and |
||||||
|
attribution notices from the Source form of the Work, |
||||||
|
excluding those notices that do not pertain to any part of |
||||||
|
the Derivative Works; and |
||||||
|
|
||||||
|
(d) If the Work includes a "NOTICE" text file as part of its |
||||||
|
distribution, then any Derivative Works that You distribute must |
||||||
|
include a readable copy of the attribution notices contained |
||||||
|
within such NOTICE file, excluding those notices that do not |
||||||
|
pertain to any part of the Derivative Works, in at least one |
||||||
|
of the following places: within a NOTICE text file distributed |
||||||
|
as part of the Derivative Works; within the Source form or |
||||||
|
documentation, if provided along with the Derivative Works; or, |
||||||
|
within a display generated by the Derivative Works, if and |
||||||
|
wherever such third-party notices normally appear. The contents |
||||||
|
of the NOTICE file are for informational purposes only and |
||||||
|
do not modify the License. You may add Your own attribution |
||||||
|
notices within Derivative Works that You distribute, alongside |
||||||
|
or as an addendum to the NOTICE text from the Work, provided |
||||||
|
that such additional attribution notices cannot be construed |
||||||
|
as modifying the License. |
||||||
|
|
||||||
|
You may add Your own copyright statement to Your modifications and |
||||||
|
may provide additional or different license terms and conditions |
||||||
|
for use, reproduction, or distribution of Your modifications, or |
||||||
|
for any such Derivative Works as a whole, provided Your use, |
||||||
|
reproduction, and distribution of the Work otherwise complies with |
||||||
|
the conditions stated in this License. |
||||||
|
|
||||||
|
5. Submission of Contributions. Unless You explicitly state otherwise, |
||||||
|
any Contribution intentionally submitted for inclusion in the Work |
||||||
|
by You to the Licensor shall be under the terms and conditions of |
||||||
|
this License, without any additional terms or conditions. |
||||||
|
Notwithstanding the above, nothing herein shall supersede or modify |
||||||
|
the terms of any separate license agreement you may have executed |
||||||
|
with Licensor regarding such Contributions. |
||||||
|
|
||||||
|
6. Trademarks. This License does not grant permission to use the trade |
||||||
|
names, trademarks, service marks, or product names of the Licensor, |
||||||
|
except as required for reasonable and customary use in describing the |
||||||
|
origin of the Work and reproducing the content of the NOTICE file. |
||||||
|
|
||||||
|
7. Disclaimer of Warranty. Unless required by applicable law or |
||||||
|
agreed to in writing, Licensor provides the Work (and each |
||||||
|
Contributor provides its Contributions) on an "AS IS" BASIS, |
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or |
||||||
|
implied, including, without limitation, any warranties or conditions |
||||||
|
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A |
||||||
|
PARTICULAR PURPOSE. You are solely responsible for determining the |
||||||
|
appropriateness of using or redistributing the Work and assume any |
||||||
|
risks associated with Your exercise of permissions under this License. |
||||||
|
|
||||||
|
8. Limitation of Liability. In no event and under no legal theory, |
||||||
|
whether in tort (including negligence), contract, or otherwise, |
||||||
|
unless required by applicable law (such as deliberate and grossly |
||||||
|
negligent acts) or agreed to in writing, shall any Contributor be |
||||||
|
liable to You for damages, including any direct, indirect, special, |
||||||
|
incidental, or consequential damages of any character arising as a |
||||||
|
result of this License or out of the use or inability to use the |
||||||
|
Work (including but not limited to damages for loss of goodwill, |
||||||
|
work stoppage, computer failure or malfunction, or any and all |
||||||
|
other commercial damages or losses), even if such Contributor |
||||||
|
has been advised of the possibility of such damages. |
||||||
|
|
||||||
|
9. Accepting Warranty or Additional Liability. While redistributing |
||||||
|
the Work or Derivative Works thereof, You may choose to offer, |
||||||
|
and charge a fee for, acceptance of support, warranty, indemnity, |
||||||
|
or other liability obligations and/or rights consistent with this |
||||||
|
License. However, in accepting such obligations, You may act only |
||||||
|
on Your own behalf and on Your sole responsibility, not on behalf |
||||||
|
of any other Contributor, and only if You agree to indemnify, |
||||||
|
defend, and hold each Contributor harmless for any liability |
||||||
|
incurred by, or claims asserted against, such Contributor by reason |
||||||
|
of your accepting any such warranty or additional liability. |
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS |
||||||
|
|
||||||
|
APPENDIX: How to apply the Apache License to your work. |
||||||
|
|
||||||
|
To apply the Apache License to your work, attach the following |
||||||
|
boilerplate notice, with the fields enclosed by brackets "[]" |
||||||
|
replaced with your own identifying information. (Don't include |
||||||
|
the brackets!) The text should be enclosed in the appropriate |
||||||
|
comment syntax for the file format. We also recommend that a |
||||||
|
file or class name and description of purpose be included on the |
||||||
|
same "printed page" as the copyright notice for easier |
||||||
|
identification within third-party archives. |
||||||
|
|
||||||
|
Copyright [yyyy] [name of copyright owner] |
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License"); |
||||||
|
you may not use this file except in compliance with the License. |
||||||
|
You may obtain a copy of the License at |
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0 |
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software |
||||||
|
distributed under the License is distributed on an "AS IS" BASIS, |
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
||||||
|
See the License for the specific language governing permissions and |
||||||
|
limitations under the License. |
@ -0,0 +1,29 @@ |
|||||||
|
# Qiniu Sdk Base [![qiniu_sdk_base](https://img.shields.io/pub/v/qiniu_sdk_base.svg?label=qiniu_sdk_base)](https://pub.dev/packages/qiniu_sdk_base) [![codecov](https://codecov.io/gh/qiniu/dart-sdk/branch/master/graph/badge.svg?token=5VOX6NJTKF)](https://codecov.io/gh/qiniu/dart-sdk) |
||||||
|
|
||||||
|
七牛 dart 平台 sdk 的 base 包,为上层 sdk 提供基础设施和共享代码。 |
||||||
|
|
||||||
|
## 功能列表 |
||||||
|
|
||||||
|
* 单文件上传 |
||||||
|
* 分片上传 |
||||||
|
* 任务状态 |
||||||
|
* 任务进度 |
||||||
|
* 上传进度 |
||||||
|
* 失败重试 |
||||||
|
|
||||||
|
## 如何测试 |
||||||
|
|
||||||
|
创建 `.env` 文件,并输入如下内容 |
||||||
|
|
||||||
|
``` |
||||||
|
export QINIU_DART_SDK_ACCESS_KEY= |
||||||
|
export QINIU_DART_SDK_SECRET_KEY= |
||||||
|
export QINIU_DART_SDK_TOKEN_SCOPE= |
||||||
|
``` |
||||||
|
|
||||||
|
|
||||||
|
在 `.env` 文件中填好敏感数据,即 ak、sk、scope |
||||||
|
|
||||||
|
接着运行如下指令 |
||||||
|
|
||||||
|
`pub run test` |
@ -0,0 +1,90 @@ |
|||||||
|
# copy from https://github.com/dart-lang/http/blob/master/analysis_options.yaml |
||||||
|
|
||||||
|
include: package:pedantic/analysis_options.yaml |
||||||
|
|
||||||
|
analyzer: |
||||||
|
# enable-experiment: |
||||||
|
# - non-nullable |
||||||
|
|
||||||
|
strong-mode: |
||||||
|
implicit-casts: false |
||||||
|
implicit-dynamic: false |
||||||
|
|
||||||
|
linter: |
||||||
|
rules: |
||||||
|
- annotate_overrides |
||||||
|
- avoid_bool_literals_in_conditional_expressions |
||||||
|
- avoid_classes_with_only_static_members |
||||||
|
- avoid_empty_else |
||||||
|
- avoid_function_literals_in_foreach_calls |
||||||
|
- avoid_init_to_null |
||||||
|
- avoid_null_checks_in_equality_operators |
||||||
|
- avoid_relative_lib_imports |
||||||
|
- avoid_renaming_method_parameters |
||||||
|
- avoid_return_types_on_setters |
||||||
|
- avoid_returning_null_for_void |
||||||
|
- avoid_returning_this |
||||||
|
- avoid_shadowing_type_parameters |
||||||
|
- avoid_single_cascade_in_expression_statements |
||||||
|
- avoid_types_as_parameter_names |
||||||
|
- avoid_unused_constructor_parameters |
||||||
|
- await_only_futures |
||||||
|
- camel_case_types |
||||||
|
- cascade_invocations |
||||||
|
# comment_references |
||||||
|
- control_flow_in_finally |
||||||
|
- curly_braces_in_flow_control_structures |
||||||
|
- directives_ordering |
||||||
|
- empty_catches |
||||||
|
- empty_constructor_bodies |
||||||
|
- empty_statements |
||||||
|
- file_names |
||||||
|
- hash_and_equals |
||||||
|
- invariant_booleans |
||||||
|
- iterable_contains_unrelated_type |
||||||
|
- library_names |
||||||
|
- library_prefixes |
||||||
|
- list_remove_unrelated_type |
||||||
|
- no_adjacent_strings_in_list |
||||||
|
- no_duplicate_case_values |
||||||
|
- non_constant_identifier_names |
||||||
|
- null_closures |
||||||
|
- omit_local_variable_types |
||||||
|
- only_throw_errors |
||||||
|
- overridden_fields |
||||||
|
- package_names |
||||||
|
- package_prefixed_library_names |
||||||
|
- prefer_adjacent_string_concatenation |
||||||
|
- prefer_conditional_assignment |
||||||
|
- prefer_contains |
||||||
|
- prefer_equal_for_default_values |
||||||
|
- prefer_final_fields |
||||||
|
- prefer_collection_literals |
||||||
|
- prefer_generic_function_type_aliases |
||||||
|
- prefer_initializing_formals |
||||||
|
- prefer_is_empty |
||||||
|
- prefer_is_not_empty |
||||||
|
- prefer_null_aware_operators |
||||||
|
- prefer_single_quotes |
||||||
|
- prefer_typing_uninitialized_variables |
||||||
|
- recursive_getters |
||||||
|
- slash_for_doc_comments |
||||||
|
- test_types_in_equals |
||||||
|
- throw_in_finally |
||||||
|
- type_init_formals |
||||||
|
- unawaited_futures |
||||||
|
- unnecessary_brace_in_string_interps |
||||||
|
- unnecessary_const |
||||||
|
- unnecessary_getters_setters |
||||||
|
- unnecessary_lambdas |
||||||
|
- unnecessary_new |
||||||
|
- unnecessary_null_aware_assignments |
||||||
|
- unnecessary_null_in_if_null_operators |
||||||
|
- unnecessary_overrides |
||||||
|
- unnecessary_parenthesis |
||||||
|
- unnecessary_statements |
||||||
|
- unnecessary_this |
||||||
|
- unrelated_type_equality_checks |
||||||
|
- use_rethrow_when_possible |
||||||
|
- valid_regexps |
||||||
|
- void_checks |
@ -0,0 +1,5 @@ |
|||||||
|
library qiniu_sdk_base; |
||||||
|
|
||||||
|
export 'src/auth/auth.dart'; |
||||||
|
export 'src/error/error.dart'; |
||||||
|
export 'src/storage/storage.dart'; |
@ -0,0 +1,125 @@ |
|||||||
|
import 'dart:convert'; |
||||||
|
import 'package:crypto/crypto.dart'; |
||||||
|
import 'package:meta/meta.dart'; |
||||||
|
import './put_policy.dart'; |
||||||
|
|
||||||
|
export 'put_policy.dart'; |
||||||
|
|
||||||
|
class TokenInfo { |
||||||
|
final String accessKey; |
||||||
|
final PutPolicy putPolicy; |
||||||
|
const TokenInfo(this.accessKey, this.putPolicy); |
||||||
|
} |
||||||
|
|
||||||
|
/// 提供用于鉴权的相关功能。 |
||||||
|
/// |
||||||
|
/// 更多信息请查看[官方文档-安全机制](https://developer.qiniu.com/kodo/manual/1644/security) |
||||||
|
class Auth { |
||||||
|
/// 鉴权所需的 [accessKey]。 |
||||||
|
/// |
||||||
|
/// 更多信息请查看[官方文档-密钥 AccessKey/SecretKey](https://developer.qiniu.com/kodo/manual/1644/security#aksk) |
||||||
|
/// 使用须知请查看[官方文档-密钥安全使用须知](https://developer.qiniu.com/kodo/kb/1334/the-access-key-secret-key-encryption-key-safe-use-instructions) |
||||||
|
final String accessKey; |
||||||
|
|
||||||
|
/// 鉴权所需的 [secretKey]。 |
||||||
|
/// |
||||||
|
/// 如何生成以及查看、使用等请参阅 [accessKey] 的说明 |
||||||
|
final String secretKey; |
||||||
|
|
||||||
|
const Auth({ |
||||||
|
@required this.accessKey, |
||||||
|
@required this.secretKey, |
||||||
|
}) : assert(accessKey != null), |
||||||
|
assert(secretKey != null); |
||||||
|
|
||||||
|
/// 根据上传策略生成上传使用的 Token。 |
||||||
|
/// |
||||||
|
/// 具体的上传策略说明请参考 [PutPolicy] 模块 |
||||||
|
String generateUploadToken({ |
||||||
|
@required PutPolicy putPolicy, |
||||||
|
}) { |
||||||
|
assert(putPolicy != null); |
||||||
|
|
||||||
|
var data = jsonEncode(putPolicy); |
||||||
|
var encodedPutPolicy = base64Url.encode(utf8.encode(data)); |
||||||
|
var baseToken = generateAccessToken(bytes: utf8.encode(encodedPutPolicy)); |
||||||
|
return '$baseToken:$encodedPutPolicy'; |
||||||
|
} |
||||||
|
|
||||||
|
/// 生成针对私有空间资源的下载 Token。 |
||||||
|
/// |
||||||
|
/// [key] 为对象的名称 |
||||||
|
/// [deadline] 有效时间,单位为秒,例如 1451491200 |
||||||
|
/// [bucketDomain] 空间绑定的域名,例如 http://test.bucket.com |
||||||
|
String generateDownloadToken({ |
||||||
|
@required String key, |
||||||
|
@required int deadline, |
||||||
|
@required String bucketDomain, |
||||||
|
}) { |
||||||
|
assert(key != null); |
||||||
|
assert(deadline != null); |
||||||
|
assert(bucketDomain != null); |
||||||
|
|
||||||
|
var downloadURL = '$bucketDomain/$key?e=$deadline'; |
||||||
|
return generateAccessToken(bytes: utf8.encode(downloadURL)); |
||||||
|
} |
||||||
|
|
||||||
|
/// 根据数据签名,生成 Token(用于接口的访问鉴权)。 |
||||||
|
/// |
||||||
|
/// 访问七牛的接口需要对请求进行签名, 该方法提供 Token 签发服务 |
||||||
|
String generateAccessToken({@required List<int> bytes}) { |
||||||
|
assert(bytes != null); |
||||||
|
|
||||||
|
var hmacEncoder = Hmac(sha1, utf8.encode(secretKey)); |
||||||
|
|
||||||
|
var sign = hmacEncoder.convert(bytes); |
||||||
|
var encodedSign = base64Url.encode(sign.bytes); |
||||||
|
return '$accessKey:$encodedSign'; |
||||||
|
} |
||||||
|
|
||||||
|
/// 解析 token 信息。 |
||||||
|
/// |
||||||
|
/// 从 Token 字符串中解析 [accessKey]、[PutPolicy] 信息 |
||||||
|
static TokenInfo parseToken(String token) { |
||||||
|
assert(token != null && token != ''); |
||||||
|
|
||||||
|
var segments = token.split(':'); |
||||||
|
if (segments.length < 2) { |
||||||
|
throw ArgumentError('invalid token'); |
||||||
|
} |
||||||
|
|
||||||
|
PutPolicy putPolicy; |
||||||
|
var accessKey = segments.first; |
||||||
|
|
||||||
|
/// 具体的 token 信息可以参考这里。 |
||||||
|
/// [内部文档](https://github.com/qbox/product/blob/master/kodo/auths/UpToken.md#admin-uptoken-authorization) |
||||||
|
if (segments.length >= 3) { |
||||||
|
if (segments.last == '') { |
||||||
|
throw ArgumentError('invalid token'); |
||||||
|
} |
||||||
|
|
||||||
|
putPolicy = PutPolicy.fromJson(jsonDecode( |
||||||
|
String.fromCharCodes( |
||||||
|
base64Url.decode( |
||||||
|
segments.last, |
||||||
|
), |
||||||
|
), |
||||||
|
) as Map<String, dynamic>); |
||||||
|
} |
||||||
|
|
||||||
|
return TokenInfo(accessKey, putPolicy); |
||||||
|
} |
||||||
|
|
||||||
|
/// 解析 up token 信息。 |
||||||
|
/// |
||||||
|
/// 从 Token 字符串中解析 [accessKey]、[PutPolicy] 信息 |
||||||
|
static TokenInfo parseUpToken(String token) { |
||||||
|
assert(token != null && token != ''); |
||||||
|
final tokenInfo = parseToken(token); |
||||||
|
if (tokenInfo.putPolicy == null) { |
||||||
|
throw ArgumentError('invalid up token'); |
||||||
|
} |
||||||
|
|
||||||
|
return tokenInfo; |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,209 @@ |
|||||||
|
import 'package:meta/meta.dart'; |
||||||
|
|
||||||
|
/// 上传策略 |
||||||
|
/// |
||||||
|
/// 更多信息请查看[官方文档-上传策略](https://developer.qiniu.com/kodo/manual/1206/put-policy) |
||||||
|
class PutPolicy { |
||||||
|
/// 指定上传的目标资源空间 Bucket 和资源键 Key(最大为 750 字节)。 |
||||||
|
/// |
||||||
|
/// 有三种格式: |
||||||
|
/// <Bucket> 表示允许用户上传文件到指定的 Bucket,在这种格式下文件只能新增。 |
||||||
|
/// <Bucket>:<Key> 表示只允许用户上传指定 Key 的文件。在这种格式下文件默认允许修改。 |
||||||
|
/// <Bucket>:<KeyPrefix> 表示只允许用户上传指定以 KeyPrefix 为前缀的文件。 |
||||||
|
/// 具体信息一定请查看上述的上传策略文档! |
||||||
|
final String scope; |
||||||
|
|
||||||
|
/// 获取 Bucket。 |
||||||
|
/// |
||||||
|
/// 从 [scope] 中获取 Bucket。 |
||||||
|
String getBucket() { |
||||||
|
return scope.split(':').first; |
||||||
|
} |
||||||
|
|
||||||
|
/// 若为 1,表示允许用户上传以 [scope] 的 KeyPrefix 为前缀的文件。 |
||||||
|
final int isPrefixalScope; |
||||||
|
|
||||||
|
/// 上传凭证有效截止时间。 |
||||||
|
/// |
||||||
|
/// Unix 时间戳,单位为秒, |
||||||
|
/// 该截止时间为上传完成后,在七牛空间生成文件的校验时间, 而非上传的开始时间, |
||||||
|
/// 一般建议设置为上传开始时间 + 3600s。 |
||||||
|
final int deadline; |
||||||
|
|
||||||
|
/// 限制为新增文件。 |
||||||
|
/// |
||||||
|
/// 如果设置为非 0 值,则无论 [scope] 设置为什么形式,仅能以新增模式上传文件。 |
||||||
|
final int insertOnly; |
||||||
|
|
||||||
|
/// 唯一属主标识。 |
||||||
|
/// |
||||||
|
/// 特殊场景下非常有用,例如根据 App-Client 标识给图片或视频打水印。 |
||||||
|
final String endUser; |
||||||
|
|
||||||
|
/// Web 端文件上传成功后,浏览器执行 303 跳转的 URL。 |
||||||
|
/// |
||||||
|
/// 文件上传成功后会跳转到 <[returnUrl]>?upload_ret=<QueryString> |
||||||
|
/// 其中 <QueryString> 包含 [returnBody] 内容。 |
||||||
|
/// 如不设置 [returnUrl],则直接将 [returnBody] 的内容返回给客户端。 |
||||||
|
final String returnUrl; |
||||||
|
|
||||||
|
/// [returnBody] 声明服务端的响应格式。 |
||||||
|
/// |
||||||
|
/// 可以使用 <魔法变量> 和 <自定义变量>,必须是合法的 JSON 格式, |
||||||
|
/// 关于 <魔法变量> 请参阅:[官方文档-魔法变量](https://developer.qiniu.com/kodo/manual/1235/vars#magicvar) |
||||||
|
/// 关于 <自定义变量> 请参阅:[官方文档-自定义变量](https://developer.qiniu.com/kodo/manual/1235/vars#xvar) |
||||||
|
final String returnBody; |
||||||
|
|
||||||
|
/// 上传成功后,七牛云向业务服务器发送 POST 请求的 URL。 |
||||||
|
final String callbackUrl; |
||||||
|
|
||||||
|
/// 上传成功后,七牛云向业务服务器发送回调通知时的 Host 值。 |
||||||
|
/// |
||||||
|
/// 与 [callbackUrl] 配合使用,仅当设置了 [callbackUrl] 时才有效。 |
||||||
|
final String callbackHost; |
||||||
|
|
||||||
|
/// 上传成功后发起的回调请求。 |
||||||
|
/// |
||||||
|
/// 七牛云向业务服务器发送 Content-Type: application/x-www-form-urlencoded 的 POST 请求, |
||||||
|
/// 例如:{"key":"$(key)","hash":"$(etag)","w":"$(imageInfo.width)","h":"$(imageInfo.height)"}, |
||||||
|
/// 可以使用 <魔法变量> 和 <自定义变量>。 |
||||||
|
final String callbackBody; |
||||||
|
|
||||||
|
/// 上传成功后发起的回调请求的 Content-Type。 |
||||||
|
/// |
||||||
|
/// 默认为 application/x-www-form-urlencoded,也可设置为 application/json。 |
||||||
|
final String callbackBodyType; |
||||||
|
|
||||||
|
/// 资源上传成功后触发执行的预转持久化处理指令列表。 |
||||||
|
/// |
||||||
|
/// [fileType] = 2(上传归档存储文件)时,不支持使用该参数, |
||||||
|
/// 每个指令是一个 API 规格字符串,多个指令用 ; 分隔, |
||||||
|
/// 可以使用 <魔法变量> 和 <自定义变量>, |
||||||
|
/// 改字段的具体使用信息可以查看:[官方文档-#persistentOps](https://developer.qiniu.com/kodo/manual/1206/put-policy#persistentOps) |
||||||
|
final String persistentOps; |
||||||
|
|
||||||
|
/// 接收持久化处理结果通知的 URL。 |
||||||
|
/// |
||||||
|
/// 必须是公网上可以正常进行 POST 请求并能响应 HTTP/1.1 200 OK 的有效 URL, |
||||||
|
/// 该 URL 获取的内容和持久化处理状态查询的处理结果一致, |
||||||
|
/// 发送 body 格式是 Content-Type 为 application/json 的 POST 请求, |
||||||
|
/// 需要按照读取流的形式读取请求的 body 才能获取。 |
||||||
|
final String persistentNotifyUrl; |
||||||
|
|
||||||
|
/// 转码队列名。 |
||||||
|
/// |
||||||
|
/// 资源上传成功后,触发转码时指定独立的队列进行转码, |
||||||
|
/// 为空则表示使用公用队列,处理速度比较慢。建议使用专用队列。 |
||||||
|
final String persistentPipeline; |
||||||
|
|
||||||
|
/// [saveKey] 的优先级设置。 |
||||||
|
/// |
||||||
|
/// 该设置为 true 时,[saveKey] 不能为空,会忽略客户端指定的 Key,强制使用 [saveKey] 进行文件命名。 |
||||||
|
/// 参数不设置时,默认值为 false。 |
||||||
|
final String forceSaveKey; |
||||||
|
|
||||||
|
/// 自定义资源名。 |
||||||
|
/// |
||||||
|
/// 支持<魔法变量>和<自定义变量>, [forceSaveKey] 为 false 时, |
||||||
|
/// 这个字段仅当用户上传的时候没有主动指定 key 时起作用, |
||||||
|
/// [forceSaveKey] 为 true 时,将强制按这个字段的格式命名。 |
||||||
|
final String saveKey; |
||||||
|
|
||||||
|
/// 限定上传文件大小最小值,单位 Byte。 |
||||||
|
final int fsizeMin; |
||||||
|
|
||||||
|
/// 限定上传文件大小最大值,单位 Byte。 |
||||||
|
/// |
||||||
|
/// 超过限制上传文件大小的最大值会被判为上传失败,返回 413 状态码。 |
||||||
|
final int fsizeLimit; |
||||||
|
|
||||||
|
/// 开启 MimeType 侦测功能。 |
||||||
|
final int detectMime; |
||||||
|
|
||||||
|
/// 限定用户上传的文件类型。 |
||||||
|
final String mimeLimit; |
||||||
|
|
||||||
|
/// 文件存储类型 |
||||||
|
/// |
||||||
|
/// 0 为标准存储(默认), |
||||||
|
/// 1 为低频存储, |
||||||
|
/// 2 为归档存储。 |
||||||
|
final int fileType; |
||||||
|
|
||||||
|
const PutPolicy({ |
||||||
|
@required this.scope, |
||||||
|
@required this.deadline, |
||||||
|
this.isPrefixalScope, |
||||||
|
this.insertOnly, |
||||||
|
this.endUser, |
||||||
|
this.returnUrl, |
||||||
|
this.returnBody, |
||||||
|
this.callbackUrl, |
||||||
|
this.callbackHost, |
||||||
|
this.callbackBody, |
||||||
|
this.callbackBodyType, |
||||||
|
this.persistentOps, |
||||||
|
this.persistentNotifyUrl, |
||||||
|
this.persistentPipeline, |
||||||
|
this.forceSaveKey, |
||||||
|
this.saveKey, |
||||||
|
this.fsizeMin, |
||||||
|
this.fsizeLimit, |
||||||
|
this.detectMime, |
||||||
|
this.mimeLimit, |
||||||
|
this.fileType, |
||||||
|
}) : assert(scope != null), |
||||||
|
assert(deadline != null); |
||||||
|
|
||||||
|
Map<String, dynamic> toJson() { |
||||||
|
return <String, dynamic>{ |
||||||
|
'scope': scope, |
||||||
|
'isPrefixalScope': isPrefixalScope, |
||||||
|
'deadline': deadline, |
||||||
|
'insertOnly': insertOnly, |
||||||
|
'endUser': endUser, |
||||||
|
'returnUrl': returnUrl, |
||||||
|
'returnBody': returnBody, |
||||||
|
'callbackUrl': callbackUrl, |
||||||
|
'callbackHost': callbackHost, |
||||||
|
'callbackBody': callbackBody, |
||||||
|
'callbackBodyType': callbackBodyType, |
||||||
|
'persistentOps': persistentOps, |
||||||
|
'persistentNotifyUrl': persistentNotifyUrl, |
||||||
|
'persistentPipeline': persistentPipeline, |
||||||
|
'forceSaveKey': forceSaveKey, |
||||||
|
'saveKey': saveKey, |
||||||
|
'fsizeMin': fsizeMin, |
||||||
|
'fsizeLimit': fsizeLimit, |
||||||
|
'detectMime': detectMime, |
||||||
|
'mimeLimit': mimeLimit, |
||||||
|
'fileType': fileType, |
||||||
|
}..removeWhere((key, dynamic value) => value == null); |
||||||
|
} |
||||||
|
|
||||||
|
factory PutPolicy.fromJson(Map<String, dynamic> json) { |
||||||
|
return PutPolicy( |
||||||
|
scope: json['scope'] as String, |
||||||
|
deadline: json['deadline'] as int, |
||||||
|
isPrefixalScope: json['isPrefixalScope'] as int, |
||||||
|
insertOnly: json['insertOnly'] as int, |
||||||
|
endUser: json['endUser'] as String, |
||||||
|
returnUrl: json['returnUrl'] as String, |
||||||
|
returnBody: json['returnBody'] as String, |
||||||
|
callbackUrl: json['callbackUrl'] as String, |
||||||
|
callbackHost: json['callbackHost'] as String, |
||||||
|
callbackBody: json['callbackBody'] as String, |
||||||
|
callbackBodyType: json['callbackBodyType'] as String, |
||||||
|
persistentOps: json['persistentOps'] as String, |
||||||
|
persistentNotifyUrl: json['persistentNotifyUrl'] as String, |
||||||
|
persistentPipeline: json['persistentPipeline'] as String, |
||||||
|
forceSaveKey: json['forceSaveKey'] as String, |
||||||
|
saveKey: json['saveKey'] as String, |
||||||
|
fsizeMin: json['fsizeMin'] as int, |
||||||
|
fsizeLimit: json['fsizeLimit'] as int, |
||||||
|
detectMime: json['detectMime'] as int, |
||||||
|
mimeLimit: json['mimeLimit'] as String, |
||||||
|
fileType: json['fileType'] as int, |
||||||
|
); |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,12 @@ |
|||||||
|
class QiniuError extends Error { |
||||||
|
final Error rawError; |
||||||
|
|
||||||
|
final String _message; |
||||||
|
|
||||||
|
String get message => _message ?? rawError?.toString() ?? ''; |
||||||
|
|
||||||
|
@override |
||||||
|
StackTrace get stackTrace => rawError?.stackTrace ?? super.stackTrace; |
||||||
|
|
||||||
|
QiniuError({this.rawError, String message}) : _message = message; |
||||||
|
} |
@ -0,0 +1,39 @@ |
|||||||
|
part of 'config.dart'; |
||||||
|
|
||||||
|
abstract class CacheProvider { |
||||||
|
/// 设置一对数据 |
||||||
|
Future setItem(String key, String item); |
||||||
|
|
||||||
|
/// 根据 key 获取缓存 |
||||||
|
Future<String> getItem(String key); |
||||||
|
|
||||||
|
/// 删除指定 key 的缓存 |
||||||
|
Future removeItem(String key); |
||||||
|
|
||||||
|
/// 清除所有 |
||||||
|
Future clear(); |
||||||
|
} |
||||||
|
|
||||||
|
class DefaultCacheProvider extends CacheProvider { |
||||||
|
Map<String, String> value = {}; |
||||||
|
|
||||||
|
@override |
||||||
|
Future clear() async { |
||||||
|
value.clear(); |
||||||
|
} |
||||||
|
|
||||||
|
@override |
||||||
|
Future<String> getItem(String key) async { |
||||||
|
return value[key]; |
||||||
|
} |
||||||
|
|
||||||
|
@override |
||||||
|
Future removeItem(String key) async { |
||||||
|
value.remove(key); |
||||||
|
} |
||||||
|
|
||||||
|
@override |
||||||
|
Future setItem(String key, String item) async { |
||||||
|
value[key] = item; |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,28 @@ |
|||||||
|
import 'package:dio/adapter.dart'; |
||||||
|
import 'package:dio/dio.dart'; |
||||||
|
import 'package:meta/meta.dart'; |
||||||
|
import 'package:qiniu_sdk_base/src/storage/error/error.dart'; |
||||||
|
|
||||||
|
part 'protocol.dart'; |
||||||
|
part 'host.dart'; |
||||||
|
part 'cache.dart'; |
||||||
|
|
||||||
|
class Config { |
||||||
|
final HostProvider hostProvider; |
||||||
|
final CacheProvider cacheProvider; |
||||||
|
final HttpClientAdapter httpClientAdapter; |
||||||
|
|
||||||
|
/// 重试次数 |
||||||
|
/// |
||||||
|
/// 各种网络请求失败的重试次数 |
||||||
|
final int retryLimit; |
||||||
|
|
||||||
|
Config({ |
||||||
|
HostProvider hostProvider, |
||||||
|
CacheProvider cacheProvider, |
||||||
|
HttpClientAdapter httpClientAdapter, |
||||||
|
this.retryLimit = 3, |
||||||
|
}) : hostProvider = hostProvider ?? DefaultHostProvider(), |
||||||
|
cacheProvider = cacheProvider ?? DefaultCacheProvider(), |
||||||
|
httpClientAdapter = httpClientAdapter ?? DefaultHttpClientAdapter(); |
||||||
|
} |
@ -0,0 +1,124 @@ |
|||||||
|
part of 'config.dart'; |
||||||
|
|
||||||
|
abstract class HostProvider { |
||||||
|
Future<String> getUpHost({ |
||||||
|
@required String accessKey, |
||||||
|
@required String bucket, |
||||||
|
}); |
||||||
|
|
||||||
|
bool isFrozen(String host); |
||||||
|
|
||||||
|
void freezeHost(String host); |
||||||
|
} |
||||||
|
|
||||||
|
class DefaultHostProvider extends HostProvider { |
||||||
|
final protocol = Protocol.Https.value; |
||||||
|
|
||||||
|
final _http = Dio(); |
||||||
|
// 缓存的上传区域 |
||||||
|
final _stashedUpDomains = <_Domain>[]; |
||||||
|
// accessKey:bucket 用此 key 判断是否 up host 需要走缓存 |
||||||
|
String _cacheKey; |
||||||
|
// 冻结的上传区域 |
||||||
|
final List<_Domain> _frozenUpDomains = []; |
||||||
|
|
||||||
|
@override |
||||||
|
Future<String> getUpHost({ |
||||||
|
@required String accessKey, |
||||||
|
@required String bucket, |
||||||
|
}) async { |
||||||
|
// 解冻需要被解冻的 host |
||||||
|
_frozenUpDomains.removeWhere((domain) => !domain.isFrozen()); |
||||||
|
|
||||||
|
var _upDomains = <_Domain>[]; |
||||||
|
if ('$accessKey:$bucket' == _cacheKey && _stashedUpDomains.isNotEmpty) { |
||||||
|
_upDomains.addAll(_stashedUpDomains); |
||||||
|
} else { |
||||||
|
final url = |
||||||
|
'$protocol://api.qiniu.com/v4/query?ak=$accessKey&bucket=$bucket'; |
||||||
|
|
||||||
|
final res = await _http.get<Map>(url); |
||||||
|
|
||||||
|
final hosts = res.data['hosts'] |
||||||
|
.map((dynamic json) => _Host.fromJson(json as Map)) |
||||||
|
.cast<_Host>() |
||||||
|
.toList() as List<_Host>; |
||||||
|
|
||||||
|
for (var host in hosts) { |
||||||
|
final domainList = host.up['domains'].cast<String>() as List<String>; |
||||||
|
final domains = domainList.map((domain) => _Domain(domain)); |
||||||
|
_upDomains.addAll(domains); |
||||||
|
} |
||||||
|
|
||||||
|
_cacheKey = '$accessKey:$bucket'; |
||||||
|
_stashedUpDomains.addAll(_upDomains); |
||||||
|
} |
||||||
|
|
||||||
|
// 每次都从头遍历一遍,最合适的 host 总是会排在最前面 |
||||||
|
for (var index = 0; index < _upDomains.length; index++) { |
||||||
|
final availableDomain = _upDomains.elementAt(index); |
||||||
|
// 检查看起来可用的 host 是否之前被冻结过 |
||||||
|
final frozen = isFrozen(protocol + '://' + availableDomain.value); |
||||||
|
|
||||||
|
if (!frozen) { |
||||||
|
return protocol + '://' + availableDomain.value; |
||||||
|
} |
||||||
|
} |
||||||
|
// 全部被冻结,几乎不存在的情况 |
||||||
|
throw StorageError( |
||||||
|
type: StorageErrorType.NO_AVAILABLE_HOST, |
||||||
|
message: '没有可用的上传域名', |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
@override |
||||||
|
bool isFrozen(String host) { |
||||||
|
final uri = Uri.parse(host); |
||||||
|
final frozenDomain = _frozenUpDomains.firstWhere( |
||||||
|
(domain) => domain.isFrozen() && domain.value == uri.host, |
||||||
|
orElse: () => null); |
||||||
|
return frozenDomain != null; |
||||||
|
} |
||||||
|
|
||||||
|
@override |
||||||
|
void freezeHost(String host) { |
||||||
|
// http://example.org |
||||||
|
// scheme: http |
||||||
|
// host: example.org |
||||||
|
final uri = Uri.parse(host); |
||||||
|
_frozenUpDomains.add(_Domain(uri.host)..freeze()); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
class _Host { |
||||||
|
String region; |
||||||
|
int ttl; |
||||||
|
// domains: [] |
||||||
|
Map<String, dynamic> up; |
||||||
|
|
||||||
|
_Host({this.region, this.ttl, this.up}); |
||||||
|
|
||||||
|
factory _Host.fromJson(Map json) { |
||||||
|
return _Host( |
||||||
|
region: json['region'] as String, |
||||||
|
ttl: json['ttl'] as int, |
||||||
|
up: json['up'] as Map<String, dynamic>, |
||||||
|
); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
class _Domain { |
||||||
|
int frozenTime = 0; |
||||||
|
final _lockTime = 1000 * 60 * 10; |
||||||
|
|
||||||
|
bool isFrozen() { |
||||||
|
return frozenTime + _lockTime > DateTime.now().millisecondsSinceEpoch; |
||||||
|
} |
||||||
|
|
||||||
|
void freeze() { |
||||||
|
frozenTime = DateTime.now().millisecondsSinceEpoch; |
||||||
|
} |
||||||
|
|
||||||
|
String value; |
||||||
|
_Domain(this.value); |
||||||
|
} |
@ -0,0 +1,11 @@ |
|||||||
|
part of 'config.dart'; |
||||||
|
|
||||||
|
enum Protocol { Http, Https } |
||||||
|
|
||||||
|
extension ProtocolExt on Protocol { |
||||||
|
String get value { |
||||||
|
if (this == Protocol.Http) return 'http'; |
||||||
|
if (this == Protocol.Https) return 'https'; |
||||||
|
return 'https'; |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,75 @@ |
|||||||
|
import 'package:dio/dio.dart'; |
||||||
|
import 'package:qiniu_sdk_base/src/error/error.dart'; |
||||||
|
|
||||||
|
enum StorageErrorType { |
||||||
|
/// 连接超时 |
||||||
|
CONNECT_TIMEOUT, |
||||||
|
|
||||||
|
/// 发送超时 |
||||||
|
SEND_TIMEOUT, |
||||||
|
|
||||||
|
/// 接收超时 |
||||||
|
RECEIVE_TIMEOUT, |
||||||
|
|
||||||
|
/// 服务端响应了但是状态码是 400 以上 |
||||||
|
RESPONSE, |
||||||
|
|
||||||
|
/// 请求被取消 |
||||||
|
CANCEL, |
||||||
|
|
||||||
|
/// 没有可用的服务器 |
||||||
|
NO_AVAILABLE_HOST, |
||||||
|
|
||||||
|
/// 已在处理队列中 |
||||||
|
IN_PROGRESS, |
||||||
|
|
||||||
|
/// 未知或者不能处理的错误 |
||||||
|
UNKNOWN, |
||||||
|
} |
||||||
|
|
||||||
|
class StorageError extends QiniuError { |
||||||
|
/// [type] 不是 [StorageErrorType.RESPONSE] 的时候为 null |
||||||
|
final int code; |
||||||
|
final StorageErrorType type; |
||||||
|
|
||||||
|
StorageError({this.type, this.code, Error rawError, String message}) |
||||||
|
: super(rawError: rawError, message: message); |
||||||
|
|
||||||
|
factory StorageError.fromError(Error error) { |
||||||
|
return StorageError(type: StorageErrorType.UNKNOWN, rawError: error); |
||||||
|
} |
||||||
|
|
||||||
|
factory StorageError.fromDioError(DioError error) { |
||||||
|
return StorageError( |
||||||
|
type: _mapDioErrorType(error.type), |
||||||
|
code: error.response?.statusCode, |
||||||
|
message: error.response?.data.toString(), |
||||||
|
rawError: error.error is Error ? (error.error as Error) : null, |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
@override |
||||||
|
String toString() { |
||||||
|
var msg = 'StorageError [$type, $code]: $message'; |
||||||
|
msg += '\n$stackTrace'; |
||||||
|
return msg; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
StorageErrorType _mapDioErrorType(DioErrorType type) { |
||||||
|
switch (type) { |
||||||
|
case DioErrorType.CONNECT_TIMEOUT: |
||||||
|
return StorageErrorType.CONNECT_TIMEOUT; |
||||||
|
case DioErrorType.SEND_TIMEOUT: |
||||||
|
return StorageErrorType.SEND_TIMEOUT; |
||||||
|
case DioErrorType.RECEIVE_TIMEOUT: |
||||||
|
return StorageErrorType.RECEIVE_TIMEOUT; |
||||||
|
case DioErrorType.RESPONSE: |
||||||
|
return StorageErrorType.RESPONSE; |
||||||
|
case DioErrorType.CANCEL: |
||||||
|
return StorageErrorType.CANCEL; |
||||||
|
case DioErrorType.DEFAULT: |
||||||
|
default: |
||||||
|
return StorageErrorType.UNKNOWN; |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,20 @@ |
|||||||
|
part of 'put_parts_task.dart'; |
||||||
|
|
||||||
|
/// 分片上传用到的缓存 mixin |
||||||
|
/// |
||||||
|
/// 分片上传的初始化文件、上传分片都应该以此实现缓存控制策略 |
||||||
|
mixin CacheMixin<T> on RequestTask<T> { |
||||||
|
String get _cacheKey; |
||||||
|
|
||||||
|
Future clearCache() async { |
||||||
|
await config.cacheProvider.removeItem(_cacheKey); |
||||||
|
} |
||||||
|
|
||||||
|
Future setCache(String data) async { |
||||||
|
await config.cacheProvider.setItem(_cacheKey, data); |
||||||
|
} |
||||||
|
|
||||||
|
Future<String> getCache() async { |
||||||
|
return await config.cacheProvider.getItem(_cacheKey); |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,51 @@ |
|||||||
|
part of 'put_parts_task.dart'; |
||||||
|
|
||||||
|
/// 创建文件,把切片信息合成为一个文件 |
||||||
|
class CompletePartsTask extends RequestTask<PutResponse> { |
||||||
|
final String token; |
||||||
|
final String uploadId; |
||||||
|
final List<Part> parts; |
||||||
|
final String key; |
||||||
|
|
||||||
|
TokenInfo _tokenInfo; |
||||||
|
|
||||||
|
CompletePartsTask({ |
||||||
|
@required this.token, |
||||||
|
@required this.uploadId, |
||||||
|
@required this.parts, |
||||||
|
this.key, |
||||||
|
PutController controller, |
||||||
|
}) : super(controller: controller); |
||||||
|
|
||||||
|
@override |
||||||
|
void preStart() { |
||||||
|
_tokenInfo = Auth.parseUpToken(token); |
||||||
|
super.preStart(); |
||||||
|
} |
||||||
|
|
||||||
|
@override |
||||||
|
Future<PutResponse> createTask() async { |
||||||
|
final bucket = _tokenInfo.putPolicy.getBucket(); |
||||||
|
|
||||||
|
final host = await config.hostProvider.getUpHost( |
||||||
|
bucket: bucket, |
||||||
|
accessKey: _tokenInfo.accessKey, |
||||||
|
); |
||||||
|
final headers = <String, dynamic>{'Authorization': 'UpToken $token'}; |
||||||
|
final encodedKey = key != null ? base64Url.encode(utf8.encode(key)) : '~'; |
||||||
|
final paramUrl = |
||||||
|
'$host/buckets/$bucket/objects/$encodedKey/uploads/$uploadId'; |
||||||
|
|
||||||
|
final response = await client.post<Map<String, dynamic>>( |
||||||
|
paramUrl, |
||||||
|
data: { |
||||||
|
'parts': parts |
||||||
|
..sort((a, b) => a.partNumber - b.partNumber) |
||||||
|
..map((part) => part.toJson()).toList() |
||||||
|
}, |
||||||
|
options: Options(headers: headers), |
||||||
|
); |
||||||
|
|
||||||
|
return PutResponse.fromJson(response.data); |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,92 @@ |
|||||||
|
part of 'put_parts_task.dart'; |
||||||
|
|
||||||
|
/// initParts 的返回体 |
||||||
|
class InitParts { |
||||||
|
final int expireAt; |
||||||
|
final String uploadId; |
||||||
|
|
||||||
|
InitParts({ |
||||||
|
@required this.expireAt, |
||||||
|
@required this.uploadId, |
||||||
|
}); |
||||||
|
|
||||||
|
factory InitParts.fromJson(Map json) { |
||||||
|
return InitParts( |
||||||
|
uploadId: json['uploadId'] as String, |
||||||
|
expireAt: json['expireAt'] as int, |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
Map<String, dynamic> toJson() { |
||||||
|
return <String, dynamic>{ |
||||||
|
'uploadId': uploadId, |
||||||
|
'expireAt': expireAt, |
||||||
|
}; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/// 初始化一个分片上传任务,为 [UploadPartsTask] 提供 uploadId |
||||||
|
class InitPartsTask extends RequestTask<InitParts> with CacheMixin<InitParts> { |
||||||
|
final File file; |
||||||
|
final String token; |
||||||
|
final String key; |
||||||
|
|
||||||
|
@override |
||||||
|
String _cacheKey; |
||||||
|
TokenInfo _tokenInfo; |
||||||
|
|
||||||
|
InitPartsTask({ |
||||||
|
@required this.file, |
||||||
|
@required this.token, |
||||||
|
this.key, |
||||||
|
PutController controller, |
||||||
|
}) : super(controller: controller); |
||||||
|
|
||||||
|
static String getCacheKey(String path, int length, String key) { |
||||||
|
return 'qiniu_dart_sdk_init_parts_task_${path}_key_${key}_size_$length'; |
||||||
|
} |
||||||
|
|
||||||
|
@override |
||||||
|
void preStart() { |
||||||
|
_tokenInfo = Auth.parseUpToken(token); |
||||||
|
_cacheKey = InitPartsTask.getCacheKey(file.path, file.lengthSync(), key); |
||||||
|
super.preStart(); |
||||||
|
} |
||||||
|
|
||||||
|
@override |
||||||
|
Future<InitParts> createTask() async { |
||||||
|
final headers = {'Authorization': 'UpToken $token'}; |
||||||
|
|
||||||
|
final initPartsCache = await getCache(); |
||||||
|
if (initPartsCache != null) { |
||||||
|
return InitParts.fromJson( |
||||||
|
json.decode(initPartsCache) as Map<String, dynamic>); |
||||||
|
} |
||||||
|
|
||||||
|
final bucket = _tokenInfo.putPolicy.getBucket(); |
||||||
|
|
||||||
|
final host = await config.hostProvider.getUpHost( |
||||||
|
bucket: bucket, |
||||||
|
accessKey: _tokenInfo.accessKey, |
||||||
|
); |
||||||
|
|
||||||
|
final encodedKey = key != null ? base64Url.encode(utf8.encode(key)) : '~'; |
||||||
|
final paramUrl = '$host/buckets/$bucket/objects/$encodedKey/uploads'; |
||||||
|
|
||||||
|
final response = await client.post<Map<String, dynamic>>( |
||||||
|
paramUrl, |
||||||
|
|
||||||
|
/// 这里 data 不传,dio 不会触发 cancel 事件 |
||||||
|
data: <String, dynamic>{}, |
||||||
|
options: Options(headers: headers), |
||||||
|
); |
||||||
|
|
||||||
|
return InitParts.fromJson(response.data); |
||||||
|
} |
||||||
|
|
||||||
|
@override |
||||||
|
void postReceive(data) async { |
||||||
|
await setCache(json.encode(data.toJson())); |
||||||
|
super.postReceive(data); |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,26 @@ |
|||||||
|
part of 'put_parts_task.dart'; |
||||||
|
|
||||||
|
/// 切片信息 |
||||||
|
class Part { |
||||||
|
final String etag; |
||||||
|
final int partNumber; |
||||||
|
|
||||||
|
Part({ |
||||||
|
@required this.etag, |
||||||
|
@required this.partNumber, |
||||||
|
}); |
||||||
|
|
||||||
|
factory Part.fromJson(Map<String, dynamic> json) { |
||||||
|
return Part( |
||||||
|
etag: json['etag'] as String, |
||||||
|
partNumber: json['partNumber'] as int, |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
Map<String, dynamic> toJson() { |
||||||
|
return <String, dynamic>{ |
||||||
|
'etag': etag, |
||||||
|
'partNumber': partNumber, |
||||||
|
}; |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,25 @@ |
|||||||
|
import '../put_controller.dart'; |
||||||
|
|
||||||
|
class PutByPartOptions { |
||||||
|
/// 资源名 |
||||||
|
/// 如果不传则后端自动生成 |
||||||
|
final String key; |
||||||
|
|
||||||
|
/// 切片大小,单位 MB |
||||||
|
/// |
||||||
|
/// 超出 [partSize] 的文件大小会把每片按照 [partSize] 的大小切片并上传 |
||||||
|
/// 默认 4MB,最小不得小于 1MB,最大不得大于 1024 MB |
||||||
|
final int partSize; |
||||||
|
|
||||||
|
final int maxPartsRequestNumber; |
||||||
|
|
||||||
|
/// 控制器 |
||||||
|
final PutController controller; |
||||||
|
|
||||||
|
const PutByPartOptions({ |
||||||
|
this.key, |
||||||
|
this.partSize = 4, |
||||||
|
this.maxPartsRequestNumber = 5, |
||||||
|
this.controller, |
||||||
|
}); |
||||||
|
} |
@ -0,0 +1,187 @@ |
|||||||
|
import 'dart:async'; |
||||||
|
import 'dart:convert'; |
||||||
|
import 'dart:io'; |
||||||
|
import 'dart:math'; |
||||||
|
|
||||||
|
import 'package:dio/dio.dart'; |
||||||
|
import 'package:meta/meta.dart'; |
||||||
|
import 'package:qiniu_sdk_base/qiniu_sdk_base.dart'; |
||||||
|
|
||||||
|
part 'cache_mixin.dart'; |
||||||
|
part 'complete_parts_task.dart'; |
||||||
|
part 'init_parts_task.dart'; |
||||||
|
part 'part.dart'; |
||||||
|
part 'upload_part_task.dart'; |
||||||
|
part 'upload_parts_task.dart'; |
||||||
|
|
||||||
|
/// 分片上传任务 |
||||||
|
class PutByPartTask extends RequestTask<PutResponse> { |
||||||
|
final File file; |
||||||
|
final String token; |
||||||
|
|
||||||
|
final int partSize; |
||||||
|
final int maxPartsRequestNumber; |
||||||
|
|
||||||
|
final String key; |
||||||
|
|
||||||
|
/// 设置为 0,避免子任务重试失败后 [PutByPartTask] 继续重试 |
||||||
|
@override |
||||||
|
int get retryLimit => 0; |
||||||
|
|
||||||
|
PutByPartTask({ |
||||||
|
@required this.file, |
||||||
|
@required this.token, |
||||||
|
@required this.partSize, |
||||||
|
@required this.maxPartsRequestNumber, |
||||||
|
this.key, |
||||||
|
PutController controller, |
||||||
|
}) : assert(file != null), |
||||||
|
assert(token != null), |
||||||
|
assert(partSize != null), |
||||||
|
assert(maxPartsRequestNumber != null), |
||||||
|
assert(() { |
||||||
|
if (partSize < 1 || partSize > 1024) { |
||||||
|
throw RangeError.range(partSize, 1, 1024, 'partSize', |
||||||
|
'partSize must be greater than 1 and less than 1024'); |
||||||
|
} |
||||||
|
return true; |
||||||
|
}()), |
||||||
|
super(controller: controller); |
||||||
|
|
||||||
|
RequestTaskController _currentWorkingTaskController; |
||||||
|
|
||||||
|
@override |
||||||
|
void preStart() { |
||||||
|
super.preStart(); |
||||||
|
|
||||||
|
// 处理相同任务 |
||||||
|
final sameTaskExsist = manager.getTasks().firstWhere( |
||||||
|
(element) => element is PutByPartTask && isEquals(element), |
||||||
|
orElse: () => null, |
||||||
|
); |
||||||
|
|
||||||
|
if (sameTaskExsist != null) { |
||||||
|
throw StorageError( |
||||||
|
type: StorageErrorType.IN_PROGRESS, |
||||||
|
message: '$file 已在上传队列中', |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
// controller 被取消后取消当前运行的子任务 |
||||||
|
controller?.cancelToken?.whenCancel?.then((_) { |
||||||
|
_currentWorkingTaskController?.cancel(); |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
@override |
||||||
|
void postReceive(PutResponse data) { |
||||||
|
_currentWorkingTaskController = null; |
||||||
|
super.postReceive(data); |
||||||
|
} |
||||||
|
|
||||||
|
@override |
||||||
|
Future<PutResponse> createTask() async { |
||||||
|
controller?.notifyStatusListeners(StorageStatus.Request); |
||||||
|
|
||||||
|
final initPartsTask = _createInitParts(); |
||||||
|
final initParts = await initPartsTask.future; |
||||||
|
|
||||||
|
// 初始化任务完成后也告诉外部一个进度 |
||||||
|
controller?.notifyProgressListeners(0.002); |
||||||
|
|
||||||
|
final uploadParts = _createUploadParts(initParts.uploadId); |
||||||
|
|
||||||
|
PutResponse putResponse; |
||||||
|
try { |
||||||
|
final parts = await uploadParts.future; |
||||||
|
putResponse = |
||||||
|
await _createCompleteParts(initParts.uploadId, parts).future; |
||||||
|
} catch (error) { |
||||||
|
// 拿不到 initPartsTask 和 uploadParts 的引用,所以不放到 postError 去 |
||||||
|
if (error is StorageError) { |
||||||
|
/// 满足以下两种情况清理缓存: |
||||||
|
/// 1、如果服务端文件被删除了,清除本地缓存 |
||||||
|
/// 2、如果 uploadId 等参数不对原因会导致 400 |
||||||
|
if (error.code == 612 || error.code == 400) { |
||||||
|
await initPartsTask.clearCache(); |
||||||
|
await uploadParts.clearCache(); |
||||||
|
} |
||||||
|
|
||||||
|
/// 如果服务端文件被删除了,重新上传 |
||||||
|
if (error.code == 612) { |
||||||
|
controller?.notifyStatusListeners(StorageStatus.Retry); |
||||||
|
return createTask(); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
rethrow; |
||||||
|
} |
||||||
|
|
||||||
|
/// 上传完成,清除缓存 |
||||||
|
await initPartsTask.clearCache(); |
||||||
|
await uploadParts.clearCache(); |
||||||
|
|
||||||
|
return putResponse; |
||||||
|
} |
||||||
|
|
||||||
|
bool isEquals(PutByPartTask target) { |
||||||
|
return target.file.path == file.path && |
||||||
|
target.key == key && |
||||||
|
target.file.lengthSync() == file.lengthSync(); |
||||||
|
} |
||||||
|
|
||||||
|
/// 初始化上传信息,分片上传的第一步 |
||||||
|
InitPartsTask _createInitParts() { |
||||||
|
final _controller = PutController(); |
||||||
|
|
||||||
|
final task = InitPartsTask( |
||||||
|
file: file, |
||||||
|
token: token, |
||||||
|
key: key, |
||||||
|
controller: _controller, |
||||||
|
); |
||||||
|
|
||||||
|
manager.addTask(task); |
||||||
|
_currentWorkingTaskController = _controller; |
||||||
|
return task; |
||||||
|
} |
||||||
|
|
||||||
|
UploadPartsTask _createUploadParts(String uploadId) { |
||||||
|
final _controller = PutController(); |
||||||
|
|
||||||
|
final task = UploadPartsTask( |
||||||
|
file: file, |
||||||
|
token: token, |
||||||
|
partSize: partSize, |
||||||
|
uploadId: uploadId, |
||||||
|
maxPartsRequestNumber: maxPartsRequestNumber, |
||||||
|
key: key, |
||||||
|
controller: _controller, |
||||||
|
); |
||||||
|
|
||||||
|
_controller.addSendProgressListener(onSendProgress); |
||||||
|
|
||||||
|
manager.addTask(task); |
||||||
|
_currentWorkingTaskController = _controller; |
||||||
|
return task; |
||||||
|
} |
||||||
|
|
||||||
|
/// 创建文件,分片上传的最后一步 |
||||||
|
CompletePartsTask _createCompleteParts( |
||||||
|
String uploadId, |
||||||
|
List<Part> parts, |
||||||
|
) { |
||||||
|
final _controller = PutController(); |
||||||
|
final task = CompletePartsTask( |
||||||
|
token: token, |
||||||
|
uploadId: uploadId, |
||||||
|
parts: parts, |
||||||
|
key: key, |
||||||
|
controller: _controller, |
||||||
|
); |
||||||
|
|
||||||
|
manager.addTask(task); |
||||||
|
_currentWorkingTaskController = _controller; |
||||||
|
return task; |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,112 @@ |
|||||||
|
part of 'put_parts_task.dart'; |
||||||
|
|
||||||
|
// 上传一个 part 的任务 |
||||||
|
class UploadPartTask extends RequestTask<UploadPart> { |
||||||
|
final String token; |
||||||
|
final String uploadId; |
||||||
|
final RandomAccessFile raf; |
||||||
|
final int partSize; |
||||||
|
|
||||||
|
// 如果 data 是 Stream 的话,Dio 需要判断 content-length 才会调用 onSendProgress |
||||||
|
// https://github.com/flutterchina/dio/blob/21136168ab39a7536835c7a59ce0465bb05feed4/dio/lib/src/dio.dart#L1000 |
||||||
|
final int byteLength; |
||||||
|
|
||||||
|
final int partNumber; |
||||||
|
|
||||||
|
final String key; |
||||||
|
|
||||||
|
TokenInfo _tokenInfo; |
||||||
|
|
||||||
|
UploadPartTask({ |
||||||
|
@required this.token, |
||||||
|
@required this.raf, |
||||||
|
@required this.uploadId, |
||||||
|
@required this.byteLength, |
||||||
|
@required this.partNumber, |
||||||
|
@required this.partSize, |
||||||
|
this.key, |
||||||
|
PutController controller, |
||||||
|
}) : super(controller: controller); |
||||||
|
|
||||||
|
@override |
||||||
|
void preStart() { |
||||||
|
_tokenInfo = Auth.parseUpToken(token); |
||||||
|
super.preStart(); |
||||||
|
} |
||||||
|
|
||||||
|
@override |
||||||
|
void postReceive(data) { |
||||||
|
controller?.notifyProgressListeners(1); |
||||||
|
super.postReceive(data); |
||||||
|
} |
||||||
|
|
||||||
|
@override |
||||||
|
Future<UploadPart> createTask() async { |
||||||
|
final headers = <String, dynamic>{ |
||||||
|
'Authorization': 'UpToken $token', |
||||||
|
Headers.contentLengthHeader: byteLength, |
||||||
|
}; |
||||||
|
|
||||||
|
final bucket = _tokenInfo.putPolicy.getBucket(); |
||||||
|
|
||||||
|
final host = await config.hostProvider.getUpHost( |
||||||
|
bucket: bucket, |
||||||
|
accessKey: _tokenInfo.accessKey, |
||||||
|
); |
||||||
|
|
||||||
|
final encodedKey = key != null ? base64Url.encode(utf8.encode(key)) : '~'; |
||||||
|
final paramUrl = 'buckets/$bucket/objects/$encodedKey'; |
||||||
|
|
||||||
|
final response = await client.put<Map<String, dynamic>>( |
||||||
|
'$host/$paramUrl/uploads/$uploadId/$partNumber', |
||||||
|
data: Stream.fromIterable([_readFileByPartNumber(partNumber)]), |
||||||
|
// 在 data 是 stream 的场景下, interceptor 传入 cancelToken 这里不传会有 bug |
||||||
|
cancelToken: controller.cancelToken, |
||||||
|
options: Options(headers: headers), |
||||||
|
); |
||||||
|
|
||||||
|
return UploadPart.fromJson(response.data); |
||||||
|
} |
||||||
|
|
||||||
|
// 分片上传是手动从 File 拿一段数据大概 4m(直穿是直接从 File 里面读取) |
||||||
|
// 如果文件是 21m,假设切片是 4 * 5 |
||||||
|
// 外部进度的话会导致一下长到 90% 多,然后变成 100% |
||||||
|
// 解决方法是覆盖父类的 onSendProgress,让 onSendProgress 不处理 Progress 的进度 |
||||||
|
// 改为发送成功后通知(见 postReceive) |
||||||
|
@override |
||||||
|
void onSendProgress(double percent) { |
||||||
|
controller?.notifySendProgressListeners(percent); |
||||||
|
} |
||||||
|
|
||||||
|
// 根据 partNumber 获取要上传的文件片段 |
||||||
|
List<int> _readFileByPartNumber(int partNumber) { |
||||||
|
final startOffset = (partNumber - 1) * partSize * 1024 * 1024; |
||||||
|
raf.setPositionSync(startOffset); |
||||||
|
return raf.readSync(byteLength); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// uploadPart 的返回体 |
||||||
|
class UploadPart { |
||||||
|
final String md5; |
||||||
|
final String etag; |
||||||
|
|
||||||
|
UploadPart({ |
||||||
|
@required this.md5, |
||||||
|
@required this.etag, |
||||||
|
}); |
||||||
|
|
||||||
|
factory UploadPart.fromJson(Map json) { |
||||||
|
return UploadPart( |
||||||
|
md5: json['md5'] as String, |
||||||
|
etag: json['etag'] as String, |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
Map<String, dynamic> toJson() { |
||||||
|
return <String, dynamic>{ |
||||||
|
'etag': etag, |
||||||
|
'md5': md5, |
||||||
|
}; |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,260 @@ |
|||||||
|
part of 'put_parts_task.dart'; |
||||||
|
|
||||||
|
// 批处理上传 parts 的任务,为 [CompletePartsTask] 提供 [Part] |
||||||
|
class UploadPartsTask extends RequestTask<List<Part>> with CacheMixin { |
||||||
|
final File file; |
||||||
|
final String token; |
||||||
|
final String uploadId; |
||||||
|
|
||||||
|
final int partSize; |
||||||
|
final int maxPartsRequestNumber; |
||||||
|
|
||||||
|
final String key; |
||||||
|
|
||||||
|
@override |
||||||
|
String _cacheKey; |
||||||
|
|
||||||
|
/// 设置为 0,避免子任务重试失败后 [UploadPartsTask] 继续重试 |
||||||
|
@override |
||||||
|
int get retryLimit => 0; |
||||||
|
|
||||||
|
// 文件 bytes 长度 |
||||||
|
int _fileByteLength; |
||||||
|
|
||||||
|
// 每个上传分片的字节长度 |
||||||
|
// |
||||||
|
// 文件会按照此长度切片 |
||||||
|
int _partByteLength; |
||||||
|
|
||||||
|
// 文件总共被拆分的分片数 |
||||||
|
int _totalPartCount; |
||||||
|
|
||||||
|
// 上传成功后把 part 信息存起来 |
||||||
|
final Map<int, Part> _uploadedPartMap = {}; |
||||||
|
|
||||||
|
// 处理分片上传任务的 UploadPartTask 的控制器 |
||||||
|
final List<RequestTaskController> _workingUploadPartTaskControllers = []; |
||||||
|
|
||||||
|
// 已发送分片数量 |
||||||
|
int _sentPartCount = 0; |
||||||
|
|
||||||
|
// 已发送到服务器的数量 |
||||||
|
int _sentPartToServerCount = 0; |
||||||
|
|
||||||
|
// 剩余多少被允许的请求数 |
||||||
|
int _idleRequestNumber; |
||||||
|
|
||||||
|
RandomAccessFile _raf; |
||||||
|
|
||||||
|
UploadPartsTask({ |
||||||
|
@required this.file, |
||||||
|
@required this.token, |
||||||
|
@required this.uploadId, |
||||||
|
@required this.partSize, |
||||||
|
@required this.maxPartsRequestNumber, |
||||||
|
this.key, |
||||||
|
PutController controller, |
||||||
|
}) : super(controller: controller); |
||||||
|
|
||||||
|
static String getCacheKey( |
||||||
|
String path, |
||||||
|
int length, |
||||||
|
int partSize, |
||||||
|
String key, |
||||||
|
) { |
||||||
|
final keyList = [ |
||||||
|
'key/$key', |
||||||
|
'path/$path', |
||||||
|
'file_size/$length', |
||||||
|
'part_size/$partSize', |
||||||
|
]; |
||||||
|
|
||||||
|
return 'qiniu_dart_sdk_upload_parts_task@[${keyList..join("/")}]'; |
||||||
|
} |
||||||
|
|
||||||
|
@override |
||||||
|
void preStart() { |
||||||
|
// 当前 controller 被取消后,所有运行中的子任务都需要被取消 |
||||||
|
controller?.cancelToken?.whenCancel?.then((_) { |
||||||
|
for (final controller in _workingUploadPartTaskControllers) { |
||||||
|
controller.cancel(); |
||||||
|
} |
||||||
|
}); |
||||||
|
_fileByteLength = file.lengthSync(); |
||||||
|
_partByteLength = partSize * 1024 * 1024; |
||||||
|
_idleRequestNumber = maxPartsRequestNumber; |
||||||
|
_totalPartCount = (_fileByteLength / _partByteLength).ceil(); |
||||||
|
_cacheKey = getCacheKey(file.path, _fileByteLength, partSize, key); |
||||||
|
// 子任务 UploadPartTask 从 file 去 open 的话虽然上传精度会颗粒更细但是会导致可能读不出文件的问题 |
||||||
|
// 可能 close 没办法立即关闭 file stream,而延迟 close 了,导致某次 open 的 stream 被立即关闭 |
||||||
|
// 所以读不出内容了 |
||||||
|
// 这里改成这里读取一次,子任务从中读取 bytes |
||||||
|
_raf = file.openSync(); |
||||||
|
super.preStart(); |
||||||
|
} |
||||||
|
|
||||||
|
@override |
||||||
|
void postReceive(data) async { |
||||||
|
await _raf.close(); |
||||||
|
super.postReceive(data); |
||||||
|
} |
||||||
|
|
||||||
|
@override |
||||||
|
void postError(Object error) async { |
||||||
|
await _raf.close(); |
||||||
|
// 取消,网络问题等可能导致上传中断,缓存已上传的分片信息 |
||||||
|
await storeUploadedPart(); |
||||||
|
super.postError(error); |
||||||
|
} |
||||||
|
|
||||||
|
Future storeUploadedPart() async { |
||||||
|
if (_uploadedPartMap.isEmpty) { |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
await setCache(jsonEncode(_uploadedPartMap.values.toList())); |
||||||
|
} |
||||||
|
|
||||||
|
// 从缓存恢复已经上传的 part |
||||||
|
Future recoverUploadedPart() async { |
||||||
|
// 获取缓存 |
||||||
|
final cachedData = await getCache(); |
||||||
|
// 尝试从缓存恢复 |
||||||
|
if (cachedData != null) { |
||||||
|
var cachedList = <Part>[]; |
||||||
|
|
||||||
|
try { |
||||||
|
final _cachedList = json.decode(cachedData) as List<dynamic>; |
||||||
|
cachedList = _cachedList |
||||||
|
.map((dynamic item) => Part.fromJson(item as Map<String, dynamic>)) |
||||||
|
.toList(); |
||||||
|
} catch (error) { |
||||||
|
rethrow; |
||||||
|
} |
||||||
|
|
||||||
|
for (final part in cachedList) { |
||||||
|
_uploadedPartMap[part.partNumber] = part; |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
@override |
||||||
|
Future<List<Part>> createTask() async { |
||||||
|
/// 如果已经取消了,直接报错 |
||||||
|
// ignore: null_aware_in_condition |
||||||
|
if (controller != null && controller.cancelToken.isCancelled) { |
||||||
|
throw StorageError(type: StorageErrorType.CANCEL); |
||||||
|
} |
||||||
|
|
||||||
|
controller.notifyStatusListeners(StorageStatus.Request); |
||||||
|
// 尝试恢复缓存,如果有 |
||||||
|
await recoverUploadedPart(); |
||||||
|
|
||||||
|
// 上传分片 |
||||||
|
await _uploadParts(); |
||||||
|
return _uploadedPartMap.values.toList(); |
||||||
|
} |
||||||
|
|
||||||
|
int _uploadingPartIndex = 0; |
||||||
|
|
||||||
|
// 从指定的分片位置往后上传切片 |
||||||
|
Future<void> _uploadParts() async { |
||||||
|
final tasksLength = |
||||||
|
min(_idleRequestNumber, _totalPartCount - _uploadingPartIndex); |
||||||
|
final taskFutures = <Future<Null>>[]; |
||||||
|
|
||||||
|
while (taskFutures.length < tasksLength && |
||||||
|
_uploadingPartIndex < _totalPartCount) { |
||||||
|
// partNumber 按照后端要求必须从 1 开始 |
||||||
|
final partNumber = ++_uploadingPartIndex; |
||||||
|
|
||||||
|
final _uploadedPart = _uploadedPartMap[partNumber]; |
||||||
|
if (_uploadedPart != null) { |
||||||
|
_sentPartCount++; |
||||||
|
_sentPartToServerCount++; |
||||||
|
notifySendProgress(); |
||||||
|
notifyProgress(); |
||||||
|
continue; |
||||||
|
} |
||||||
|
|
||||||
|
final future = _createUploadPartTaskFutureByPartNumber(partNumber); |
||||||
|
taskFutures.add(future); |
||||||
|
} |
||||||
|
|
||||||
|
await Future.wait<Null>(taskFutures); |
||||||
|
} |
||||||
|
|
||||||
|
Future<Null> _createUploadPartTaskFutureByPartNumber(int partNumber) async { |
||||||
|
// 上传分片(part)的字节大小 |
||||||
|
final _byteLength = _getPartSizeByPartNumber(partNumber); |
||||||
|
|
||||||
|
_idleRequestNumber--; |
||||||
|
final _controller = PutController(); |
||||||
|
_workingUploadPartTaskControllers.add(_controller); |
||||||
|
|
||||||
|
final task = UploadPartTask( |
||||||
|
token: token, |
||||||
|
raf: _raf, |
||||||
|
uploadId: uploadId, |
||||||
|
byteLength: _byteLength, |
||||||
|
partNumber: partNumber, |
||||||
|
partSize: partSize, |
||||||
|
key: key, |
||||||
|
controller: _controller, |
||||||
|
); |
||||||
|
|
||||||
|
_controller |
||||||
|
// UploadPartTask 一次上传一个 chunk,通知一次进度 |
||||||
|
..addSendProgressListener((percent) { |
||||||
|
_sentPartCount++; |
||||||
|
notifySendProgress(); |
||||||
|
}) |
||||||
|
// UploadPartTask 上传完成后触发 |
||||||
|
..addProgressListener((percent) { |
||||||
|
_sentPartToServerCount++; |
||||||
|
notifyProgress(); |
||||||
|
}); |
||||||
|
|
||||||
|
manager.addTask(task); |
||||||
|
|
||||||
|
final data = await task.future; |
||||||
|
|
||||||
|
_idleRequestNumber++; |
||||||
|
_uploadedPartMap[partNumber] = |
||||||
|
Part(partNumber: partNumber, etag: data.etag); |
||||||
|
_workingUploadPartTaskControllers.remove(_controller); |
||||||
|
|
||||||
|
await storeUploadedPart(); |
||||||
|
|
||||||
|
// 检查任务是否已经完成 |
||||||
|
if (_uploadedPartMap.length != _totalPartCount) { |
||||||
|
// 上传下一片 |
||||||
|
await _uploadParts(); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// 根据 partNumber 算出当前切片的 byte 大小 |
||||||
|
int _getPartSizeByPartNumber(int partNumber) { |
||||||
|
final startOffset = (partNumber - 1) * _partByteLength; |
||||||
|
|
||||||
|
if (partNumber == _totalPartCount) { |
||||||
|
return _fileByteLength - startOffset; |
||||||
|
} |
||||||
|
|
||||||
|
return _partByteLength; |
||||||
|
} |
||||||
|
|
||||||
|
void notifySendProgress() { |
||||||
|
controller?.notifySendProgressListeners(_sentPartCount / _totalPartCount); |
||||||
|
} |
||||||
|
|
||||||
|
void notifyProgress() { |
||||||
|
controller?.notifyProgressListeners(_sentPartToServerCount / |
||||||
|
_totalPartCount * |
||||||
|
RequestTask.onSendProgressTakePercentOfTotal); |
||||||
|
} |
||||||
|
|
||||||
|
// UploadPartsTask 自身不包含进度,在其他地方处理 |
||||||
|
@override |
||||||
|
void onSendProgress(double percent) {} |
||||||
|
} |
@ -0,0 +1,13 @@ |
|||||||
|
import '../put_controller.dart'; |
||||||
|
|
||||||
|
class PutBySingleOptions { |
||||||
|
/// 资源名 |
||||||
|
/// |
||||||
|
/// 如果不传则后端自动生成 |
||||||
|
final String key; |
||||||
|
|
||||||
|
/// 控制器 |
||||||
|
final PutController controller; |
||||||
|
|
||||||
|
const PutBySingleOptions({this.key, this.controller}); |
||||||
|
} |
@ -0,0 +1,60 @@ |
|||||||
|
import 'dart:io'; |
||||||
|
|
||||||
|
import 'package:dio/dio.dart'; |
||||||
|
import 'package:meta/meta.dart'; |
||||||
|
import 'package:qiniu_sdk_base/src/storage/task/task.dart'; |
||||||
|
|
||||||
|
import '../../../../auth/auth.dart'; |
||||||
|
import '../put_response.dart'; |
||||||
|
|
||||||
|
// 直传任务 |
||||||
|
class PutBySingleTask extends RequestTask<PutResponse> { |
||||||
|
/// 上传文件 |
||||||
|
final File file; |
||||||
|
|
||||||
|
/// 上传凭证 |
||||||
|
final String token; |
||||||
|
|
||||||
|
/// 资源名 |
||||||
|
/// 如果不传则后端自动生成 |
||||||
|
final String key; |
||||||
|
|
||||||
|
TokenInfo _tokenInfo; |
||||||
|
|
||||||
|
PutBySingleTask({ |
||||||
|
@required this.file, |
||||||
|
@required this.token, |
||||||
|
this.key, |
||||||
|
RequestTaskController controller, |
||||||
|
}) : assert(file != null), |
||||||
|
assert(token != null), |
||||||
|
super(controller: controller); |
||||||
|
|
||||||
|
@override |
||||||
|
void preStart() { |
||||||
|
_tokenInfo = Auth.parseUpToken(token); |
||||||
|
super.preStart(); |
||||||
|
} |
||||||
|
|
||||||
|
@override |
||||||
|
Future<PutResponse> createTask() async { |
||||||
|
final formData = FormData.fromMap(<String, dynamic>{ |
||||||
|
'file': await MultipartFile.fromFile(file.path), |
||||||
|
'token': token, |
||||||
|
'key': key, |
||||||
|
}); |
||||||
|
|
||||||
|
final host = await config.hostProvider.getUpHost( |
||||||
|
accessKey: _tokenInfo.accessKey, |
||||||
|
bucket: _tokenInfo.putPolicy.getBucket(), |
||||||
|
); |
||||||
|
|
||||||
|
final response = await client.post<Map<String, dynamic>>( |
||||||
|
host, |
||||||
|
data: formData, |
||||||
|
cancelToken: controller?.cancelToken, |
||||||
|
); |
||||||
|
|
||||||
|
return PutResponse.fromJson(response.data); |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,5 @@ |
|||||||
|
export 'by_part/put_by_part_options.dart'; |
||||||
|
export 'by_single/put_by_single_options.dart'; |
||||||
|
export 'put_controller.dart'; |
||||||
|
export 'put_options.dart'; |
||||||
|
export 'put_response.dart'; |
@ -0,0 +1,3 @@ |
|||||||
|
import '../../task/request_task.dart'; |
||||||
|
|
||||||
|
class PutController extends RequestTaskController {} |
@ -0,0 +1,28 @@ |
|||||||
|
import 'put_controller.dart'; |
||||||
|
|
||||||
|
class PutOptions { |
||||||
|
/// 资源名 |
||||||
|
/// |
||||||
|
/// 如果不传则后端自动生成 |
||||||
|
final String key; |
||||||
|
|
||||||
|
/// 强制使用单文件上传,不使用分片,默认值 false |
||||||
|
final bool forceBySingle; |
||||||
|
|
||||||
|
/// 使用分片上传时的分片大小,默认值 4,单位为 MB |
||||||
|
final int partSize; |
||||||
|
|
||||||
|
/// 并发上传的队列长度,默认值为 5 |
||||||
|
final int maxPartsRequestNumber; |
||||||
|
|
||||||
|
/// 控制器 |
||||||
|
final PutController controller; |
||||||
|
|
||||||
|
const PutOptions({ |
||||||
|
this.key, |
||||||
|
this.forceBySingle = false, |
||||||
|
this.partSize = 4, |
||||||
|
this.maxPartsRequestNumber = 5, |
||||||
|
this.controller, |
||||||
|
}); |
||||||
|
} |