package com.qiniu.android.storage;
import com.qiniu.android.http.Client;
import com.qiniu.android.http.CompletionHandler;
import com.qiniu.android.http.ProgressHandler;
import com.qiniu.android.http.ResponseInfo;
import com.qiniu.android.utils.AndroidNetwork;
import com.qiniu.android.utils.Crc32;
import com.qiniu.android.utils.StringMap;
import com.qiniu.android.utils.StringUtils;
import com.qiniu.android.utils.UrlSafeBase64;
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;
import java.io.File;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.RandomAccessFile;
import java.net.URI;
import java.net.URISyntaxException;
import java.util.Locale;
import java.util.Map;
import static java.lang.String.format;
/**
* 分片上传
* 文档:<a href="http://developer.qiniu.com/docs/v6/api/overview/up/chunked-upload.html">分片上传</a>
* <p/>
* 分片上传通过将一个文件分割为固定大小的块(4M),然后再将每个块分割为固定大小的片,每次
* 上传一个分片的内容,等待所有块的所有分片都上传完成之后,再将这些块拼接起来,构成一个
* 完整的文件。另外分片上传还支持纪录上传进度,如果本次上传被暂停,那么下次还可以从上次
* 上次完成的文件偏移位置,继续开始上传,这样就实现了断点续传功能。
* <p/>
* 分片上传在网络环境较差的情况下,可以有效地实现大文件的上传。
*/
final class ResumeUploader implements Runnable {
private final long size;
private final String key;
private final UpCompletionHandler completionHandler;
private final UploadOptions options;
private final Client client;
private final Configuration config;
private final byte[] chunkBuffer;
private final String[] contexts;
// private final Header[] headers;
private final StringMap headers;
private final long modifyTime;
private final String recorderKey;
private RandomAccessFile file;
private File f;
private long crc32;
private UpToken token;
ResumeUploader(Client client, Configuration config, File f, String key, UpToken token,
final UpCompletionHandler completionHandler, UploadOptions options, String recorderKey) {
this.client = client;
this.config = config;
this.f = f;
this.recorderKey = recorderKey;
this.size = f.length();
this.key = key;
this.headers = new StringMap().put("Authorization", "UpToken " + token.token);
this.file = null;
this.completionHandler = new UpCompletionHandler() {
@Override
public void complete(String key, ResponseInfo info, JSONObject response) {
if (file != null) {
try {
file.close();
} catch (IOException e) {
e.printStackTrace();
}
}
completionHandler.complete(key, info, response);
}
};
this.options = options != null ? options : UploadOptions.defaultOptions();
chunkBuffer = new byte[config.chunkSize];
long count = (size + Configuration.BLOCK_SIZE - 1) / Configuration.BLOCK_SIZE;
contexts = new String[(int) count];
modifyTime = f.lastModified();
this.token = token;
}
private static boolean isChunkOK(ResponseInfo info, JSONObject response) {
return info.statusCode == 200 && info.error == null && (info.hasReqId() || isChunkResOK(response));
}
private static boolean isChunkResOK(JSONObject response) {
try {
// getXxxx 若获取不到值,会抛出异常
response.getString("ctx");
response.getLong("crc32");
} catch (Exception e) {
return false;
}
return true;
}
private static boolean isNotChunkToQiniu(ResponseInfo info, JSONObject response) {
return info.statusCode < 500 && info.statusCode >= 200 && (!info.hasReqId() && !isChunkResOK(response));
}
public void run() {
long offset = recoveryFromRecord();
try {
file = new RandomAccessFile(f, "r");
} catch (FileNotFoundException e) {
e.printStackTrace();
completionHandler.complete(key, ResponseInfo.fileError(e, token), null);
return;
}
nextTask(offset, 0, config.zone.upHost(token.token, config.useHttps, null));
}
/**
* 创建块,并上传第一个分片内容
*
* @param upHost 上传主机
* @param offset 本地文件偏移量
* @param blockSize 分块的块大小
* @param chunkSize 分片的片大小
* @param progress 上传进度
* @param _completionHandler 上传完成处理动作
*/
private void makeBlock(String upHost, long offset, int blockSize, int chunkSize, ProgressHandler progress,
CompletionHandler _completionHandler, UpCancellationSignal c) {
String path = format(Locale.ENGLISH, "/mkblk/%d", blockSize);
try {
file.seek(offset);
file.read(chunkBuffer, 0, chunkSize);
} catch (IOException e) {
completionHandler.complete(key, ResponseInfo.fileError(e, token), null);
return;
}
this.crc32 = Crc32.bytes(chunkBuffer, 0, chunkSize);
String postUrl = String.format("%s%s", upHost, path);
post(postUrl, chunkBuffer, 0, chunkSize, progress, _completionHandler, c);
}
private void putChunk(String upHost, long offset, int chunkSize, String context, ProgressHandler progress,
CompletionHandler _completionHandler, UpCancellationSignal c) {
int chunkOffset = (int) (offset % Configuration.BLOCK_SIZE);
String path = format(Locale.ENGLISH, "/bput/%s/%d", context, chunkOffset);
try {
file.seek(offset);
file.read(chunkBuffer, 0, chunkSize);
} catch (IOException e) {
completionHandler.complete(key, ResponseInfo.fileError(e, token), null);
return;
}
this.crc32 = Crc32.bytes(chunkBuffer, 0, chunkSize);
String postUrl = String.format("%s%s", upHost, path);
post(postUrl, chunkBuffer, 0, chunkSize, progress, _completionHandler, c);
}
private void makeFile(String upHost, CompletionHandler _completionHandler, UpCancellationSignal c) {
String mime = format(Locale.ENGLISH, "/mimeType/%s/fname/%s",
UrlSafeBase64.encodeToString(options.mimeType), UrlSafeBase64.encodeToString(f.getName()));
String keyStr = "";
if (key != null) {
keyStr = format("/key/%s", UrlSafeBase64.encodeToString(key));
}
String paramStr = "";
if (options.params.size() != 0) {
String str[] = new String[options.params.size()];
int j = 0;
for (Map.Entry<String, String> i : options.params.entrySet()) {
str[j++] = format(Locale.ENGLISH, "%s/%s", i.getKey(), UrlSafeBase64.encodeToString(i.getValue()));
}
paramStr = "/" + StringUtils.join(str, "/");
}
String path = format(Locale.ENGLISH, "/mkfile/%d%s%s%s", size, mime, keyStr, paramStr);
String bodyStr = StringUtils.join(contexts, ",");
byte[] data = bodyStr.getBytes();
String postUrl = String.format("%s%s", upHost, path);
post(postUrl, data, 0, data.length, null, _completionHandler, c);
}
private void post(String upHost, byte[] data, int offset, int size, ProgressHandler progress,
CompletionHandler completion, UpCancellationSignal c) {
client.asyncPost(upHost, data, offset, size, headers, token, progress, completion, c);
}
private long calcPutSize(long offset) {
long left = size - offset;
return left < config.chunkSize ? left : config.chunkSize;
}
private long calcBlockSize(long offset) {
long left = size - offset;
return left < Configuration.BLOCK_SIZE ? left : Configuration.BLOCK_SIZE;
}
private boolean isCancelled() {
return options.cancellationSignal.isCancelled();
}
private void nextTask(final long offset, final int retried, final String upHost) {
if (isCancelled()) {
ResponseInfo i = ResponseInfo.cancelled(token);
completionHandler.complete(key, i, null);
return;
}
if (offset == size) {
//完成操作,返回的内容不确定,是否真正成功逻辑让用户自己判断
CompletionHandler complete = new CompletionHandler() {
@Override
public void complete(ResponseInfo info, JSONObject response) {
if (info.isNetworkBroken() && !AndroidNetwork.isNetWorkReady()) {
options.netReadyHandler.waitReady();
if (!AndroidNetwork.isNetWorkReady()) {
completionHandler.complete(key, info, response);
return;
}
}
if (info.isOK()) {
removeRecord();
options.progressHandler.progress(key, 1.0);
completionHandler.complete(key, info, response);
return;
}
String upHostRetry = config.zone.upHost(token.token, config.useHttps, upHost);
if (upHostRetry != null
&& ((info.isNotQiniu() && !token.hasReturnUrl() || info.needRetry())
&& retried < config.retryMax)) {
nextTask(offset, retried + 1, upHostRetry);
return;
}
completionHandler.complete(key, info, response);
}
};
makeFile(upHost, complete, options.cancellationSignal);
return;
}
final int chunkSize = (int) calcPutSize(offset);
ProgressHandler progress = new ProgressHandler() {
@Override
public void onProgress(int bytesWritten, int totalSize) {
double percent = (double) (offset + bytesWritten) / size;
if (percent > 0.95) {
percent = 0.95;
}
options.progressHandler.progress(key, percent);
}
};
// 分片上传,七牛响应内容固定,若缺少reqId,可通过响应体判断
CompletionHandler complete = new CompletionHandler() {
@Override
public void complete(ResponseInfo info, JSONObject response) {
if (info.isNetworkBroken() && !AndroidNetwork.isNetWorkReady()) {
options.netReadyHandler.waitReady();
if (!AndroidNetwork.isNetWorkReady()) {
completionHandler.complete(key, info, response);
return;
}
}
String upHostRetry = config.zone.upHost(token.token, config.useHttps, upHost);
if (!isChunkOK(info, response)) {
if (info.statusCode == 701 && retried < config.retryMax) {
nextTask((offset / Configuration.BLOCK_SIZE) * Configuration.BLOCK_SIZE, retried + 1, upHost);
return;
}
if (upHostRetry != null
&& ((isNotChunkToQiniu(info, response) || info.needRetry())
&& retried < config.retryMax)) {
nextTask(offset, retried + 1, upHostRetry);
return;
}
completionHandler.complete(key, info, response);
return;
}
String context = null;
if (response == null && retried < config.retryMax) {
nextTask(offset, retried + 1, upHostRetry);
return;
}
long crc = 0;
try {
context = response.getString("ctx");
crc = response.getLong("crc32");
} catch (Exception e) {
e.printStackTrace();
}
if ((context == null || crc != ResumeUploader.this.crc32) && retried < config.retryMax) {
nextTask(offset, retried + 1, upHostRetry);
return;
}
contexts[(int) (offset / Configuration.BLOCK_SIZE)] = context;
record(offset + chunkSize);
nextTask(offset + chunkSize, retried, upHost);
}
};
if (offset % Configuration.BLOCK_SIZE == 0) {
int blockSize = (int) calcBlockSize(offset);
makeBlock(upHost, offset, blockSize, chunkSize, progress, complete, options.cancellationSignal);
return;
}
String context = contexts[(int) (offset / Configuration.BLOCK_SIZE)];
putChunk(upHost, offset, chunkSize, context, progress, complete, options.cancellationSignal);
}
private long recoveryFromRecord() {
if (config.recorder == null) {
return 0;
}
byte[] data = config.recorder.get(recorderKey);
if (data == null) {
return 0;
}
String jsonStr = new String(data);
JSONObject obj;
try {
obj = new JSONObject(jsonStr);
} catch (JSONException e) {
e.printStackTrace();
return 0;
}
long offset = obj.optLong("offset", 0);
long modify = obj.optLong("modify_time", 0);
long fSize = obj.optLong("size", 0);
JSONArray array = obj.optJSONArray("contexts");
if (offset == 0 || modify != modifyTime || fSize != size || array == null || array.length() == 0) {
return 0;
}
for (int i = 0; i < array.length(); i++) {
contexts[i] = array.optString(i);
}
return offset;
}
private void removeRecord() {
if (config.recorder != null) {
config.recorder.del(recorderKey);
}
}
// save json value
//{
// "size":filesize,
// "offset":lastSuccessOffset,
// "modify_time": lastFileModifyTime,
// "contexts": contexts
//}
private void record(long offset) {
if (config.recorder == null || offset == 0) {
return;
}
String data = format(Locale.ENGLISH, "{\"size\":%d,\"offset\":%d, \"modify_time\":%d, \"contexts\":[%s]}",
size, offset, modifyTime, StringUtils.jsonJoin(contexts));
config.recorder.set(recorderKey, data.getBytes());
}
private URI newURI(URI uri, String path) {
try {
return new URI(uri.getScheme(), null, uri.getHost(), uri.getPort(), path, null, null);
} catch (URISyntaxException e) {
e.printStackTrace();
}
return uri;
}
}