From 275f393f5b90e039958a174bd4e6fe7f2088262f Mon Sep 17 00:00:00 2001 From: SuperMonster003 Date: Tue, 3 Feb 2026 23:55:14 +0800 Subject: [PATCH] =?UTF-8?q?6.7.0=20-=20Alpha19=20-=20=E7=89=88=E6=9C=AC?= =?UTF-8?q?=E5=8E=86=E5=8F=B2=20M2=20-=20=E5=A2=9E=E5=8A=A0=E4=BB=A3?= =?UTF-8?q?=E7=A0=81=E7=BC=96=E8=BE=91=E5=99=A8=E5=8E=86=E5=8F=B2=E7=89=88?= =?UTF-8?q?=E6=9C=AC=E5=8A=9F=E8=83=BD,=20=E6=94=AF=E6=8C=81=E6=9F=A5?= =?UTF-8?q?=E7=9C=8B=E5=92=8C=E6=81=A2=E5=A4=8D=E5=8E=86=E5=8F=B2=E7=89=88?= =?UTF-8?q?=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .changelog/lang_zh-Hans.json | 1 + app/src/main/java/org/autojs/autojs/App.kt | 5 +- .../storage/history/HistoryBlobStore.kt | 31 +++ .../history/HistoryCleanupScheduler.kt | 44 +++++ .../storage/history/HistoryCleanupWorker.kt | 124 ++++++++++++ .../autojs/storage/history/HistoryDao.kt | 43 +++++ .../autojs/storage/history/HistoryDatabase.kt | 36 ++++ .../autojs/storage/history/HistoryEntities.kt | 66 +++++++ .../history/HistoryRepositoryHandler.kt | 170 +++++++++++++++++ .../autojs/storage/history/HistoryUriUtils.kt | 41 ++++ .../autojs/ui/common/NotAskAgainDialog.java | 11 +- .../org/autojs/autojs/ui/edit/EditorMenu.java | 8 + .../org/autojs/autojs/ui/edit/EditorView.kt | 176 +++++++++++++++++- app/src/main/res/menu/menu_editor.xml | 27 +-- app/src/main/res/values-ar/strings.xml | 3 + app/src/main/res/values-en/strings.xml | 3 + app/src/main/res/values-es/strings.xml | 3 + app/src/main/res/values-fr/strings.xml | 3 + app/src/main/res/values-ja/strings.xml | 3 + app/src/main/res/values-ko/strings.xml | 3 + app/src/main/res/values-ru/strings.xml | 3 + app/src/main/res/values-zh-rHK/strings.xml | 3 + app/src/main/res/values-zh-rTW/strings.xml | 3 + app/src/main/res/values-zh/strings.xml | 3 + app/src/main/res/values/strings.xml | 4 + version.properties | 6 +- 26 files changed, 784 insertions(+), 39 deletions(-) create mode 100644 app/src/main/java/org/autojs/autojs/storage/history/HistoryBlobStore.kt create mode 100644 app/src/main/java/org/autojs/autojs/storage/history/HistoryCleanupScheduler.kt create mode 100644 app/src/main/java/org/autojs/autojs/storage/history/HistoryCleanupWorker.kt create mode 100644 app/src/main/java/org/autojs/autojs/storage/history/HistoryDao.kt create mode 100644 app/src/main/java/org/autojs/autojs/storage/history/HistoryDatabase.kt create mode 100644 app/src/main/java/org/autojs/autojs/storage/history/HistoryEntities.kt create mode 100644 app/src/main/java/org/autojs/autojs/storage/history/HistoryRepositoryHandler.kt create mode 100644 app/src/main/java/org/autojs/autojs/storage/history/HistoryUriUtils.kt diff --git a/.changelog/lang_zh-Hans.json b/.changelog/lang_zh-Hans.json index 072f4ffc..4af83e9a 100644 --- a/.changelog/lang_zh-Hans.json +++ b/.changelog/lang_zh-Hans.json @@ -4,6 +4,7 @@ "released_date": "2026/02/03", "feature": [ "插件中心功能, 支持插件的安装/卸载/更新等操作 (入口: 主页抽屉按钮/主页标签页)", + "版本历史功能, 支持查看/恢复可编辑文件的历史版本 (入口: 代码编辑器菜单)", "Paddle OCR (PP-OCRv5) 插件, 用于光学字符识别", "cvt 模块, 用于数据单位转换 (参阅 项目文档 > [单位转换](https://docs.autojs6.com/#/cvt))", "fmt 模块, 用于数据格式化 (参阅 项目文档 > [格式化](https://docs.autojs6.com/#/fmt))", diff --git a/app/src/main/java/org/autojs/autojs/App.kt b/app/src/main/java/org/autojs/autojs/App.kt index 2be1bb7b..bf5036d3 100644 --- a/app/src/main/java/org/autojs/autojs/App.kt +++ b/app/src/main/java/org/autojs/autojs/App.kt @@ -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.history.HistoryCleanupScheduler import org.autojs.autojs.theme.ThemeColorManager import org.autojs.autojs.timing.TimedTaskManager import org.autojs.autojs.timing.TimedTaskScheduler @@ -39,7 +40,7 @@ import java.lang.reflect.Method /** * Created by Stardust on Jan 27, 2017. - * Modified by SuperMonster003 as of Jan 17, 2026. + * Modified by SuperMonster003 as of Feb 3, 2026. */ class App : MultiDexApplication() { @@ -71,6 +72,8 @@ class App : MultiDexApplication() { ThemeColorManager.init() setUpDefaultNightMode() + + HistoryCleanupScheduler.schedule(this) } } } diff --git a/app/src/main/java/org/autojs/autojs/storage/history/HistoryBlobStore.kt b/app/src/main/java/org/autojs/autojs/storage/history/HistoryBlobStore.kt new file mode 100644 index 00000000..c2fe244b --- /dev/null +++ b/app/src/main/java/org/autojs/autojs/storage/history/HistoryBlobStore.kt @@ -0,0 +1,31 @@ +package org.autojs.autojs.storage.history + +import android.content.Context +import java.io.File +import java.util.UUID + +/** + * Created by JetBrains AI Assistant (GPT-5.2) on Feb 3, 2026. + */ +class HistoryBlobStore(private val context: Context) { + + fun writeRevisionBlob(fileId: String, revId: String, bytes: ByteArray): String { + val rel = "history/blob/$fileId/$revId.bin" + val f = File(context.filesDir, rel) + f.parentFile?.mkdirs() + f.outputStream().use { it.write(bytes) } + return rel + } + + fun deleteBlobByRelPath(relPath: String) { + val f = File(context.filesDir, relPath) + if (f.exists()) { + // noinspection ResultOfMethodCallIgnored + f.delete() + } + } + + fun newFileId(): String = UUID.randomUUID().toString() + + fun newRevId(): String = UUID.randomUUID().toString() +} diff --git a/app/src/main/java/org/autojs/autojs/storage/history/HistoryCleanupScheduler.kt b/app/src/main/java/org/autojs/autojs/storage/history/HistoryCleanupScheduler.kt new file mode 100644 index 00000000..4de39d9a --- /dev/null +++ b/app/src/main/java/org/autojs/autojs/storage/history/HistoryCleanupScheduler.kt @@ -0,0 +1,44 @@ +package org.autojs.autojs.storage.history + +import android.content.Context +import androidx.work.Constraints +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.NetworkType +import androidx.work.PeriodicWorkRequestBuilder +import androidx.work.WorkManager +import java.util.concurrent.TimeUnit + +/** + * Scheduler for HistoryCleanupWorker. + * zh-CN: HistoryCleanupWorker 的调度器. + * + * Created by JetBrains AI Assistant (GPT-5.2) on Feb 3, 2026. + */ +object HistoryCleanupScheduler { + + private const val UNIQUE_WORK_NAME = "history_cleanup_periodic" + + /** + * Schedule periodic cleanup (once per day). + * zh-CN: 调度周期性清理 (每天一次). + */ + fun schedule(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(1, TimeUnit.DAYS) + .setConstraints(constraints) + .build() + + WorkManager.getInstance(appContext).enqueueUniquePeriodicWork( + UNIQUE_WORK_NAME, + ExistingPeriodicWorkPolicy.KEEP, + request, + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/autojs/autojs/storage/history/HistoryCleanupWorker.kt b/app/src/main/java/org/autojs/autojs/storage/history/HistoryCleanupWorker.kt new file mode 100644 index 00000000..7d0f2df9 --- /dev/null +++ b/app/src/main/java/org/autojs/autojs/storage/history/HistoryCleanupWorker.kt @@ -0,0 +1,124 @@ +package org.autojs.autojs.storage.history + +import android.content.Context +import androidx.work.CoroutineWorker +import androidx.work.WorkerParameters +import java.io.File + +/** + * Periodic cleanup worker for history/trash/drafts. + * zh-CN: 用于 history/trash/drafts 的周期性清理 Worker. + * + * Created by JetBrains AI Assistant (GPT-5.2) on Feb 3, 2026. + */ +class HistoryCleanupWorker( + appContext: Context, + params: WorkerParameters, +) : CoroutineWorker(appContext, params) { + + override suspend fun doWork(): Result { + return runCatching { + cleanupExpiredRevisions() + cleanupOrphanHistoryBlobs() + cleanupEmergencyDrafts() + }.fold( + onSuccess = { Result.success() }, + onFailure = { Result.retry() }, + ) + } + + private fun cleanupExpiredRevisions() { + val db = HistoryDatabase.getInstance(applicationContext) + val dao = db.historyDao() + val blobs = HistoryBlobStore(applicationContext) + + // Remove expired revisions by days (30 days). + // zh-CN: 按天数删除过期 revision (30 天). + val now = System.currentTimeMillis() + val expiredBefore = now - MAX_DAYS_MS + val expired = dao.listExpiredRevisions(expiredBefore) + if (expired.isEmpty()) return + + dao.deleteRevisionsByIds(expired.map { it.revId }) + expired.forEach { blobs.deleteBlobByRelPath(it.blobRelPath) } + } + + private fun cleanupOrphanHistoryBlobs() { + val db = HistoryDatabase.getInstance(applicationContext) + val dao = db.historyDao() + + // Build the referenced blob set from DB. + // zh-CN: 从 DB 构建被引用的 blob 集合. + val referenced = dao.listAllBlobRelPaths().toHashSet() + + val root = File(applicationContext.filesDir, "history/blob") + if (!root.exists() || !root.isDirectory) return + + // Delete blobs that are not referenced by DB. + // zh-CN: 删除 DB 未引用的 blob. + root.walkTopDown() + .filter { it.isFile } + .forEach { f -> + val rel = f.relativeTo(applicationContext.filesDir).invariantSeparatorsPath + if (!referenced.contains(rel)) { + // noinspection ResultOfMethodCallIgnored + f.delete() + } + } + + // Best-effort: remove empty directories. + // zh-CN: 尽力删除空目录. + root.walkBottomUp() + .filter { it.isDirectory } + .forEach { dir -> + val children = dir.listFiles() + if (children == null || children.isEmpty()) { + // noinspection ResultOfMethodCallIgnored + dir.delete() + } + } + } + + private fun cleanupEmergencyDrafts() { + // Keep consistent with EditorView's local cleanup policy. + // zh-CN: 与 EditorView 的本地清理策略保持一致. + val draftsDir = File(applicationContext.filesDir, "drafts") + if (!draftsDir.exists() || !draftsDir.isDirectory) return + + val now = System.currentTimeMillis() + + // 1) Remove expired (older than 7 days). + // zh-CN: 1) 删除过期草稿 (超过 7 天). + val expiredBefore = now - DRAFT_MAX_DAYS_MS + draftsDir.listFiles()?.forEach { f -> + if (f.isFile && f.lastModified() < expiredBefore) { + // noinspection ResultOfMethodCallIgnored + f.delete() + } + } + + // 2) Enforce total bytes limit (keep newest first). + // zh-CN: 2) 约束总容量上限 (优先保留最新). + val remained = draftsDir.listFiles() + ?.filter { it.isFile } + ?.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 + } + } + } + + companion object { + private const val MAX_DAYS_MS: Long = 30L * 24L * 60L * 60L * 1000L + private const val DRAFT_MAX_DAYS_MS: Long = 7L * 24L * 60L * 60L * 1000L + private const val DRAFT_MAX_TOTAL_BYTES: Long = 200L * 1024L * 1024L + } +} \ No newline at end of file diff --git a/app/src/main/java/org/autojs/autojs/storage/history/HistoryDao.kt b/app/src/main/java/org/autojs/autojs/storage/history/HistoryDao.kt new file mode 100644 index 00000000..9590d860 --- /dev/null +++ b/app/src/main/java/org/autojs/autojs/storage/history/HistoryDao.kt @@ -0,0 +1,43 @@ +package org.autojs.autojs.storage.history + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query + +/** + * Created by JetBrains AI Assistant (GPT-5.2) on Feb 3, 2026. + */ +@Dao +interface HistoryDao { + + @Query("SELECT * FROM file_entry WHERE logicalPath = :logicalPath LIMIT 1") + fun findFileByPath(logicalPath: String): HistoryEntities.FileEntry? + + @Query("SELECT * FROM file_entry WHERE latestFingerprint = :fingerprint ORDER BY lastSeenAt DESC LIMIT 1") + fun findFileByLatestFingerprint(fingerprint: String): HistoryEntities.FileEntry? + + @Insert(onConflict = OnConflictStrategy.REPLACE) + fun upsertFile(entry: HistoryEntities.FileEntry) + + @Insert + fun insertRevision(rev: HistoryEntities.Revision) + + @Query("SELECT * FROM revision WHERE fileId = :fileId ORDER BY createdAt ASC") + fun listRevisionsAsc(fileId: String): List + + @Query("DELETE FROM revision WHERE revId IN (:revIds)") + fun deleteRevisionsByIds(revIds: List) + + @Query("SELECT SUM(sizeBytes) FROM revision WHERE fileId = :fileId") + fun sumBytesByFileId(fileId: String): Long? + + @Query("SELECT * FROM file_entry") + fun listAllFiles(): List + + @Query("SELECT * FROM revision WHERE createdAt < :expiredBefore ORDER BY createdAt ASC") + fun listExpiredRevisions(expiredBefore: Long): List + + @Query("SELECT blobRelPath FROM revision") + fun listAllBlobRelPaths(): List +} \ No newline at end of file diff --git a/app/src/main/java/org/autojs/autojs/storage/history/HistoryDatabase.kt b/app/src/main/java/org/autojs/autojs/storage/history/HistoryDatabase.kt new file mode 100644 index 00000000..e6067e85 --- /dev/null +++ b/app/src/main/java/org/autojs/autojs/storage/history/HistoryDatabase.kt @@ -0,0 +1,36 @@ +package org.autojs.autojs.storage.history + +import android.content.Context +import androidx.room.Database +import androidx.room.Room +import androidx.room.RoomDatabase + +/** + * Created by JetBrains AI Assistant (GPT-5.2) on Feb 3, 2026. + */ +@Database( + entities = [ + HistoryEntities.FileEntry::class, + HistoryEntities.Revision::class, + ], + version = 1, + exportSchema = false, +) +abstract class HistoryDatabase : RoomDatabase() { + + abstract fun historyDao(): HistoryDao + + companion object { + + @Volatile + private var instance: HistoryDatabase? = null + + fun getInstance(applicationContext: Context): HistoryDatabase = instance ?: synchronized(this) { + instance ?: Room.databaseBuilder( + applicationContext, + HistoryDatabase::class.java, + "history-database.db", + ).build().also { instance = it } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/autojs/autojs/storage/history/HistoryEntities.kt b/app/src/main/java/org/autojs/autojs/storage/history/HistoryEntities.kt new file mode 100644 index 00000000..afeef36a --- /dev/null +++ b/app/src/main/java/org/autojs/autojs/storage/history/HistoryEntities.kt @@ -0,0 +1,66 @@ +package org.autojs.autojs.storage.history + +import androidx.room.Entity +import androidx.room.Index +import androidx.room.PrimaryKey + +/** + * Created by JetBrains AI Assistant (GPT-5.2) on Feb 3, 2026. + */ +object HistoryEntities { + + @Entity( + tableName = "file_entry", + indices = [ + Index(value = ["logicalPath"], unique = true), + Index(value = ["latestFingerprint"]), + Index(value = ["lastSeenAt"]), + ], + ) + data class FileEntry( + @PrimaryKey + val fileId: String, + + val logicalPath: String, + + val createdAt: Long, + val lastSeenAt: Long, + + // Latest content fingerprint for "path lost but content same" matching. + // zh-CN: 用于 "路径丢失但内容相同" 匹配的最新内容指纹. + val latestFingerprint: String, + ) + + @Entity( + tableName = "revision", + indices = [ + Index(value = ["fileId"]), + Index(value = ["createdAt"]), + ], + ) + data class Revision( + @PrimaryKey + val revId: String, + + val fileId: String, + + // Operation type for future extension (SAVE_PRE / RESTORE / TRASH etc.). + // zh-CN: 操作类型, 供未来扩展 (SAVE_PRE / RESTORE / TRASH 等). + val op: String, + + val createdAt: Long, + + val logicalPathAtThatTime: String, + + val encoding: String, + val hadBom: Boolean, + + val sizeBytes: Long, + + val sha256: String, + + // Blob relative path under filesDir. + // zh-CN: 位于 filesDir 下的相对路径. + val blobRelPath: String, + ) +} diff --git a/app/src/main/java/org/autojs/autojs/storage/history/HistoryRepositoryHandler.kt b/app/src/main/java/org/autojs/autojs/storage/history/HistoryRepositoryHandler.kt new file mode 100644 index 00000000..63904ddc --- /dev/null +++ b/app/src/main/java/org/autojs/autojs/storage/history/HistoryRepositoryHandler.kt @@ -0,0 +1,170 @@ +package org.autojs.autojs.storage.history + +import android.content.Context +import android.net.Uri +import java.io.File +import java.security.MessageDigest +import java.util.Locale + +/** + * Created by JetBrains AI Assistant (GPT-5.2) on Feb 3, 2026. + */ +class HistoryRepository(private val context: Context) { + + private val db by lazy { HistoryDatabase.getInstance(context.applicationContext) } + private val dao by lazy { db.historyDao() } + private val blobs by lazy { HistoryBlobStore(context.applicationContext) } + + /** + * Read revision blob bytes. + * zh-CN: 读取 revision 对应的 blob bytes. + */ + fun readRevisionBytes(rev: HistoryEntities.Revision): ByteArray { + val f = File(context.filesDir, rev.blobRelPath) + return f.inputStream().use { it.readBytes() } + } + + /** + * Record a SAVE_PRE snapshot (old content bytes). + * + * Rules: + * - Only tracks files under internal storage root. + * - Uses logicalPath first; fallback by latestFingerprint matching. + * - Applies per-file retention after inserting. + * + * zh-CN: + * + * 记录一次 SAVE_PRE 快照 (旧内容 bytes). + * + * 规则: + * - 仅纳管内部存储根目录下的文件. + * - 优先按 logicalPath 匹配; 找不到时用 latestFingerprint 回退匹配. + * - 插入后执行单文件保留策略清理. + */ + fun recordSavePre( + uri: Uri, + logicalPath: String, + oldBytes: ByteArray, + encodingName: String, + hadBom: Boolean, + ) { + val now = System.currentTimeMillis() + + val oldSha = sha256Hex(oldBytes) + val fileEntry = getOrCreateFileEntry( + logicalPath = logicalPath, + latestFingerprint = oldSha, + now = now, + ) + + val revId = blobs.newRevId() + val blobRel = blobs.writeRevisionBlob(fileEntry.fileId, revId, oldBytes) + + dao.insertRevision( + HistoryEntities.Revision( + revId = revId, + fileId = fileEntry.fileId, + op = "SAVE_PRE", + createdAt = now, + logicalPathAtThatTime = logicalPath, + encoding = encodingName, + hadBom = hadBom, + sizeBytes = oldBytes.size.toLong(), + sha256 = oldSha, + blobRelPath = blobRel, + ) + ) + + cleanupPerFile(fileEntry.fileId, now) + } + + private fun getOrCreateFileEntry(logicalPath: String, latestFingerprint: String, now: Long): HistoryEntities.FileEntry { + val byPath = dao.findFileByPath(logicalPath) + if (byPath != null) { + val updated = byPath.copy( + lastSeenAt = now, + latestFingerprint = latestFingerprint, + ) + dao.upsertFile(updated) + return updated + } + + val byFingerprint = dao.findFileByLatestFingerprint(latestFingerprint) + if (byFingerprint != null) { + // Re-attach to new path. + // zh-CN: 重新绑定到新路径. + val updated = byFingerprint.copy( + logicalPath = logicalPath, + lastSeenAt = now, + latestFingerprint = latestFingerprint, + ) + dao.upsertFile(updated) + return updated + } + + val created = HistoryEntities.FileEntry( + fileId = blobs.newFileId(), + logicalPath = logicalPath, + createdAt = now, + lastSeenAt = now, + latestFingerprint = latestFingerprint, + ) + dao.upsertFile(created) + return created + } + + private fun cleanupPerFile(fileId: String, now: Long) { + val revisions = dao.listRevisionsAsc(fileId).toMutableList() + if (revisions.isEmpty()) return + + // 1) Remove expired by days. + // zh-CN: 1) 按天数删除过期版本. + val expiredBefore = now - MAX_DAYS_MS + val expired = revisions.filter { it.createdAt < expiredBefore } + if (expired.isNotEmpty()) { + deleteRevisionsAndBlobs(expired) + revisions.removeAll(expired.toSet()) + } + + // 2) Enforce max count (remove oldest). + // zh-CN: 2) 约束最大版本数 (删除最旧). + if (revisions.size > MAX_VERSIONS) { + val toRemove = revisions.take(revisions.size - MAX_VERSIONS) + deleteRevisionsAndBlobs(toRemove) + revisions.removeAll(toRemove.toSet()) + } + + // 3) Enforce max total bytes per file (remove oldest). + // zh-CN: 3) 约束单文件历史总容量 (删除最旧). + var totalBytes = (dao.sumBytesByFileId(fileId) ?: 0L).coerceAtLeast(0L) + if (totalBytes > MAX_TOTAL_BYTES_PER_FILE) { + for (rev in revisions.toList()) { + if (totalBytes <= MAX_TOTAL_BYTES_PER_FILE) break + deleteRevisionsAndBlobs(listOf(rev)) + totalBytes -= rev.sizeBytes.coerceAtLeast(0L) + revisions.remove(rev) + } + } + } + + private fun deleteRevisionsAndBlobs(revs: List) { + if (revs.isEmpty()) return + dao.deleteRevisionsByIds(revs.map { it.revId }) + revs.forEach { blobs.deleteBlobByRelPath(it.blobRelPath) } + } + + private fun sha256Hex(bytes: ByteArray): String { + val digest = MessageDigest.getInstance("SHA-256").digest(bytes) + val sb = StringBuilder(digest.size * 2) + for (b in digest) { + sb.append(String.format(Locale.US, "%02x", b)) + } + return sb.toString() + } + + companion object { + private const val MAX_DAYS_MS: Long = 30L * 24L * 60L * 60L * 1000L + private const val MAX_VERSIONS: Int = 50 + private const val MAX_TOTAL_BYTES_PER_FILE: Long = 200L * 1024L * 1024L + } +} diff --git a/app/src/main/java/org/autojs/autojs/storage/history/HistoryUriUtils.kt b/app/src/main/java/org/autojs/autojs/storage/history/HistoryUriUtils.kt new file mode 100644 index 00000000..00b6efd8 --- /dev/null +++ b/app/src/main/java/org/autojs/autojs/storage/history/HistoryUriUtils.kt @@ -0,0 +1,41 @@ +package org.autojs.autojs.storage.history + +import android.net.Uri + +/** + * Created by JetBrains AI Assistant (GPT-5.2) on Feb 3, 2026. + */ +object HistoryUriUtils { + + private const val INTERNAL_ROOT = "/storage/emulated/0" + + /** + * Resolve a logical path for history indexing. + * + * Strategy: + * - Prefer extracting "/storage/emulated/0/..." from uri.toString() for third-party providers. + * - Fallback to uri.path. + * - Return null if not under internal storage root. + * + * zh-CN: + * + * 为历史索引解析 logical path. + * + * 策略: + * - 对第三方 provider 优先从 uri.toString() 中提取 "/storage/emulated/0/..." 子串. + * - 回退到 uri.path. + * - 若不在内部存储根目录下则返回 null. + */ + fun toLogicalPathOrNull(uri: Uri): String? { + val raw = uri.toString() + val idx = raw.indexOf(INTERNAL_ROOT) + val extracted = if (idx >= 0) { + raw.substring(idx) + } else { + uri.path + } ?: return null + + val normalized = extracted.trimEnd('/') + return if (normalized.startsWith(INTERNAL_ROOT)) normalized else null + } +} diff --git a/app/src/main/java/org/autojs/autojs/ui/common/NotAskAgainDialog.java b/app/src/main/java/org/autojs/autojs/ui/common/NotAskAgainDialog.java index 8e6f3549..16a7cc9a 100644 --- a/app/src/main/java/org/autojs/autojs/ui/common/NotAskAgainDialog.java +++ b/app/src/main/java/org/autojs/autojs/ui/common/NotAskAgainDialog.java @@ -2,18 +2,16 @@ package org.autojs.autojs.ui.common; import android.content.Context; import android.text.TextUtils; - import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.preference.PreferenceManager; - import com.afollestad.materialdialogs.MaterialDialog; - import org.autojs.autojs.util.MD5Utils; import org.autojs.autojs6.R; /** * Created by Stardust on Jan 30, 2017. + * Modified by SuperMonster003 as of Feb 3, 2026. */ public class NotAskAgainDialog extends MaterialDialog { @@ -38,6 +36,13 @@ public class NotAskAgainDialog extends MaterialDialog { } @Nullable + @Override + public MaterialDialog build() { + return mRemind ? super.build() : null; + } + + @Nullable + @Override public MaterialDialog show() { return mRemind ? super.show() : null; } diff --git a/app/src/main/java/org/autojs/autojs/ui/edit/EditorMenu.java b/app/src/main/java/org/autojs/autojs/ui/edit/EditorMenu.java index dd723112..4a067287 100644 --- a/app/src/main/java/org/autojs/autojs/ui/edit/EditorMenu.java +++ b/app/src/main/java/org/autojs/autojs/ui/edit/EditorMenu.java @@ -141,6 +141,10 @@ public class EditorMenu { showFileDetails(); return true; } + if (itemId == R.id.action_version_history) { + showHistory(); + return true; + } if (itemId == R.id.action_build_apk) { startBuildApkActivity(); return true; @@ -148,6 +152,10 @@ public class EditorMenu { return false; } + private void showHistory() { + mEditorView.showVersionHistoryDialog(); + } + private void importJavaPackageOrClass() { mEditor.getSelection() .observeOn(AndroidSchedulers.mainThread()) diff --git a/app/src/main/java/org/autojs/autojs/ui/edit/EditorView.kt b/app/src/main/java/org/autojs/autojs/ui/edit/EditorView.kt index 863eab8a..2212ecc3 100644 --- a/app/src/main/java/org/autojs/autojs/ui/edit/EditorView.kt +++ b/app/src/main/java/org/autojs/autojs/ui/edit/EditorView.kt @@ -44,10 +44,15 @@ import org.autojs.autojs.model.script.Scripts.EXTRA_EXCEPTION_LINE_NUMBER 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.runWithBroadcastSender +import org.autojs.autojs.pio.PFiles import org.autojs.autojs.pio.PFiles.getNameWithoutExtension import org.autojs.autojs.pio.PFiles.write import org.autojs.autojs.storage.file.TmpScriptFiles +import org.autojs.autojs.storage.history.HistoryDatabase +import org.autojs.autojs.storage.history.HistoryRepository +import org.autojs.autojs.storage.history.HistoryUriUtils import org.autojs.autojs.tool.Callback +import org.autojs.autojs.ui.common.NotAskAgainDialog import org.autojs.autojs.ui.doc.ManualDialog import org.autojs.autojs.ui.edit.completion.CodeCompletionBar import org.autojs.autojs.ui.edit.completion.CodeCompletionBar.OnHintClickListener @@ -71,8 +76,10 @@ import org.autojs.autojs.util.ClipboardUtils import org.autojs.autojs.util.DisplayUtils.pxToSp import org.autojs.autojs.util.DocsUtils.getUrl import org.autojs.autojs.util.MaterialDialogUtils.choiceWidgetThemeColor +import org.autojs.autojs.util.MaterialDialogUtils.widgetThemeColor import org.autojs.autojs.util.Observers import org.autojs.autojs.util.StringUtils +import org.autojs.autojs.util.StringUtils.key import org.autojs.autojs.util.ViewUtils.showSnack import org.autojs.autojs.util.ViewUtils.showToast import org.autojs.autojs6.R @@ -82,6 +89,8 @@ import java.io.IOException import java.nio.charset.Charset import java.nio.charset.StandardCharsets import java.security.MessageDigest +import java.text.SimpleDateFormat +import java.util.Date import java.util.Locale /** @@ -497,14 +506,19 @@ class EditorView : LinearLayout, OnHintClickListener, ClickCallback, ToolbarFrag 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 = ...) + // Track history only when within size guardrails and inside internal storage. + // zh-CN: 仅在满足大小护栏且位于内部存储时纳管历史. + val logicalPath = HistoryUriUtils.toLogicalPathOrNull(uri) + if (logicalPath != null && shouldTrackHistory(oldBytes, newBytes) && oldBytes != null) { + runCatching { + HistoryRepository(context.applicationContext).recordSavePre( + uri = uri, + logicalPath = logicalPath, + oldBytes = oldBytes, + encodingName = targetCharset.name(), + hadBom = needBom, + ) + } } val newHash = sha256(newBytes) @@ -574,7 +588,7 @@ class EditorView : LinearLayout, OnHintClickListener, ClickCallback, ToolbarFrag // Prompt user on main thread. // zh-CN: 在主线程提示用户. - DialogUtils.buildAndShowAdaptive( + DialogUtils.buildAndShowAdaptive { MaterialDialog.Builder(context) .title(R.string.error_save_failed) .content( @@ -658,7 +672,8 @@ class EditorView : LinearLayout, OnHintClickListener, ClickCallback, ToolbarFrag } .autoDismiss(false) .cancelable(false) - ) + .build() + } if (error is IOException) { throw error @@ -701,6 +716,143 @@ class EditorView : LinearLayout, OnHintClickListener, ClickCallback, ToolbarFrag return if (niceExt.isBlank()) sanitized else "$sanitized.$niceExt" } + fun showVersionHistoryDialog() { + val logicalPath = HistoryUriUtils.toLogicalPathOrNull(uri) + if (logicalPath == null) { + // History is only tracked under internal storage root. + // zh-CN: 历史记录仅纳管内部存储根目录下的文件. + DialogUtils.buildAndShowAdaptive { + MaterialDialog.Builder(context) + .title(R.string.text_version_history) + .content(R.string.text_no_version_history) + .positiveText(R.string.dialog_button_dismiss) + .positiveColorRes(R.color.dialog_button_default) + .cancelable(false) + .build() + } + return + } + + Schedulers.io().scheduleDirect { + runCatching { + val appCtx = context.applicationContext + val db = HistoryDatabase.getInstance(appCtx) + val dao = db.historyDao() + + val fileEntry = dao.findFileByPath(logicalPath) + val fileId = fileEntry?.fileId + + val revs = if (fileId != null) { + // Show latest first for selection. + // zh-CN: 列表按最新优先展示供选择. + dao.listRevisionsAsc(fileId).asReversed().take(HISTORY_DIALOG_MAX_ITEMS) + } else { + emptyList() + } + + post { + if (revs.isEmpty()) { + DialogUtils.buildAndShowAdaptive { + MaterialDialog.Builder(context) + .title(R.string.text_version_history) + .content(R.string.text_no_version_history) + .positiveText(R.string.dialog_button_dismiss) + .positiveColorRes(R.color.dialog_button_default) + .cancelable(false) + .build() + } + return@post + } + + val fmt = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()) + val items = revs.map { rev -> + val t = fmt.format(Date(rev.createdAt)) + val size = rev.sizeBytes + "$t | ${PFiles.formatSizeWithUnit(size)}" + } + + val selectedIndex = intArrayOf(0) + + DialogUtils.buildAndShowAdaptive { + MaterialDialog.Builder(context) + .title(R.string.dialog_button_history) + .items(items) + .itemsCallbackSingleChoice(0) { _, _, which, _ -> + selectedIndex[0] = which + true + } + .negativeText(R.string.dialog_button_cancel) + .negativeColorRes(R.color.dialog_button_default) + .positiveText(R.string.dialog_button_retrieve) + .positiveColorRes(R.color.dialog_button_attraction) + .onPositive { _, _ -> + val chosen = revs.getOrNull(selectedIndex[0]) ?: return@onPositive + + // Confirm before restoring to editor. + // zh-CN: 恢复到编辑器前先做二次确认. + showRestoreConfirmDialog() { + // Load blob and restore into editor (not auto-save). + // zh-CN: 读取 blob 并恢复到编辑器 (不自动保存). + Schedulers.io().scheduleDirect { + runCatching { + val bytes = HistoryRepository(appCtx).readRevisionBytes(chosen) + val restored = decodeRevisionBytes( + bytes = bytes, + encodingName = chosen.encoding, + hadBom = chosen.hadBom, + ) + post { + editor.text = restored + setMenuItemStatus(R.id.save, true) + showSnack(this@EditorView, R.string.text_done) + } + }.onFailure { + it.printStackTrace() + post { showToast(context, it.message, true) } + } + } + } + } + .cancelable(true) + .build() + } + } + }.onFailure { + it.printStackTrace() + post { showToast(context, it.message, true) } + } + } + } + + private fun showRestoreConfirmDialog(onConfirm: () -> Unit) { + DialogUtils.buildAndShowAdaptiveOrNull { + NotAskAgainDialog.Builder( + context, + key(R.string.key_version_history_restore_does_not_auto_save_to_disk) + ).apply { + title(R.string.text_prompt) + content(R.string.text_version_history_restore_does_not_auto_save_to_disk) + widgetThemeColor() + negativeText(R.string.dialog_button_cancel) + negativeColorRes(R.color.dialog_button_default) + positiveText(R.string.dialog_button_confirm) + positiveColorRes(R.color.dialog_button_attraction) + onPositive { _, _ -> onConfirm() } + cancelable(false) + }.build() + } ?: onConfirm() + } + + private fun decodeRevisionBytes(bytes: ByteArray, encodingName: String, hadBom: Boolean): String { + val charset = runCatching { Charset.forName(encodingName) }.getOrElse { DEFAULT_CHARSET_TO_WRITE_FILE } + val effective = if (hadBom) { + // Drop BOM before decoding because BOM presence is tracked by metadata. + // zh-CN: BOM 是否存在由元数据记录, 解码前需丢弃 BOM. + StringUtils.dropBom(bytes, charset) + } else bytes + return String(effective, charset) + } + // A minimal emergency draft store. // zh-CN: 一个最小实现的草稿存储器. private class EmergencyDraftStore(private val context: Context) { @@ -1059,6 +1211,10 @@ class EditorView : LinearLayout, OnHintClickListener, ClickCallback, ToolbarFrag // zh-CN: 草稿总容量上限 (200MB). private const val DRAFT_MAX_TOTAL_BYTES: Long = 200L * 1024L * 1024L + // Max items shown in history dialog. + // zh-CN: 历史对话框最多展示条数. + private const val HISTORY_DIALOG_MAX_ITEMS: Int = 20 + // Internal storage root (no external SD). // zh-CN: 内部存储根目录 (不访问外置 SD). private const val INTERNAL_STORAGE_ROOT: String = "/storage/emulated/0" diff --git a/app/src/main/res/menu/menu_editor.xml b/app/src/main/res/menu/menu_editor.xml index ec683392..8e8da704 100644 --- a/app/src/main/res/menu/menu_editor.xml +++ b/app/src/main/res/menu/menu_editor.xml @@ -2,16 +2,13 @@ - - - - - - - - - - - - - - - + + - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index cb08f87c..9282c54f 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -1230,4 +1230,7 @@ مسار المسودة نسخ حفظ باسم + سجل الاصدارات + لا يوجد سجل للاصدارات + عند الاستعادة، يتم تغيير محتوى المحرر فقط. لن يتم الحفظ تلقائيا في الملف. يرجى الضغط على زر \"حفظ\" يدويا. \ No newline at end of file diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml index 9bb6afbe..623a44d1 100644 --- a/app/src/main/res/values-en/strings.xml +++ b/app/src/main/res/values-en/strings.xml @@ -1225,4 +1225,7 @@ Draft file path Copy path Save as + Version history + No version history + Restoring only changes the editor content. It will not automatically save to the file. Please tap the Save button manually. \ No newline at end of file diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 169d26ae..4155ca66 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -1228,4 +1228,7 @@ Ruta del borrador Copiar Guardar como + Historial de versiones + Sin historial de versiones + Al restaurar, solo se cambia el contenido del editor. No se guardara automaticamente en el archivo. Debes pulsar el boton Guardar manualmente. \ No newline at end of file diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index ad9e7292..0f574a72 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -1228,4 +1228,7 @@ Chemin du brouillon Copier Enregistrer sous + Historique des versions + Aucun historique des versions + La restauration ne modifie que le contenu de l\'editeur. Elle n\'enregistre pas automatiquement dans le fichier. Veuillez appuyer manuellement sur le bouton Enregistrer. \ No newline at end of file diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index e2f7f375..97ac6a21 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -1229,4 +1229,7 @@ 下書きのパス コピー 名前を付けて保存 + バージョン履歴 + バージョン履歴なし + 復元すると編集内容のみが変更されます. ファイルには自動保存されません. 保存ボタンを手動でタップしてください. \ No newline at end of file diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 05674df7..dd19d906 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -1230,4 +1230,7 @@ 초안 경로 복사 다른 이름으로 저장 + 버전 기록 + 버전 기록 없음 + 복원하면 편집기 내용만 변경됩니다. 파일에 자동으로 저장되지 않습니다. 저장 버튼을 수동으로 눌러 주세요. \ No newline at end of file diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 46cb5b88..220c2592 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -1228,4 +1228,7 @@ Путь к черновику Копировать Сохранить как + История версий + Нет истории версий + Восстановление изменяет только содержимое редактора. Файл не будет сохранен автоматически. Нажмите кнопку \"Сохранить\" вручную. \ No newline at end of file diff --git a/app/src/main/res/values-zh-rHK/strings.xml b/app/src/main/res/values-zh-rHK/strings.xml index 3a5c50fa..18abee6d 100644 --- a/app/src/main/res/values-zh-rHK/strings.xml +++ b/app/src/main/res/values-zh-rHK/strings.xml @@ -1226,4 +1226,7 @@ 草稿文件路徑 複製路徑 另存為 + 版本歷史 + 無版本歷史 + 恢復時只修改編輯器內容, 不會自動保存到文件, 需要手動點擊保存按鈕. \ No newline at end of file diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 0d4c267b..d3f69cdd 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -1226,4 +1226,7 @@ 草稿檔案路徑 複製路徑 另存為 + 版本歷史 + 無版本歷史 + 恢復時只修改編輯器內容, 不會自動儲存到檔案, 需要手動點選儲存按鈕. \ No newline at end of file diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index 22d9d9b5..9d77b12e 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -1226,4 +1226,7 @@ 草稿文件路径 复制路径 另存为 + 版本历史 + 无版本历史 + 恢复时只修改编辑器内容, 不会自动保存到文件, 需要手动点击保存按钮. \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 12476e10..d9f59f94 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -175,6 +175,7 @@ key_$_use_volume_control_record key_$_use_volume_control_running key_$_version_histories + key_$_version_history_restore_does_not_auto_save_to_disk key_$_working_directory key_$_working_directory_histories key_$_working_directory_initialized @@ -1486,4 +1487,7 @@ Draft file path Copy path Save as + Version history + No version history + Restore will only change editor content; it will not save to file automatically. \ No newline at end of file diff --git a/version.properties b/version.properties index 99e1322d..1f80329f 100644 --- a/version.properties +++ b/version.properties @@ -1,5 +1,5 @@ -#Tue Feb 03 15:47:53 CST 2026 -BUILD_TIME=1770104873073 +#Tue Feb 03 21:34:34 CST 2026 +BUILD_TIME=1770125674544 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=3689 +VERSION_BUILD=3691 VERSION_NAME=6.7.0 Alpha19 VSCODE_EXT_REQUIRED_VERSION=1.0.13