6.7.0 - Alpha19 - 版本历史 M1 - 代码编辑器保存文件失败时自动存为草稿并支持另存为新文件
This commit is contained in:
@@ -132,6 +132,7 @@
|
|||||||
"控制台浮动窗口背景色彩行为相关 API (透明度/着色/基色) 更符合安卓设计规范 _[`issue #458`](http://issues.autojs6.com/458)_",
|
"控制台浮动窗口背景色彩行为相关 API (透明度/着色/基色) 更符合安卓设计规范 _[`issue #458`](http://issues.autojs6.com/458)_",
|
||||||
"文件管理器浮动按钮展开后点击菜单项时优化菜单收起时机",
|
"文件管理器浮动按钮展开后点击菜单项时优化菜单收起时机",
|
||||||
"文件管理器/任务面板支持显示文件/任务数量统计信息",
|
"文件管理器/任务面板支持显示文件/任务数量统计信息",
|
||||||
|
"代码编辑器保存文件失败时自动存为草稿并支持另存为新文件",
|
||||||
"打包应用页面默认勾选必要权限 (WAKE_LOCK/INTERNET/WRITE_EXTERNAL_STORAGE) _[`issue #397`](http://issues.autojs6.com/397)_",
|
"打包应用页面默认勾选必要权限 (WAKE_LOCK/INTERNET/WRITE_EXTERNAL_STORAGE) _[`issue #397`](http://issues.autojs6.com/397)_",
|
||||||
"打包应用设置页面增加前台服务开关 _[`issue #406`](http://issues.autojs6.com/406)_",
|
"打包应用设置页面增加前台服务开关 _[`issue #406`](http://issues.autojs6.com/406)_",
|
||||||
"脚本项目配置文件保存时增加键名冲突检测机制防止键名歧义",
|
"脚本项目配置文件保存时增加键名冲突检测机制防止键名歧义",
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ object DialogUtils {
|
|||||||
@JvmOverloads
|
@JvmOverloads
|
||||||
@Deprecated("Use showAdaptive instead.", ReplaceWith("showAdaptive(dialog, focusable)"))
|
@Deprecated("Use showAdaptive instead.", ReplaceWith("showAdaptive(dialog, focusable)"))
|
||||||
@ReservedForCompatibility
|
@ReservedForCompatibility
|
||||||
fun <T : MaterialDialog> showDialog(dialog: T, focusable: Boolean = true): T {
|
fun showDialog(dialog: MaterialDialog, focusable: Boolean = true): MaterialDialog {
|
||||||
runOnMain {
|
runOnMain {
|
||||||
// Prevent duplicated show.
|
// Prevent duplicated show.
|
||||||
// zh-CN: 防止重复 show().
|
// zh-CN: 防止重复 show().
|
||||||
@@ -205,9 +205,9 @@ object DialogUtils {
|
|||||||
* - 在后台线程 build 可能触发 "Can't create handler inside thread ..." 崩溃.
|
* - 在后台线程 build 可能触发 "Can't create handler inside thread ..." 崩溃.
|
||||||
*/
|
*/
|
||||||
@JvmStatic
|
@JvmStatic
|
||||||
fun <T : MaterialDialog> buildAdaptive(builder: MaterialDialog.Builder): T {
|
fun buildAdaptive(builder: MaterialDialog.Builder): MaterialDialog {
|
||||||
@Suppress("UNCHECKED_CAST")
|
@Suppress("UNCHECKED_CAST")
|
||||||
return buildAdaptive { builder.build() as T }
|
return buildAdaptive { builder.build() }
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -216,12 +216,12 @@ object DialogUtils {
|
|||||||
* zh-CN: 通过 callable 工厂在主线程 build 对话框.
|
* zh-CN: 通过 callable 工厂在主线程 build 对话框.
|
||||||
*/
|
*/
|
||||||
@JvmStatic
|
@JvmStatic
|
||||||
fun <T : MaterialDialog> buildAdaptive(factory: Callable<T>): T {
|
fun buildAdaptive(factory: Callable<MaterialDialog>): MaterialDialog {
|
||||||
if (Looper.getMainLooper() == Looper.myLooper()) {
|
if (Looper.getMainLooper() == Looper.myLooper()) {
|
||||||
return factory.call()
|
return factory.call()
|
||||||
}
|
}
|
||||||
|
|
||||||
val ref = AtomicReference<T>()
|
val ref = AtomicReference<MaterialDialog>()
|
||||||
val err = AtomicReference<Throwable?>()
|
val err = AtomicReference<Throwable?>()
|
||||||
val latch = CountDownLatch(1)
|
val latch = CountDownLatch(1)
|
||||||
|
|
||||||
@@ -256,8 +256,8 @@ object DialogUtils {
|
|||||||
*/
|
*/
|
||||||
@JvmStatic
|
@JvmStatic
|
||||||
@JvmOverloads
|
@JvmOverloads
|
||||||
fun <T : MaterialDialog> buildAndShowAdaptive(builder: MaterialDialog.Builder, focusable: Boolean = true): T {
|
fun buildAndShowAdaptive(builder: MaterialDialog.Builder, focusable: Boolean = true): MaterialDialog {
|
||||||
val dialog = buildAdaptive<T>(builder)
|
val dialog = buildAdaptive(builder)
|
||||||
@Suppress("DEPRECATION")
|
@Suppress("DEPRECATION")
|
||||||
return showDialog(dialog, focusable)
|
return showDialog(dialog, focusable)
|
||||||
}
|
}
|
||||||
@@ -269,7 +269,7 @@ object DialogUtils {
|
|||||||
*/
|
*/
|
||||||
@JvmStatic
|
@JvmStatic
|
||||||
@JvmOverloads
|
@JvmOverloads
|
||||||
fun <T : MaterialDialog> buildAndShowAdaptive(factory: Callable<T>, focusable: Boolean = true): T {
|
fun buildAndShowAdaptive(factory: Callable<MaterialDialog>, focusable: Boolean = true): MaterialDialog {
|
||||||
val dialog = buildAdaptive(factory)
|
val dialog = buildAdaptive(factory)
|
||||||
@Suppress("DEPRECATION")
|
@Suppress("DEPRECATION")
|
||||||
return showDialog(dialog, focusable)
|
return showDialog(dialog, focusable)
|
||||||
@@ -284,7 +284,7 @@ object DialogUtils {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@JvmStatic
|
@JvmStatic
|
||||||
fun <T : MaterialDialog> fixCheckBoxGravity(dialog: T): T = dialog.also {
|
fun fixCheckBoxGravity(dialog: MaterialDialog): MaterialDialog = dialog.also {
|
||||||
it.view.findViewById<CheckBox>(com.afollestad.materialdialogs.R.id.md_promptCheckbox)?.gravity = Gravity.CENTER_VERTICAL
|
it.view.findViewById<CheckBox>(com.afollestad.materialdialogs.R.id.md_promptCheckbox)?.gravity = Gravity.CENTER_VERTICAL
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import android.os.Bundle
|
|||||||
import android.os.Parcelable
|
import android.os.Parcelable
|
||||||
import android.text.TextUtils
|
import android.text.TextUtils
|
||||||
import android.util.AttributeSet
|
import android.util.AttributeSet
|
||||||
import android.util.Log
|
|
||||||
import android.util.SparseBooleanArray
|
import android.util.SparseBooleanArray
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.widget.ImageView
|
import android.widget.ImageView
|
||||||
@@ -27,13 +26,13 @@ import io.reactivex.Observable
|
|||||||
import io.reactivex.android.schedulers.AndroidSchedulers
|
import io.reactivex.android.schedulers.AndroidSchedulers
|
||||||
import io.reactivex.schedulers.Schedulers
|
import io.reactivex.schedulers.Schedulers
|
||||||
import org.autojs.autojs.AutoJs
|
import org.autojs.autojs.AutoJs
|
||||||
|
import org.autojs.autojs.app.DialogUtils
|
||||||
import org.autojs.autojs.core.pref.Pref.getEditorTextSize
|
import org.autojs.autojs.core.pref.Pref.getEditorTextSize
|
||||||
import org.autojs.autojs.core.pref.Pref.setEditorTextSize
|
import org.autojs.autojs.core.pref.Pref.setEditorTextSize
|
||||||
import org.autojs.autojs.engine.JavaScriptEngine
|
import org.autojs.autojs.engine.JavaScriptEngine
|
||||||
import org.autojs.autojs.engine.ScriptEngine
|
import org.autojs.autojs.engine.ScriptEngine
|
||||||
import org.autojs.autojs.event.BackPressedHandler.HostActivity
|
import org.autojs.autojs.event.BackPressedHandler.HostActivity
|
||||||
import org.autojs.autojs.execution.ScriptExecution
|
import org.autojs.autojs.execution.ScriptExecution
|
||||||
import org.autojs.autojs.util.MaterialDialogUtils.choiceWidgetThemeColor
|
|
||||||
import org.autojs.autojs.model.autocomplete.AutoCompletion
|
import org.autojs.autojs.model.autocomplete.AutoCompletion
|
||||||
import org.autojs.autojs.model.autocomplete.CodeCompletions
|
import org.autojs.autojs.model.autocomplete.CodeCompletions
|
||||||
import org.autojs.autojs.model.autocomplete.Symbols
|
import org.autojs.autojs.model.autocomplete.Symbols
|
||||||
@@ -46,7 +45,6 @@ import org.autojs.autojs.model.script.Scripts.EXTRA_EXCEPTION_MESSAGE
|
|||||||
import org.autojs.autojs.model.script.Scripts.openByOtherApps
|
import org.autojs.autojs.model.script.Scripts.openByOtherApps
|
||||||
import org.autojs.autojs.model.script.Scripts.runWithBroadcastSender
|
import org.autojs.autojs.model.script.Scripts.runWithBroadcastSender
|
||||||
import org.autojs.autojs.pio.PFiles.getNameWithoutExtension
|
import org.autojs.autojs.pio.PFiles.getNameWithoutExtension
|
||||||
import org.autojs.autojs.pio.PFiles.move
|
|
||||||
import org.autojs.autojs.pio.PFiles.write
|
import org.autojs.autojs.pio.PFiles.write
|
||||||
import org.autojs.autojs.storage.file.TmpScriptFiles
|
import org.autojs.autojs.storage.file.TmpScriptFiles
|
||||||
import org.autojs.autojs.tool.Callback
|
import org.autojs.autojs.tool.Callback
|
||||||
@@ -65,11 +63,14 @@ import org.autojs.autojs.ui.edit.toolbar.DebugToolbarFragment
|
|||||||
import org.autojs.autojs.ui.edit.toolbar.NormalToolbarFragment
|
import org.autojs.autojs.ui.edit.toolbar.NormalToolbarFragment
|
||||||
import org.autojs.autojs.ui.edit.toolbar.SearchToolbarFragment
|
import org.autojs.autojs.ui.edit.toolbar.SearchToolbarFragment
|
||||||
import org.autojs.autojs.ui.edit.toolbar.ToolbarFragment
|
import org.autojs.autojs.ui.edit.toolbar.ToolbarFragment
|
||||||
|
import org.autojs.autojs.ui.filechooser.FileChooserDialogBuilder
|
||||||
import org.autojs.autojs.ui.log.LogActivity
|
import org.autojs.autojs.ui.log.LogActivity
|
||||||
import org.autojs.autojs.ui.widget.EWebView
|
import org.autojs.autojs.ui.widget.EWebView
|
||||||
import org.autojs.autojs.ui.widget.SimpleTextWatcher
|
import org.autojs.autojs.ui.widget.SimpleTextWatcher
|
||||||
|
import org.autojs.autojs.util.ClipboardUtils
|
||||||
import org.autojs.autojs.util.DisplayUtils.pxToSp
|
import org.autojs.autojs.util.DisplayUtils.pxToSp
|
||||||
import org.autojs.autojs.util.DocsUtils.getUrl
|
import org.autojs.autojs.util.DocsUtils.getUrl
|
||||||
|
import org.autojs.autojs.util.MaterialDialogUtils.choiceWidgetThemeColor
|
||||||
import org.autojs.autojs.util.Observers
|
import org.autojs.autojs.util.Observers
|
||||||
import org.autojs.autojs.util.StringUtils
|
import org.autojs.autojs.util.StringUtils
|
||||||
import org.autojs.autojs.util.ViewUtils.showSnack
|
import org.autojs.autojs.util.ViewUtils.showSnack
|
||||||
@@ -80,11 +81,14 @@ import java.io.File
|
|||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
import java.nio.charset.Charset
|
import java.nio.charset.Charset
|
||||||
import java.nio.charset.StandardCharsets
|
import java.nio.charset.StandardCharsets
|
||||||
|
import java.security.MessageDigest
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Created by Stardust on Sep 28, 2017.
|
* Created by Stardust on Sep 28, 2017.
|
||||||
* Modified by SuperMonster003 as of May 1, 2023.
|
|
||||||
* Transformed by SuperMonster003 on May 1, 2023.
|
* Transformed by SuperMonster003 on May 1, 2023.
|
||||||
|
* Modified by JetBrains AI Assistant (GPT-5.2) as of Feb 3, 2026.
|
||||||
|
* Modified by SuperMonster003 as of Feb 3, 2026.
|
||||||
*/
|
*/
|
||||||
@SuppressLint("CheckResult")
|
@SuppressLint("CheckResult")
|
||||||
class EditorView : LinearLayout, OnHintClickListener, ClickCallback, ToolbarFragment.OnMenuItemClickListener {
|
class EditorView : LinearLayout, OnHintClickListener, ClickCallback, ToolbarFragment.OnMenuItemClickListener {
|
||||||
@@ -429,47 +433,331 @@ class EditorView : LinearLayout, OnHintClickListener, ClickCallback, ToolbarFrag
|
|||||||
|
|
||||||
private fun redo() = editor.redo()
|
private fun redo() = editor.redo()
|
||||||
|
|
||||||
fun save(): Observable<String> {
|
fun save(): Observable<String> =
|
||||||
val path = uri.path!!
|
Observable
|
||||||
val backPath = "$path.save"
|
|
||||||
move(path, backPath)
|
|
||||||
return Observable
|
|
||||||
.fromCallable {
|
.fromCallable {
|
||||||
|
// Use a transactional save flow for both file:// and content://.
|
||||||
|
// zh-CN: 对 file:// 与 content:// 统一使用事务式保存流程.
|
||||||
editor.text.apply {
|
editor.text.apply {
|
||||||
writeTextWithCharset(uri, this)
|
writeTextWithCharsetTransactional(uri, this)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.observeOn(Schedulers.io())
|
.subscribeOn(Schedulers.io())
|
||||||
.observeOn(AndroidSchedulers.mainThread())
|
.observeOn(AndroidSchedulers.mainThread())
|
||||||
.doOnNext {
|
.doOnNext {
|
||||||
editor.markTextAsSaved()
|
editor.markTextAsSaved()
|
||||||
setMenuItemStatus(R.id.save, false)
|
setMenuItemStatus(R.id.save, false)
|
||||||
}
|
}
|
||||||
.doOnNext {
|
|
||||||
if (!File(backPath).delete()) {
|
/**
|
||||||
Log.e(TAG, "save: failed")
|
* Transactional save for text files.
|
||||||
}
|
*
|
||||||
|
* Behavior:
|
||||||
|
* 1) SAVE_PRE: read old bytes (best-effort).
|
||||||
|
* 2) Build new bytes with charset/BOM strategy.
|
||||||
|
* 3) Write with openOutputStream(uri, "rwt").
|
||||||
|
* 4) Verify by reading back and comparing hash.
|
||||||
|
* 5) On failure:
|
||||||
|
* - Try rollback to SAVE_PRE when possible.
|
||||||
|
* - Always save an emergency draft, then prompt user to "Save as".
|
||||||
|
*
|
||||||
|
* zh-CN:
|
||||||
|
*
|
||||||
|
* 面向文本文件的事务式保存.
|
||||||
|
*
|
||||||
|
* 行为:
|
||||||
|
* 1) SAVE_PRE: 尽力读取旧 bytes.
|
||||||
|
* 2) 按 charset/BOM 策略生成新 bytes.
|
||||||
|
* 3) 通过 openOutputStream(uri, "rwt") 写入.
|
||||||
|
* 4) 写后读回并比对 hash 校验.
|
||||||
|
* 5) 失败时:
|
||||||
|
* - 在可能时回滚到 SAVE_PRE.
|
||||||
|
* - 无论能否回滚, 均保存草稿, 并提示用户 "另存为".
|
||||||
|
*/
|
||||||
|
private fun writeTextWithCharsetTransactional(uri: Uri, text: String) {
|
||||||
|
val resolver = context.contentResolver
|
||||||
|
|
||||||
|
val (targetCharset, needBom) = when {
|
||||||
|
mHadBom -> {
|
||||||
|
mCurrentCharset to true
|
||||||
}
|
}
|
||||||
|
mCurrentCharsetConfidence >= MIN_CONFIDENCE_TO_WRITE_FILE -> {
|
||||||
|
mCurrentCharset to false
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
DEFAULT_CHARSET_TO_WRITE_FILE to false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val newBytes = buildBytesToWrite(text, targetCharset, needBom)
|
||||||
|
|
||||||
|
// SAVE_PRE snapshot (best-effort).
|
||||||
|
// zh-CN: SAVE_PRE 快照 (尽力而为).
|
||||||
|
val oldBytes = runCatching {
|
||||||
|
resolver.openInputStream(uri)?.use { it.readBytes() }
|
||||||
|
}.getOrNull()
|
||||||
|
|
||||||
|
// Track history only when within size guardrails.
|
||||||
|
// zh-CN: 仅在满足大小护栏时纳管历史.
|
||||||
|
if (shouldTrackHistory(oldBytes, newBytes)) {
|
||||||
|
// TODO Hook HistoryRepository here.
|
||||||
|
// zh-CN: 在这里接入 HistoryRepository.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
// historyRepo.recordPreSave(uri = uri, oldBytes = oldBytes, meta = ...)
|
||||||
|
}
|
||||||
|
|
||||||
|
val newHash = sha256(newBytes)
|
||||||
|
|
||||||
|
try {
|
||||||
|
resolver.openOutputStream(uri, "rwt")?.use { out ->
|
||||||
|
out.write(newBytes)
|
||||||
|
out.flush()
|
||||||
|
} ?: throw IOException("Cannot open output stream for $uri")
|
||||||
|
|
||||||
|
val readBack = runCatching {
|
||||||
|
resolver.openInputStream(uri)?.use { it.readBytes() } ?: ByteArray(0)
|
||||||
|
}.getOrElse { e ->
|
||||||
|
throw IOException("Read-back failed after write: $uri", e)
|
||||||
|
}
|
||||||
|
|
||||||
|
val readBackHash = sha256(readBack)
|
||||||
|
if (!readBackHash.contentEquals(newHash)) {
|
||||||
|
throw IOException("Write verification failed (hash mismatch) for uri: $uri")
|
||||||
|
}
|
||||||
|
} catch (e: SecurityException) {
|
||||||
|
// Permission denied by provider or missing URI grants.
|
||||||
|
// zh-CN: provider 拒绝访问或缺少 URI 授权导致权限被拒绝.
|
||||||
|
handleSaveFailure(uri, oldBytes, newBytes, IOException("Permission denied for uri: $uri", e))
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
handleSaveFailure(uri, oldBytes, newBytes, e)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun writeTextWithCharset(uri: Uri, text: String) {
|
/**
|
||||||
context.contentResolver.openOutputStream(uri, "rwt")?.use { out ->
|
* Handle transactional save failure.
|
||||||
val (targetCharset, needBom) = when {
|
*
|
||||||
mHadBom -> {
|
* Steps:
|
||||||
mCurrentCharset to true
|
* - Try rollback (best-effort).
|
||||||
}
|
* - Save emergency draft (always).
|
||||||
mCurrentCharsetConfidence >= MIN_CONFIDENCE_TO_WRITE_FILE -> {
|
* - Prompt user actions on main thread.
|
||||||
mCurrentCharset to false
|
*
|
||||||
}
|
* zh-CN:
|
||||||
else -> {
|
*
|
||||||
DEFAULT_CHARSET_TO_WRITE_FILE to false
|
* 处理事务保存失败.
|
||||||
|
*
|
||||||
|
* 步骤:
|
||||||
|
* - 尝试回滚 (尽力而为).
|
||||||
|
* - 保存草稿 (始终执行).
|
||||||
|
* - 在主线程弹窗提示用户后续操作.
|
||||||
|
*/
|
||||||
|
private fun handleSaveFailure(uri: Uri, oldBytes: ByteArray?, newBytes: ByteArray, error: Throwable): Nothing {
|
||||||
|
val resolver = context.contentResolver
|
||||||
|
|
||||||
|
// Try rollback to SAVE_PRE if possible.
|
||||||
|
// zh-CN: 若可能则回滚到 SAVE_PRE.
|
||||||
|
if (oldBytes != null) {
|
||||||
|
runCatching {
|
||||||
|
resolver.openOutputStream(uri, "rwt")?.use { out ->
|
||||||
|
out.write(oldBytes)
|
||||||
|
out.flush()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (needBom) {
|
}
|
||||||
out.write(StringUtils.bomBytes(targetCharset))
|
|
||||||
|
val draftFile = runCatching {
|
||||||
|
EmergencyDraftStore(context).saveDraft(
|
||||||
|
displayName = name.ifBlank { "untitled" },
|
||||||
|
bytes = newBytes,
|
||||||
|
)
|
||||||
|
}.getOrNull()
|
||||||
|
|
||||||
|
// Prompt user on main thread.
|
||||||
|
// zh-CN: 在主线程提示用户.
|
||||||
|
DialogUtils.buildAndShowAdaptive(
|
||||||
|
MaterialDialog.Builder(context)
|
||||||
|
.title(R.string.error_save_failed)
|
||||||
|
.content(
|
||||||
|
buildString {
|
||||||
|
append(error.message ?: error.toString())
|
||||||
|
if (draftFile != null) {
|
||||||
|
append("\n\n")
|
||||||
|
append(context.getString(R.string.text_draft_file_path))
|
||||||
|
append(":\n")
|
||||||
|
append(draftFile.absolutePath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.also { builder ->
|
||||||
|
if (draftFile != null) {
|
||||||
|
builder.neutralText(R.string.dialog_button_copy_path)
|
||||||
|
builder.neutralColorRes(R.color.dialog_button_hint)
|
||||||
|
builder.onNeutral { d, _ ->
|
||||||
|
ClipboardUtils.setClip(context, draftFile.absolutePath)
|
||||||
|
showSnack(d.view, R.string.text_already_copied_to_clip, false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.negativeText(R.string.dialog_button_dismiss)
|
||||||
|
.negativeColorRes(R.color.dialog_button_default)
|
||||||
|
.onNegative { d, _ -> d.dismiss() }
|
||||||
|
.positiveText(R.string.dialog_button_save_as)
|
||||||
|
.positiveColorRes(R.color.dialog_button_attraction)
|
||||||
|
.onPositive { d, _ ->
|
||||||
|
// Guide user to save as a normal file path (file://).
|
||||||
|
// zh-CN: 引导用户另存为普通文件路径 (file://).
|
||||||
|
runCatching {
|
||||||
|
FileChooserDialogBuilder(context)
|
||||||
|
.title(R.string.text_save_to)
|
||||||
|
.dir(INTERNAL_STORAGE_ROOT)
|
||||||
|
.chooseDir()
|
||||||
|
.singleChoice()
|
||||||
|
.subscribeOn(Schedulers.io())
|
||||||
|
.observeOn(AndroidSchedulers.mainThread())
|
||||||
|
.subscribe({ dir ->
|
||||||
|
// Determine extension from current uri path if possible.
|
||||||
|
// zh-CN: 尽可能从当前 uri.path 推导扩展名.
|
||||||
|
val ext = uri.path?.let { targetExtFromPath(it) } ?: ""
|
||||||
|
|
||||||
|
val dest = File(
|
||||||
|
dir.path,
|
||||||
|
buildSafeFileNameForSaveAs(
|
||||||
|
baseName = name,
|
||||||
|
ext = ext,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
Schedulers.io().scheduleDirect {
|
||||||
|
runCatching {
|
||||||
|
dest.parentFile?.mkdirs()
|
||||||
|
File(dest.path).outputStream().use { it.write(newBytes) }
|
||||||
|
|
||||||
|
// If "Save as" succeeded, update current uri to the new file.
|
||||||
|
// zh-CN: 若另存为成功, 则将当前 uri 更新为新文件.
|
||||||
|
this@EditorView.uri = Uri.fromFile(dest)
|
||||||
|
this@EditorView.name = getNameWithoutExtension(dest.path)
|
||||||
|
|
||||||
|
// Mark as saved on UI thread.
|
||||||
|
// zh-CN: 在 UI 线程标记为已保存.
|
||||||
|
post {
|
||||||
|
d.dismiss()
|
||||||
|
editor.markTextAsSaved()
|
||||||
|
setMenuItemStatus(R.id.save, false)
|
||||||
|
}
|
||||||
|
}.onFailure {
|
||||||
|
post {
|
||||||
|
showSnack(d.view, R.string.error_save_failed, false)
|
||||||
|
showToast(context, it.message, true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, { e ->
|
||||||
|
e.printStackTrace()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.autoDismiss(false)
|
||||||
|
.cancelable(false)
|
||||||
|
)
|
||||||
|
|
||||||
|
if (error is IOException) {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
throw IOException(error)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildBytesToWrite(text: String, charset: Charset, needBom: Boolean): ByteArray {
|
||||||
|
val body = text.toByteArray(charset)
|
||||||
|
if (!needBom) return body
|
||||||
|
|
||||||
|
val bom = StringUtils.bomBytes(charset)
|
||||||
|
return ByteArray(bom.size + body.size).also {
|
||||||
|
System.arraycopy(bom, 0, it, 0, bom.size)
|
||||||
|
System.arraycopy(body, 0, it, bom.size, body.size)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun shouldTrackHistory(oldBytes: ByteArray?, newBytes: ByteArray): Boolean {
|
||||||
|
val oldSize = oldBytes?.size ?: 0
|
||||||
|
val newSize = newBytes.size
|
||||||
|
return oldSize <= MAX_FILE_SIZE_TO_TRACK_BYTES && newSize <= MAX_FILE_SIZE_TO_TRACK_BYTES
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun sha256(bytes: ByteArray): ByteArray {
|
||||||
|
return MessageDigest.getInstance("SHA-256").digest(bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun targetExtFromPath(path: String): String {
|
||||||
|
val name = File(path).name
|
||||||
|
val dot = name.lastIndexOf('.')
|
||||||
|
if (dot <= 0 || dot >= name.length - 1) return ""
|
||||||
|
return name.substring(dot + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildSafeFileNameForSaveAs(baseName: String, ext: String): String {
|
||||||
|
val raw = if (baseName.isBlank()) "untitled" else baseName
|
||||||
|
val sanitized = raw.replace(Regex("""[\\/:*?"<>|]"""), "_")
|
||||||
|
val niceExt = ext.trim().trimStart('.')
|
||||||
|
return if (niceExt.isBlank()) sanitized else "$sanitized.$niceExt"
|
||||||
|
}
|
||||||
|
|
||||||
|
// A minimal emergency draft store.
|
||||||
|
// zh-CN: 一个最小实现的草稿存储器.
|
||||||
|
private class EmergencyDraftStore(private val context: Context) {
|
||||||
|
|
||||||
|
private val draftsDir: File by lazy { File(context.filesDir, "drafts") }
|
||||||
|
|
||||||
|
fun saveDraft(displayName: String, bytes: ByteArray): File {
|
||||||
|
draftsDir.mkdirs()
|
||||||
|
|
||||||
|
val safeName = displayName
|
||||||
|
.ifBlank { "untitled" }
|
||||||
|
.replace(Regex("""[\\/:*?"<>|]"""), "_")
|
||||||
|
.lowercase(Locale.getDefault())
|
||||||
|
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
val f = File(draftsDir, "${now}_${safeName}.bin")
|
||||||
|
|
||||||
|
f.outputStream().use { it.write(bytes) }
|
||||||
|
|
||||||
|
// Cleanup drafts after save (best-effort).
|
||||||
|
// zh-CN: 保存后顺便清理草稿 (尽力而为).
|
||||||
|
runCatching { cleanupLocked() }
|
||||||
|
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun cleanupLocked() {
|
||||||
|
val files = draftsDir.listFiles()?.toList() ?: return
|
||||||
|
if (files.isEmpty()) return
|
||||||
|
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
|
||||||
|
// 1) Remove expired (older than 7 days).
|
||||||
|
// zh-CN: 1) 删除过期草稿 (超过 7 天).
|
||||||
|
val expiredBefore = now - DRAFT_MAX_DAYS_MS
|
||||||
|
files.forEach { f ->
|
||||||
|
if (f.lastModified() < expiredBefore) {
|
||||||
|
// noinspection ResultOfMethodCallIgnored
|
||||||
|
f.delete()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
out.write(text.toByteArray(targetCharset))
|
|
||||||
} ?: throw IOException("Cannot open output stream for $uri")
|
// 2) Enforce total bytes limit (keep newest first).
|
||||||
|
// zh-CN: 2) 约束总容量上限 (优先保留最新).
|
||||||
|
val remained = draftsDir.listFiles()?.toList()?.sortedByDescending { it.lastModified() } ?: return
|
||||||
|
var total = remained.sumOf { it.length().coerceAtLeast(0L) }
|
||||||
|
|
||||||
|
if (total <= DRAFT_MAX_TOTAL_BYTES) return
|
||||||
|
|
||||||
|
for (f in remained.asReversed()) {
|
||||||
|
if (total <= DRAFT_MAX_TOTAL_BYTES) break
|
||||||
|
val len = f.length().coerceAtLeast(0L)
|
||||||
|
if (f.delete()) {
|
||||||
|
total -= len
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun forceStop() {
|
fun forceStop() {
|
||||||
@@ -756,18 +1044,30 @@ class EditorView : LinearLayout, OnHintClickListener, ClickCallback, ToolbarFrag
|
|||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|
||||||
private val TAG = EditorView::class.java.simpleName
|
|
||||||
|
|
||||||
private const val MIN_CONFIDENCE_TO_WRITE_FILE = 90
|
private const val MIN_CONFIDENCE_TO_WRITE_FILE = 90
|
||||||
private val DEFAULT_CHARSET_TO_WRITE_FILE = StandardCharsets.UTF_8
|
private val DEFAULT_CHARSET_TO_WRITE_FILE = StandardCharsets.UTF_8
|
||||||
|
|
||||||
|
// Max file size to track history snapshots.
|
||||||
|
// zh-CN: 纳管历史快照的最大文件大小.
|
||||||
|
private const val MAX_FILE_SIZE_TO_TRACK_BYTES: Int = 20 * 1024 * 1024
|
||||||
|
|
||||||
|
// Emergency draft cleanup window (7 days).
|
||||||
|
// zh-CN: 草稿清理时间窗口 (7 天).
|
||||||
|
private const val DRAFT_MAX_DAYS_MS: Long = 7L * 24L * 60L * 60L * 1000L
|
||||||
|
|
||||||
|
// Emergency draft total size limit (200MB).
|
||||||
|
// zh-CN: 草稿总容量上限 (200MB).
|
||||||
|
private const val DRAFT_MAX_TOTAL_BYTES: Long = 200L * 1024L * 1024L
|
||||||
|
|
||||||
|
// Internal storage root (no external SD).
|
||||||
|
// zh-CN: 内部存储根目录 (不访问外置 SD).
|
||||||
|
private const val INTERNAL_STORAGE_ROOT: String = "/storage/emulated/0"
|
||||||
|
|
||||||
const val EXTRA_PATH = "path"
|
const val EXTRA_PATH = "path"
|
||||||
const val EXTRA_NAME = "name"
|
const val EXTRA_NAME = "name"
|
||||||
const val EXTRA_CONTENT = "content"
|
const val EXTRA_CONTENT = "content"
|
||||||
const val EXTRA_READ_ONLY = "readOnly"
|
const val EXTRA_READ_ONLY = "readOnly"
|
||||||
const val EXTRA_SAVE_ENABLED = "saveEnabled"
|
const val EXTRA_SAVE_ENABLED = "saveEnabled"
|
||||||
const val EXTRA_RUN_ENABLED = "runEnabled"
|
const val EXTRA_RUN_ENABLED = "runEnabled"
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -1226,4 +1226,8 @@
|
|||||||
<string name="text_write_system_settings">كتابة إعدادات النظام</string>
|
<string name="text_write_system_settings">كتابة إعدادات النظام</string>
|
||||||
<string name="text_xiaomi_background_popup_permission">النوافذ المنبثقة في الخلفية</string>
|
<string name="text_xiaomi_background_popup_permission">النوافذ المنبثقة في الخلفية</string>
|
||||||
<string name="error_refresh_failed">فشل التحديث</string>
|
<string name="error_refresh_failed">فشل التحديث</string>
|
||||||
|
<string name="error_save_failed">فشل الحفظ</string>
|
||||||
|
<string name="text_draft_file_path">مسار المسودة</string>
|
||||||
|
<string name="dialog_button_copy_path">نسخ</string>
|
||||||
|
<string name="dialog_button_save_as">حفظ باسم</string>
|
||||||
</resources>
|
</resources>
|
||||||
@@ -1221,4 +1221,8 @@
|
|||||||
<string name="text_write_system_settings">Write system settings</string>
|
<string name="text_write_system_settings">Write system settings</string>
|
||||||
<string name="text_xiaomi_background_popup_permission">Display pop-up windows while running in the background</string>
|
<string name="text_xiaomi_background_popup_permission">Display pop-up windows while running in the background</string>
|
||||||
<string name="error_refresh_failed">Refresh failed</string>
|
<string name="error_refresh_failed">Refresh failed</string>
|
||||||
|
<string name="error_save_failed">Save failed</string>
|
||||||
|
<string name="text_draft_file_path">Draft file path</string>
|
||||||
|
<string name="dialog_button_copy_path">Copy path</string>
|
||||||
|
<string name="dialog_button_save_as">Save as</string>
|
||||||
</resources>
|
</resources>
|
||||||
@@ -1224,4 +1224,8 @@
|
|||||||
<string name="text_write_system_settings">Escribir la configuración del sistema</string>
|
<string name="text_write_system_settings">Escribir la configuración del sistema</string>
|
||||||
<string name="text_xiaomi_background_popup_permission">Ventanas emergentes en segundo plano</string>
|
<string name="text_xiaomi_background_popup_permission">Ventanas emergentes en segundo plano</string>
|
||||||
<string name="error_refresh_failed">Error al actualizar</string>
|
<string name="error_refresh_failed">Error al actualizar</string>
|
||||||
|
<string name="error_save_failed">Error al guardar</string>
|
||||||
|
<string name="text_draft_file_path">Ruta del borrador</string>
|
||||||
|
<string name="dialog_button_copy_path">Copiar</string>
|
||||||
|
<string name="dialog_button_save_as">Guardar como</string>
|
||||||
</resources>
|
</resources>
|
||||||
@@ -1224,4 +1224,8 @@
|
|||||||
<string name="text_write_system_settings">Écrire les paramètres système</string>
|
<string name="text_write_system_settings">Écrire les paramètres système</string>
|
||||||
<string name="text_xiaomi_background_popup_permission">Fenêtres contextuelles en arrière-plan</string>
|
<string name="text_xiaomi_background_popup_permission">Fenêtres contextuelles en arrière-plan</string>
|
||||||
<string name="error_refresh_failed">Echec de l\'actualisation</string>
|
<string name="error_refresh_failed">Echec de l\'actualisation</string>
|
||||||
|
<string name="error_save_failed">Echec de l\'enregistrement</string>
|
||||||
|
<string name="text_draft_file_path">Chemin du brouillon</string>
|
||||||
|
<string name="dialog_button_copy_path">Copier</string>
|
||||||
|
<string name="dialog_button_save_as">Enregistrer sous</string>
|
||||||
</resources>
|
</resources>
|
||||||
@@ -1225,4 +1225,8 @@
|
|||||||
<string name="text_write_system_settings">システム設定の書き込み</string>
|
<string name="text_write_system_settings">システム設定の書き込み</string>
|
||||||
<string name="text_xiaomi_background_popup_permission">バックグラウンドでのポップアップ表示</string>
|
<string name="text_xiaomi_background_popup_permission">バックグラウンドでのポップアップ表示</string>
|
||||||
<string name="error_refresh_failed">更新に失敗しました</string>
|
<string name="error_refresh_failed">更新に失敗しました</string>
|
||||||
|
<string name="error_save_failed">保存に失敗しました</string>
|
||||||
|
<string name="text_draft_file_path">下書きのパス</string>
|
||||||
|
<string name="dialog_button_copy_path">コピー</string>
|
||||||
|
<string name="dialog_button_save_as">名前を付けて保存</string>
|
||||||
</resources>
|
</resources>
|
||||||
@@ -1226,4 +1226,8 @@
|
|||||||
<string name="text_write_system_settings">시스템 설정을 작성하십시오</string>
|
<string name="text_write_system_settings">시스템 설정을 작성하십시오</string>
|
||||||
<string name="text_xiaomi_background_popup_permission">백그라운드 팝업</string>
|
<string name="text_xiaomi_background_popup_permission">백그라운드 팝업</string>
|
||||||
<string name="error_refresh_failed">새로고침에 실패했습니다</string>
|
<string name="error_refresh_failed">새로고침에 실패했습니다</string>
|
||||||
|
<string name="error_save_failed">저장에 실패했습니다</string>
|
||||||
|
<string name="text_draft_file_path">초안 경로</string>
|
||||||
|
<string name="dialog_button_copy_path">복사</string>
|
||||||
|
<string name="dialog_button_save_as">다른 이름으로 저장</string>
|
||||||
</resources>
|
</resources>
|
||||||
@@ -1224,4 +1224,8 @@
|
|||||||
<string name="text_write_system_settings">Запись системных настроек</string>
|
<string name="text_write_system_settings">Запись системных настроек</string>
|
||||||
<string name="text_xiaomi_background_popup_permission">Всплывающие окна в фоне</string>
|
<string name="text_xiaomi_background_popup_permission">Всплывающие окна в фоне</string>
|
||||||
<string name="error_refresh_failed">Не удалось обновить</string>
|
<string name="error_refresh_failed">Не удалось обновить</string>
|
||||||
|
<string name="error_save_failed">Не удалось сохранить</string>
|
||||||
|
<string name="text_draft_file_path">Путь к черновику</string>
|
||||||
|
<string name="dialog_button_copy_path">Копировать</string>
|
||||||
|
<string name="dialog_button_save_as">Сохранить как</string>
|
||||||
</resources>
|
</resources>
|
||||||
@@ -1222,4 +1222,8 @@
|
|||||||
<string name="text_write_system_settings">修改系統設置</string>
|
<string name="text_write_system_settings">修改系統設置</string>
|
||||||
<string name="text_xiaomi_background_popup_permission">後台彈出界面</string>
|
<string name="text_xiaomi_background_popup_permission">後台彈出界面</string>
|
||||||
<string name="error_refresh_failed">刷新失敗</string>
|
<string name="error_refresh_failed">刷新失敗</string>
|
||||||
|
<string name="error_save_failed">保存失敗</string>
|
||||||
|
<string name="text_draft_file_path">草稿文件路徑</string>
|
||||||
|
<string name="dialog_button_copy_path">複製路徑</string>
|
||||||
|
<string name="dialog_button_save_as">另存為</string>
|
||||||
</resources>
|
</resources>
|
||||||
@@ -1222,4 +1222,8 @@
|
|||||||
<string name="text_write_system_settings">修改系統設定</string>
|
<string name="text_write_system_settings">修改系統設定</string>
|
||||||
<string name="text_xiaomi_background_popup_permission">後臺彈出介面</string>
|
<string name="text_xiaomi_background_popup_permission">後臺彈出介面</string>
|
||||||
<string name="error_refresh_failed">刷新失敗</string>
|
<string name="error_refresh_failed">刷新失敗</string>
|
||||||
|
<string name="error_save_failed">儲存失敗</string>
|
||||||
|
<string name="text_draft_file_path">草稿檔案路徑</string>
|
||||||
|
<string name="dialog_button_copy_path">複製路徑</string>
|
||||||
|
<string name="dialog_button_save_as">另存為</string>
|
||||||
</resources>
|
</resources>
|
||||||
@@ -1222,4 +1222,8 @@
|
|||||||
<string name="text_write_system_settings">修改系统设置</string>
|
<string name="text_write_system_settings">修改系统设置</string>
|
||||||
<string name="text_xiaomi_background_popup_permission">后台弹出界面</string>
|
<string name="text_xiaomi_background_popup_permission">后台弹出界面</string>
|
||||||
<string name="error_refresh_failed">刷新失败</string>
|
<string name="error_refresh_failed">刷新失败</string>
|
||||||
|
<string name="error_save_failed">保存失败</string>
|
||||||
|
<string name="text_draft_file_path">草稿文件路径</string>
|
||||||
|
<string name="dialog_button_copy_path">复制路径</string>
|
||||||
|
<string name="dialog_button_save_as">另存为</string>
|
||||||
</resources>
|
</resources>
|
||||||
@@ -1482,4 +1482,8 @@
|
|||||||
<string name="text_write_system_settings">Write system settings</string>
|
<string name="text_write_system_settings">Write system settings</string>
|
||||||
<string name="text_xiaomi_background_popup_permission">Display pop-up windows while running in the background</string>
|
<string name="text_xiaomi_background_popup_permission">Display pop-up windows while running in the background</string>
|
||||||
<string name="error_refresh_failed">Refresh failed</string>
|
<string name="error_refresh_failed">Refresh failed</string>
|
||||||
|
<string name="error_save_failed">Save failed</string>
|
||||||
|
<string name="text_draft_file_path">Draft file path</string>
|
||||||
|
<string name="dialog_button_copy_path">Copy path</string>
|
||||||
|
<string name="dialog_button_save_as">Save as</string>
|
||||||
</resources>
|
</resources>
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
#Tue Feb 03 01:13:39 CST 2026
|
#Tue Feb 03 15:47:53 CST 2026
|
||||||
BUILD_TIME=1770052419058
|
BUILD_TIME=1770104873073
|
||||||
COMPILE_SDK_VERSION=36
|
COMPILE_SDK_VERSION=36
|
||||||
IMAGE_QUANT_CMAKE_VERSION=3.22.1
|
IMAGE_QUANT_CMAKE_VERSION=3.22.1
|
||||||
IMAGE_QUANT_NDK_VERSION=26.1.10909125
|
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
|
RAPID_OCR_OPENCV_MOBILE_VERSION=4.5.3
|
||||||
TARGET_SDK_VERSION=36
|
TARGET_SDK_VERSION=36
|
||||||
TARGET_SDK_VERSION_INRT=29
|
TARGET_SDK_VERSION_INRT=29
|
||||||
VERSION_BUILD=3688
|
VERSION_BUILD=3689
|
||||||
VERSION_NAME=6.7.0 Alpha19
|
VERSION_NAME=6.7.0 Alpha19
|
||||||
VSCODE_EXT_REQUIRED_VERSION=1.0.13
|
VSCODE_EXT_REQUIRED_VERSION=1.0.13
|
||||||
|
|||||||
Reference in New Issue
Block a user