6.7.0 - Alpha19 - 修复检查更新中止下载过程后, 网络数据包依然持续后台获取的问题 (试修)

This commit is contained in:
SuperMonster003
2026-02-03 01:30:14 +08:00
parent ff0ca695a9
commit 1986353077
4 changed files with 324 additions and 126 deletions

View File

@@ -2,11 +2,9 @@ package org.autojs.autojs.network.download;
import android.annotation.SuppressLint;
import android.content.Context;
import android.content.res.ColorStateList;
import android.os.Environment;
import android.os.Handler;
import android.os.Looper;
import android.widget.ProgressBar;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import com.afollestad.materialdialogs.DialogAction;
@@ -17,18 +15,18 @@ import io.reactivex.android.schedulers.AndroidSchedulers;
import io.reactivex.disposables.Disposable;
import io.reactivex.schedulers.Schedulers;
import io.reactivex.subjects.PublishSubject;
import io.reactivex.subjects.Subject;
import okhttp3.Call;
import okhttp3.Interceptor;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.Response;
import okhttp3.ResponseBody;
import org.autojs.autojs.app.DialogUtils;
import org.autojs.autojs.concurrent.VolatileBox;
import org.autojs.autojs.network.UpdateChecker;
import org.autojs.autojs.network.api.DownloadApi;
import org.autojs.autojs.network.entity.VersionInfo;
import org.autojs.autojs.pio.PFiles;
import org.autojs.autojs.tool.SimpleObserver;
import org.autojs.autojs.util.IntentUtils;
import org.autojs.autojs.util.IntentUtils.ToastExceptionHolder;
import org.autojs.autojs.util.StreamUtils;
@@ -37,40 +35,69 @@ import org.autojs.autojs6.R;
import retrofit2.Retrofit;
import retrofit2.adapter.rxjava2.RxJava2CallAdapterFactory;
import java.io.BufferedOutputStream;
import java.io.File;
import java.io.FileOutputStream;
import java.io.InputStream;
import java.io.UnsupportedEncodingException;
import java.net.URLDecoder;
import java.util.Locale;
import java.util.concurrent.CancellationException;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
/**
* Created by Stardust on Oct 20, 2017.
* Modified by JetBrains AI Assistant (GPT-5.2) as of Feb 2, 2026.
* Modified by OpenAI ChatGPT (GPT-5.2 Thinking) as of Feb 2, 2026.
* Modified by SuperMonster003 as of Feb 3, 2026.
*/
public class DownloadManager {
private static DownloadManager sInstance;
private final int mRetryCount = 3;
private final Handler mHandler;
private final DownloadApi mDownloadApi;
private final ConcurrentHashMap<String, VolatileBox<Boolean>> mDownloadStatuses = new ConcurrentHashMap<>();
private final OkHttpClient mOkHttpClient;
private MaterialDialog mProgressDialog;
private Disposable mDisposable;
// Track active tasks so cancel can close streams to break blocking reads.
// zh-CN: 跟踪活动任务, 以便 cancel 时可关闭流从而打断阻塞 read().
private final ConcurrentHashMap<String, DownloadTask> mActiveTasks = new ConcurrentHashMap<>();
/**
* Partial file policy for cancellations/failures.
* zh-CN: 用于取消/失败场景的未完成文件处理策略.
*/
public enum PartialFilePolicy {
DELETE_ALWAYS,
DELETE_APK_ZIP_ONLY,
KEEP_ALWAYS
}
private volatile PartialFilePolicy mPartialFilePolicy = PartialFilePolicy.DELETE_ALWAYS;
/**
* A dedicated exception type for user cancellations.
* zh-CN: 专用于用户取消的异常类型.
*/
private static final class DownloadCancelledException extends CancellationException {
DownloadCancelledException(String message) {
super(message);
}
}
public DownloadManager() {
mHandler = new Handler(Looper.getMainLooper());
mOkHttpClient = getOkHttpClient();
Retrofit mRetrofit = new Retrofit.Builder()
.baseUrl(UpdateChecker.URL_BASE_GITHUB_RAW)
.addCallAdapterFactory(RxJava2CallAdapterFactory.create())
.client(mOkHttpClient)
.build();
mDownloadApi = mRetrofit.create(DownloadApi.class);
}
@@ -82,6 +109,11 @@ public class DownloadManager {
int tryCount = 0;
while (!response.isSuccessful() && tryCount < mRetryCount) {
tryCount++;
try {
response.close();
} catch (Throwable ignored) {
/* Ignored. */
}
response = chain.proceed(request);
}
return response;
@@ -98,10 +130,13 @@ public class DownloadManager {
return sInstance;
}
public void disposeIfNeeded() {
if (mDisposable != null) {
mDisposable.dispose();
}
public void setPartialFilePolicy(@NonNull PartialFilePolicy policy) {
mPartialFilePolicy = policy;
}
@NonNull
public PartialFilePolicy getPartialFilePolicy() {
return mPartialFilePolicy;
}
@SuppressWarnings("CharsetObjectCanBeUsed")
@@ -165,12 +200,10 @@ public class DownloadManager {
if (contentText != null) {
mProgressDialog.setContent(contentText);
}
if (versionInfo != null) {
long size = versionInfo.getSize();
DialogUtils.setProgressNumberFormatByBytes(mProgressDialog, 0, size, context.getString(R.string.text_half_ellipsis));
}
DialogUtils.applyProgressThemeColorTintLists(mProgressDialog);
}
@@ -182,55 +215,144 @@ public class DownloadManager {
initProgressDialog(context, versionInfo.getDownloadUrl(), versionInfo, null);
}
@SuppressLint("CheckResult")
@SuppressWarnings("ResultOfMethodCallIgnored")
private Observable<File> download(String url, String path) {
PublishSubject<File> downloadSubject = PublishSubject.create();
DownloadTask task = new DownloadTask(url, path);
PublishSubject<ProgressInfo> progressSubject = task.progress();
mDownloadApi.download(url)
.subscribeOn(Schedulers.io())
.subscribe(task::start, progressSubject::onError);
progressSubject
.observeOn(AndroidSchedulers.mainThread())
.doOnNext(o -> {
DialogUtils.setProgressNumberFormatByBytes(mProgressDialog, o.getReadBytes(), o.getTotalBytes());
mProgressDialog.setProgress(o.getProgress());
})
.subscribe(new SimpleObserver<>() {
@Override
public void onSubscribe(Disposable disposable) {
mDisposable = disposable;
}
@Override
public void onComplete() {
mProgressDialog.dismiss();
mProgressDialog = null;
downloadSubject.onNext(new File(path));
downloadSubject.onComplete();
}
@Override
public void onError(Throwable error) {
mProgressDialog.dismiss();
mProgressDialog = null;
disposeIfNeeded();
mOkHttpClient.dispatcher().cancelAll();
downloadSubject.onError(error);
}
});
return downloadSubject;
/**
* Dismiss progress dialog safely to avoid races with async progress updates.
* zh-CN: 安全关闭进度对话框, 以避免与异步进度更新产生竞态.
*/
private void dismissProgressDialogSafely() {
try {
MaterialDialog dialog = mProgressDialog;
if (dialog != null && dialog.isShowing()) {
dialog.dismiss();
}
} catch (Throwable ignored) {
/* Ignored. */
} finally {
mProgressDialog = null;
}
}
/**
* Cancel only OkHttp calls matching the given url instead of cancelAll().
* zh-CN: 仅取消与 url 匹配的 OkHttp 请求, 而不是使用 cancelAll().
*/
private void cancelOkHttpCallsByUrl(@NonNull String url) {
try {
for (Call call : mOkHttpClient.dispatcher().queuedCalls()) {
if (url.equals(call.request().url().toString())) {
call.cancel();
}
}
for (Call call : mOkHttpClient.dispatcher().runningCalls()) {
if (url.equals(call.request().url().toString())) {
call.cancel();
}
}
} catch (Throwable ignored) {
/* Ignored. */
}
}
private boolean shouldDeletePartialFile(@NonNull String path) {
PartialFilePolicy policy = getPartialFilePolicy();
if (policy == PartialFilePolicy.KEEP_ALWAYS) return false;
if (policy == PartialFilePolicy.DELETE_ALWAYS) return true;
String name = new File(path).getName().toLowerCase(Locale.US);
return name.endsWith(".apk") || name.endsWith(".zip");
}
private void deleteFileSilentlyAsync(@NonNull String path) {
Schedulers.io().scheduleDirect(() -> {
try {
File f = new File(path);
if (f.exists()) {
// noinspection ResultOfMethodCallIgnored
f.delete();
}
} catch (Throwable ignored) {
/* Ignored. */
}
});
}
/**
* Simplified single-source Observable without PublishSubject<File>.
* zh-CN: 通过 Observable.create() 精简结构, 不再使用 PublishSubject<File>.
*/
@SuppressLint("CheckResult")
private Observable<File> download(String url, String path) {
return Observable.create(emitter -> {
DownloadTask task = new DownloadTask(url, path);
DownloadTask previous = mActiveTasks.put(url, task);
if (previous != null) {
previous.cancel();
}
// Downstream dispose should behave like user cancellation.
// zh-CN: 下游 dispose() 应与用户取消行为一致.
emitter.setCancellable(() -> cancelDownload(url));
Disposable progressDisposable = task.progress()
.sample(50, TimeUnit.MILLISECONDS, true)
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
o -> {
MaterialDialog dialog = mProgressDialog;
if (dialog == null || !dialog.isShowing()) return;
DialogUtils.setProgressNumberFormatByBytes(dialog, o.getReadBytes(), o.getTotalBytes());
dialog.setProgress(o.getProgress());
},
error -> {
mActiveTasks.remove(url);
dismissProgressDialogSafely();
if (error instanceof DownloadCancelledException || error instanceof CancellationException) {
// Cancellation is expected; cancel only the matching call.
// zh-CN: 取消属于预期行为, 仅取消匹配的请求.
cancelOkHttpCallsByUrl(url);
} else {
cancelOkHttpCallsByUrl(url);
if (shouldDeletePartialFile(path)) {
deleteFileSilentlyAsync(path);
}
}
if (!emitter.isDisposed()) {
emitter.onError(error);
}
},
() -> {
mActiveTasks.remove(url);
dismissProgressDialogSafely();
if (!emitter.isDisposed()) {
emitter.onNext(new File(path));
emitter.onComplete();
}
}
);
Disposable networkDisposable = mDownloadApi.download(url)
.subscribeOn(Schedulers.io())
.subscribe(task::start, task::fail);
task.setNetworkDisposable(networkDisposable);
task.setProgressDisposable(progressDisposable);
});
}
/**
* Cancel by url. Button click and downstream dispose both arrive here.
* zh-CN: 按 url 取消, 按钮点击与下游 dispose 都会走到这里.
*/
public void cancelDownload(String url) {
VolatileBox<Boolean> status = mDownloadStatuses.get(url);
if (status != null) {
status.set(false);
DownloadTask task = mActiveTasks.get(url);
if (task != null) {
task.cancel();
} else {
cancelOkHttpCallsByUrl(url);
}
}
@@ -238,91 +360,166 @@ public class DownloadManager {
private final String mUrl;
private final String mPath;
private final VolatileBox<Boolean> mStatus;
private InputStream mInputStream;
private FileOutputStream mFileOutputStream;
private final PublishSubject<ProgressInfo> mProgress;
private BufferedOutputStream mBufferedOutputStream;
private final Subject<ProgressInfo> mProgress;
private Disposable mNetworkDisposable;
private Disposable mProgressDisposable;
private final AtomicBoolean mCancelled = new AtomicBoolean(false);
private final AtomicBoolean mTerminated = new AtomicBoolean(false);
// Larger IO buffer for higher throughput.
// zh-CN: 更大的 IO 缓冲区, 用于提升吞吐.
private static final int IO_BUFFER_SIZE = 256 * 1024;
// Minimum interval between progress emissions to avoid Rx/UI overhead.
// zh-CN: 进度上报的最小间隔, 用于避免 Rx/UI 开销过大.
private static final long PROGRESS_EMIT_MIN_INTERVAL_MS = 50L;
public DownloadTask(String url, String path) {
mUrl = url;
mPath = path;
mStatus = new VolatileBox<>(true);
VolatileBox<Boolean> previous = mDownloadStatuses.put(mUrl, mStatus);
if (previous != null) {
previous.set(false);
}
mProgress = PublishSubject.create();
mProgress = PublishSubject.<ProgressInfo>create().toSerialized();
}
private void startImpl(ResponseBody body) throws Exception {
byte[] buffer = new byte[4096];
mFileOutputStream = new FileOutputStream(mPath);
mInputStream = body.byteStream();
long total = body.contentLength();
ProgressInfo o = new ProgressInfo(total);
while (true) {
if (!mStatus.get()) {
onCancel();
break;
}
int len = mInputStream.read(buffer);
if (len == -1) {
mProgress.onComplete();
recycle();
break;
}
o.incrementRead(len);
mFileOutputStream.write(buffer, 0, len);
if (o.getTotalBytes() > 0) {
mProgress.onNext(o);
}
}
public void setNetworkDisposable(@Nullable Disposable disposable) {
mNetworkDisposable = disposable;
}
public void start(ResponseBody body) throws Exception {
public void setProgressDisposable(@Nullable Disposable disposable) {
mProgressDisposable = disposable;
}
public Observable<ProgressInfo> progress() {
return mProgress;
}
public void fail(Throwable error) {
terminate(error);
}
public void start(ResponseBody body) {
PFiles.ensureDir(mPath);
mHandler.post(this::activeProgressDialogButton);
startImpl(body);
}
private void startImpl(ResponseBody body) {
byte[] buffer = new byte[IO_BUFFER_SIZE];
long total = body.contentLength();
ProgressInfo o = new ProgressInfo(total);
long lastEmitAt = 0L;
try (ResponseBody ignoredBody = body) {
mFileOutputStream = new FileOutputStream(mPath);
mBufferedOutputStream = new BufferedOutputStream(mFileOutputStream, IO_BUFFER_SIZE);
mInputStream = body.byteStream();
while (true) {
if (mCancelled.get()) {
cancel();
break;
}
int len = mInputStream.read(buffer);
if (len == -1) {
mBufferedOutputStream.flush();
terminate(null);
break;
}
o.incrementRead(len);
mBufferedOutputStream.write(buffer, 0, len);
if (o.getTotalBytes() > 0) {
long now = android.os.SystemClock.uptimeMillis();
if (now - lastEmitAt >= PROGRESS_EMIT_MIN_INTERVAL_MS) {
lastEmitAt = now;
mProgress.onNext(o);
}
}
}
} catch (Throwable t) {
if (mCancelled.get()) {
terminate(new DownloadCancelledException("Download cancelled: " + mUrl));
} else {
terminate(t);
}
} finally {
recycle();
}
}
public void cancel() {
// Mark as cancelled first so read loop can stop ASAP.
// zh-CN: 先标记为已取消, 让 read 循环尽快停止.
if (!mCancelled.compareAndSet(false, true)) {
return;
}
// Close streams off the main thread; close() may trigger network I/O (HTTP/2 RST_STREAM, TLS write).
// zh-CN: 在非主线程关闭流, close() 可能触发网络 I/O (HTTP/2 RST_STREAM, TLS write).
final InputStream in = mInputStream;
final BufferedOutputStream bout = mBufferedOutputStream;
final FileOutputStream fos = mFileOutputStream;
Schedulers.io().scheduleDirect(() -> StreamUtils.closeSilently(in, bout, fos));
Disposable nd = mNetworkDisposable;
if (nd != null && !nd.isDisposed()) {
nd.dispose();
}
Disposable pd = mProgressDisposable;
if (pd != null && !pd.isDisposed()) {
pd.dispose();
}
cancelOkHttpCallsByUrl(mUrl);
onCancel();
terminate(new DownloadCancelledException("Download cancelled: " + mUrl));
}
private void activeProgressDialogButton() {
MDButton button = mProgressDialog.getActionButton(DialogAction.POSITIVE);
button.setTextColor(mProgressDialog.getContext().getColor(R.color.dialog_button_caution));
MaterialDialog dialog = mProgressDialog;
if (dialog == null || !dialog.isShowing()) {
return;
}
MDButton button = dialog.getActionButton(DialogAction.POSITIVE);
button.setTextColor(dialog.getContext().getColor(R.color.dialog_button_caution));
button.setOnClickListener(view -> {
mProgressDialog.dismiss();
dismissProgressDialogSafely();
DownloadManager.getInstance().cancelDownload(mUrl);
ViewUtils.showToast(mProgressDialog.getContext(), R.string.text_download_cancelled);
ViewUtils.showToast(dialog.getContext(), R.string.text_download_cancelled);
});
}
private void onCancel() {
recycle();
// TODO by Stardust on Dec 6, 2017.
// ! notify?
if (shouldDeletePartialFile(mPath)) {
deleteFileSilentlyAsync(mPath);
}
}
private void terminate(@Nullable Throwable error) {
if (!mTerminated.compareAndSet(false, true)) {
return;
}
if (error == null) {
mProgress.onComplete();
} else {
mProgress.onError(error);
}
}
public void recycle() {
// FIXME by SuperMonster003 on May 31, 2022.
// ! Seems like none of the ways below could stop the downloading process.
// ! Even worse, progress may stuck at around 99% and suspend.
// ! zh-CN:
// ! 看起来下面几种方法均无法停止下载进程.
// ! 更糟的是, 下载进程可能在 99% 附近卡住.
// !
// # [1] disposeIfNeeded();
// # [2] getOkHttpClient().dispatcher().cancelAll();
// # [3] body.close();
mDownloadStatuses.remove(mUrl);
StreamUtils.closeSilently(mInputStream, mFileOutputStream);
StreamUtils.closeSilently(mInputStream, mBufferedOutputStream, mFileOutputStream);
}
public PublishSubject<ProgressInfo> progress() {
return mProgress;
}
}
}
}

View File

@@ -3,10 +3,10 @@ package org.autojs.autojs.util;
import androidx.annotation.Nullable;
import java.io.Closeable;
import java.io.IOException;
/**
* Created by SuperMonster003 on Jun 3, 2022.
* Modified by SuperMonster003 as of Feb 2, 2026.
*/
public class StreamUtils {
@@ -15,7 +15,7 @@ public class StreamUtils {
for (Closeable c : closeable) {
try {
c.close();
} catch (IOException ignored) {
} catch (Throwable ignored) {
/* Ignored. */
}
}