From 1986353077769acbd9f607ac34cb569dea2e2aed Mon Sep 17 00:00:00 2001 From: SuperMonster003 Date: Tue, 3 Feb 2026 01:30:14 +0800 Subject: [PATCH] =?UTF-8?q?6.7.0=20-=20Alpha19=20-=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E6=A3=80=E6=9F=A5=E6=9B=B4=E6=96=B0=E4=B8=AD=E6=AD=A2=E4=B8=8B?= =?UTF-8?q?=E8=BD=BD=E8=BF=87=E7=A8=8B=E5=90=8E,=20=E7=BD=91=E7=BB=9C?= =?UTF-8?q?=E6=95=B0=E6=8D=AE=E5=8C=85=E4=BE=9D=E7=84=B6=E6=8C=81=E7=BB=AD?= =?UTF-8?q?=E5=90=8E=E5=8F=B0=E8=8E=B7=E5=8F=96=E7=9A=84=E9=97=AE=E9=A2=98?= =?UTF-8?q?=20(=E8=AF=95=E4=BF=AE)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .changelog/lang_zh-Hans.json | 3 +- .../network/download/DownloadManager.java | 437 +++++++++++++----- .../org/autojs/autojs/util/StreamUtils.java | 4 +- version.properties | 6 +- 4 files changed, 324 insertions(+), 126 deletions(-) diff --git a/.changelog/lang_zh-Hans.json b/.changelog/lang_zh-Hans.json index fce51f92..90496344 100644 --- a/.changelog/lang_zh-Hans.json +++ b/.changelog/lang_zh-Hans.json @@ -1,7 +1,7 @@ { "$data": { "v6.7.0": { - "released_date": "2026/02/02", + "released_date": "2026/02/03", "feature": [ "插件中心功能, 支持插件的安装/卸载/更新等操作 (入口: 主页抽屉按钮/主页标签页)", "Paddle OCR (PP-OCRv5) 插件, 用于光学字符识别", @@ -110,6 +110,7 @@ "浮动按钮 \"运行脚本\" 对话框后台操作文件时可能导致应用崩溃的问题", "主页活动页面生命周期结束后重新进入主页时, 浮动按钮状态可能被重置的问题", "小米设备 \"显示在其他应用上层\" 开关可能跳转到错误设置页面的问题", + "检查更新中止下载过程后, 网络数据包依然持续后台获取的问题 (试修)", "构建工具启用 isCleanup[Paddle/Rapid]Ocr 配置选项时无法正常完成 Rebuild Project 任务的问题" ], "improvement": [ diff --git a/app/src/main/java/org/autojs/autojs/network/download/DownloadManager.java b/app/src/main/java/org/autojs/autojs/network/download/DownloadManager.java index b2301745..6817eca7 100644 --- a/app/src/main/java/org/autojs/autojs/network/download/DownloadManager.java +++ b/app/src/main/java/org/autojs/autojs/network/download/DownloadManager.java @@ -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> 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 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 download(String url, String path) { - - PublishSubject downloadSubject = PublishSubject.create(); - - DownloadTask task = new DownloadTask(url, path); - PublishSubject 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. + * zh-CN: 通过 Observable.create() 精简结构, 不再使用 PublishSubject. + */ + @SuppressLint("CheckResult") + private Observable 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 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 mStatus; + private InputStream mInputStream; private FileOutputStream mFileOutputStream; - private final PublishSubject mProgress; + private BufferedOutputStream mBufferedOutputStream; + + private final Subject 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 previous = mDownloadStatuses.put(mUrl, mStatus); - if (previous != null) { - previous.set(false); - } - mProgress = PublishSubject.create(); + mProgress = PublishSubject.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 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 progress() { - return mProgress; - } - } - -} +} \ No newline at end of file diff --git a/app/src/main/java/org/autojs/autojs/util/StreamUtils.java b/app/src/main/java/org/autojs/autojs/util/StreamUtils.java index 91552e69..3dac0f39 100644 --- a/app/src/main/java/org/autojs/autojs/util/StreamUtils.java +++ b/app/src/main/java/org/autojs/autojs/util/StreamUtils.java @@ -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. */ } } diff --git a/version.properties b/version.properties index cf6452f1..53e56984 100644 --- a/version.properties +++ b/version.properties @@ -1,5 +1,5 @@ -#Mon Feb 02 21:31:36 CST 2026 -BUILD_TIME=1770039096757 +#Tue Feb 03 01:13:39 CST 2026 +BUILD_TIME=1770052419058 COMPILE_SDK_VERSION=36 IMAGE_QUANT_CMAKE_VERSION=3.22.1 IMAGE_QUANT_NDK_VERSION=26.1.10909125 @@ -27,6 +27,6 @@ RAPID_OCR_OPENCV_MOBILE_LABEL_VERSION=13 RAPID_OCR_OPENCV_MOBILE_VERSION=4.5.3 TARGET_SDK_VERSION=36 TARGET_SDK_VERSION_INRT=29 -VERSION_BUILD=3687 +VERSION_BUILD=3688 VERSION_NAME=6.7.0 Alpha19 VSCODE_EXT_REQUIRED_VERSION=1.0.13