6.7.0 - Alpha20 - 增加临时文件自动清理机制; 增强代码编辑器后台草稿稳定性

This commit is contained in:
SuperMonster003
2026-02-15 17:09:30 +08:00
parent 43b1e890fd
commit 9757e3690b
13 changed files with 347 additions and 69 deletions

View File

@@ -25,6 +25,7 @@ import org.autojs.autojs.event.GlobalKeyObserver
import org.autojs.autojs.external.receiver.DynamicBroadcastReceivers
import org.autojs.autojs.ipc.InAppEventBus
import org.autojs.autojs.leakcanary.LeakCanarySetup
import org.autojs.autojs.storage.file.TmpScriptFilesCleanupScheduler
import org.autojs.autojs.storage.history.HistoryCleanupScheduler
import org.autojs.autojs.theme.ThemeColorManager
import org.autojs.autojs.timing.TimedTaskManager
@@ -76,6 +77,8 @@ class App : MultiDexApplication() {
HistoryCleanupScheduler.scheduleStartupCleanup(this)
HistoryCleanupScheduler.schedulePeriodicCleanup(this)
TmpScriptFilesCleanupScheduler.scheduleStartupCleanup(this)
TmpScriptFilesCleanupScheduler.schedulePeriodicCleanup(this)
}
}
}

View File

