From 27a43fbd62b419a6dcea66bd8251fde0dba7f1b0 Mon Sep 17 00:00:00 2001 From: SuperMonster003 Date: Mon, 12 Jan 2026 00:03:50 +0800 Subject: [PATCH] =?UTF-8?q?6.7.0=20-=20Alpha14=20-=20=E4=BF=AE=E5=A4=8D=20?= =?UTF-8?q?images.captureScreen=20=E7=BB=93=E6=9E=9C=E6=96=B9=E5=90=91?= =?UTF-8?q?=E9=94=99=E8=AF=AF=E6=88=96=E9=BB=91=E8=BE=B9=E9=97=AE=E9=A2=98?= =?UTF-8?q?=20(issue=20#337);=20=E4=BF=AE=E5=A4=8D=20ImageWrapper#recycle;?= =?UTF-8?q?=20=E4=BC=98=E5=8C=96=20device.rotation/rotation/width/height?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .changelog/lang_zh-Hans.json | 3 + .../autojs/autojs/core/image/CapturedImage.kt | 20 -- .../autojs/autojs/core/image/ImageWrapper.kt | 14 +- .../capture/OnImageAvailableListenerSync.java | 17 +- .../core/image/capture/ScreenCapturer.java | 335 +++++++++++++++--- .../engine/LoopBasedJavaScriptEngine.java | 7 +- .../org/autojs/autojs/runtime/api/Images.java | 62 +++- .../autojs/runtime/api/ScreenMetrics.kt | 138 ++++++-- .../runtime/api/augment/images/Images.kt | 4 +- version.properties | 6 +- 10 files changed, 471 insertions(+), 135 deletions(-) delete mode 100644 app/src/main/java/org/autojs/autojs/core/image/CapturedImage.kt diff --git a/.changelog/lang_zh-Hans.json b/.changelog/lang_zh-Hans.json index 41699aac..c65579c8 100644 --- a/.changelog/lang_zh-Hans.json +++ b/.changelog/lang_zh-Hans.json @@ -56,10 +56,12 @@ "canvas 元素控件 setMaxFps 方法内部帧率计算错误", "images.concat 方法纵向拼接时宽度值计算错误", "images.requestScreenCapture 方法调用时用户取消授权可能导致应用崩溃的问题", + "images.captureScreen 方法获取的截图可能存在方向错误或黑边填充的问题 _[`issue #337`](http://issues.autojs6.com/337)_", "images 部分方法使用区域参数时 1 被误解析为百分比的问题", "images 部分相关方法出现异常时 oneShot 标记功能失效的问题 _[`issue #372`](http://issues.autojs6.com/372)_", "images 部分相关方法可能引发内存泄露的问题 _[`issue #372`](http://issues.autojs6.com/372)_", "images.save 及 ImageWrapper#saveTo 方法传入路径不存在时无法正常写入文件的问题", + "ImageWrapper#recycle 方法回收图像资源功能失效的问题", "ocr 部分重载方法可能无法正常使用的问题", "ocr.detect 方法获得的结果可能与 ocr.mode 不匹配的问题 _[`issue #468`](http://issues.autojs6.com/468)_", "auto.registerEvent 注册的无障碍服务事件会被其他脚本误清理的问题 _[`issue #466`](http://issues.autojs6.com/466)_ _[`issue #343`](http://issues.autojs6.com/343#issuecomment-3263953918)_", @@ -92,6 +94,7 @@ "ui.getStatusBarHeight 方法/statusBarHeight 属性 (getter) 增强一定程度的兼容性", "console.log 等方法打印全局对象 (images, app, ocr 等) 时支持显示详细信息", "android.graphics.Paint#setColor 支持正常解析 ColorInt/ColorHex/ColorName 等颜色参数", + "device.rotation/rotation/width/height 方法获取屏幕方向及宽高数据的稳定性与一致性", "app.launchDual/startDualActivity 等方法提升执行效率并增强兼容性", "内置模块相关方法实参类型的异常消息增加类型摘要信息", "控制台相关方法打印巨型数据时根据上限适当省略部分内容以降低打印耗时", diff --git a/app/src/main/java/org/autojs/autojs/core/image/CapturedImage.kt b/app/src/main/java/org/autojs/autojs/core/image/CapturedImage.kt deleted file mode 100644 index bfba107d..00000000 --- a/app/src/main/java/org/autojs/autojs/core/image/CapturedImage.kt +++ /dev/null @@ -1,20 +0,0 @@ -package org.autojs.autojs.core.image - -import android.media.Image -import org.autojs.autojs.runtime.ScriptRuntime - -/** - * Created by SuperMonster003 on Dec 15, 2023. - * Modified by SuperMonster003 as of May 20, 2025. - */ -// @Reference to Auto.js Pro 9.3.11 by SuperMonster003 on Dec 15, 2023. -class CapturedImage(scriptRuntime: ScriptRuntime, image: Image) : ImageWrapper(scriptRuntime, image) { - - override fun recycle() { - /* Doing nothing to suppress default recycle method. */ - } - - fun recycleInternal() { - super.recycle() - } -} diff --git a/app/src/main/java/org/autojs/autojs/core/image/ImageWrapper.kt b/app/src/main/java/org/autojs/autojs/core/image/ImageWrapper.kt index d55ca02d..0592e40d 100644 --- a/app/src/main/java/org/autojs/autojs/core/image/ImageWrapper.kt +++ b/app/src/main/java/org/autojs/autojs/core/image/ImageWrapper.kt @@ -139,9 +139,21 @@ open class ImageWrapper : Shootable { constructor(scriptRuntime: ScriptRuntime, mediaImage: Image) { mScriptRuntime = scriptRuntime - mMediaImage = mediaImage.also { addToList(it) } mWidth = mediaImage.width mHeight = mediaImage.height + + // Detach from ImageReader lifecycle by copying pixels immediately. + // zh-CN: 通过立即拷贝像素来与 ImageReader 的生命周期解耦. + mBitmap = toBitmap(mediaImage).also { addToList(it) } + + // Close the original Image ASAP to avoid holding unstable buffers. + // zh-CN: 尽快关闭原始 Image, 避免持有不稳定的底层 buffer. + mediaImage.close() + + // Keep media references null after detaching. + // zh-CN: 解耦后不再保留 media 引用. + mMediaImage = null + mPlane = null } init { diff --git a/app/src/main/java/org/autojs/autojs/core/image/capture/OnImageAvailableListenerSync.java b/app/src/main/java/org/autojs/autojs/core/image/capture/OnImageAvailableListenerSync.java index 21015fc2..5af2ced9 100644 --- a/app/src/main/java/org/autojs/autojs/core/image/capture/OnImageAvailableListenerSync.java +++ b/app/src/main/java/org/autojs/autojs/core/image/capture/OnImageAvailableListenerSync.java @@ -4,20 +4,15 @@ import android.media.ImageReader; /** * Created by SuperMonster003 on Dec 15, 2023. + * Modified by SuperMonster003 as of Jan 11, 2026. */ // @Reference to Auto.js Pro 9.3.11 by SuperMonster003 on Dec 15, 2023. -public class OnImageAvailableListenerSync implements ImageReader.OnImageAvailableListener { +public record OnImageAvailableListenerSync(ScreenCapturer screenCapturer) implements ImageReader.OnImageAvailableListener { - public final ScreenCapturer mScreenCapturer; - public final ImageReader mImageReader; - - public OnImageAvailableListenerSync(ScreenCapturer screenCapturer, ImageReader imageReader) { - this.mScreenCapturer = screenCapturer; - this.mImageReader = imageReader; - } - - public final void onImageAvailable(ImageReader imageReader) { - mScreenCapturer.setImageListenerSync(mImageReader); + public void onImageAvailable(ImageReader imageReader) { + // Use the actual callback parameter ImageReader. + // zh-CN: 使用回调参数中的实际 ImageReader, 避免 refreshImageReader 后因引用过期导致无法唤醒等待线程. + screenCapturer.setImageListenerSync(imageReader); } } diff --git a/app/src/main/java/org/autojs/autojs/core/image/capture/ScreenCapturer.java b/app/src/main/java/org/autojs/autojs/core/image/capture/ScreenCapturer.java index 64988556..d844d07b 100644 --- a/app/src/main/java/org/autojs/autojs/core/image/capture/ScreenCapturer.java +++ b/app/src/main/java/org/autojs/autojs/core/image/capture/ScreenCapturer.java @@ -13,6 +13,10 @@ import android.media.projection.MediaProjection; import android.media.projection.MediaProjectionManager; import android.os.Build; import android.os.Handler; +import android.os.HandlerThread; +import android.os.SystemClock; +import android.view.Display; +import android.view.Surface; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import org.autojs.autojs.runtime.api.ScreenMetrics; @@ -27,13 +31,35 @@ import java.util.regex.Pattern; /** * Created by Stardust on May 17, 2017. - * Modified by SuperMonster003 as of May 19, 2022. + * Modified by SuperMonster003 as of Jan 11, 2026. */ // @Reference to Auto.js Pro 9.3.11 by SuperMonster003 on Dec 19, 2023. public class ScreenCapturer { private static final Pattern PATTERN_BUFFER_FORMAT_EXCEPTION = Pattern.compile("buffer format ([0-9a-zA-Z]+) doesn't match"); + // Reduce total wait budget and use shorter waits to fail fast on abandoned BufferQueue. + // zh-CN: 降低总等待预算并使用更短的等待粒度, 以在 BufferQueue 已被 abandoned 等场景下更快失败. + private static final long CAPTURE_TOTAL_TIMEOUT_MS = 1200; + + // Use smaller wait slices for faster convergence. + // zh-CN: 使用更小的等待切片以更快收敛. + private static final long IMAGE_AVAILABLE_WAIT_SLICE_MS = 60; + + // Trigger self-healing earlier in the capture window. + // zh-CN: 在 capture 窗口的更早阶段触发自愈. + private static final long EARLY_HEALING_AT_MS = 600; + + // Use a dedicated thread for ImageReader callbacks to avoid deadlock when capture() blocks. + // zh-CN: 使用独立线程处理 ImageReader 回调, 避免 capture() 阻塞时与回调线程相同导致的 "自锁式等待". + private final HandlerThread mImageCallbackThread; + private final Handler mImageCallbackHandler; + + // Listen to display changes even when AutoJs6 is in background (no foreground Activity). + // zh-CN: 即使 AutoJs6 在后台 (无前台 Activity), 也通过 DisplayListener 监听显示变化, 避免配置事件丢失. + private final DisplayManager mDisplayManager; + private final DisplayManager.DisplayListener mDisplayListener; + public static final int ORIENTATION_AUTO = Configuration.ORIENTATION_UNDEFINED; // 0 public static final int ORIENTATION_LANDSCAPE = Configuration.ORIENTATION_LANDSCAPE; // 1 public static final int ORIENTATION_PORTRAIT = Configuration.ORIENTATION_PORTRAIT; // 2 @@ -42,7 +68,6 @@ public class ScreenCapturer { private volatile Image mUnderUsingImage; private final int mScreenDensity; private final Handler mHandler; - private final Context mContext; private final int mOrientation; private final Object mImageAvailableLock = new Object(); private final Options mOptions; @@ -51,24 +76,83 @@ public class ScreenCapturer { private VirtualDisplay mVirtualDisplay; private OnScreenCaptureAvailableListener mOnScreenCaptureAvailableListener; private int mDetectedOrientation; + private int mAppliedOrientation = ORIENTATION_AUTO; private int mPixelFormat = PixelFormat.RGBA_8888; private volatile boolean mImageAvailable = false; private boolean mShouldRefreshVirtualDisplayOnNextCapture = false; public ScreenCapturer(Context context, Intent data, Options options, Handler handler) { - mContext = context; mOptions = options; mHandler = handler; - mMediaProjection = ((MediaProjectionManager) mContext.getSystemService(Context.MEDIA_PROJECTION_SERVICE)).getMediaProjection(Activity.RESULT_OK, (Intent) data.clone()); + + mImageCallbackThread = new HandlerThread("ScreenCapturer-ImageReader"); + mImageCallbackThread.start(); + mImageCallbackHandler = new Handler(mImageCallbackThread.getLooper()); + + mDisplayManager = (DisplayManager) context.getSystemService(Context.DISPLAY_SERVICE); + + mDisplayListener = new DisplayManager.DisplayListener() { + @Override + public void onDisplayAdded(int displayId) { + /* Ignored. */ + } + + @Override + public void onDisplayRemoved(int displayId) { + /* Ignored. */ + } + + @Override + public void onDisplayChanged(int displayId) { + // Only care about DEFAULT_DISPLAY. + // zh-CN: 只关心 DEFAULT_DISPLAY. + if (displayId != Display.DEFAULT_DISPLAY) return; + + // Mark refresh for next capture to survive background state. + // zh-CN: 标记下一次 capture 刷新, 用于后台状态下避免错过配置变化事件. + refreshDetectedOrientation(); + if (mOptions.isAsync) { + mHandler.post(() -> refreshVirtualDisplay(mDetectedOrientation, false)); + } else { + mShouldRefreshVirtualDisplayOnNextCapture = true; + } + } + }; + + // Register on a non-blocking handler. + // zh-CN: 使用不易被阻塞的 handler 注册监听. + mDisplayManager.registerDisplayListener(mDisplayListener, mImageCallbackHandler); + + mMediaProjection = ((MediaProjectionManager) context.getSystemService(Context.MEDIA_PROJECTION_SERVICE)) + .getMediaProjection(Activity.RESULT_OK, (Intent) data.clone()); mScreenDensity = options.density; mOrientation = options.orientation; - refreshVirtualDisplay(mOrientation == ORIENTATION_AUTO ? mDetectedOrientation : mOrientation, true); + refreshDetectedOrientation(); + refreshVirtualDisplay(mOrientation == ORIENTATION_AUTO ? mDetectedOrientation : mOrientation, true); + EventBus.getDefault().register(this); } private void refreshDetectedOrientation() { - mDetectedOrientation = mContext.getResources().getConfiguration().orientation; + // Prefer rotation over (w/h) for orientation detection. + // zh-CN: 使用 rotation 而不是 (w/h) 判断方向. + int rotation = ScreenMetrics.getRotation(); + mDetectedOrientation = (rotation == Surface.ROTATION_90 || rotation == Surface.ROTATION_270) + ? ORIENTATION_LANDSCAPE + : ORIENTATION_PORTRAIT; + } + + private int getExpectedWidthByDetectedOrientation() { + // Keep consistent with refreshVirtualDisplay() sizing logic. + // zh-CN: 与 refreshVirtualDisplay() 的尺寸计算保持一致. + return ScreenMetrics.getOrientationAwareScreenWidth(mDetectedOrientation); + } + + private int getExpectedHeightByDetectedOrientation() { + // Keep consistent with refreshVirtualDisplay() sizing logic. + // zh-CN: 与 refreshVirtualDisplay() 的尺寸计算保持一致. + return ScreenMetrics.getOrientationAwareScreenHeight(mDetectedOrientation); } public record Options(int width, int height, int orientation, int density, boolean isAsync) { @@ -85,18 +169,64 @@ public class ScreenCapturer { void onCaptureAvailable(Image image); } - private Image acquireLatestImage() { - waitForImageAvailable(); - try { - return mImageReader.acquireLatestImage(); - } catch (UnsupportedOperationException ex) { - Integer pixelFormat = getPixelFormat(ex); - if (pixelFormat != null) { - setPixelFormat(pixelFormat); - waitForImageAvailable(); - return mImageReader.acquireLatestImage(); + private Image acquireLatestImage(long deadlineUptimeMillis) { + // Always wait for a fresh frame in sync mode. + // zh-CN: 同步模式下每次都等待一帧新图. + if (!mOptions.isAsync) { + synchronized (mImageAvailableLock) { + mImageAvailable = false; + } + } + + // Try several times but never exceed the given deadline. + // zh-CN: 做有限次数重试, 但绝不超过 deadline 指定的总时间预算. + for (int i = 0; i < 20; i++) { + long now = SystemClock.uptimeMillis(); + if (now >= deadlineUptimeMillis) return null; + + long remain = deadlineUptimeMillis - now; + waitForImageAvailable(Math.min(IMAGE_AVAILABLE_WAIT_SLICE_MS, remain)); + + try { + Image img = mImageReader.acquireLatestImage(); + if (img != null) return img; + } catch (UnsupportedOperationException ex) { + Integer pixelFormat = getPixelFormat(ex); + if (pixelFormat != null) { + setPixelFormat(pixelFormat); + if (!mOptions.isAsync) { + synchronized (mImageAvailableLock) { + mImageAvailable = false; + } + } + continue; + } + throw ex; + } + + // Reset and wait again. + // zh-CN: 重置标记并继续等待下一帧. + if (!mOptions.isAsync) { + synchronized (mImageAvailableLock) { + mImageAvailable = false; + } + } + } + + return null; + } + + private void waitForImageAvailable(long timeoutMillis) { + if (!mImageAvailable) { + synchronized (mImageAvailableLock) { + if (!mImageAvailable) { + try { + mImageAvailableLock.wait(timeoutMillis); + } catch (InterruptedException ex) { + throw new ScriptInterruptedException(); + } + } } - throw ex; } } @@ -124,7 +254,16 @@ public class ScreenCapturer { }, mHandler); } - mVirtualDisplay = mMediaProjection.createVirtualDisplay(ScreenCapturer.class.getSimpleName(), width, height, screenDensity, DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR, mImageReader.getSurface(), null, null); + mVirtualDisplay = mMediaProjection.createVirtualDisplay( + ScreenCapturer.class.getSimpleName(), + width, + height, + screenDensity, + DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR, + mImageReader.getSurface(), + null, + null + ); } private void refreshImageReader(int width, int height) { @@ -136,7 +275,10 @@ public class ScreenCapturer { } int maxImages = mOptions.isAsync ? 1 : 3; mImageReader = ImageReader.newInstance(width, height, mPixelFormat, maxImages); - setImageListener(mHandler); + + // Always dispatch ImageReader callbacks on the dedicated thread. + // zh-CN: 始终在独立线程分发 ImageReader 回调. + setImageListener(mImageCallbackHandler); } private void refreshVirtualDisplay(int orientation, boolean isInit) { @@ -149,21 +291,44 @@ public class ScreenCapturer { width.set(ScreenMetrics.getOrientationAwareScreenWidth(orientation)); height.set(ScreenMetrics.getOrientationAwareScreenHeight(orientation)); } + + // Recreate VirtualDisplay when orientation changes in AUTO mode. + // VirtualDisplay.resize(...) may not reliably switch output orientation on some devices, + // causing canvas size to mismatch content orientation. + // zh-CN: + // AUTO 模式下只要方向发生变化, 就直接重建 VirtualDisplay. + // 部分设备上 VirtualDisplay.resize(...) 可能无法可靠切换输出方向, 从而导致画布尺寸与内容方向错配. + boolean shouldRecreate = !isInit + && mVirtualDisplay != null + && mOrientation == ORIENTATION_AUTO + && orientation != mAppliedOrientation; + if (mVirtualDisplay == null) { if (isInit) { initVirtualDisplay(width.get(), height.get(), mScreenDensity); + mAppliedOrientation = orientation; } - } else { - refreshImageReader(width.get(), height.get()); - mVirtualDisplay.setSurface(mImageReader.getSurface()); - mVirtualDisplay.resize(width.get(), height.get(), mScreenDensity); + return; } + + if (shouldRecreate) { + mVirtualDisplay.release(); + mVirtualDisplay = null; + initVirtualDisplay(width.get(), height.get(), mScreenDensity); + mAppliedOrientation = orientation; + return; + } + + refreshImageReader(width.get(), height.get()); + mVirtualDisplay.setSurface(mImageReader.getSurface()); + mVirtualDisplay.resize(width.get(), height.get(), mScreenDensity); + mAppliedOrientation = orientation; } private void setImageListener(Handler handler) { ImageReader.OnImageAvailableListener o = mOptions.isAsync ? new OnImageAvailableListenerAsync(this) - : new OnImageAvailableListenerSync(this, mImageReader); + : new OnImageAvailableListenerSync(this); mImageReader.setOnImageAvailableListener(o, handler); } @@ -182,12 +347,12 @@ public class ScreenCapturer { } public void setImageListenerSync(ImageReader imageReader) { - imageReader.setOnImageAvailableListener(null, null); - if (!mImageAvailable && imageReader == mImageReader) { - synchronized (mImageAvailableLock) { - mImageAvailable = true; - mImageAvailableLock.notifyAll(); - } + // Always notify for the current ImageReader. + // zh-CN: 只要回调来自当前 ImageReader, 就直接唤醒等待线程, 避免信号丢失. + if (imageReader != mImageReader) return; + synchronized (mImageAvailableLock) { + mImageAvailable = true; + mImageAvailableLock.notifyAll(); } } @@ -197,37 +362,88 @@ public class ScreenCapturer { mVirtualDisplay.setSurface(mImageReader.getSurface()); } - private void waitForImageAvailable() { - if (!mImageAvailable) { - synchronized (mImageAvailableLock) { - if (!mImageAvailable) { - try { - mImageAvailableLock.wait(); - } catch (InterruptedException ex) { - throw new ScriptInterruptedException(); - } - } - } - } - } - @Nullable public Image capture() { if (mOptions.isAsync) { throw new IllegalStateException("capture() is not available in async mode"); } + + final long start = SystemClock.uptimeMillis(); + final long deadline = start + CAPTURE_TOTAL_TIMEOUT_MS; + + // For AUTO mode, do a best-effort self-check before acquiring the frame. + // zh-CN: AUTO 模式下, 在取帧之前做一次尽力自检, 发现画布尺寸不匹配则主动刷新 VirtualDisplay. + if (mOrientation == ORIENTATION_AUTO) { + refreshDetectedOrientation(); + + int expectedWidth = getExpectedWidthByDetectedOrientation(); + int expectedHeight = getExpectedHeightByDetectedOrientation(); + + // ImageReader size represents the "canvas" size of VirtualDisplay. + // zh-CN: ImageReader 尺寸代表 VirtualDisplay 的 "画布" 尺寸. + if (mImageReader != null + && (mImageReader.getWidth() != expectedWidth || mImageReader.getHeight() != expectedHeight)) { + refreshVirtualDisplay(mDetectedOrientation, false); + } + } + if (mShouldRefreshVirtualDisplayOnNextCapture) { mShouldRefreshVirtualDisplayOnNextCapture = false; refreshVirtualDisplay(mDetectedOrientation, false); } - Image acquireLatestImage = acquireLatestImage(); - if (acquireLatestImage != null) { + + // Retry a few times to skip transitional frames after resizing/switching, + // but respect the total timeout budget. + // zh-CN: 在 resize/切换后重试少量次数以跳过过渡帧, 但必须遵守总超时预算. + for (int i = 0; i < 5; i++) { + long now = SystemClock.uptimeMillis(); + if (now >= deadline) break; + + // Early self-healing in AUTO mode to avoid spending the whole budget waiting on a bad pipeline. + // zh-CN: AUTO 模式下尽早自愈, 避免把整个预算都耗在一个已失效的管线 (如 BufferQueue abandoned) 上. + if (mOrientation == ORIENTATION_AUTO && (now - start) >= EARLY_HEALING_AT_MS) { + refreshDetectedOrientation(); + refreshVirtualDisplay(mDetectedOrientation, false); + } + + Image acquireLatestImage = acquireLatestImage(deadline); + if (acquireLatestImage == null) continue; + + if (mOrientation == ORIENTATION_AUTO) { + int expectedWidth = getExpectedWidthByDetectedOrientation(); + int expectedHeight = getExpectedHeightByDetectedOrientation(); + if (acquireLatestImage.getWidth() != expectedWidth || acquireLatestImage.getHeight() != expectedHeight) { + // Drop mismatched frame and refresh display once more. + // zh-CN: 丢弃尺寸不匹配的帧, 并再次刷新 display. + acquireLatestImage.close(); + refreshVirtualDisplay(mDetectedOrientation, false); + continue; + } + } + if (mUnderUsingImage != null) { mUnderUsingImage.close(); } mUnderUsingImage = acquireLatestImage; + return mUnderUsingImage; } - return mUnderUsingImage; + + // If timed out, force a best-effort rebuild once to recover from "no-frame" bad state. + // zh-CN: 若超时, 尝试强制重建一次以从 "无帧" 坏状态中自愈. + if (mOrientation == ORIENTATION_AUTO) { + refreshDetectedOrientation(); + if (mVirtualDisplay != null) { + mVirtualDisplay.release(); + mVirtualDisplay = null; + } + initVirtualDisplay(getExpectedWidthByDetectedOrientation(), getExpectedHeightByDetectedOrientation(), mScreenDensity); + mAppliedOrientation = mDetectedOrientation; + mShouldRefreshVirtualDisplayOnNextCapture = false; + } + + // Do not return stale cached image when no valid frame is available. + // zh-CN: 当无法获取到有效帧时, 不要返回旧缓存帧. + return null; } public Options getOptions() { @@ -236,13 +452,15 @@ public class ScreenCapturer { @Subscribe public void onConfigurationChanged(Configuration configuration) { - if (mOrientation == ORIENTATION_AUTO && mDetectedOrientation != configuration.orientation) { - refreshDetectedOrientation(); - if (mOptions.isAsync) { - mHandler.post(() -> refreshVirtualDisplay(mDetectedOrientation, false)); - } else { - mShouldRefreshVirtualDisplayOnNextCapture = true; - } + if (mOrientation != ORIENTATION_AUTO) return; + + // Always schedule refresh for AUTO mode. + // zh-CN: AUTO 模式下收到配置变化事件时总是安排下一次 capture 刷新. + refreshDetectedOrientation(); + if (mOptions.isAsync) { + mHandler.post(() -> refreshVirtualDisplay(mDetectedOrientation, false)); + } else { + mShouldRefreshVirtualDisplayOnNextCapture = true; } } @@ -260,6 +478,15 @@ public class ScreenCapturer { if (mUnderUsingImage != null) { mUnderUsingImage.close(); } + + // Unregister display listener to avoid leaks. + // zh-CN: 反注册 DisplayListener 以避免泄漏. + mDisplayManager.unregisterDisplayListener(mDisplayListener); + + // Quit callback thread to avoid leaks. + // zh-CN: 退出回调线程以避免泄漏. + mImageCallbackThread.quitSafely(); + EventBus.getDefault().unregister(this); } diff --git a/app/src/main/java/org/autojs/autojs/engine/LoopBasedJavaScriptEngine.java b/app/src/main/java/org/autojs/autojs/engine/LoopBasedJavaScriptEngine.java index e6067c19..c0854e48 100644 --- a/app/src/main/java/org/autojs/autojs/engine/LoopBasedJavaScriptEngine.java +++ b/app/src/main/java/org/autojs/autojs/engine/LoopBasedJavaScriptEngine.java @@ -8,6 +8,7 @@ import android.util.Log; import org.autojs.autojs.AbstractAutoJs; import org.autojs.autojs.core.looper.LooperHelper; import org.autojs.autojs.runtime.ScriptRuntime; +import org.autojs.autojs.runtime.exception.ScriptInterruptedException; import org.autojs.autojs.script.JavaScriptSource; import org.autojs.autojs.script.ScriptSource; import org.jetbrains.annotations.NotNull; @@ -74,8 +75,10 @@ public class LoopBasedJavaScriptEngine extends RhinoJavaScriptEngine { continue; } catch (Throwable t) { mLooping = false; - if (AbstractAutoJs.isInrt() && t.getMessage() != null) { - ScriptRuntime.popException(t.getMessage()); + if (AbstractAutoJs.isInrt() && !ScriptInterruptedException.causedByInterrupt(t)) { + if (t.getMessage() != null) { + ScriptRuntime.popException(t.getMessage()); + } } throw t; } diff --git a/app/src/main/java/org/autojs/autojs/runtime/api/Images.java b/app/src/main/java/org/autojs/autojs/runtime/api/Images.java index 139f5640..6ce4b7d4 100644 --- a/app/src/main/java/org/autojs/autojs/runtime/api/Images.java +++ b/app/src/main/java/org/autojs/autojs/runtime/api/Images.java @@ -21,7 +21,6 @@ import org.autojs.autojs.AutoJs; import org.autojs.autojs.annotation.ScriptInterface; import org.autojs.autojs.annotation.ScriptVariable; import org.autojs.autojs.concurrent.VolatileDispose; -import org.autojs.autojs.core.image.CapturedImage; import org.autojs.autojs.core.image.ImageWrapper; import org.autojs.autojs.core.image.RhinoColorFinder; import org.autojs.autojs.core.image.Shootable; @@ -110,7 +109,7 @@ public class Images { private final ScreenMetrics mScreenMetrics; private volatile ScreenCapturer.OnScreenCaptureAvailableListener mOnScreenCaptureAvailableListener; private Image mPreCapture; - private CapturedImage mPreCaptureImage; + private ImageWrapper mPreCaptureImage; private ScreenCapturer mScreenCapturer; private ScreenCaptureRequester mScreenCaptureRequester; @@ -271,15 +270,60 @@ public class Images { if (mScreenCapturer == null) { throw new SecurityException(mContext.getString(R.string.error_no_screen_capture_permission)); } - Image capture = mScreenCapturer.capture(); - if (capture != mPreCapture || mPreCaptureImage == null) { + + // Retry in Java side to avoid leaking transient null frames to JS. + // zh-CN: 在 Java 层做重试, 避免把短暂的 null 帧暴露给 JS 层. + Image capture = null; + for (int i = 0; i < 6; i++) { + capture = mScreenCapturer.capture(); + if (capture != null) break; + try { + Thread.sleep(40); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + break; + } + } + + // Optional extra small backoff for switching moments. + // zh-CN: 可选的额外小退避, 用于方向/应用切换瞬间. + if (capture == null) { + try { + Thread.sleep(60); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + for (int i = 0; i < 2; i++) { + capture = mScreenCapturer.capture(); + if (capture != null) break; + try { + Thread.sleep(40); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + break; + } + } + } + + // If still no valid frame, fallback to previous cached image when possible. + // zh-CN: 若仍无有效帧, 尽可能回退到上一帧缓存图像. + if (capture == null) { + if (mPreCaptureImage != null && !mPreCaptureImage.isRecycled()) { + // Return a clone to avoid user recycling the internal cache. + // zh-CN: 返回 clone, 避免用户 recycle() 影响内部缓存. + return mPreCaptureImage.clone(); + } + return null; + } + + // Recreate wrapper when image instance changed OR cached wrapper is missing OR cached wrapper was recycled. + // zh-CN: 当 Image 实例发生变化/缓存包装对象为空/缓存包装对象已被回收时, 重新创建包装对象. + if (capture != mPreCapture || mPreCaptureImage == null || mPreCaptureImage.isRecycled()) { mPreCapture = capture; if (mPreCaptureImage != null) { - mPreCaptureImage.recycleInternal(); - } - if (capture != null) { - mPreCaptureImage = new CapturedImage(mScriptRuntime, capture); + mPreCaptureImage.recycle(); } + mPreCaptureImage = new ImageWrapper(mScriptRuntime, capture); } return mPreCaptureImage; } @@ -584,7 +628,7 @@ public class Images { mPreCapture = null; } if (mPreCaptureImage != null) { - mPreCaptureImage.recycleInternal(); + mPreCaptureImage.recycle(); mPreCaptureImage = null; } releaseScreenCaptureRequester(); diff --git a/app/src/main/java/org/autojs/autojs/runtime/api/ScreenMetrics.kt b/app/src/main/java/org/autojs/autojs/runtime/api/ScreenMetrics.kt index d338e2ce..922f74a2 100644 --- a/app/src/main/java/org/autojs/autojs/runtime/api/ScreenMetrics.kt +++ b/app/src/main/java/org/autojs/autojs/runtime/api/ScreenMetrics.kt @@ -6,14 +6,20 @@ import android.content.res.Configuration.ORIENTATION_LANDSCAPE import android.content.res.Configuration.ORIENTATION_PORTRAIT import android.content.res.Resources import android.graphics.Point +import android.hardware.display.DisplayManager +import android.os.Build import android.util.DisplayMetrics +import android.view.Display +import android.view.Surface import android.view.Surface.ROTATION_0 import android.view.WindowManager import org.autojs.autojs.app.GlobalAppContext import java.lang.ref.WeakReference +import kotlin.math.absoluteValue /** * Created by Stardust on Apr 26, 2017. + * Modified by SuperMonster003 as of Jan 11, 2026. */ @Suppress("unused") class ScreenMetrics { @@ -59,13 +65,86 @@ class ScreenMetrics { get() = mActivityRef.get()?.windowManager ?: GlobalAppContext.get().getSystemService(Context.WINDOW_SERVICE) as WindowManager + // Prefer DisplayManager for rotation to reduce Activity/ROM inconsistencies. + // zh-CN: rotation 优先使用 DisplayManager, 以降低 Activity 引用/ROM 行为差异导致的方向读数不一致. + private val mDisplayManager: DisplayManager? + get() = GlobalAppContext.get().getSystemService(Context.DISPLAY_SERVICE) as? DisplayManager + private var mIsInitialized = false private var mActivityRef = WeakReference(null) @Suppress("DEPRECATION") @JvmStatic val rotation: Int - get() = mWindowManager.defaultDisplay?.rotation ?: ROTATION_0 + get() { + // Prefer system Display rotation when available. + // zh-CN: 尽可能使用系统 Display 的 rotation. + val dm = mDisplayManager + val display: Display? = dm?.getDisplay(Display.DEFAULT_DISPLAY) + val rot = display?.rotation + if (rot != null) return rot + + // Fallback to legacy WindowManager/defaultDisplay. + // zh-CN: 回退到旧实现 WindowManager/defaultDisplay. + return mWindowManager.defaultDisplay?.rotation ?: ROTATION_0 + } + + // Cache stable long/short sides to avoid inconsistent width/height on some ROMs. + // zh-CN: 缓存稳定的长边/短边, 避免部分 ROM 在多次运行脚本/跨应用切换后出现宽高读数不一致. + @Volatile + private var sStableLongSide: Int = 0 + + @Volatile + private var sStableShortSide: Int = 0 + + private fun ensureStableSides() { + if (sStableLongSide > 0 && sStableShortSide > 0) return + + // Prefer WindowMetrics on Android R+. + // zh-CN: 在 Android R+ 优先使用 WindowMetrics. + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + val bounds = mWindowManager.maximumWindowMetrics.bounds + val w = bounds.width().absoluteValue + val h = bounds.height().absoluteValue + if (w > 0 && h > 0) { + sStableLongSide = maxOf(w, h) + sStableShortSide = minOf(w, h) + return + } + } + + // Fallback to getRealMetrics(). + // zh-CN: 回退到 getRealMetrics(). + @Suppress("DEPRECATION") + run { + val metricsLegacy = DisplayMetrics() + mWindowManager.defaultDisplay.apply { getRealMetrics(metricsLegacy) } + val w = metricsLegacy.widthPixels + val h = metricsLegacy.heightPixels + if (w > 0 && h > 0) { + sStableLongSide = maxOf(w, h) + sStableShortSide = minOf(w, h) + } + } + } + + @JvmStatic + val deviceScreenWidth: Int + get() { + ensureStableSides() + val rot = rotation + val isLandscape = rot == Surface.ROTATION_90 || rot == Surface.ROTATION_270 + return if (isLandscape) sStableLongSide else sStableShortSide + } + + @JvmStatic + val deviceScreenHeight: Int + get() { + ensureStableSides() + val rot = rotation + val isLandscape = rot == Surface.ROTATION_90 || rot == Surface.ROTATION_270 + return if (isLandscape) sStableShortSide else sStableLongSide + } @JvmStatic val orientation: Int @@ -79,32 +158,6 @@ class ScreenMetrics { val isScreenLandscape: Boolean get() = orientation == ORIENTATION_LANDSCAPE - @JvmStatic - val deviceScreenWidth: Int - get() = getScreenWidthCompat() - - @JvmStatic - val deviceScreenHeight: Int - get() = getScreenHeightCompat() - - @Suppress("DEPRECATION") - private fun getScreenWidthCompat(): Int { - // resources?.displayMetrics?.widthPixels?.takeIf { it > 0 }?.let { return it } - val metricsLegacy = DisplayMetrics() - return mWindowManager.defaultDisplay.apply { getRealMetrics(metricsLegacy) }.let { display -> - maxOf(metricsLegacy.widthPixels, display.width, 0) - } - } - - @Suppress("DEPRECATION") - private fun getScreenHeightCompat(): Int { - // resources?.displayMetrics?.heightPixels?.takeIf { it > 0 }?.let { return it } - val metricsLegacy = DisplayMetrics() - return mWindowManager.defaultDisplay.apply { getRealMetrics(metricsLegacy) }.let { display -> - maxOf(metricsLegacy.heightPixels, display.height, 0) - } - } - @JvmStatic @Suppress("DEPRECATION") val deviceScreenDensity: Int @@ -118,6 +171,11 @@ class ScreenMetrics { fun init(activity: Activity) { mActivityRef = WeakReference(activity) mIsInitialized = true + + // Reset stable cache when Activity is (re)initialized. + // zh-CN: Activity 初始化/重建时重置稳定缓存, 以适配可能的显示模式变化. + sStableLongSide = 0 + sStableShortSide = 0 } private fun toOriAwarePoint(a: Int, b: Int) = arrayOf(minOf(a, b), maxOf(a, b)) @@ -125,15 +183,29 @@ class ScreenMetrics { .let { Point(it[0], it[1]) } @JvmStatic - fun getOrientationAwareScreenWidth(orientation: Int) = when (orientation) { - ORIENTATION_LANDSCAPE -> deviceScreenHeight - else -> deviceScreenWidth + fun getOrientationAwareScreenWidth(orientation: Int): Int { + // Use stable sides instead of querying deviceScreenWidth/Height multiple times. + // zh-CN: 使用稳定长边/短边, 避免重复查询导致的不一致. + ensureStableSides() + val longSide = sStableLongSide + val shortSide = sStableShortSide + return when (orientation) { + ORIENTATION_LANDSCAPE -> longSide + else -> shortSide + } } @JvmStatic - fun getOrientationAwareScreenHeight(orientation: Int) = when (orientation) { - ORIENTATION_LANDSCAPE -> deviceScreenWidth - else -> deviceScreenHeight + fun getOrientationAwareScreenHeight(orientation: Int): Int { + // Use stable sides instead of querying deviceScreenWidth/Height multiple times. + // zh-CN: 使用稳定长边/短边, 避免重复查询导致的不一致. + ensureStableSides() + val longSide = sStableLongSide + val shortSide = sStableShortSide + return when (orientation) { + ORIENTATION_LANDSCAPE -> shortSide + else -> longSide + } } } diff --git a/app/src/main/java/org/autojs/autojs/runtime/api/augment/images/Images.kt b/app/src/main/java/org/autojs/autojs/runtime/api/augment/images/Images.kt index 69d79979..8b8360be 100644 --- a/app/src/main/java/org/autojs/autojs/runtime/api/augment/images/Images.kt +++ b/app/src/main/java/org/autojs/autojs/runtime/api/augment/images/Images.kt @@ -282,9 +282,9 @@ class Images(scriptRuntime: ScriptRuntime) : Augmentable(scriptRuntime), AsEmitt requestScreenCapture(scriptRuntime, arrayOf()) } when { - path.isJsNullish() -> rtImages.captureScreen() as ImageWrapper + path.isJsNullish() -> rtImages.captureScreen() else -> rtImages.captureScreen(scriptRuntime.files.nonNullPath(coerceString(path))) - } + } ?: throw WrappedRuntimeException("Failed to capture screen image") } // @Reference to module __images__.js from Auto.js Pro 9.3.11 by SuperMonster003 on Dec 19, 2023. diff --git a/version.properties b/version.properties index 7e1cf656..3988e892 100644 --- a/version.properties +++ b/version.properties @@ -1,5 +1,5 @@ -#Sat Jan 10 19:06:16 CST 2026 -BUILD_TIME=1768043176049 +#Sun Jan 11 23:41:15 CST 2026 +BUILD_TIME=1768146075102 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=3593 +VERSION_BUILD=3597 VERSION_NAME=6.7.0 Alpha14 VSCODE_EXT_REQUIRED_VERSION=1.0.13