save image for TWA
This commit is contained in:
@@ -172,6 +172,10 @@
|
||||
android:resource="@xml/filepaths" />
|
||||
</provider>
|
||||
|
||||
<service
|
||||
android:name="androidx.browser.customtabs.PostMessageService"
|
||||
android:exported="true" />
|
||||
|
||||
<service
|
||||
android:name=".DelegationService"
|
||||
android:enabled="@bool/enableNotification"
|
||||
|
||||
@@ -19,11 +19,39 @@ import android.content.pm.ActivityInfo;
|
||||
import android.net.Uri;
|
||||
import android.os.Build;
|
||||
import android.os.Bundle;
|
||||
import android.os.Handler;
|
||||
import android.os.Looper;
|
||||
import android.util.Base64;
|
||||
import android.util.Log;
|
||||
|
||||
import org.json.JSONObject;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.browser.customtabs.CustomTabsCallback;
|
||||
import androidx.browser.customtabs.CustomTabsService;
|
||||
import androidx.browser.customtabs.CustomTabsSession;
|
||||
import com.google.androidbrowserhelper.trusted.QualityEnforcer;
|
||||
import com.google.androidbrowserhelper.trusted.TwaLauncher;
|
||||
|
||||
|
||||
|
||||
public class LauncherActivity
|
||||
extends com.google.androidbrowserhelper.trusted.LauncherActivity {
|
||||
private static final String TAG = "GaoTwaBridge";
|
||||
private static final Uri WEB_ORIGIN = Uri.parse("https://gao.imyeyu.com");
|
||||
private static final long CHANNEL_RETRY_DELAY_MILLIS = 500L;
|
||||
private static final int MAX_CHANNEL_ATTEMPTS = 20;
|
||||
|
||||
private final Handler mainHandler = new Handler(Looper.getMainLooper());
|
||||
private final CustomTabsCallback bridgeCallback = new BridgeCallback();
|
||||
private TwaMediaStoreBridge mediaStoreBridge;
|
||||
private CustomTabsSession postMessageSession;
|
||||
private boolean originValidationRequested;
|
||||
private boolean originValidated;
|
||||
private boolean postMessageChannelRequested;
|
||||
private boolean postMessageChannelReady;
|
||||
|
||||
|
||||
|
||||
@@ -31,6 +59,8 @@ public class LauncherActivity
|
||||
@Override
|
||||
protected void onCreate(Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
mediaStoreBridge = new TwaMediaStoreBridge(this);
|
||||
Log.i(TAG, "TWA Activity 已创建,开始初始化媒体相册桥接");
|
||||
// Setting an orientation crashes the app due to the transparent background on Android 8.0
|
||||
// Oreo and below. We only set the orientation on Oreo and above. This only affects the
|
||||
// splash screen and Chrome will still respect the orientation.
|
||||
@@ -51,4 +81,242 @@ public class LauncherActivity
|
||||
|
||||
return uri;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected CustomTabsCallback getCustomTabsCallback() {
|
||||
return bridgeCallback;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onDestroy() {
|
||||
Log.i(TAG, "TWA Activity 销毁,清理未完成的媒体传输");
|
||||
if (mediaStoreBridge != null) {
|
||||
mediaStoreBridge.abortAll();
|
||||
}
|
||||
mainHandler.removeCallbacksAndMessages(null);
|
||||
super.onDestroy();
|
||||
}
|
||||
|
||||
private void scheduleChannelRequest(int attempt) {
|
||||
if (attempt >= MAX_CHANNEL_ATTEMPTS || postMessageChannelReady) {
|
||||
return;
|
||||
}
|
||||
mainHandler.postDelayed(() -> requestPostMessageChannel(attempt), CHANNEL_RETRY_DELAY_MILLIS);
|
||||
}
|
||||
|
||||
private void requestPostMessageChannel(int attempt) {
|
||||
if (isFinishing() || postMessageChannelReady) {
|
||||
return;
|
||||
}
|
||||
|
||||
CustomTabsSession session = getCustomTabsSession();
|
||||
if (session == null) {
|
||||
Log.w(TAG, "请求 PostMessage 通道失败:当前没有可用的 CustomTabsSession,attempt=" + attempt);
|
||||
scheduleChannelRequest(attempt + 1);
|
||||
return;
|
||||
}
|
||||
postMessageSession = session;
|
||||
|
||||
if (!originValidationRequested) {
|
||||
originValidationRequested = true;
|
||||
Log.i(TAG, "开始验证 TWA 来源关系:" + WEB_ORIGIN);
|
||||
session.validateRelationship(CustomTabsService.RELATION_USE_AS_ORIGIN, WEB_ORIGIN, null);
|
||||
return;
|
||||
}
|
||||
if (!originValidated || postMessageChannelRequested) {
|
||||
return;
|
||||
}
|
||||
|
||||
postMessageChannelRequested = true;
|
||||
boolean requested = session.requestPostMessageChannel(WEB_ORIGIN, WEB_ORIGIN, new Bundle());
|
||||
Log.i(TAG, "请求 PostMessage 通道,结果=" + requested);
|
||||
if (!requested) {
|
||||
postMessageChannelRequested = false;
|
||||
scheduleChannelRequest(attempt + 1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 当前 Bubblewrap 生成的 Android Browser Helper 版本没有公开 TWA 会话访问器,
|
||||
* 这里按字段类型读取会话,不依赖混淆后的私有字段名称。
|
||||
*/
|
||||
@Nullable
|
||||
private CustomTabsSession getCustomTabsSession() {
|
||||
try {
|
||||
Object twaLauncher = findFieldValue(this, TwaLauncher.class);
|
||||
if (twaLauncher == null) {
|
||||
Log.w(TAG, "未找到 TwaLauncher 实例");
|
||||
return null;
|
||||
}
|
||||
CustomTabsSession session = (CustomTabsSession) findFieldValue(twaLauncher, CustomTabsSession.class);
|
||||
if (session == null) {
|
||||
Log.w(TAG, "未找到 CustomTabsSession 实例");
|
||||
}
|
||||
return session;
|
||||
} catch (ReflectiveOperationException | RuntimeException exception) {
|
||||
Log.w(TAG, "读取 TWA 会话失败", exception);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@Nullable
|
||||
private static Object findFieldValue(Object target, Class<?> fieldType) throws IllegalAccessException {
|
||||
Class<?> currentClass = target.getClass();
|
||||
while (currentClass != null) {
|
||||
for (Field field : currentClass.getDeclaredFields()) {
|
||||
if (!fieldType.isAssignableFrom(field.getType())) {
|
||||
continue;
|
||||
}
|
||||
field.setAccessible(true);
|
||||
return field.get(target);
|
||||
}
|
||||
currentClass = currentClass.getSuperclass();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private void handleBridgeMessage(String message) {
|
||||
if (mediaStoreBridge == null || !postMessageChannelReady) {
|
||||
Log.w(TAG, "忽略媒体消息:桥接尚未就绪");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
JSONObject payload = new JSONObject(message);
|
||||
String type = payload.optString("type");
|
||||
String id = payload.optString("id");
|
||||
Log.d(TAG, "收到网页桥接消息,type=" + type + ", id=" + id);
|
||||
if ("twa-bridge-ready-ack".equals(type)) {
|
||||
Log.i(TAG, "网页已确认 TWA 桥接就绪");
|
||||
return;
|
||||
}
|
||||
if (id.length() == 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ("media-start".equals(type)) {
|
||||
String error = mediaStoreBridge.start(
|
||||
id,
|
||||
payload.optString("mimeType"),
|
||||
payload.optString("fileName"),
|
||||
payload.optLong("size", -1L)
|
||||
);
|
||||
Log.i(TAG, "开始保存媒体,id=" + id + ", mimeType=" + payload.optString("mimeType")
|
||||
+ ", fileName=" + payload.optString("fileName") + ", size=" + payload.optLong("size", -1L)
|
||||
+ ", result=" + (error == null ? "success" : error));
|
||||
postBridgeResponse(error == null ? "media-started" : "media-error", id, null, error);
|
||||
return;
|
||||
}
|
||||
if ("media-chunk".equals(type)) {
|
||||
int index = payload.optInt("index", -1);
|
||||
if (index < 0) {
|
||||
mediaStoreBridge.abort(id);
|
||||
postBridgeResponse("media-error", id, null, "媒体分块编号无效");
|
||||
return;
|
||||
}
|
||||
byte[] chunk = Base64.decode(payload.optString("data"), Base64.NO_WRAP);
|
||||
String error = mediaStoreBridge.append(id, index, chunk);
|
||||
if (error != null || index == 0 || index % 16 == 0) {
|
||||
Log.d(TAG, "写入媒体分块,id=" + id + ", index=" + index + ", bytes=" + chunk.length
|
||||
+ ", result=" + (error == null ? "success" : error));
|
||||
}
|
||||
postBridgeResponse(error == null ? "media-chunk-ack" : "media-error", id, index, error);
|
||||
return;
|
||||
}
|
||||
if ("media-finish".equals(type)) {
|
||||
String error = mediaStoreBridge.finish(id);
|
||||
Log.i(TAG, "完成保存媒体,id=" + id + ", result=" + (error == null ? "success" : error));
|
||||
postBridgeResponse(error == null ? "media-saved" : "media-error", id, null, error);
|
||||
return;
|
||||
}
|
||||
if ("media-abort".equals(type)) {
|
||||
Log.w(TAG, "网页主动中止媒体保存,id=" + id);
|
||||
mediaStoreBridge.abort(id);
|
||||
}
|
||||
} catch (Exception exception) {
|
||||
Log.e(TAG, "解析 TWA 媒体消息失败,原始消息长度=" + message.length(), exception);
|
||||
}
|
||||
}
|
||||
|
||||
private void postBridgeResponse(String type, String id, @Nullable Integer index, @Nullable String error) {
|
||||
JSONObject response = new JSONObject();
|
||||
try {
|
||||
response.put("type", type);
|
||||
response.put("id", id);
|
||||
if (index != null) {
|
||||
response.put("index", index);
|
||||
}
|
||||
if (error != null) {
|
||||
response.put("error", error);
|
||||
}
|
||||
} catch (Exception exception) {
|
||||
return;
|
||||
}
|
||||
|
||||
mainHandler.post(() -> {
|
||||
if (!postMessageChannelReady || postMessageSession == null) {
|
||||
return;
|
||||
}
|
||||
int result = postMessageSession.postMessage(response.toString(), null);
|
||||
Log.d(TAG, "发送桥接响应,type=" + type + ", id=" + id + ", result=" + result);
|
||||
});
|
||||
}
|
||||
|
||||
private final class BridgeCallback extends QualityEnforcer {
|
||||
@Override
|
||||
public void onNavigationEvent(int navigationEvent, @Nullable Bundle extras) {
|
||||
super.onNavigationEvent(navigationEvent, extras);
|
||||
Log.d(TAG, "TWA 导航事件=" + navigationEvent);
|
||||
if (navigationEvent == NAVIGATION_FINISHED) {
|
||||
scheduleChannelRequest(0);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onRelationshipValidationResult(
|
||||
int relation,
|
||||
@Nullable Uri requestedOrigin,
|
||||
boolean result,
|
||||
@Nullable Bundle extras
|
||||
) {
|
||||
super.onRelationshipValidationResult(relation, requestedOrigin, result, extras);
|
||||
if (relation != CustomTabsService.RELATION_USE_AS_ORIGIN || !WEB_ORIGIN.equals(requestedOrigin)) {
|
||||
return;
|
||||
}
|
||||
originValidated = result;
|
||||
Log.i(TAG, "TWA 来源验证结果=" + result + ", origin=" + requestedOrigin);
|
||||
if (result) {
|
||||
scheduleChannelRequest(0);
|
||||
} else {
|
||||
Log.w(TAG, "TWA 来源验证失败,请检查 assetlinks.json");
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onMessageChannelReady(@Nullable Bundle extras) {
|
||||
super.onMessageChannelReady(extras);
|
||||
postMessageChannelReady = true;
|
||||
Log.i(TAG, "PostMessage 通道已就绪");
|
||||
postMessageSession = getCustomTabsSession();
|
||||
if (postMessageSession == null) {
|
||||
Log.e(TAG, "PostMessage 通道已就绪,但无法获取 CustomTabsSession");
|
||||
return;
|
||||
}
|
||||
JSONObject readyMessage = new JSONObject();
|
||||
try {
|
||||
readyMessage.put("type", "twa-bridge-ready");
|
||||
readyMessage.put("version", 1);
|
||||
} catch (Exception exception) {
|
||||
return;
|
||||
}
|
||||
int result = postMessageSession.postMessage(readyMessage.toString(), null);
|
||||
Log.i(TAG, "发送 TWA 桥接就绪消息,result=" + result);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onPostMessage(String message, @Nullable Bundle extras) {
|
||||
super.onPostMessage(message, extras);
|
||||
handleBridgeMessage(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
/*
|
||||
* Copyright (c) 2026 imyeyu.com
|
||||
*
|
||||
* MIT License
|
||||
*/
|
||||
package com.imyeyu.gao.customer;
|
||||
|
||||
import android.content.ContentResolver;
|
||||
import android.content.ContentValues;
|
||||
import android.content.Context;
|
||||
import android.net.Uri;
|
||||
import android.os.Build;
|
||||
import android.os.Environment;
|
||||
import android.provider.MediaStore;
|
||||
import android.util.Log;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import java.io.BufferedOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.OutputStream;
|
||||
import java.util.HashMap;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 将 TWA 传来的媒体分块写入 Android 系统相册。
|
||||
*/
|
||||
final class TwaMediaStoreBridge {
|
||||
private static final String TAG = "GaoTwaMediaStore";
|
||||
private static final long MAX_MEDIA_SIZE = 512L * 1024L * 1024L;
|
||||
private static final int BUFFER_SIZE = 64 * 1024;
|
||||
|
||||
private final ContentResolver contentResolver;
|
||||
private final Map<String, PendingMedia> pendingMediaMap = new HashMap<>();
|
||||
|
||||
TwaMediaStoreBridge(Context context) {
|
||||
contentResolver = context.getContentResolver();
|
||||
}
|
||||
|
||||
synchronized String start(
|
||||
String id,
|
||||
String mimeType,
|
||||
String fileName,
|
||||
long expectedSize
|
||||
) {
|
||||
Log.i(TAG, "准备创建相册媒体,id=" + id + ", mimeType=" + mimeType + ", fileName=" + fileName
|
||||
+ ", expectedSize=" + expectedSize + ", sdk=" + Build.VERSION.SDK_INT);
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
|
||||
Log.w(TAG, "Android 版本低于 10,无法无权限写入相册");
|
||||
return "Android 10 以下系统不支持无权限写入系统相册";
|
||||
}
|
||||
if (id.length() == 0 || id.length() > 128) {
|
||||
return "媒体标识无效";
|
||||
}
|
||||
if (pendingMediaMap.containsKey(id)) {
|
||||
return "媒体传输已存在";
|
||||
}
|
||||
if (expectedSize <= 0 || expectedSize > MAX_MEDIA_SIZE) {
|
||||
return "媒体文件大小超出限制";
|
||||
}
|
||||
|
||||
String normalizedMimeType = normalizeMimeType(mimeType);
|
||||
if (normalizedMimeType == null) {
|
||||
return "只支持照片和视频";
|
||||
}
|
||||
|
||||
Uri mediaUri = null;
|
||||
try {
|
||||
ContentValues values = new ContentValues();
|
||||
values.put(MediaStore.MediaColumns.DISPLAY_NAME, buildDisplayName(fileName, normalizedMimeType));
|
||||
values.put(MediaStore.MediaColumns.MIME_TYPE, normalizedMimeType);
|
||||
values.put(MediaStore.MediaColumns.RELATIVE_PATH, buildRelativePath(normalizedMimeType));
|
||||
values.put(MediaStore.MediaColumns.IS_PENDING, 1);
|
||||
|
||||
mediaUri = contentResolver.insert(getCollectionUri(normalizedMimeType), values);
|
||||
if (mediaUri == null) {
|
||||
Log.e(TAG, "MediaStore insert 返回空 Uri,id=" + id);
|
||||
return "创建相册媒体失败";
|
||||
}
|
||||
|
||||
OutputStream outputStream = contentResolver.openOutputStream(mediaUri);
|
||||
if (outputStream == null) {
|
||||
deleteQuietly(mediaUri);
|
||||
Log.e(TAG, "无法打开 MediaStore 输出流,id=" + id + ", uri=" + mediaUri);
|
||||
return "打开相册媒体失败";
|
||||
}
|
||||
pendingMediaMap.put(id, new PendingMedia(mediaUri, outputStream, expectedSize));
|
||||
Log.i(TAG, "相册媒体创建成功,id=" + id + ", uri=" + mediaUri + ", path="
|
||||
+ buildRelativePath(normalizedMimeType));
|
||||
return null;
|
||||
} catch (Exception exception) {
|
||||
if (mediaUri != null) {
|
||||
deleteQuietly(mediaUri);
|
||||
}
|
||||
Log.e(TAG, "创建相册媒体异常,id=" + id, exception);
|
||||
return "创建相册媒体失败";
|
||||
}
|
||||
}
|
||||
|
||||
synchronized String append(String id, int index, byte[] chunk) {
|
||||
PendingMedia pendingMedia = pendingMediaMap.get(id);
|
||||
if (pendingMedia == null) {
|
||||
return "媒体传输不存在";
|
||||
}
|
||||
if (index != pendingMedia.nextChunkIndex) {
|
||||
abort(id);
|
||||
return "媒体分块顺序错误";
|
||||
}
|
||||
if (chunk.length == 0 || pendingMedia.receivedSize + chunk.length > pendingMedia.expectedSize) {
|
||||
abort(id);
|
||||
return "媒体分块大小错误";
|
||||
}
|
||||
|
||||
try {
|
||||
pendingMedia.outputStream.write(chunk);
|
||||
pendingMedia.outputStream.flush();
|
||||
pendingMedia.receivedSize += chunk.length;
|
||||
pendingMedia.nextChunkIndex++;
|
||||
return null;
|
||||
} catch (IOException exception) {
|
||||
abort(id);
|
||||
Log.e(TAG, "写入相册媒体异常,id=" + id + ", index=" + index, exception);
|
||||
return "写入相册媒体失败";
|
||||
}
|
||||
}
|
||||
|
||||
synchronized String finish(String id) {
|
||||
PendingMedia pendingMedia = pendingMediaMap.remove(id);
|
||||
if (pendingMedia == null) {
|
||||
return "媒体传输不存在";
|
||||
}
|
||||
if (pendingMedia.receivedSize != pendingMedia.expectedSize) {
|
||||
closeQuietly(pendingMedia.outputStream);
|
||||
deleteQuietly(pendingMedia.mediaUri);
|
||||
Log.e(TAG, "媒体内容不完整,id=" + id + ", received=" + pendingMedia.receivedSize
|
||||
+ ", expected=" + pendingMedia.expectedSize);
|
||||
return "媒体内容不完整";
|
||||
}
|
||||
|
||||
try {
|
||||
pendingMedia.outputStream.flush();
|
||||
pendingMedia.outputStream.close();
|
||||
ContentValues values = new ContentValues();
|
||||
values.put(MediaStore.MediaColumns.IS_PENDING, 0);
|
||||
if (contentResolver.update(pendingMedia.mediaUri, values, null, null) != 1) {
|
||||
deleteQuietly(pendingMedia.mediaUri);
|
||||
Log.e(TAG, "发布相册媒体失败,MediaStore update 返回 0,id=" + id);
|
||||
return "发布相册媒体失败";
|
||||
}
|
||||
Log.i(TAG, "相册媒体发布成功,id=" + id + ", uri=" + pendingMedia.mediaUri
|
||||
+ ", bytes=" + pendingMedia.receivedSize);
|
||||
return null;
|
||||
} catch (Exception exception) {
|
||||
closeQuietly(pendingMedia.outputStream);
|
||||
deleteQuietly(pendingMedia.mediaUri);
|
||||
Log.e(TAG, "保存相册媒体异常,id=" + id, exception);
|
||||
return "保存相册媒体失败";
|
||||
}
|
||||
}
|
||||
|
||||
synchronized void abort(String id) {
|
||||
PendingMedia pendingMedia = pendingMediaMap.remove(id);
|
||||
if (pendingMedia == null) {
|
||||
return;
|
||||
}
|
||||
closeQuietly(pendingMedia.outputStream);
|
||||
deleteQuietly(pendingMedia.mediaUri);
|
||||
Log.w(TAG, "已清理中止的相册媒体,id=" + id + ", uri=" + pendingMedia.mediaUri);
|
||||
}
|
||||
|
||||
synchronized void abortAll() {
|
||||
for (PendingMedia pendingMedia : pendingMediaMap.values()) {
|
||||
closeQuietly(pendingMedia.outputStream);
|
||||
deleteQuietly(pendingMedia.mediaUri);
|
||||
}
|
||||
pendingMediaMap.clear();
|
||||
Log.i(TAG, "已清理全部未完成的相册媒体");
|
||||
}
|
||||
|
||||
@Nullable
|
||||
private static String normalizeMimeType(String mimeType) {
|
||||
String normalizedMimeType = mimeType == null ? "" : mimeType.trim().toLowerCase(Locale.ROOT);
|
||||
if (normalizedMimeType.startsWith("image/")) {
|
||||
return normalizedMimeType.equals("image/*") ? "image/jpeg" : normalizedMimeType;
|
||||
}
|
||||
if (normalizedMimeType.startsWith("video/")) {
|
||||
return normalizedMimeType.equals("video/*") ? "video/mp4" : normalizedMimeType;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static Uri getCollectionUri(String mimeType) {
|
||||
if (mimeType.startsWith("video/")) {
|
||||
return MediaStore.Video.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY);
|
||||
}
|
||||
return MediaStore.Images.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY);
|
||||
}
|
||||
|
||||
private static String buildRelativePath(String mimeType) {
|
||||
if (mimeType.startsWith("video/")) {
|
||||
return Environment.DIRECTORY_MOVIES + "/糕店管理";
|
||||
}
|
||||
return Environment.DIRECTORY_PICTURES + "/糕店管理";
|
||||
}
|
||||
|
||||
private static String buildDisplayName(String fileName, String mimeType) {
|
||||
String safeFileName = fileName == null ? "" : fileName
|
||||
.replaceAll("[^A-Za-z0-9._-]", "_")
|
||||
.replaceAll("^\\.+$", "");
|
||||
if (safeFileName.length() == 0) {
|
||||
safeFileName = "capture-" + System.currentTimeMillis();
|
||||
}
|
||||
if (safeFileName.lastIndexOf('.') < 0) {
|
||||
safeFileName += mimeType.startsWith("video/") ? ".mp4" : ".jpg";
|
||||
}
|
||||
return safeFileName;
|
||||
}
|
||||
|
||||
private void deleteQuietly(Uri uri) {
|
||||
try {
|
||||
contentResolver.delete(uri, null, null);
|
||||
} catch (Exception ignored) {
|
||||
// 相册清理失败时不能覆盖原始错误。
|
||||
}
|
||||
}
|
||||
|
||||
private static void closeQuietly(OutputStream outputStream) {
|
||||
try {
|
||||
outputStream.close();
|
||||
} catch (IOException ignored) {
|
||||
// 关闭失败时继续清理媒体记录。
|
||||
}
|
||||
}
|
||||
|
||||
private static final class PendingMedia {
|
||||
private final Uri mediaUri;
|
||||
private final OutputStream outputStream;
|
||||
private final long expectedSize;
|
||||
private long receivedSize;
|
||||
private int nextChunkIndex;
|
||||
|
||||
private PendingMedia(Uri mediaUri, OutputStream outputStream, long expectedSize) {
|
||||
this.mediaUri = mediaUri;
|
||||
this.outputStream = new BufferedOutputStream(outputStream, BUFFER_SIZE);
|
||||
this.expectedSize = expectedSize;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user