@@ -368,6 +368,43 @@ object PFiles {
}
}
@JvmStatic
@JvmOverloads
fun deleteRecursivelyOlderThan(file: File, maxAgeMs: Long, now: Long = System.currentTimeMillis()): Boolean {
if (!file.exists()) return true
var ok = true
if (file.isDirectory) {
val dirLastModified = file.lastModified()
val children = file.listFiles() ?: return false
for (child in children) {
val childResult = deleteRecursivelyOlderThan(child, maxAgeMs, now)
if (!childResult) {
ok = false
}
}
val remaining = file.listFiles()
val isEmpty = (remaining != null && remaining.isEmpty())
if (isEmpty) {
val age = now - dirLastModified
if (age >= maxAgeMs) {
ok = runCatching { file.delete() }.getOrDefault(false) && ok
}
}
return ok
}
val age = now - file.lastModified()
if (age < maxAgeMs) return true
val deleted = runCatching { file.delete() }.getOrDefault(false)
return deleted && ok
}
@JvmStatic
fun deleteRecursively(file: File): Boolean {
if (file.isDirectory()) {

View File

@@ -0,0 +1,56 @@
package org.autojs.autojs.storage.file
import android.content.Context
import org.autojs.autojs.pio.PFiles
import java.io.File
import java.security.MessageDigest
import java.util.Locale
/**
* Created by SuperMonster003 on Feb 15, 2026.
* Modified by JetBrains AI Assistant (GPT-5.2) as of Feb 15, 2026.
*/
class StableDraftFileHelper(private val context: Context, private val keyPath: String? = null) {
fun saveDraft(text: String): File? =
runCatching {
val target = stableDraftFileForKeyPathOrNull(keyPath) ?: return@runCatching null
// Atomic write: write to .part then rename.
// zh-CN: 原子写入: 先写入 .part 再重命名.
val part = File(target.parentFile, target.name + ".part")
PFiles.write(part, text)
if (!part.renameTo(target)) {
// Fallback: try best-effort copy/replace.
// zh-CN: 后备方案: 尽力 copy/replace.
PFiles.write(target, text)
runCatching { part.delete() }
}
target
}.onFailure { it.printStackTrace() }.getOrNull()
fun deleteDraft() {
stableDraftFileForKeyPathOrNull(keyPath)?.let { f ->
// Best-effort cleanup.
// zh-CN: 尽力清理.
runCatching { f.delete() }
runCatching { File(f.parentFile, f.name + ".part").delete() }
}
}
private fun stableDraftFileForKeyPathOrNull(keyPath: String?): File? {
if (keyPath.isNullOrBlank()) return null
// Stable name: sha256(path).js.
// zh-CN: 稳定文件名: sha256(path).js.
val digest = MessageDigest.getInstance("SHA-256")
.digest(keyPath.toByteArray(Charsets.UTF_8))
.joinToString("") { "%02x".format(Locale.US, it) }
val dir = File(context.cacheDir, "editor-drafts").apply { mkdirs() }
return File(dir, "draft-$digest.js")
}
}

View File

@@ -10,6 +10,7 @@ import static org.autojs.autojs.util.FileUtils.TYPE.JAVASCRIPT;
/**
* Created by Stardust on Oct 21, 2017.
* Modified by SuperMonster003 as of Feb 15, 2026.
*/
public class TmpScriptFiles {
@@ -20,6 +21,11 @@ public class TmpScriptFiles {
return tmp;
}
public static void clearTmpDir(Context context, long maxAgeMs) {
File dir = getTmpDir(context);
PFiles.deleteRecursivelyOlderThan(dir, maxAgeMs);
}
public static void clearTmpDir(Context context) {
File dir = getTmpDir(context);
PFiles.deleteRecursively(dir);

View File

@@ -0,0 +1,62 @@
package org.autojs.autojs.storage.file
import android.content.Context
import androidx.work.Constraints
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.ExistingWorkPolicy
import androidx.work.NetworkType
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager
import java.util.concurrent.TimeUnit
/**
* Created by SuperMonster003 on Feb 8, 2026.
*/
object TmpScriptFilesCleanupScheduler {
private const val UNIQUE_WORK_NAME = "tmp_script_files_cleanup_periodic"
fun scheduleStartupCleanup(context: Context) {
val appContext = context.applicationContext
runCatching {
val req = OneTimeWorkRequestBuilder<TmpScriptFilesCleanupWorker>()
// Delay a bit to reduce cold-start I/O pressure.
// zh-CN: 适当延迟以降低冷启动 I/O 压力.
.setInitialDelay(10L, TimeUnit.SECONDS)
.build()
WorkManager.getInstance(appContext)
.enqueueUniqueWork(
"startup-tmp-script-files-cleanup",
ExistingWorkPolicy.KEEP,
req,
)
}
}
/**
* Schedule periodic cleanup (once per day).
* zh-CN: 调度周期性清理 (每天一次).
*/
fun schedulePeriodicCleanup(context: Context) {
val appContext = context.applicationContext
// No network is required; keep it light and battery-friendly.
// zh-CN: 不需要网络; 尽量轻量/省电.
val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.NOT_REQUIRED)
.build()
val request = PeriodicWorkRequestBuilder<TmpScriptFilesCleanupWorker>(1, TimeUnit.DAYS)
.setConstraints(constraints)
.build()
WorkManager.getInstance(appContext).enqueueUniquePeriodicWork(
UNIQUE_WORK_NAME,
ExistingPeriodicWorkPolicy.KEEP,
request,
)
}
}

View File

@@ -0,0 +1,28 @@
package org.autojs.autojs.storage.file
import android.content.Context
import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters
/**
* Created by SuperMonster003 on Feb 15, 2026.
*/
class TmpScriptFilesCleanupWorker(
appContext: Context,
params: WorkerParameters,
) : CoroutineWorker(appContext, params) {
override suspend fun doWork(): Result {
return runCatching {
val maxAgeMs = TMP_FILES_CLEANUP_THRESHOLD
TmpScriptFiles.clearTmpDir(applicationContext, maxAgeMs)
}.fold(
onSuccess = { Result.success() },
onFailure = { Result.retry() },
)
}
companion object {
private const val TMP_FILES_CLEANUP_THRESHOLD: Long = 7L * 24L * 60L * 60L * 1000L
}
}

View File

@@ -31,7 +31,7 @@ import org.autojs.autojs.core.permission.OnRequestPermissionsResultCallback
import org.autojs.autojs.core.permission.PermissionRequestProxyActivity
import org.autojs.autojs.core.permission.RequestPermissionCallbacks
import org.autojs.autojs.pio.PFiles
import org.autojs.autojs.storage.file.TmpScriptFiles
import org.autojs.autojs.storage.file.StableDraftFileHelper
import org.autojs.autojs.theme.widget.ThemeColorToolbar
import org.autojs.autojs.ui.BaseActivity
import org.autojs.autojs.ui.error.ErrorDialogActivity
@@ -47,12 +47,11 @@ import org.autojs.autojs.util.ViewUtils.titleView
import org.autojs.autojs6.R
import org.autojs.autojs6.databinding.ActivityEditBinding
import java.io.File
import java.io.IOException
/**
* Created by Stardust on Jan 29, 2017.
* Modified by JetBrains AI Assistant (GPT-5.2) as of Feb 12, 2026.
* Modified by SuperMonster003 as of Feb 12, 2026.
* Modified by JetBrains AI Assistant (GPT-5.2) as of Feb 15, 2026.
* Modified by SuperMonster003 as of Feb 15, 2026.
*/
open class EditActivity : BaseActivity(), DelegateHost, PermissionRequestProxyActivity {
@@ -89,34 +88,49 @@ open class EditActivity : BaseActivity(), DelegateHost, PermissionRequestProxyAc
private val mRequestPermissionCallbacks = RequestPermissionCallbacks()
private var mNewTask = false
private lateinit var draftFileHelper: StableDraftFileHelper
@SuppressLint("CheckResult")
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
mReadOnly = intent.getBooleanExtra(EditorView.EXTRA_READ_ONLY, false)
val binding = ActivityEditBinding.inflate(layoutInflater).also {
setContentView(it.root)
}
val readOnly = intent.getBooleanExtra(EditorView.EXTRA_READ_ONLY, false).also {
mReadOnly = it
}
val toolbar = findViewById<ThemeColorToolbar>(R.id.toolbar).also {
mToolbar = it
}
val editorView = binding.editorView.also {
mEditorView = it
}
EditorMenu(editorView, readOnly).also {
mEditorMenu = it
}
StableDraftFileHelper(this, editorView.uri?.path).also {
draftFileHelper = it
}
(intent.flags and Intent.FLAG_ACTIVITY_NEW_TASK != 0).also {
mNewTask = it
}
val binding = ActivityEditBinding.inflate(layoutInflater).also { setContentView(it.root) }
mToolbar = findViewById<ThemeColorToolbar>(R.id.toolbar).apply {
setTitleTextAppearance(this@EditActivity, R.style.TextAppearanceEditorTitle)
setOnTitleViewClickListener {
val path = mEditorView.uri?.path
if (path != null) {
EditableFileInfoDialogManager.showEditableFileInfoDialog(this@EditActivity, File(path)) {
mEditorView.editor.text
}
toolbar.setTitleTextAppearance(this, R.style.TextAppearanceEditorTitle)
toolbar.setOnTitleViewClickListener {
val path = mEditorView.uri?.path
if (path != null) {
EditableFileInfoDialogManager.showEditableFileInfoDialog(this, File(path)) {
mEditorView.editor.text
}
}
}
mEditorView = binding.editorView.apply {
handleIntent(intent)
.observeOn(AndroidSchedulers.mainThread())
.subscribe(Observers.emptyConsumer()) { ex: Throwable -> onLoadFileError(ex.message) }
}
mEditorMenu = EditorMenu(mEditorView, mReadOnly)
mNewTask = intent.flags and Intent.FLAG_ACTIVITY_NEW_TASK != 0
setUpToolbar()
editorView.handleIntent(intent)
.observeOn(AndroidSchedulers.mainThread())
.subscribe(Observers.emptyConsumer()) { ex: Throwable -> onLoadFileError(ex.message) }
setToolbarAsBack(editorView.name)
onBackPressedDispatcher.addCallback(this, mOnBackPressedCallback)
}
@@ -131,10 +145,6 @@ open class EditActivity : BaseActivity(), DelegateHost, PermissionRequestProxyAc
.show()
}
private fun setUpToolbar() {
setToolbarAsBack(mEditorView.name)
}
override fun onCreateOptionsMenu(menu: Menu): Boolean {
menuInflater.inflate(R.menu.menu_editor, menu)
mToolbar?.let { toolbar ->
@@ -350,6 +360,10 @@ open class EditActivity : BaseActivity(), DelegateHost, PermissionRequestProxyAc
.save()
.observeOn(AndroidSchedulers.mainThread())
.subscribe({
// Save succeeded: remove draft for this file.
// zh-CN: 保存成功: 删除该文件对应草稿.
draftFileHelper.deleteDraft()
runCatching { d.dismiss() }
finishAndRemoveFromRecents()
}, { e: Throwable ->
@@ -389,33 +403,32 @@ open class EditActivity : BaseActivity(), DelegateHost, PermissionRequestProxyAc
}
override fun onSaveInstanceState(outState: Bundle) {
if (!mEditorView.isTextChanged) {
// Save draft when UI indicates "needs save".
// zh-CN: 当 UI 表示 "需要保存" 时保存草稿.
if (!mEditorView.saveStickyDirty) {
super.onSaveInstanceState(outState)
return
}
val text = mEditorView.editor.text
if (text.length < 256 * 1024) {
outState.putString("text", text)
} else {
val tmp = saveToTmpFile(text)
if (tmp != null) {
outState.putString("path", tmp.path)
when {
text.length < 256 * 1024 -> {
Log.d(LOG_TAG, "saveDraftText, length: ${text.length}")
outState.putString("text", text)
}
else -> {
Log.d(LOG_TAG, "saveDraftFile, length: ${text.length}")
// Use stable file key (real script path) to avoid accumulating tmp files.
// zh-CN: 使用稳定 key (脚本真实 path) 避免累计 tmp 文件.
draftFileHelper.saveDraft(text)?.let { tmp ->
outState.putString("path", tmp.path)
}
}
}
super.onSaveInstanceState(outState)
}
@SuppressLint("CheckResult")
private fun saveToTmpFile(text: String): File? = try {
TmpScriptFiles.create(this).also { tmp ->
Observable.just(text)
.observeOn(Schedulers.io())
.subscribe { t: String? -> PFiles.write(tmp, t) }
}
} catch (e: IOException) {
e.printStackTrace()
null
}
override fun onResume() {
super.onResume()
runCatching { mEditorView.refreshSymbolsBar() }

View File

@@ -58,6 +58,7 @@ import org.autojs.autojs.model.script.Scripts.openByOtherApps
import org.autojs.autojs.model.script.Scripts.runWithBroadcastSender
import org.autojs.autojs.pio.PFiles.getNameWithoutExtension
import org.autojs.autojs.pio.PFiles.write
import org.autojs.autojs.storage.file.StableDraftFileHelper
import org.autojs.autojs.storage.file.TmpScriptFiles
import org.autojs.autojs.storage.history.HistoryPrefs
import org.autojs.autojs.storage.history.HistoryRepository
@@ -119,8 +120,8 @@ import java.util.regex.Pattern
/**
* Created by Stardust on Sep 28, 2017.
* Transformed by SuperMonster003 on May 1, 2023.
* Modified by SuperMonster003 as of Feb 3, 2026.
* Modified by JetBrains AI Assistant (GPT-5.2) as of Feb 12, 2026.
* Modified by SuperMonster003 as of Feb 15, 2026.
*/
@SuppressLint("CheckResult")
class EditorView : LinearLayout, OnHintClickListener, ClickCallback, ToolbarFragment.OnMenuItemClickListener {
@@ -1055,6 +1056,8 @@ class EditorView : LinearLayout, OnHintClickListener, ClickCallback, ToolbarFrag
// zh-CN: 因刚刚建立了新的基线, 重置 sticky 脏标记.
saveStickyDirty = false
mHadDirectEditSinceSave = false
mEditorLoading = false
syncPrimaryMenuState()
// Refresh highlight for restored text if size allows.
@@ -1556,6 +1559,10 @@ class EditorView : LinearLayout, OnHintClickListener, ClickCallback, ToolbarFrag
mHadDirectEditSinceSave = false
setMenuItemStatus(R.id.save, false)
val keyPath = uri?.path
val draftFileHelper = StableDraftFileHelper(context, keyPath)
draftFileHelper.deleteDraft()
}
/**

View File

@@ -35,6 +35,7 @@ import android.util.AttributeSet
import android.view.Gravity
import android.view.KeyEvent
import android.view.MotionEvent
import android.view.accessibility.AccessibilityEvent
import android.view.inputmethod.EditorInfo
import android.view.inputmethod.InputConnection
import android.view.inputmethod.InputConnectionWrapper
@@ -72,7 +73,11 @@ class CodeEditText : AppCompatEditText {
// Fixed gutter digits used during loading to avoid frequent requestLayout().
// zh-CN: 加载期间使用固定 gutter 位数, 避免频繁 requestLayout().
@Volatile
private var mLoadingGutterDigits: Int = 1
private var mLoadingGutterDigits: Int = 3
// Accessibility payload guardrail threshold.
// zh-CN: 无障碍事件负载护栏阈值.
private val mA11yLargeTextThresholdChars: Int = 64 * 1024
private var mTheme: Theme = Theme.getDefault(context)
private val mLineHighlightPaint = Paint().apply { style = Paint.Style.FILL }
@@ -136,6 +141,10 @@ class CodeEditText : AppCompatEditText {
// zh-CN: 默认确保可以进行文本选择.
setTextIsSelectable(true)
isLongClickable = true
// Update accessibility importance at init.
// zh-CN: 初始化时更新无障碍重要性配置.
updateAccessibilityImportanceIfNeeded()
}
// Toggle loading state.
@@ -544,16 +553,50 @@ class CodeEditText : AppCompatEditText {
return
}
// 调用父类的 onSelectionChanged 时会发送一个 AccessibilityEvent, 当文本过大时造成异常
// super.onSelectionChanged(selStart, selEnd);
// 父类构造函数会调用 onSelectionChanged, 此时 mCursorChangeCallbacks 还没有初始化
super.onSelectionChanged(selStart, selEnd)
// Update accessibility importance when selection changes (input usually changes selection).
// zh-CN: selection 变化时更新无障碍重要性 (输入通常会改变 selection).
updateAccessibilityImportanceIfNeeded()
// Avoid sending large accessibility events for huge text on every keystroke.
// zh-CN: 避免在超大文本下每次按键都发送巨大的无障碍事件.
if (shouldSuppressAccessibilityForLargeText()) {
// Do NOT call super.onSelectionChanged() here because it may dispatch
// TYPE_VIEW_TEXT_SELECTION_CHANGED with huge payload and cause Binder TTLE.
// zh-CN:
// 此处不要调用 super.onSelectionChanged(),
// 因其可能派发携带巨大负载的 TYPE_VIEW_TEXT_SELECTION_CHANGED,
// 从而导致 Binder TTLE.
} else {
// 调用父类的 onSelectionChanged 时会发送一个 AccessibilityEvent, 当文本过大时造成异常
// super.onSelectionChanged(selStart, selEnd);
// 父类构造函数会调用 onSelectionChanged, 此时 mCursorChangeCallbacks 还没有初始化
super.onSelectionChanged(selStart, selEnd)
}
mCursorChangeCallbacks?.let { it.takeUnless { it.isEmpty() } } ?: return
if (selStart != selEnd) return
callCursorChangeCallback(text, selStart)
matchesBracket(text, selStart)
}
override fun sendAccessibilityEventUnchecked(event: AccessibilityEvent) {
// Guardrail: drop or shrink accessibility payload when text is huge.
// zh-CN: 护栏: 文本很大时丢弃或瘦身无障碍事件负载.
if (shouldSuppressAccessibilityForLargeText()) {
// Clear potentially huge text payload to avoid Binder transaction overflow.
// zh-CN: 清空可能非常大的 text 负载, 避免 Binder 事务溢出.
runCatching { event.text.clear() }
runCatching { event.contentDescription = null }
// Also drop selection-changed events entirely in large-text mode.
// zh-CN: 大文本模式下直接丢弃 selection-changed 事件.
if (event.eventType == AccessibilityEvent.TYPE_VIEW_TEXT_SELECTION_CHANGED) {
return
}
}
super.sendAccessibilityEventUnchecked(event)
}
private fun matchesBracket(text: CharSequence?, cursor: Int) {
if (checkBracketMatchingAt(text, cursor)) return
if (checkBracketMatchingAt(text, cursor - 1)) return
@@ -713,6 +756,25 @@ class CodeEditText : AppCompatEditText {
invalidate()
}
// Whether accessibility should be suppressed for current content size.
// zh-CN: 是否需要针对当前内容大小抑制无障碍事件.
private fun shouldSuppressAccessibilityForLargeText(): Boolean {
val len = text?.length ?: 0
return len >= mA11yLargeTextThresholdChars
}
// Apply accessibility importance based on current text length.
// zh-CN: 根据当前文本长度应用无障碍重要性配置.
private fun updateAccessibilityImportanceIfNeeded() {
val target = when (shouldSuppressAccessibilityForLargeText()) {
true -> IMPORTANT_FOR_ACCESSIBILITY_NO
else -> IMPORTANT_FOR_ACCESSIBILITY_AUTO
}
if (importantForAccessibility != target) {
importantForAccessibility = target
}
}
companion object {
const val TAG = "CodeEditText"

View File

@@ -142,7 +142,8 @@
android:id="@+id/docs"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_gravity="start" />
android:layout_gravity="start"
android:saveEnabled="false" />
</androidx.drawerlayout.widget.DrawerLayout>

View File

@@ -1,11 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.cardview.widget.CardView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:cardBackgroundColor="@android:color/white"
app:cardCornerRadius="6dp">
<androidx.cardview.widget.CardView
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:cardBackgroundColor="@android:color/white"
app:cardCornerRadius="6dp">
<LinearLayout
android:layout_width="match_parent"
@@ -26,7 +27,7 @@
android:paddingStart="20dp"
android:textColor="#2a2a2a"
android:textSize="16sp"
tools:text="标题"/>
tools:text="标题" />
<ImageView
android:id="@+id/pin_to_left"
@@ -35,7 +36,7 @@
android:background="?selectableItemBackgroundBorderless"
android:padding="16dp"
android:src="@drawable/ic_ali_pin_to_left"
app:tint="#3c3c3c" />
app:tint="#3c3c3c" />
<ImageView
android:id="@+id/fullscreen"
@@ -44,7 +45,7 @@
android:background="?selectableItemBackgroundBorderless"
android:padding="16dp"
android:src="@drawable/ic_ali_fullscreen"
app:tint="#3c3c3c" />
app:tint="#3c3c3c" />
<ImageView
android:id="@+id/close"
@@ -54,18 +55,19 @@
android:background="?selectableItemBackgroundBorderless"
android:padding="16dp"
android:src="@drawable/ic_ali_close"
app:tint="#3c3c3c" />
app:tint="#3c3c3c" />
</LinearLayout>
<View
android:layout_width="match_parent"
android:layout_height="1.2px"
android:background="#cccccc"/>
android:background="#cccccc" />
<org.autojs.autojs.ui.widget.EWebView
android:id="@+id/eweb_view"
android:layout_width="match_parent"
android:layout_height="match_parent"/>
android:layout_height="match_parent"
android:saveEnabled="false" />
</LinearLayout>
</androidx.cardview.widget.CardView>