6.7.0 - Alpha19 - 版本历史 M4 - 支持排序, 搜索, 摘要显示, 删除, 清空等; 优化 UI 显示

This commit is contained in:
SuperMonster003
2026-02-08 00:40:00 +08:00
parent 140870b5ec
commit 4dc0f7c60b
143 changed files with 4724 additions and 1711 deletions

View File

@@ -25,7 +25,7 @@
"v6.6.3": {
"released_date": "2025/05/27",
"feature": [
"ميزة سجل الإصدارات: عرض سجل التغييرات متعدد اللغات والإحصائيات",
"ميزة سجل الإصدار: عرض سجلات التغيير والإحصائيات متعددة اللغات",
"الدالة timers.keepAlive (أصبحت عالمية) للحفاظ على بقاء السكربت نشطًا",
"مستمعو الأحداث مثل engines.on('start/stop/error', callback) لأحداث المحرك العامة",
"الدالة images.detectMultiColors للتحقق من ألوان متعددة النقاط _[`issue #374`](http://issues.autojs6.com/374)_",
@@ -40,7 +40,7 @@
"fix": [
"قد يتم حجب بعض محتوى الوثائق عبر الإنترنت في تبويب Docs وصفحة الوثائق بواسطة شريط التنقل",
"في بعض الصفحات قد يؤدي النقر على أزرار شريط الأدوات إلى تفعيل حدث النقر على العنوان بالخطأ",
"السطور الفارغة في محرّر الشيفرة تظهر كرموز مربّعة على بعض الأجهزة",
"السطور الفارغة في محرّر الشيفرة تظهر كرموز مربّعة على بعض الأجهزة (حل مؤقت)",
"مربع حوار لوحة الألوان في إعداد لون السمة قد يتراكم بلا حدود",
"زر رفع الصوت لا يوقف جميع السكربتات عند تعطيل خدمة الوصول",
"تداخل لوحة المفاتيح عند تحرير بث مخصّص في صفحة المهام المجدولة",

View File

@@ -25,7 +25,7 @@
"v6.6.3": {
"released_date": "2025/05/27",
"feature": [
"Version history feature: view multi-language release changelogs and statistics",
"Release history feature: view multi-language release changelogs and statistics",
"timers.keepAlive method (now global) for keeping the script alive",
"Event listeners such as engines.on('start/stop/error', callback) for global engine events",
"images.detectMultiColors method for multi-point color verification _[`issue #374`](http://issues.autojs6.com/374)_",
@@ -40,7 +40,7 @@
"fix": [
"Online docs in the Home > Docs tab and Docs activity may be covered by the system navigation bar",
"Clicking Toolbar buttons on some pages could accidentally trigger the title click event",
"Blank lines in the code editor showed box glyphs on some devices",
"Blank lines in the code editor showed box glyphs on some devices (tentative fix)",
"Color-picker dialog in the theme-color settings page could stack infinitely",
"Volume-up key failed to stop all scripts when the accessibility service was disabled",
"IME overlay issue when editing custom broadcast content in the Scheduled Tasks page",

View File

@@ -25,7 +25,7 @@
"v6.6.3": {
"released_date": "2025/05/27",
"feature": [
"Función de historial de versiones: ver registros de cambios multilenguaje y estadísticas",
"Función de historial de lanzamientos: ver registros de cambios y estadísticas de lanzamientos en múltiples idiomas",
"Método timers.keepAlive (ahora global) para mantener activo el script",
"Métodos de escucha de eventos como engines.on('start/stop/error', callback) para eventos globales del motor",
"Método images.detectMultiColors para verificación de colores multipunto _[`issue #374`](http://issues.autojs6.com/374)_",
@@ -40,7 +40,7 @@
"fix": [
"El contenido de la documentación en línea en la pestaña Documentos y la actividad Documentos podía quedar cubierto por la barra de navegación del sistema",
"En algunas páginas, al pulsar botones de la Toolbar se podía activar por error el evento de clic en el título",
"Las líneas en blanco del editor de código mostraban caracteres de cuadro en ciertos dispositivos",
"Las líneas en blanco del editor de código mostraban caracteres de cuadro en ciertos dispositivos (solución tentativa)",
"El cuadro de diálogo del selector de colores en los ajustes de color de tema podía apilarse infinitamente",
"La tecla de subir volumen no detenía todos los scripts cuando el servicio de accesibilidad estaba desactivado",
"Superposición del teclado IME al editar contenido de difusión personalizado en la página de tareas programadas",

View File

@@ -25,7 +25,7 @@
"v6.6.3": {
"released_date": "2025/05/27",
"feature": [
"Fonction historique des versions : consulter les journaux de mise à jour multilingues et les statistiques",
"Fonctionnalité d'historique des versions : visualisation des changelogs de versions multilingues et des statistiques",
"Méthode timers.keepAlive (désormais globale) pour maintenir le script actif",
"Méthodes d'écoute d'événements telles que engines.on('start/stop/error', callback) pour les événements globaux du moteur",
"Méthode images.detectMultiColors pour la vérification de couleurs multipoints _[`issue #374`](http://issues.autojs6.com/374)_",
@@ -40,7 +40,7 @@
"fix": [
"Le contenu de la documentation en ligne dans l'onglet Docs et l'activité Docs pouvait être recouvert par la barre de navigation système",
"Sur certaines pages, cliquer sur les boutons de la Toolbar pouvait déclencher par erreur l'événement de clic sur le titre",
"Les lignes vides dans l'éditeur de code affichaient des carrés sur certains appareils",
"Les lignes vides dans l'éditeur de code affichaient des carrés sur certains appareils (solution provisoire)",
"La boîte de dialogue du sélecteur de couleurs dans les paramètres de couleur du thème pouvait se superposer indéfiniment",
"La touche volume + ne stoppait pas tous les scripts lorsque le service d'accessibilité était désactivé",
"Chevauchement du clavier IME lors de l'édition d'un contenu de diffusion personnalisé dans la page des tâches planifiées",

View File

@@ -25,7 +25,7 @@
"v6.6.3": {
"released_date": "2025/05/27",
"feature": [
"バージョン履歴機能: 多言語変更履歴と統計を閲覧",
"リリース履歴機能: 多言語リリース変更履歴と統計の表示",
"timers.keepAlive メソッド (グローバルへ昇格) によりスクリプトの活動を維持",
"engines.on('start/stop/error', callback) などのイベントリスナーでエンジンのグローバルイベントを受信",
"images.detectMultiColors メソッドで複数点のカラーを検査 _[`issue #374`](http://issues.autojs6.com/374)_",
@@ -40,7 +40,7 @@
"fix": [
"Docs タブおよび Docs アクティビティのオンラインドキュメントがシステムナビゲーションバーと重なる場合がある問題を修正",
"一部ページで Toolbar ボタンをタップすると誤ってタイトルのクリックイベントが発火する問題を修正",
"コードエディタの空行が一部デバイスで四角形として表示される問題を修正",
"コードエディタの空行が一部デバイスで四角形として表示される問題を修正 (暫定対応)",
"テーマカラー設定のカラーピッカーダイアログが無限に重なる問題を修正",
"アクセシビリティサービスが無効のときに音量アップキーが全スクリプトを停止しない問題を修正",
"スケジュールタスクでユーザー定義ブロードキャストメッセージを編集する際に IME が入力エリアを覆う問題を修正",

View File

@@ -25,7 +25,7 @@
"v6.6.3": {
"released_date": "2025/05/27",
"feature": [
"버전 히스토리 기능: 다국어 변경 로그 및 통계 보기",
"릴리스 히스토리 기능: 다국어 릴리스 체인지로그 및 통계 보기",
"timers.keepAlive 메서드 (전역으로 승격) 로 스크립트 활성 유지",
"engines.on('start/stop/error', callback) 등의 이벤트 리스너로 엔진 전역 이벤트 수신",
"images.detectMultiColors 메서드로 여러 지점 색상 검사 _[`issue #374`](http://issues.autojs6.com/374)_",
@@ -40,7 +40,7 @@
"fix": [
"Docs 탭 및 Docs 액티비티의 온라인 문서가 시스템 네비게이션 바에 가려질 수 있는 문제 수정",
"일부 페이지에서 Toolbar 버튼을 누르면 제목 클릭 이벤트가 잘못 발생하는 문제 수정",
"코드 편집기 빈 줄이 일부 기기에서 사각형으로 보이는 문제 수정",
"일부 기기에서 코드 편집기 빈 줄이 사각형 문자로 표시되는 문제 (수정 시도)",
"테마 색상 설정의 컬러 피커 대화상자가 무한 중첩되는 문제 수정",
"접근성 서비스가 꺼진 상태에서 볼륨 업 키가 모든 스크립트를 중지하지 않는 문제 수정",
"예약 작업에서 사용자 정의 브로드캐스트 메시지를 편집할 때 IME가 입력 영역을 덮는 문제 수정",

View File

@@ -25,7 +25,7 @@
"v6.6.3": {
"released_date": "2025/05/27",
"feature": [
"Функция истории версий: просмотр многоязычных журналов изменений и статистики",
"Функция истории релизов: просмотр многоязычных журналов изменений релизов и статистики",
"Метод timers.keepAlive (теперь глобальный) для поддержания активности скрипта",
"Слушатели событий, такие как engines.on('start/stop/error', callback), для глобальных событий движка",
"Метод images.detectMultiColors для проверки нескольких точек цвета _[`issue #374`](http://issues.autojs6.com/374)_",
@@ -40,7 +40,7 @@
"fix": [
"Онлайн-документация на вкладке Docs и в активности Docs могла перекрываться системной навигационной панелью",
"На некоторых страницах нажатие кнопок Toolbar могло ошибочно вызывать событие клика по заголовку",
"Пустые строки в редакторе кода отображались квадратиками на ряде устройств",
"Пустые строки в редакторе кода отображались квадратиками на ряде устройств (предварительное решение)",
"Диалог выбора цвета в настройках темы мог бесконечно накладываться друг на друга",
"Кнопка увеличения громкости не останавливала все скрипты при выключенной службе доступности",
"IME перекрывал ввод при редактировании пользовательского широковещательного сообщения в расписании задач",

View File

@@ -93,7 +93,7 @@
"输入事件观察器 InputEventObserver 可能导致应用启动时明显卡顿的问题",
"Shizuku 用户服务进程未能正常结束导致进程堆积的问题 _[`issue #474`](http://issues.autojs6.com/474)_",
"打包应用无法正常使用 Paddle OCR 与 Rapid OCR 功能的问题",
"版本历史页面部分系统因字体差别导致统计数据显示不完整的问题",
"发行历史页面部分系统因字体差别导致统计数据显示不完整的问题",
"部分设备无法正常初始化 MLKit Google OCR 的问题 (试修) _[`issue #8`](http://issues.autojs6.com/8#issuecomment-3117061768)_",
"部分设备无法正常触发文件管理器功能按钮点击事件的问题 (试修) _[`issue #465`](http://issues.autojs6.com/465)_",
"ErrorDialogActivity 可能无法正常启动或短时间自动消失的问题 _[`issue #479`](http://issues.autojs6.com/479)_ _[`issue #471`](http://issues.autojs6.com/471)_ _[`issue #414`](http://issues.autojs6.com/414)_ _[`issue #340`](http://issues.autojs6.com/340#issuecomment-2973485826)_",
@@ -169,6 +169,7 @@
"附加 AndroidX Activity (KTX) 版本 1.12.2",
"附加 AndroidX Core (KTX) 版本 1.15.0",
"附加 AndroidX Work Runtime 版本 2.11.0",
"附加 Room RxJava2 版本 2.8.1",
"升级 Android SDK 版本 35 -> 36",
"升级 Gradle 版本 8.14 -> 8.14.3",
"升级 Rhino 版本 1.8.1-SNAPSHOT -> 2.0.0-SNAPSHOT",
@@ -215,7 +216,7 @@
"v6.6.3": {
"released_date": "2025/05/27",
"feature": [
"版本历史功能, 可查看发行版本历史更新记录 (多语言) 与统计数据",
"发行历史功能, 可查看发行版本的发行历史记录 (多语言) 与统计数据",
"timers.keepAlive 方法 (已全局化), 用于保持脚本活跃状态",
"engines.on('start/stop/error', callback) 等事件监听方法, 用于监听脚本引擎全局事件",
"images.detectMultiColors 方法, 用于多点颜色校验 _[`issue #374`](http://issues.autojs6.com/374)_",
@@ -230,7 +231,7 @@
"fix": [
"主页文档标签及文档活动页面显示在线文档时部分内容被系统导航栏遮挡的问题",
"部分页面 Toolbar 点击按钮时可能导致标题点击事件误触发的问题",
"部分设备代码编辑器空行显示方框字符的问题",
"部分设备代码编辑器空行显示方框字符的问题 (试修)",
"主题色设置页面调色盘对话框可能无限叠加的问题",
"无障碍服务关闭时音量加键停止所有脚本功能失效的问题",
"定时任务页面编辑自定义广播内容时出现的输入法遮挡问题",

View File

@@ -25,7 +25,7 @@
"v6.6.3": {
"released_date": "2025/05/27",
"feature": [
"版本歷史功能, 可查看發行版本歷史更新記錄 (多語言) 與統計數據",
"發行歷史功能, 可查看發行版本的發行歷史記錄 (多語言) 與統計數據",
"timers.keepAlive 方法 (已全局化), 用於保持腳本活躍狀態",
"engines.on('start/stop/error', callback) 等事件監聽方法, 用於監聽腳本引擎全局事件",
"images.detectMultiColors 方法, 用於多點顏色校驗 _[`issue #374`](http://issues.autojs6.com/374)_",
@@ -40,7 +40,7 @@
"fix": [
"主頁文檔標籤及文檔活動頁面顯示在線文檔時部分內容被系統導航欄遮擋的問題",
"部分頁面 Toolbar 點擊按鈕時可能導致標題點擊事件誤觸發的問題",
"部分設備代碼編輯器空行顯示方框字符的問題",
"部分設備代碼編輯器空行顯示方框字符的問題 (試修)",
"主題色設置頁面調色盤對話框可能無限疊加的問題",
"無障礙服務關閉時音量加鍵停止所有腳本功能失效的問題",
"定時任務頁面編輯自定義廣播內容時出現的輸入法遮擋問題",

View File

@@ -25,7 +25,7 @@
"v6.6.3": {
"released_date": "2025/05/27",
"feature": [
"版本歷史功能, 可檢視發行版本歷史更新記錄 (多語言) 與統計資料",
"發行歷史功能, 可檢視發行版本的發行歷史記錄 (多語言) 與統計資料",
"timers.keepAlive 方法 (已全域性化), 用於保持指令碼活躍狀態",
"engines.on('start/stop/error', callback) 等事件監聽方法, 用於監聽指令碼引擎全域性事件",
"images.detectMultiColors 方法, 用於多點顏色校驗 _[`issue #374`](http://issues.autojs6.com/374)_",
@@ -40,7 +40,7 @@
"fix": [
"主頁文件標籤及文件活動頁面顯示線上文件時部分內容被系統導航欄遮擋的問題",
"部分頁面 Toolbar 點選按鈕時可能導致標題點選事件誤觸發的問題",
"部分裝置程式碼編輯器空行顯示方框字元的問題",
"部分裝置程式碼編輯器空行顯示方框字元的問題 (試修)",
"主題色設定頁面調色盤對話方塊可能無限疊加的問題",
"無障礙服務關閉時音量加鍵停止所有指令碼功能失效的問題",
"定時任務頁面編輯自定義廣播內容時出現的輸入法遮擋問題",

View File

@@ -229,6 +229,7 @@ dependencies /* Unclassified */ {
// Room
implementation(libs.room.runtime)
implementation(libs.room.ktx)
implementation(libs.room.rxjava2)
ksp(libs.room.compiler)
// ApkSig

View File

@@ -278,7 +278,7 @@
android:theme="@style/AppTheme.Settings" />
<activity
android:name="org.autojs.autojs.ui.settings.DisplayVersionHistoriesActivity"
android:name="org.autojs.autojs.ui.settings.DisplayReleaseHistoryActivity"
android:theme="@style/AppTheme.Settings" />
<activity

View File

@@ -41,6 +41,7 @@ import java.lang.reflect.Method
/**
* Created by Stardust on Jan 27, 2017.
* Modified by SuperMonster003 as of Feb 3, 2026.
* Modified by JetBrains AI Assistant (GPT-5.2) as of Feb 7, 2026.
*/
class App : MultiDexApplication() {
@@ -73,7 +74,8 @@ class App : MultiDexApplication() {
ThemeColorManager.init()
setUpDefaultNightMode()
HistoryCleanupScheduler.schedule(this)
HistoryCleanupScheduler.scheduleStartupCleanup(this)
HistoryCleanupScheduler.schedulePeriodicCleanup(this)
}
}
}

View File

@@ -13,13 +13,13 @@ import io.reactivex.Observable
import io.reactivex.android.schedulers.AndroidSchedulers
import io.reactivex.disposables.Disposable
import io.reactivex.schedulers.Schedulers
import org.autojs.autojs.app.DialogUtils
import org.autojs.autojs.util.DialogUtils
import org.autojs.autojs.core.pref.Pref
import org.autojs.autojs.util.MaterialDialogUtils.widgetThemeColor
import org.autojs.autojs.util.DialogUtils.widgetThemeColor
import org.autojs.autojs.pluginclient.DevPluginService
import org.autojs.autojs.pluginclient.JsonSocketClient
import org.autojs.autojs.ui.common.NotAskAgainDialog
import org.autojs.autojs.util.MaterialDialogUtils.choiceWidgetThemeColor
import org.autojs.autojs.util.DialogUtils.choiceWidgetThemeColor
import org.autojs.autojs.util.Observers
import org.autojs.autojs.util.ThreadUtils.runOnMain
import org.autojs.autojs.util.ViewUtils
@@ -222,16 +222,16 @@ class JsonSocketClientTool(context: Context) : AbstractJsonSocketTool(context) {
.neutralColorRes(R.color.dialog_button_hint)
.onNeutral { dialog, _ ->
MaterialDialog.Builder(context)
.title(R.string.text_histories)
.content(R.string.text_no_histories)
.items(JsonSocketClient.serverAddressHistories)
.itemsCallback { dHistories, _, _, text ->
dHistories.dismiss()
.title(R.string.text_history)
.content(R.string.text_no_history)
.items(JsonSocketClient.serverAddressHistory)
.itemsCallback { dHistory, _, _, text ->
dHistory.dismiss()
dialog.inputEditText?.setText(text)
validateAndConnectToRemoteServer(dialog)
}
.choiceWidgetThemeColor()
.itemsLongCallback { dHistories, _, _, text ->
.itemsLongCallback { dHistory, _, _, text ->
MaterialDialog.Builder(context)
.title(R.string.text_prompt)
.content(R.string.text_confirm_to_delete)
@@ -241,12 +241,12 @@ class JsonSocketClientTool(context: Context) : AbstractJsonSocketTool(context) {
.positiveColorRes(R.color.dialog_button_caution)
.onPositive { ds, _ ->
ds.dismiss()
JsonSocketClient.removeFromHistories(text.toString())
dHistories.items?.let {
JsonSocketClient.removeFromHistory(text.toString())
dHistory.items?.let {
it.remove(text)
dHistories.notifyItemsChanged()
DialogUtils.toggleContentViewByItems(dHistories)
DialogUtils.toggleActionButtonAbilityByItems(dHistories, DialogAction.NEUTRAL)
dHistory.notifyItemsChanged()
DialogUtils.toggleContentViewByItems(dHistory)
DialogUtils.toggleActionButtonAbilityByItems(dHistory, DialogAction.NEUTRAL)
}
}
.show()
@@ -254,29 +254,29 @@ class JsonSocketClientTool(context: Context) : AbstractJsonSocketTool(context) {
}
.neutralText(R.string.dialog_button_clear_items)
.neutralColorRes(R.color.dialog_button_warn)
.onNeutral { dHistories, _ ->
.onNeutral { dHistory, _ ->
MaterialDialog.Builder(context)
.title(R.string.text_prompt)
.content(R.string.text_confirm_to_clear_all_histories)
.content(R.string.text_confirm_to_clear_the_history)
.negativeText(R.string.dialog_button_cancel)
.negativeColorRes(R.color.dialog_button_default)
.positiveText(R.string.dialog_button_confirm)
.positiveColorRes(R.color.dialog_button_caution)
.onPositive { _, _ ->
JsonSocketClient.clearAllHistories()
dHistories.items?.let {
JsonSocketClient.clearHistory()
dHistory.items?.let {
it.clear()
dHistories.notifyItemsChanged()
DialogUtils.toggleContentViewByItems(dHistories)
DialogUtils.toggleActionButtonAbilityByItems(dHistories, DialogAction.NEUTRAL)
dHistory.notifyItemsChanged()
DialogUtils.toggleContentViewByItems(dHistory)
DialogUtils.toggleActionButtonAbilityByItems(dHistory, DialogAction.NEUTRAL)
}
ViewUtils.showSnack(dHistories.view, R.string.text_all_histories_cleared)
ViewUtils.showSnack(dHistory.view, R.string.text_history_has_been_cleared)
}
.show()
}
.positiveText(R.string.dialog_button_back)
.positiveColorRes(R.color.dialog_button_default)
.onPositive { dHistories, _ -> dHistories.dismiss() }
.onPositive { dHistory, _ -> dHistory.dismiss() }
.autoDismiss(false)
.show()
.also {
@@ -552,7 +552,7 @@ class JsonSocketClientTool(context: Context) : AbstractJsonSocketTool(context) {
return
}
val isInHistory = { JsonSocketClient.serverAddressHistories.contains(input) }
val isInHistory = { JsonSocketClient.serverAddressHistory.contains(input) }
val isPotentiallyInvalid = { POTENTIALLY_INVALID_IP_ADRESS_LIST_FOR_REMOTE_SERVER.any { input.matches(it) } }
when {

View File

@@ -26,7 +26,7 @@ import org.autojs.autojs.annotation.OmniColor
import org.autojs.autojs.annotation.ScriptInterface
import org.autojs.autojs.core.pref.Language
import org.autojs.autojs.core.ui.inflater.util.Gravities
import org.autojs.autojs.util.MaterialDialogUtils.widgetThemeColor
import org.autojs.autojs.util.DialogUtils.widgetThemeColor
import org.autojs.autojs.permission.DisplayOverOtherAppsPermission
import org.autojs.autojs.runtime.ScriptRuntime
import org.autojs.autojs.runtime.api.AbstractConsole
@@ -191,7 +191,7 @@ open class ConsoleImpl(val uiHandler: UiHandler) : AbstractConsole() {
} catch (e: Exception) {
e.printStackTrace()
if (text.length < mSafeSizeToCopy || cutOutEntriesSize >= 0) {
ViewUtils.showToast(context, R.string.text_failed_to_copy)
ViewUtils.showToast(context, R.string.error_failed_to_copy)
return
}
try {
@@ -199,7 +199,7 @@ open class ConsoleImpl(val uiHandler: UiHandler) : AbstractConsole() {
copyAll(cutOutEntries.joinToString("\n") { it.content }, cutOutEntries.size)
} catch (e: Exception) {
e.printStackTrace()
ViewUtils.showToast(context, R.string.text_failed_to_copy)
ViewUtils.showToast(context, R.string.error_failed_to_copy)
}
}
}

View File

@@ -14,7 +14,7 @@ import kotlinx.coroutines.launch
import org.autojs.autojs.ui.BaseActivity
import org.autojs.autojs.ui.widget.SearchViewItem
import org.autojs.autojs.util.IntentUtils.startSafely
import org.autojs.autojs.util.MaterialDialogUtils.choiceWidgetThemeColor
import org.autojs.autojs.util.DialogUtils.choiceWidgetThemeColor
import org.autojs.autojs.util.ViewUtils
import org.autojs.autojs.util.ViewUtils.onceGlobalLayout
import org.autojs.autojs.util.ViewUtils.setMenuIconsColorByThemeColorLuminance

View File

@@ -17,10 +17,10 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.autojs.autojs.app.DialogUtils.showAdaptive
import org.autojs.autojs.util.MaterialDialogUtils.makeSettingsLaunchable
import org.autojs.autojs.util.MaterialDialogUtils.makeTextCopyable
import org.autojs.autojs.util.MaterialDialogUtils.setCopyableTextIfAbsent
import org.autojs.autojs.util.DialogUtils.showAdaptive
import org.autojs.autojs.util.DialogUtils.makeSettingsLaunchable
import org.autojs.autojs.util.DialogUtils.makeTextCopyable
import org.autojs.autojs.util.DialogUtils.setCopyableTextIfAbsent
import org.autojs.autojs.runtime.api.augment.converter.core.Bytes
import org.autojs.autojs.theme.ThemeColorManager
import org.autojs.autojs.util.ColorUtils
@@ -223,10 +223,10 @@ object PluginInfoDialogManager {
.title(info.version ?: info.title)
.options(listOf(ignoreUpdateOption))
.content(R.string.text_retrieving_release_notes)
.neutralText(R.string.dialog_button_version_histories)
.neutralText(R.string.dialog_button_release_history)
.neutralColor(context.getColor(R.color.dialog_button_hint))
.onNeutral { _, _ ->
// DisplayVersionHistoriesActivity.launch(context)
// DisplayReleaseHistoryActivity.launch(context)
}
.negativeText(R.string.dialog_button_cancel)
.negativeColor(context.getColor(R.color.dialog_button_default))

View File

@@ -5,7 +5,7 @@ import androidx.lifecycle.LifecycleCoroutineScope
import com.afollestad.materialdialogs.DialogAction
import com.afollestad.materialdialogs.MaterialDialog
import kotlinx.coroutines.launch
import org.autojs.autojs.util.MaterialDialogUtils.widgetThemeColor
import org.autojs.autojs.util.DialogUtils.widgetThemeColor
import org.autojs.autojs6.R
object PluginInstallActions {

View File

@@ -10,8 +10,8 @@ import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withContext
import org.autojs.autojs.app.DialogUtils.applyProgressThemeColorTintLists
import org.autojs.autojs.app.DialogUtils.setProgressNumberFormatByBytes
import org.autojs.autojs.util.DialogUtils.applyProgressThemeColorTintLists
import org.autojs.autojs.util.DialogUtils.setProgressNumberFormatByBytes
import org.autojs.autojs.runtime.api.Mime
import org.autojs.autojs.ui.main.scripts.ApkInfoDialogManager
import org.autojs.autojs.util.ClipboardUtils

View File

@@ -0,0 +1,44 @@
package org.autojs.autojs.core.pref
import android.content.SharedPreferences
import io.reactivex.BackpressureStrategy
import io.reactivex.Flowable
import org.autojs.autojs.util.StringUtils.key
/**
* Rx wrappers for Pref.
* zh-CN: Pref 的 Rx 包装.
*
* Created by JetBrains AI Assistant (GPT-5.2) on Feb 5, 2026.
*/
object PrefRx {
/**
* Observe a Long preference value.
* zh-CN: 监听一个 Long 类型的偏好值.
*/
fun observeLong(keyRes: Int, defaultValue: Long): Flowable<Long> {
val sp = Pref.get()
val k = key(keyRes)
return Flowable.create({ emitter ->
val listener = SharedPreferences.OnSharedPreferenceChangeListener { _, changedKey ->
if (changedKey == k && !emitter.isCancelled) {
emitter.onNext(sp.getLong(k, defaultValue))
}
}
// Emit current value immediately.
// zh-CN: 立即发送当前值.
emitter.onNext(sp.getLong(k, defaultValue))
sp.registerOnSharedPreferenceChangeListener(listener)
// Unregister on dispose.
// zh-CN: 取消订阅时反注册监听器.
emitter.setCancellable {
sp.unregisterOnSharedPreferenceChangeListener(listener)
}
}, BackpressureStrategy.LATEST).distinctUntilChanged()
}
}

View File

@@ -3,14 +3,13 @@ package org.autojs.autojs.core.record;
import android.content.Context;
import android.view.ContextThemeWrapper;
import com.afollestad.materialdialogs.MaterialDialog;
import org.autojs.autojs.app.DialogUtils;
import org.autojs.autojs.util.DialogUtils;
import org.autojs.autojs.app.GlobalAppContext;
import org.autojs.autojs.core.pref.Pref;
import org.autojs.autojs.core.record.inputevent.InputEventRecorder;
import org.autojs.autojs.core.record.inputevent.InputEventToAutoFileRecorder;
import org.autojs.autojs.core.record.inputevent.InputEventToRootAutomatorRecorder;
import org.autojs.autojs.core.record.inputevent.TouchRecorder;
import org.autojs.autojs.util.MaterialDialogUtils;
import org.autojs.autojs.ui.common.ScriptOperations;
import org.autojs.autojs.util.ClipboardUtils;
import org.autojs.autojs.util.ViewUtils;
@@ -183,8 +182,8 @@ public class GlobalActionRecorder implements Recorder.OnStateChangedListener {
.positiveText(R.string.dialog_button_confirm)
.positiveColorRes(R.color.dialog_button_attraction)
.canceledOnTouchOutside(false);
MaterialDialogUtils.choiceWidgetThemeColor(builder);
DialogUtils.showAdaptive(builder.build());
DialogUtils.choiceWidgetThemeColor(builder);
DialogUtils.buildAndShowAdaptive(builder::build);
}
private String getString(int res) {

View File

@@ -3,6 +3,7 @@ package org.autojs.autojs.core.ui.dialog;
import android.app.ActionBar;
import android.app.Activity;
import android.content.Context;
import android.content.ContextWrapper;
import android.content.DialogInterface;
import android.graphics.Typeface;
import android.graphics.drawable.Drawable;
@@ -36,7 +37,6 @@ import com.afollestad.materialdialogs.DialogAction;
import com.afollestad.materialdialogs.MaterialDialog;
import com.afollestad.materialdialogs.internal.MDButton;
import com.google.android.material.snackbar.Snackbar;
import org.autojs.autojs.app.DialogUtils;
import org.autojs.autojs.core.eventloop.EventEmitter;
import org.autojs.autojs.tool.UiHandler;
import org.autojs.autojs6.R;
@@ -81,7 +81,7 @@ public class JsDialog {
@SuppressWarnings("deprecation")
private void checkWindowType() {
Context context = mDialog.getContext();
if (!DialogUtils.isActivityContext(context)) {
if (!isActivityContext(context)) {
Window window = mDialog.getWindow();
if (window != null) {
int type;
@@ -95,6 +95,16 @@ public class JsDialog {
}
}
private boolean isActivityContext(Context context) {
if (context instanceof Activity) {
return true;
} else if (context instanceof ContextWrapper) {
return isActivityContext(((ContextWrapper) context).getBaseContext());
} else {
return false;
}
}
private DialogAction getDialogAction(String action) {
return switch (action) {
case "positive" -> DialogAction.POSITIVE;

View File

@@ -53,7 +53,7 @@ public class TaskerScriptEditActivity extends BaseActivity {
ViewUtils.showToast(TaskerScriptEditActivity.this, ex.getMessage(), true);
finish();
});
setToolbarAsBack(mEditorView.name);
setToolbarAsBack(mEditorView.getName());
}
public static void edit(Activity activity, String title, String summary, String content) {

View File

@@ -29,7 +29,7 @@ import org.autojs.autojs.network.download.DownloadManager;
import org.autojs.autojs.network.entity.ExtendedVersionInfo;
import org.autojs.autojs.network.entity.VersionInfo;
import org.autojs.autojs.tool.SimpleObserver;
import org.autojs.autojs.ui.settings.DisplayVersionHistoriesActivity;
import org.autojs.autojs.ui.settings.DisplayReleaseHistoryActivity;
import org.autojs.autojs.util.AndroidUtils;
import org.autojs.autojs.util.IntentUtils;
import org.autojs.autojs.util.IntentUtils.SnackExceptionHolder;
@@ -857,8 +857,8 @@ public class UpdateChecker {
})
.show();
}),
new MaterialDialog.OptionMenuItemSpec(context.getString(R.string.dialog_button_version_histories), parentDialog -> {
DisplayVersionHistoriesActivity.launch(context);
new MaterialDialog.OptionMenuItemSpec(context.getString(R.string.dialog_button_release_history), parentDialog -> {
DisplayReleaseHistoryActivity.launch(context);
})
))
.content(R.string.text_retrieving_changelog)

View File

@@ -22,7 +22,7 @@ import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.Response;
import okhttp3.ResponseBody;
import org.autojs.autojs.app.DialogUtils;
import org.autojs.autojs.util.DialogUtils;
import org.autojs.autojs.network.UpdateChecker;
import org.autojs.autojs.network.api.DownloadApi;
import org.autojs.autojs.network.entity.VersionInfo;

View File

@@ -10,7 +10,7 @@ import android.os.Environment
import android.provider.Settings
import androidx.annotation.RequiresApi
import androidx.core.app.ActivityCompat
import org.autojs.autojs.util.MaterialDialogUtils.widgetThemeColor
import org.autojs.autojs.util.DialogUtils.widgetThemeColor
import org.autojs.autojs.permission.Base.getPermissionsToRequest
import org.autojs.autojs.ui.common.NotAskAgainDialog
import org.autojs.autojs.ui.main.MainActivity

View File

@@ -4,7 +4,7 @@ import android.content.Context
import android.os.Handler
import android.os.Looper
import ezy.assist.compat.SettingsCompat
import org.autojs.autojs.util.MaterialDialogUtils.widgetThemeColor
import org.autojs.autojs.util.DialogUtils.widgetThemeColor
import org.autojs.autojs.ui.common.NotAskAgainDialog
import org.autojs.autojs.ui.main.drawer.PermissionItemHelper
import org.autojs.autojs.util.IntentUtils

View File

@@ -11,7 +11,7 @@ import org.autojs.autojs.core.pref.Pref
import org.autojs.autojs.runtime.api.Permissions
import org.autojs.autojs.ui.main.drawer.PermissionItemHelper
import org.autojs.autojs.util.ContextUtils.findActivity
import org.autojs.autojs.util.MaterialDialogUtils.widgetThemeColor
import org.autojs.autojs.util.DialogUtils.widgetThemeColor
import org.autojs.autojs.util.NotificationUtils
import org.autojs.autojs6.R
import java.util.WeakHashMap

View File

@@ -162,7 +162,7 @@ class DevPluginService(val context: Context) {
.sayHello()
} else {
if (client.isExtensionVersionCheckFailed || client.hasErrorMessageOnHello) {
JsonSocketClient.addIntoHistories(ip)
JsonSocketClient.addIntoHistory(ip)
client.switchOff()
return@doOnNext
}

View File

@@ -321,20 +321,20 @@ class JsonSocketClient(service: DevPluginService?, private val ctx: Context, pri
// zh-CN: 连接超时 = 握手超时 + 5 秒 (默认).
private const val CONNECT_TIMEOUT = HANDSHAKE_TIMEOUT + 5_000
var serverAddressHistories: LinkedHashSet<String>
get() = Pref.getLinkedHashSet(R.string.key_pc_server_address_histories)
private set(value) = Pref.putLinkedHashSet(R.string.key_pc_server_address_histories, value)
var serverAddressHistory: LinkedHashSet<String>
get() = Pref.getLinkedHashSet(R.string.key_pc_server_address_history)
private set(value) = Pref.putLinkedHashSet(R.string.key_pc_server_address_history, value)
fun addIntoHistories(ip: String) {
serverAddressHistories = linkedSetOf(ip).apply { addAll(serverAddressHistories) }
fun addIntoHistory(ip: String) {
serverAddressHistory = linkedSetOf(ip).apply { addAll(serverAddressHistory) }
}
fun removeFromHistories(ip: String) {
serverAddressHistories = serverAddressHistories.apply { remove(ip) }
fun removeFromHistory(ip: String) {
serverAddressHistory = serverAddressHistory.apply { remove(ip) }
}
fun clearAllHistories() {
serverAddressHistories = linkedSetOf()
fun clearHistory() {
serverAddressHistory = linkedSetOf()
}
// Track current active instance for guarding state emission.

View File

@@ -65,7 +65,7 @@ class AndroidClassLoader(private val parent: ClassLoader, private val cacheDir:
Log.d(TAG, "loadDex: file = $originalPath")
if (!file.exists() || !file.canRead()) {
throw FileNotFoundException(str(R.string.file_not_exist_or_readable, originalPath))
throw FileNotFoundException(str(R.string.file_not_exist_or_readable_with_path, originalPath))
}
// @Hint by SuperMonster003 on Nov 30, 2023.
@@ -99,7 +99,7 @@ class AndroidClassLoader(private val parent: ClassLoader, private val cacheDir:
fun loadJar(jar: File): DexClassLoader {
Log.d(TAG, "loadJar: jar = ${jar.path}")
if (!jar.exists() || !jar.canRead()) {
throw FileNotFoundException(str(R.string.file_not_exist_or_readable, jar.path))
throw FileNotFoundException(str(R.string.file_not_exist_or_readable_with_path, jar.path))
}
val key = generateArtifactsCacheName(jar)

View File

@@ -3,7 +3,9 @@ package org.autojs.autojs.storage.history
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
@@ -13,16 +15,38 @@ import java.util.concurrent.TimeUnit
* zh-CN: HistoryCleanupWorker 的调度器.
*
* Created by JetBrains AI Assistant (GPT-5.2) on Feb 3, 2026.
* Modified by SuperMonster003 as of Feb 7, 2026.
*/
object HistoryCleanupScheduler {
private const val UNIQUE_WORK_NAME = "history_cleanup_periodic"
fun scheduleStartupCleanup(context: Context) {
val appContext = context.applicationContext
// Enqueue a one-time startup cleanup for stale partial trash blobs.
// zh-CN: 排队一个启动时的一次性清理任务, 用于清理过期的 partial 回收站 blob.
runCatching {
val req = OneTimeWorkRequestBuilder<TrashPartialCleanupWorker>()
// Delay a bit to reduce cold-start I/O pressure.
// zh-CN: 适当延迟以降低冷启动 I/O 压力.
.setInitialDelay(10L, TimeUnit.SECONDS)
.build()
WorkManager.getInstance(appContext)
.enqueueUniqueWork(
"startup-trash-partial-cleanup",
ExistingWorkPolicy.KEEP,
req,
)
}
}
/**
* Schedule periodic cleanup (once per day).
* zh-CN: 调度周期性清理 (每天一次).
*/
fun schedule(context: Context) {
fun schedulePeriodicCleanup(context: Context) {
val appContext = context.applicationContext
// No network is required; keep it light and battery-friendly.

View File

@@ -3,6 +3,7 @@ package org.autojs.autojs.storage.history
import android.content.Context
import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters
import org.autojs.autojs.storage.history.TrashBlobStore.Companion.TRASH_BLOB_DIR_REL
import java.io.File
/**
@@ -10,6 +11,7 @@ import java.io.File
* zh-CN: 用于 history/trash/drafts 的周期性清理 Worker.
*
* Created by JetBrains AI Assistant (GPT-5.2) on Feb 3, 2026.
* Modified by SuperMonster003 as of Feb 8, 2026.
*/
class HistoryCleanupWorker(
appContext: Context,
@@ -20,6 +22,7 @@ class HistoryCleanupWorker(
return runCatching {
cleanupExpiredRevisions()
cleanupExpiredTrashItems()
cleanupTrashTotalBytesLimit()
cleanupOrphanHistoryBlobs()
cleanupOrphanTrashBlobs()
cleanupEmergencyDrafts()
@@ -34,10 +37,12 @@ class HistoryCleanupWorker(
val dao = db.historyDao()
val blobs = HistoryBlobStore(applicationContext)
// Remove expired revisions by days (30 days).
// zh-CN: 按天数删除过期 revision (30 天).
// Remove expired revisions by days.
// zh-CN: 按天数删除过期 revision.
val now = System.currentTimeMillis()
val expiredBefore = now - MAX_DAYS_MS
val maxDaysMs = HistoryPrefs.historyMaxDays().coerceAtLeast(0).toLong() * 24L * 60L * 60L * 1000L
val expiredBefore = now - maxDaysMs
val expired = dao.listExpiredRevisions(expiredBefore)
if (expired.isEmpty()) return
@@ -45,6 +50,52 @@ class HistoryCleanupWorker(
expired.forEach { blobs.deleteBlobByRelPath(it.blobRelPath) }
}
private fun cleanupExpiredTrashItems() {
val db = HistoryDatabase.getInstance(applicationContext)
val dao = db.historyDao()
val trashBlobs = TrashBlobStore(applicationContext)
// Remove expired trash items by days.
// zh-CN: 按天数删除过期回收站条目.
val now = System.currentTimeMillis()
val maxDaysMs = HistoryPrefs.trashMaxDays().coerceAtLeast(0).toLong() * 24L * 60L * 60L * 1000L
val expiredBefore = now - maxDaysMs
val expired = dao.listExpiredTrashItems(expiredBefore)
if (expired.isEmpty()) return
dao.deleteTrashItemsByIds(expired.map { it.trashId })
expired.forEach { trashBlobs.deleteBlobByRelPath(it.blobRelPath) }
}
/**
* Enforce trash total bytes limit by deleting oldest items first.
* zh-CN: 通过优先删除最旧条目来约束回收站总容量上限.
*/
private fun cleanupTrashTotalBytesLimit() {
val limit = HistoryPrefs.trashMaxTotalBytes().coerceAtLeast(0L)
if (limit <= 0L) return
val db = HistoryDatabase.getInstance(applicationContext)
val dao = db.historyDao()
val trashBlobs = TrashBlobStore(applicationContext)
val items = dao.listTrashItemsDesc()
if (items.isEmpty()) return
var total = items.sumOf { it.sizeBytes.coerceAtLeast(0L) }
if (total <= limit) return
// Delete from oldest to newest until within limit.
// zh-CN: 从最旧到最新删除, 直到满足上限.
for (item in items.asReversed()) {
if (total <= limit) break
dao.deleteTrashItemsByIds(listOf(item.trashId))
trashBlobs.deleteBlobByRelPath(item.blobRelPath)
total -= item.sizeBytes.coerceAtLeast(0L)
}
}
private fun cleanupOrphanHistoryBlobs() {
val db = HistoryDatabase.getInstance(applicationContext)
val dao = db.historyDao()
@@ -81,22 +132,6 @@ class HistoryCleanupWorker(
}
}
private fun cleanupExpiredTrashItems() {
val db = HistoryDatabase.getInstance(applicationContext)
val dao = db.historyDao()
val trashBlobs = TrashBlobStore(applicationContext)
// Remove expired trash items by days (default 30 days).
// zh-CN: 按天数删除过期回收站条目 (默认 30 天).
val now = System.currentTimeMillis()
val expiredBefore = now - TRASH_MAX_DAYS_MS
val expired = dao.listExpiredTrashItems(expiredBefore)
if (expired.isEmpty()) return
dao.deleteTrashItemsByIds(expired.map { it.trashId })
expired.forEach { trashBlobs.deleteBlobByRelPath(it.blobRelPath) }
}
private fun cleanupOrphanTrashBlobs() {
val db = HistoryDatabase.getInstance(applicationContext)
val dao = db.historyDao()
@@ -105,14 +140,23 @@ class HistoryCleanupWorker(
// zh-CN: 从 DB 构建被引用的回收站 blob 集合.
val referenced = dao.listAllTrashBlobRelPaths().toHashSet()
val root = File(applicationContext.filesDir, "trash/blob")
val root = File(applicationContext.filesDir, TRASH_BLOB_DIR_REL)
if (!root.exists() || !root.isDirectory) return
val now = System.currentTimeMillis()
// Delete trash blobs that are not referenced by DB.
// zh-CN: 删除 DB 未引用的回收站 blob.
root.walkTopDown()
.filter { it.isFile }
.forEach { f ->
// Protect very new files to avoid deleting blobs created while DB write is pending.
// zh-CN: 保护非常新的文件, 避免在 DB 写入尚未完成时误删刚创建的 blob.
val ageMs = now - f.lastModified()
if (ageMs >= 0L && ageMs < ORPHAN_TRASH_BLOB_MIN_AGE_MS) {
return@forEach
}
val rel = f.relativeTo(applicationContext.filesDir).invariantSeparatorsPath
if (!referenced.contains(rel)) {
// noinspection ResultOfMethodCallIgnored
@@ -134,16 +178,15 @@ class HistoryCleanupWorker(
}
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
// 1) Remove expired by days.
// zh-CN: 1) 按天数删除过期草稿.
val maxDaysMs = HistoryPrefs.draftsMaxDays().coerceAtLeast(0).toLong() * 24L * 60L * 60L * 1000L
val expiredBefore = now - maxDaysMs
draftsDir.listFiles()?.forEach { f ->
if (f.isFile && f.lastModified() < expiredBefore) {
// noinspection ResultOfMethodCallIgnored
@@ -158,11 +201,12 @@ class HistoryCleanupWorker(
?.sortedByDescending { it.lastModified() }
?: return
val limit = HistoryPrefs.draftsMaxTotalBytes().coerceAtLeast(0L)
var total = remained.sumOf { it.length().coerceAtLeast(0L) }
if (total <= DRAFT_MAX_TOTAL_BYTES) return
if (total <= limit) return
for (f in remained.asReversed()) {
if (total <= DRAFT_MAX_TOTAL_BYTES) break
if (total <= limit) break
val len = f.length().coerceAtLeast(0L)
if (f.delete()) {
total -= len
@@ -171,9 +215,8 @@ class HistoryCleanupWorker(
}
companion object {
private const val MAX_DAYS_MS: Long = 30L * 24L * 60L * 60L * 1000L
private const val TRASH_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
// Minimum age for orphan final trash blobs before deletion to avoid races.
// zh-CN: orphan 正式回收站 blob 的最小删除年龄, 用于避免并发时序竞争.
private const val ORPHAN_TRASH_BLOB_MIN_AGE_MS: Long = 30L * 60L * 1000L
}
}
}

View File

@@ -4,9 +4,11 @@ import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import io.reactivex.Flowable
/**
* Created by JetBrains AI Assistant (GPT-5.2) on Feb 3, 2026.
* Modified by JetBrains AI Assistant (GPT-5.2) as of Feb 7, 2026.
*/
@Dao
interface HistoryDao {
@@ -32,6 +34,26 @@ interface HistoryDao {
@Query("SELECT SUM(sizeBytes) FROM revision WHERE fileId = :fileId")
fun sumBytesByFileId(fileId: String): Long?
/**
* Sum revision bytes for multiple fileIds in one query.
* zh-CN: 在一次查询中批量统计多个 fileId 的 revision 字节总和.
*/
@Query(
"SELECT fileId AS fileId, SUM(sizeBytes) AS totalBytes " +
"FROM revision WHERE fileId IN (:fileIds) GROUP BY fileId"
)
fun sumBytesByFileIds(fileIds: List<String>): List<FileBytesSumRow>
/**
* Aggregate revision stats for multiple fileIds in one query.
* zh-CN: 在一次查询中批量聚合多个 fileId 的 revision 统计信息.
*/
@Query(
"SELECT fileId AS fileId, COUNT(*) AS revisionCount, SUM(sizeBytes) AS totalBytes " +
"FROM revision WHERE fileId IN (:fileIds) GROUP BY fileId"
)
fun aggStatsByFileIds(fileIds: List<String>): List<FileAggStatsRow>
@Query("SELECT * FROM file_entry")
fun listAllFiles(): List<HistoryEntities.FileEntry>
@@ -55,4 +77,85 @@ interface HistoryDao {
@Query("SELECT blobRelPath FROM trash_item")
fun listAllTrashBlobRelPaths(): List<String>
}
@Query("DELETE FROM trash_item")
fun deleteAllTrashItems()
@Query("SELECT * FROM file_entry WHERE logicalPath LIKE :pathPrefixEscape || '%'")
fun listFilesByPathPrefix(pathPrefixEscape: String): List<HistoryEntities.FileEntry>
@Query("DELETE FROM file_entry WHERE fileId IN (:fileIds)")
fun deleteFileEntriesByIds(fileIds: List<String>)
@Query("SELECT * FROM revision WHERE fileId IN (:fileIds) ORDER BY createdAt ASC")
fun listRevisionsByFileIds(fileIds: List<String>): List<HistoryEntities.Revision>
@Query("DELETE FROM revision WHERE fileId IN (:fileIds)")
fun deleteRevisionsByFileIds(fileIds: List<String>)
@Query("SELECT SUM(sizeBytes) FROM revision")
fun sumAllRevisionBytes(): Long?
@Query("SELECT * FROM revision ORDER BY createdAt ASC LIMIT :limit")
fun listOldestRevisions(limit: Int): List<HistoryEntities.Revision>
/**
* Aggregated stats for trash.
* zh-CN: 回收站聚合统计.
*/
@Query("SELECT COUNT(*) AS count, SUM(sizeBytes) AS totalBytes FROM trash_item")
fun observeTrashStats(): Flowable<TrashStatsRow>
/**
* Aggregated stats for version history (all revisions).
* zh-CN: 版本历史聚合统计 (所有 revisions).
*/
@Query("SELECT COUNT(*) AS fileCount, (SELECT SUM(sizeBytes) FROM revision) AS totalBytes FROM file_entry")
fun observeVersionHistoryStats(): Flowable<VersionHistoryStatsRow>
@Query("SELECT * FROM revision ORDER BY createdAt ASC")
fun listAllRevisionsAsc(): List<HistoryEntities.Revision>
@Query("DELETE FROM revision")
fun deleteAllRevisions()
@Query("DELETE FROM file_entry")
fun deleteAllFileEntries()
}
/**
* Aggregated bytes row for revisions.
* zh-CN: revision 的聚合字节统计行.
*/
data class FileBytesSumRow(
val fileId: String,
val totalBytes: Long?,
)
/**
* Aggregated stats row for revisions by fileId.
* zh-CN: 按 fileId 聚合的 revision 统计行.
*/
data class FileAggStatsRow(
val fileId: String,
val revisionCount: Long,
val totalBytes: Long?,
)
/**
* Aggregated stats row for trash.
* zh-CN: 回收站统计行.
*/
data class TrashStatsRow(
val count: Long,
val totalBytes: Long?,
)
/**
* Aggregated stats row for version history.
* zh-CN: 版本历史统计行.
*/
data class VersionHistoryStatsRow(
val fileCount: Long,
val totalBytes: Long?,
)

View File

@@ -61,4 +61,4 @@ abstract class HistoryDatabase : RoomDatabase() {
}.build().also { instance = it }
}
}
}
}

View File

@@ -1,11 +1,13 @@
package org.autojs.autojs.storage.history
import android.net.Uri
import androidx.room.Entity
import androidx.room.Index
import androidx.room.PrimaryKey
/**
* Created by JetBrains AI Assistant (GPT-5.2) on Feb 3, 2026.
* Modified by SuperMonster003 as of Feb 6, 2026.
*/
object HistoryEntities {
@@ -29,7 +31,13 @@ object HistoryEntities {
// Latest content fingerprint for "path lost but content same" matching.
// zh-CN: 用于 "路径丢失但内容相同" 匹配的最新内容指纹.
val latestFingerprint: String,
)
) {
val displayPath: String
get() = runCatching { Uri.decode(logicalPath) }.getOrElse { logicalPath }
val canonicalPath: String
get() = logicalPath
}
@Entity(
tableName = "revision",

View File

@@ -0,0 +1,78 @@
package org.autojs.autojs.storage.history
import org.autojs.autojs.core.pref.Pref
import org.autojs.autojs6.R
/**
* Preferences for history/trash/drafts policies.
* zh-CN: history/trash/drafts 策略相关的偏好设置读取.
*
* Created by JetBrains AI Assistant (GPT-5.2) on Feb 4, 2026.
* Modified by JetBrains AI Assistant (GPT-5.2) as of Feb 5, 2026.
*/
object HistoryPrefs {
// Max file size to track history snapshots (default 20MB).
// zh-CN: 纳管历史快照的最大文件大小 (默认 20MB).
fun maxFileSizeToTrackBytes(): Long =
Pref.getLong(R.string.key_history_max_file_size_to_track_bytes, DEFAULT_MAX_FILE_SIZE_TO_TRACK_BYTES)
// History retention days (default 30).
// zh-CN: 历史保留天数 (默认 30).
fun historyMaxDays(): Int =
Pref.getInt(R.string.key_history_max_days, DEFAULT_HISTORY_MAX_DAYS)
// History max versions per file (default 50).
// zh-CN: 单文件历史最大版本数 (默认 50).
fun historyMaxVersions(): Int =
Pref.getInt(R.string.key_history_max_versions, DEFAULT_HISTORY_MAX_VERSIONS)
// History max total bytes per file (default 200MB).
// zh-CN: 单文件历史总容量上限 (默认 200MB).
fun historyMaxTotalBytesPerFile(): Long =
Pref.getLong(R.string.key_history_max_total_bytes_per_file, DEFAULT_HISTORY_MAX_TOTAL_BYTES_PER_FILE)
// History max total bytes (default 2GB).
// zh-CN: 历史总容量上限 (默认 2GB).
fun historyMaxTotalBytes(): Long =
Pref.getLong(R.string.key_history_max_total_bytes, DEFAULT_HISTORY_MAX_TOTAL_BYTES)
// Trash retention days (default 30).
// zh-CN: 回收站保留天数 (默认 30).
fun trashMaxDays(): Int =
Pref.getInt(R.string.key_trash_max_days, DEFAULT_TRASH_MAX_DAYS)
// Trash max total bytes (default 500MB).
// zh-CN: 回收站总容量上限 (默认 500MB).
fun trashMaxTotalBytes(): Long =
Pref.getLong(R.string.key_trash_max_total_bytes, DEFAULT_TRASH_MAX_TOTAL_BYTES)
// Draft retention days (default 7).
// zh-CN: 草稿保留天数 (默认 7).
fun draftsMaxDays(): Int =
Pref.getInt(R.string.key_drafts_max_days, DEFAULT_DRAFTS_MAX_DAYS)
// Draft max total bytes (default 200MB).
// zh-CN: 草稿总容量上限 (默认 200MB).
fun draftsMaxTotalBytes(): Long =
Pref.getLong(R.string.key_drafts_max_total_bytes, DEFAULT_DRAFTS_MAX_TOTAL_BYTES)
// Whether to clear all history when deleting permanently (default false).
// zh-CN: 是否在彻底删除时同时清除历史记录 (默认 false).
@JvmStatic
fun deletePermanentlyAlsoClearHistory(): Boolean =
Pref.getBoolean(R.string.key_delete_permanently_also_clear_history, false)
private const val DEFAULT_MAX_FILE_SIZE_TO_TRACK_BYTES: Long = 20L * 1024L * 1024L
private const val DEFAULT_HISTORY_MAX_DAYS: Int = 30
private const val DEFAULT_HISTORY_MAX_VERSIONS: Int = 50
internal const val DEFAULT_HISTORY_MAX_TOTAL_BYTES_PER_FILE: Long = 200L * 1024L * 1024L
internal const val DEFAULT_HISTORY_MAX_TOTAL_BYTES: Long = 2L * 1024L * 1024L * 1024L
private const val DEFAULT_TRASH_MAX_DAYS: Int = 30
internal const val DEFAULT_TRASH_MAX_TOTAL_BYTES: Long = 500L * 1024L * 1024L
private const val DEFAULT_DRAFTS_MAX_DAYS: Int = 7
private const val DEFAULT_DRAFTS_MAX_TOTAL_BYTES: Long = 200L * 1024L * 1024L
}

View File

@@ -1,13 +1,13 @@
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.
* Modified by JetBrains AI Assistant (GPT-5.2) as of Feb 6, 2026.
*/
class HistoryRepository(private val context: Context) {
@@ -42,7 +42,6 @@ class HistoryRepository(private val context: Context) {
* - 插入后执行单文件保留策略清理.
*/
fun recordSavePre(
uri: Uri,
logicalPath: String,
oldBytes: ByteArray,
encodingName: String,
@@ -76,6 +75,41 @@ class HistoryRepository(private val context: Context) {
)
cleanupPerFile(fileEntry.fileId, now)
cleanupGlobal()
}
/**
* Clear all history revisions/blobs for a given path.
*
* Rules:
* - If target is a file: clear exact match.
* - If target is a directory: clear all entries under this directory by path prefix.
*
* zh-CN:
*
* 清除指定路径对应的全部历史版本与 blob.
*
* 规则:
* - 若目标是文件: 精确匹配清理.
* - 若目标是目录: 按路径前缀清理该目录下所有已纳管条目.
*/
fun clearHistoryForPath(path: String) {
val normalized = path.trimEnd('/')
val target = File(normalized)
val fileIds = if (target.isDirectory) {
val prefix = "$normalized/"
dao.listFilesByPathPrefix(prefix).map { it.fileId }
} else {
dao.findFileByPath(normalized)?.let { listOf(it.fileId) } ?: emptyList()
}
if (fileIds.isEmpty()) return
val revisions = dao.listRevisionsByFileIds(fileIds)
dao.deleteRevisionsByFileIds(fileIds)
revisions.forEach { blobs.deleteBlobByRelPath(it.blobRelPath) }
dao.deleteFileEntriesByIds(fileIds)
}
private fun getOrCreateFileEntry(logicalPath: String, latestFingerprint: String, now: Long): HistoryEntities.FileEntry {
@@ -117,9 +151,15 @@ class HistoryRepository(private val context: Context) {
val revisions = dao.listRevisionsAsc(fileId).toMutableList()
if (revisions.isEmpty()) return
// Read policy from preferences.
// zh-CN: 从偏好设置读取策略参数.
val maxDaysMs = HistoryPrefs.historyMaxDays().coerceAtLeast(0).toLong() * 24L * 60L * 60L * 1000L
val maxVersions = HistoryPrefs.historyMaxVersions().coerceAtLeast(0)
val maxTotalBytesPerFile = HistoryPrefs.historyMaxTotalBytesPerFile().coerceAtLeast(0L)
// 1) Remove expired by days.
// zh-CN: 1) 按天数删除过期版本.
val expiredBefore = now - MAX_DAYS_MS
val expiredBefore = now - maxDaysMs
val expired = revisions.filter { it.createdAt < expiredBefore }
if (expired.isNotEmpty()) {
deleteRevisionsAndBlobs(expired)
@@ -128,8 +168,8 @@ class HistoryRepository(private val context: Context) {
// 2) Enforce max count (remove oldest).
// zh-CN: 2) 约束最大版本数 (删除最旧).
if (revisions.size > MAX_VERSIONS) {
val toRemove = revisions.take(revisions.size - MAX_VERSIONS)
if (revisions.size > maxVersions) {
val toRemove = revisions.take(revisions.size - maxVersions)
deleteRevisionsAndBlobs(toRemove)
revisions.removeAll(toRemove.toSet())
}
@@ -137,9 +177,9 @@ class HistoryRepository(private val context: Context) {
// 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) {
if (totalBytes > maxTotalBytesPerFile) {
for (rev in revisions.toList()) {
if (totalBytes <= MAX_TOTAL_BYTES_PER_FILE) break
if (totalBytes <= maxTotalBytesPerFile) break
deleteRevisionsAndBlobs(listOf(rev))
totalBytes -= rev.sizeBytes.coerceAtLeast(0L)
revisions.remove(rev)
@@ -147,6 +187,31 @@ class HistoryRepository(private val context: Context) {
}
}
/**
* Delete a single revision and its blob.
* zh-CN: 删除单个 revision 及其 blob.
*/
fun deleteRevision(rev: HistoryEntities.Revision) {
deleteRevisionsAndBlobs(listOf(rev))
}
/**
* Clear all version history (all file entries, all revisions, all blobs).
* zh-CN: 清空全部版本历史 (所有 file_entry, 所有 revision, 所有 blobs).
*/
fun clearAllHistory() {
val revs = dao.listAllRevisionsAsc()
if (revs.isNotEmpty()) {
revs.forEach { blobs.deleteBlobByRelPath(it.blobRelPath) }
}
dao.deleteAllRevisions()
dao.deleteAllFileEntries()
}
fun getRevisionsCount(fileId: String): Int {
return dao.listRevisionsAsc(fileId).size
}
private fun deleteRevisionsAndBlobs(revs: List<HistoryEntities.Revision>) {
if (revs.isEmpty()) return
dao.deleteRevisionsByIds(revs.map { it.revId })
@@ -162,9 +227,41 @@ class HistoryRepository(private val context: Context) {
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
/**
* Enforce global history total bytes limit (remove oldest revisions first).
* zh-CN: 约束全局历史总容量上限 (优先删除最旧 revision).
*/
private fun cleanupGlobal() {
val maxTotalBytes = HistoryPrefs.historyMaxTotalBytes().coerceAtLeast(0L)
if (maxTotalBytes <= 0L) return
var totalBytes = (dao.sumAllRevisionBytes() ?: 0L).coerceAtLeast(0L)
if (totalBytes <= maxTotalBytes) return
// Delete in batches to reduce DB roundtrips.
// zh-CN: 批量删除以减少 DB 往返次数.
val batchSize = 50
while (totalBytes > maxTotalBytes) {
val oldest = dao.listOldestRevisions(batchSize)
if (oldest.isEmpty()) break
// Remove until under limit or batch exhausted.
// zh-CN: 删除直到满足上限或本批用尽.
val toRemove = ArrayList<HistoryEntities.Revision>(oldest.size)
var bytesToFree = 0L
for (rev in oldest) {
if (totalBytes - bytesToFree <= maxTotalBytes) break
toRemove.add(rev)
bytesToFree += rev.sizeBytes.coerceAtLeast(0L)
}
if (toRemove.isEmpty()) break
deleteRevisionsAndBlobs(toRemove)
totalBytes = (totalBytes - bytesToFree).coerceAtLeast(0L)
}
}
}

View File

@@ -0,0 +1,136 @@
package org.autojs.autojs.storage.history
import android.content.Context
import java.io.ByteArrayOutputStream
import java.io.File
import java.io.FileInputStream
import java.io.OutputStream
import java.util.UUID
import java.util.zip.ZipEntry
import java.util.zip.ZipOutputStream
/**
* Created by JetBrains AI Assistant (GPT-5.2) on Feb 4, 2026.
* Modified by JetBrains AI Assistant (GPT-5.2) as of Feb 7, 2026.
*/
class TrashBlobStore(private val context: Context) {
fun newTrashId(): String = UUID.randomUUID().toString()
/**
* Open an OutputStream for writing trash blob into a temporary file, returns tempRelPath and stream.
* zh-CN: 打开用于写入回收站 blob 的临时文件输出流, 并返回 tempRelPath 与 stream.
*/
fun openTrashBlobTempOutputStream(trashId: String): Pair<String, OutputStream> {
val tempRel = tempTrashBlobRelPath(trashId)
val f = File(context.filesDir, tempRel)
f.parentFile?.mkdirs()
return tempRel to f.outputStream()
}
/**
* Commit a temporary trash blob into its final path by atomic rename, returns final relPath.
* zh-CN: 通过原子 rename 将临时回收站 blob 提交到最终路径, 并返回最终 relPath.
*/
fun commitTrashBlobTemp(tempRelPath: String, trashId: String): String {
val finalRel = finalTrashBlobRelPath(trashId)
val tempFile = File(context.filesDir, tempRelPath)
val finalFile = File(context.filesDir, finalRel)
require(tempFile.exists()) { "Temp trash blob not exists: $tempRelPath" }
finalFile.parentFile?.mkdirs()
if (finalFile.exists()) {
// Remove existing final blob first to ensure rename success.
// zh-CN: 先删除已存在的最终 blob, 以确保 rename 能成功.
// noinspection ResultOfMethodCallIgnored
finalFile.delete()
}
// Rename in the same directory is expected to be atomic on Android/Linux.
// zh-CN: 同目录 rename 在 Android/Linux 上通常具备原子性.
if (!tempFile.renameTo(finalFile)) {
throw IllegalStateException("Commit trash blob failed: $tempFile -> $finalFile")
}
return finalRel
}
fun writeTrashBlob(trashId: String, bytes: ByteArray): String {
val rel = finalTrashBlobRelPath(trashId)
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()
}
}
/**
* Cleanup stale temporary trash blobs (partial files).
* zh-CN: 清理过期的临时回收站 blob (partial 文件).
*/
fun cleanupStalePartialTrashBlobs(maxAgeMs: Long) {
val dir = File(context.filesDir, TRASH_BLOB_DIR_REL)
if (!dir.exists() || !dir.isDirectory) return
val now = System.currentTimeMillis()
val files = dir.listFiles() ?: return
for (f in files) {
if (!f.isFile) continue
if (!f.name.contains(PARTIAL_MARK)) continue
val age = now - f.lastModified()
if (age < maxAgeMs) continue
// Best-effort delete, ignore result.
// zh-CN: 尽力删除, 忽略结果.
runCatching {
// noinspection ResultOfMethodCallIgnored
f.delete()
}
}
}
private fun finalTrashBlobRelPath(trashId: String): String =
"$TRASH_BLOB_DIR_REL/$trashId.bin"
private fun tempTrashBlobRelPath(trashId: String): String =
"$TRASH_BLOB_DIR_REL/$trashId$PARTIAL_MARK.${UUID.randomUUID()}.bin"
/**
* Pack a directory into zip bytes.
* zh-CN: 将目录打包为 zip bytes.
*/
fun zipDirectoryToBytes(dir: File): ByteArray {
require(dir.isDirectory) { "Not a directory: $dir" }
val baos = ByteArrayOutputStream()
ZipOutputStream(baos).use { zos ->
val basePathLen = dir.absolutePath.trimEnd('/').length + 1
dir.walkTopDown().forEach { f ->
if (!f.isFile) return@forEach
val relPath = f.absolutePath.substring(basePathLen).replace(File.separatorChar, '/')
val entry = ZipEntry(relPath)
zos.putNextEntry(entry)
FileInputStream(f).use { it.copyTo(zos) }
zos.closeEntry()
}
}
return baos.toByteArray()
}
companion object {
const val TRASH_BLOB_DIR_REL: String = "trash/blob"
const val PARTIAL_MARK: String = ".partial"
}
}

View File

@@ -1,55 +0,0 @@
package org.autojs.autojs.storage.history
import android.content.Context
import java.io.ByteArrayOutputStream
import java.io.File
import java.io.FileInputStream
import java.util.UUID
import java.util.zip.ZipEntry
import java.util.zip.ZipOutputStream
/**
* Created by JetBrains AI Assistant (GPT-5.2) on Feb 4, 2026.
*/
class TrashBlobStore(private val context: Context) {
fun newTrashId(): String = UUID.randomUUID().toString()
fun writeTrashBlob(trashId: String, bytes: ByteArray): String {
val rel = "trash/blob/$trashId.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()
}
}
/**
* Pack a directory into zip bytes.
* zh-CN: 将目录打包为 zip bytes.
*/
fun zipDirectoryToBytes(dir: File): ByteArray {
require(dir.isDirectory) { "Not a directory: $dir" }
val baos = ByteArrayOutputStream()
ZipOutputStream(baos).use { zos ->
val basePathLen = dir.absolutePath.trimEnd('/').length + 1
dir.walkTopDown().forEach { f ->
if (!f.isFile) return@forEach
val relPath = f.absolutePath.substring(basePathLen).replace(File.separatorChar, '/')
val entry = ZipEntry(relPath)
zos.putNextEntry(entry)
FileInputStream(f).use { it.copyTo(zos) }
zos.closeEntry()
}
}
return baos.toByteArray()
}
}

View File

@@ -0,0 +1,33 @@
package org.autojs.autojs.storage.history
import android.content.Context
import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters
/**
* One-time startup cleanup worker for stale partial trash blobs.
* zh-CN: 用于启动时一次性清理过期 partial 回收站 blob 的 Worker.
*
* Created by JetBrains AI Assistant (GPT-5.2) on Feb 8, 2026.
*/
class TrashPartialCleanupWorker(
appContext: Context,
params: WorkerParameters,
) : CoroutineWorker(appContext, params) {
override suspend fun doWork(): Result {
return runCatching {
val maxAgeMs = PARTIAL_TRASH_BLOB_MAX_AGE_MS_STARTUP
TrashBlobStore(applicationContext).cleanupStalePartialTrashBlobs(maxAgeMs)
}.fold(
onSuccess = { Result.success() },
onFailure = { Result.retry() },
)
}
companion object {
// Maximum age for partial trash blob files before deletion on startup.
// zh-CN: 启动时用于删除 partial 回收站 blob 的最大保留时长.
private const val PARTIAL_TRASH_BLOB_MAX_AGE_MS_STARTUP: Long = 30L * 60L * 1000L
}
}

View File

@@ -1,14 +1,26 @@
package org.autojs.autojs.storage.history
import android.content.Context
import org.autojs.autojs.ui.common.ScriptOperations
import org.autojs.autojs.util.DialogUtils
import java.io.BufferedInputStream
import java.io.File
import java.io.FileInputStream
import java.io.InputStream
import java.io.OutputStream
import java.security.DigestOutputStream
import java.security.MessageDigest
import java.util.Locale
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicLong
import java.util.zip.ZipEntry
import java.util.zip.ZipInputStream
import java.util.zip.ZipOutputStream
/**
* Created by JetBrains AI Assistant (GPT-5.2) on Feb 4, 2026.
* Modified by SuperMonster003 as of Feb 7, 2026.
* Modified by JetBrains AI Assistant (GPT-5.2) as of Feb 7, 2026.
*/
class TrashRepository(private val context: Context) {
@@ -21,7 +33,7 @@ class TrashRepository(private val context: Context) {
*
* Notes:
* - Only internal storage paths are supported.
* - Directory is archived as zip bytes.
* - Directory is archived as zip stream (not bytes) to avoid OOM.
*
* zh-CN:
*
@@ -29,9 +41,31 @@ class TrashRepository(private val context: Context) {
*
* 注意:
* - 仅支持内部存储路径.
* - 目录会被打包为 zip bytes.
* - 目录会 zip 流形式归档 (不再生成 bytes), 以避免 OOM.
*/
fun moveToTrash(path: String): TrashEntities.TrashItem {
/**
* Move a file or directory into trash.
*
* Notes:
* - Only internal storage paths are supported.
* - Directory is archived as zip stream (not bytes) to avoid OOM.
*
* zh-CN:
*
* 将文件或目录移入回收站.
*
* 注意:
* - 仅支持内部存储路径.
* - 目录会以 zip 流形式归档 (不再生成 bytes), 以避免 OOM.
*/
fun moveToTrashWithProgress(
path: String,
progressCallback: ScriptOperations.ProgressCallback?,
fileCallback: ScriptOperations.FileProgressCallback?,
operationController: DialogUtils.OperationController,
): TrashEntities.TrashItem {
operationController.throwIfCancelled()
val src = File(path)
require(src.exists()) { "Source not exists: $path" }
@@ -40,38 +74,208 @@ class TrashRepository(private val context: Context) {
val trashId = blobs.newTrashId()
val now = System.currentTimeMillis()
val (bytes, isDir) = if (src.isDirectory) {
blobs.zipDirectoryToBytes(src) to true
val isDir = src.isDirectory
val digest = MessageDigest.getInstance("SHA-256")
var sizeBytes = 0L
// Compute total bytes asynchronously because walking a directory can be expensive.
// zh-CN: 异步计算 totalBytes, 因为目录 walk 可能很耗时.
val totalReady = AtomicBoolean(false)
val totalBytesRef = AtomicLong(0L)
if (src.isFile) {
totalBytesRef.set(src.length().coerceAtLeast(0L))
totalReady.set(true)
} else {
src.inputStream().use { it.readBytes() } to false
Thread {
runCatching {
fun computeTotalBytesCancellable(f: File): Long {
operationController.throwIfCancelled()
if (!f.exists()) return 0L
if (f.isFile) return f.length().coerceAtLeast(0L)
var sum = 0L
val children = f.listFiles() ?: return 0L
for (c in children) {
operationController.throwIfCancelled()
sum += computeTotalBytesCancellable(c)
}
return sum
}
val total = computeTotalBytesCancellable(src).coerceAtLeast(0L)
totalBytesRef.set(total)
totalReady.set(true)
}
}.apply {
// Best-effort background computation; should not block main operation.
// zh-CN: 尽力而为的后台计算, 不应阻塞主流程.
isDaemon = true
name = "TrashTotalBytes-$trashId"
start()
}
}
val sha = sha256Hex(bytes)
val rel = blobs.writeTrashBlob(trashId, bytes)
val processedBytes = AtomicLong(0L)
// Delete original after blob persisted.
// zh-CN: 在 blob 落盘后删除源文件/目录.
val deleted = if (src.isDirectory) {
src.deleteRecursively()
} else {
src.delete()
}
if (!deleted && src.exists()) {
throw IllegalStateException("Failed to delete source after trash: $src")
// Report progress; if total not ready, pass 0 as requested.
// zh-CN: 上报进度; 若 total 尚未计算完成, 按约定传入 0.
fun reportProcessed(delta: Int) {
if (delta <= 0) return
val p = processedBytes.addAndGet(delta.toLong())
val t = if (totalReady.get()) totalBytesRef.get().coerceAtLeast(0L) else 0L
progressCallback?.onProgress(p.coerceAtLeast(0L), t)
}
val item = TrashEntities.TrashItem(
trashId = trashId,
originalPath = logicalPath,
fileId = null,
trashedAt = now,
isDirectory = isDir,
sizeBytes = bytes.size.toLong(),
sha256 = sha,
blobRelPath = rel,
)
dao.upsertTrashItem(item)
return item
// Open temp blob stream and commit by rename after write completed.
// zh-CN: 打开临时 blob 流, 写入完成后通过 rename 提交.
val (tempRel, rawOut) = blobs.openTrashBlobTempOutputStream(trashId)
// Commit point marker for cross-resource operation (FS + DB).
// zh-CN: 跨资源操作 (文件系统 + 数据库) 的提交点标记, 用于避免在源文件已删除后错误回滚导致数据丢失.
var operationCommitted = false
// Track whether temp blob has been committed.
// zh-CN: 标记临时 blob 是否已提交.
var blobCommitted = false
// Final relPath after commit.
// zh-CN: 提交后的最终 relPath.
var finalRel: String? = null
try {
operationController.throwIfCancelled()
rawOut.use { out ->
val digestOut = DigestOutputStream(out, digest)
val countingOut = CountingOutputStream(digestOut)
if (isDir) {
// Zip directory to stream to avoid large memory usage.
// zh-CN: 将目录以 zip 流写入, 避免大内存占用.
zipDirectoryToStream(
dir = src,
out = countingOut,
operationController = operationController,
fileCallback = fileCallback,
onBytesProcessed = { n -> reportProcessed(n) },
)
} else {
// Copy file to stream to avoid large memory usage.
// zh-CN: 将文件以流复制写入, 避免大内存占用.
fileCallback?.onFile(src)
copyFileToStream(
src = src,
out = countingOut,
operationController = operationController,
onBytesProcessed = { n -> reportProcessed(n) },
)
}
countingOut.flush()
sizeBytes = countingOut.count
}
operationController.throwIfCancelled()
// Commit temp blob into final blob name.
// zh-CN: 将临时 blob 提交为最终 blob 文件名.
finalRel = blobs.commitTrashBlobTemp(tempRel, trashId)
blobCommitted = true
val sha = sha256HexFromDigest(digest)
val item = TrashEntities.TrashItem(
trashId = trashId,
originalPath = logicalPath,
fileId = null,
trashedAt = now,
isDirectory = isDir,
sizeBytes = sizeBytes.coerceAtLeast(0L),
sha256 = sha,
blobRelPath = finalRel,
)
// Persist DB record before deleting source to reduce risk of data loss.
// zh-CN: 先写入数据库记录再删除源文件, 降低 "源已删但记录未写入" 的数据丢失风险.
dao.upsertTrashItem(item)
// Delete original after blob persisted and DB record persisted.
// zh-CN: 在 blob 落盘且数据库记录写入后删除源文件/目录.
val deleted = if (isDir) src.deleteRecursively() else src.delete()
if (!deleted && src.exists()) {
// Roll back if source deletion failed.
// zh-CN: 若源文件删除失败则回滚, 避免出现 "回收站有记录但源文件仍存在" 的不一致状态.
throw IllegalStateException("Failed to delete source after trash: $src")
}
// Mark committed after DB persisted and source deleted successfully.
// zh-CN: 在数据库写入成功且源文件删除成功后标记为已提交, 避免后续异常触发破坏性回滚.
operationCommitted = true
/* Some code which may cause any exception in the future. */
return item
} catch (t: Throwable) {
// Clean up partial data on failure or abort, but avoid rollback after commit.
// zh-CN: 失败或中止时清理残留数据, 但在已提交后避免回滚以防数据丢失.
@Suppress("KotlinConstantConditions")
if (!operationCommitted) {
runCatching { dao.deleteTrashItemsByIds(listOf(trashId)) }
// Delete final blob if committed, otherwise delete temp blob.
// zh-CN: 若已提交则删除最终 blob, 否则删除临时 blob.
if (blobCommitted) {
finalRel?.let { runCatching { blobs.deleteBlobByRelPath(it) } }
} else {
runCatching { blobs.deleteBlobByRelPath(tempRel) }
}
}
throw t
}
}
private fun copyFileToStream(
src: File,
out: OutputStream,
operationController: DialogUtils.OperationController,
onBytesProcessed: ((Int) -> Unit)?,
) {
operationController.throwIfCancelled()
BufferedInputStream(FileInputStream(src)).use { input ->
val buffer = ByteArray(256 * 1024)
while (true) {
operationController.throwIfCancelled()
val n = input.read(buffer)
if (n <= 0) break
out.write(buffer, 0, n)
onBytesProcessed?.invoke(n)
}
}
}
/**
* Permanently delete a trash item (remove from trash only).
* zh-CN: 彻底删除一个回收站条目 (仅从回收站移除).
*/
fun deleteTrashItem(item: TrashEntities.TrashItem) {
dao.deleteTrashItemsByIds(listOf(item.trashId))
blobs.deleteBlobByRelPath(item.blobRelPath)
}
/**
* Clear all trash items.
* zh-CN: 清空回收站.
*/
fun clearTrash() {
val items = dao.listTrashItemsDesc()
if (items.isEmpty()) {
return
}
dao.deleteAllTrashItems()
items.forEach { blobs.deleteBlobByRelPath(it.blobRelPath) }
}
/**
@@ -89,14 +293,31 @@ class TrashRepository(private val context: Context) {
* - 若为目录, dest 必须是目录路径.
* - 恢复成功后会删除回收站条目.
*/
fun restoreTrashItemToPath(item: TrashEntities.TrashItem, dest: File) {
fun restoreTrashItemToPath(item: TrashEntities.TrashItem, dest: File, operationController: DialogUtils.OperationController) {
operationController.throwIfCancelled()
val blobFile = File(context.filesDir, item.blobRelPath)
require(blobFile.exists()) { "Trash blob not exists: ${item.blobRelPath}" }
// Copy stream with cancellation checkpoints.
// zh-CN: 带取消检查点的流拷贝, 以便在大文件恢复过程中及时响应终止操作.
fun copyToCancellable(input: InputStream, output: OutputStream) {
val buffer = ByteArray(256 * 1024)
while (true) {
operationController.throwIfCancelled()
val read = input.read(buffer)
if (read < 0) break
output.write(buffer, 0, read)
}
output.flush()
}
if (item.isDirectory) {
dest.mkdirs()
ZipInputStream(FileInputStream(blobFile)).use { zis ->
while (true) {
operationController.throwIfCancelled()
val entry = zis.nextEntry ?: break
val outFile = File(dest, entry.name)
@@ -107,14 +328,18 @@ class TrashRepository(private val context: Context) {
}
outFile.parentFile?.mkdirs()
outFile.outputStream().use { os -> zis.copyTo(os) }
outFile.outputStream().use { os ->
copyToCancellable(zis, os)
}
zis.closeEntry()
}
}
} else {
dest.parentFile?.mkdirs()
blobFile.inputStream().use { input ->
dest.outputStream().use { output -> input.copyTo(output) }
dest.outputStream().use { output ->
copyToCancellable(input, output)
}
}
}
@@ -124,12 +349,83 @@ class TrashRepository(private val context: Context) {
blobs.deleteBlobByRelPath(item.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))
private fun zipDirectoryToStream(
dir: File,
out: OutputStream,
operationController: DialogUtils.OperationController,
fileCallback: ScriptOperations.FileProgressCallback?,
onBytesProcessed: ((Int) -> Unit)?,
) {
ZipOutputStream(out).use { zos ->
val base = dir.absolutePath.trimEnd(File.separatorChar) + File.separator
fun addFile(f: File) {
operationController.throwIfCancelled()
val abs = f.absolutePath
val relPath = abs.removePrefix(base).replace(File.separatorChar, '/')
if (f.isDirectory) {
val children = f.listFiles()
if (children.isNullOrEmpty()) {
// Explicit directory entry for empty folder.
// zh-CN: 对空目录写入显式条目.
val entryName = if (relPath.endsWith("/")) relPath else "$relPath/"
zos.putNextEntry(ZipEntry(entryName))
zos.closeEntry()
} else {
for (c in children) addFile(c)
}
return
}
fileCallback?.onFile(f)
zos.putNextEntry(ZipEntry(relPath))
BufferedInputStream(FileInputStream(f)).use { input ->
val buffer = ByteArray(256 * 1024)
while (true) {
operationController.throwIfCancelled()
val n = input.read(buffer)
if (n <= 0) break
zos.write(buffer, 0, n)
onBytesProcessed?.invoke(n)
}
}
operationController.throwIfCancelled()
zos.closeEntry()
}
addFile(dir)
zos.finish()
}
}
private fun sha256HexFromDigest(digest: MessageDigest): String {
val bytes = digest.digest()
val sb = StringBuilder(bytes.size * 2)
for (b in bytes) sb.append(String.format(Locale.US, "%02x", b))
return sb.toString()
}
/**
* Counting output stream wrapper.
* zh-CN: 计数输出流包装器.
*/
private class CountingOutputStream(private val delegate: OutputStream) : OutputStream() {
var count: Long = 0L
private set
override fun write(b: Int) {
delegate.write(b)
count += 1L
}
override fun write(b: ByteArray, off: Int, len: Int) {
delegate.write(b, off, len)
count += len.toLong()
}
override fun flush() = delegate.flush()
override fun close() = delegate.close()
}
}

View File

@@ -2,14 +2,22 @@ package org.autojs.autojs.storage.history
import android.annotation.SuppressLint
import android.content.Context
import android.text.TextUtils
import android.view.Gravity
import com.afollestad.materialdialogs.DialogAction
import com.afollestad.materialdialogs.MaterialDialog
import io.reactivex.android.schedulers.AndroidSchedulers
import io.reactivex.schedulers.Schedulers
import org.autojs.autojs.app.DialogUtils
import org.autojs.autojs.app.DialogUtils.setActionButtonText
import org.autojs.autojs.model.explorer.ExplorerDirPage
import org.autojs.autojs.model.explorer.Explorers
import org.autojs.autojs.model.script.ScriptFile
import org.autojs.autojs.ui.filechooser.FileChooserDialogBuilder
import org.autojs.autojs.util.MaterialDialogUtils.choiceWidgetThemeColor
import org.autojs.autojs.util.DialogUtils
import org.autojs.autojs.util.DialogUtils.OperationAbortedException
import org.autojs.autojs.util.DialogUtils.OperationController
import org.autojs.autojs.util.DialogUtils.ProgressDialogSession
import org.autojs.autojs.util.DialogUtils.choiceWidgetThemeColor
import org.autojs.autojs.util.DialogUtils.setActionButtonText
import org.autojs.autojs.util.ViewUtils
import org.autojs.autojs6.R
import java.io.File
@@ -20,67 +28,108 @@ import java.util.regex.Pattern
* zh-CN: 回收站条目的恢复流程控制器.
*
* Created by JetBrains AI Assistant (GPT-5.2) on Feb 4, 2026.
* Modified by SuperMonster003 as of Feb 7, 2026.
*/
@SuppressLint("CheckResult")
class TrashRestoreController(private val context: Context) {
fun startRestoreFlow(item: TrashEntities.TrashItem) {
fun startRestoreFlow(
item: TrashEntities.TrashItem,
optionMenuItems: List<MaterialDialog.OptionMenuItemSpec> = emptyList(),
onFinished: (() -> Unit)? = null,
) {
val originalPath = item.originalPath
val originalName = File(originalPath).name
val selected = intArrayOf(0)
fun generateContent(context: Context, isUserChoice: Boolean): String {
val pathContent = if (isUserChoice) {
"[ ${context.getString(R.string.text_to_be_chosen)} ]"
} else originalPath.removeSuffix(originalName)
return listOf(
if (item.isDirectory) {
context.getString(R.string.text_folder_colon_value, originalName)
} else {
context.getString(R.string.text_file_colon_value, originalName)
},
context.getString(R.string.text_path_colon_value, pathContent),
).joinToString("\n")
}
DialogUtils.buildAndShowAdaptive {
MaterialDialog.Builder(context)
.title(R.string.text_choose_restore_path)
.content(context.getString(R.string.text_path_colon_value, originalPath))
.options(optionMenuItems)
.content(generateContent(context, false))
.items(
context.getString(R.string.text_restore_to_original_path),
context.getString(R.string.text_restore_to_specified_path),
)
.itemsCallbackSingleChoice(0) { dialog, _, which, _ ->
selected[0] = which
// Update content and positive button text dynamically.
// zh-CN: 动态更新内容与确认按钮文本.
when (which) {
0 -> {
dialog.setContent(context.getString(R.string.text_path_colon_value, originalPath))
dialog.setContent(generateContent(context, isUserChoice = false))
dialog.setActionButtonText(DialogAction.POSITIVE, context.getString(R.string.dialog_button_confirm))
}
else -> {
dialog.setContent(context.getString(R.string.text_file_name_colon_value, originalName))
dialog.setContent(generateContent(context, isUserChoice = true))
dialog.setActionButtonText(DialogAction.POSITIVE, context.getString(R.string.dialog_button_choose_path))
}
}
true
}
.alwaysCallSingleChoiceCallback()
.choiceWidgetThemeColor()
.negativeText(R.string.dialog_button_cancel)
.negativeColorRes(R.color.dialog_button_default)
.onNegative { d, _ -> d.dismiss() }
.positiveText(R.string.dialog_button_confirm)
.positiveColorRes(R.color.dialog_button_attraction)
.onPositive { _, _ ->
.onPositive { d, _ ->
when (selected[0]) {
0 -> restoreToOriginalPathWithConflictHandling(item)
else -> chooseDirectoryThenRestore(item, originalName)
0 -> restoreToOriginalPathWithConflictHandling(
item = item,
onRestore = { d.dismiss() },
onFinished = onFinished,
)
else -> chooseDirectoryThenRestore(
item = item,
fileName = originalName,
onRestore = { d.dismiss() },
onFinished = onFinished,
)
}
}
.cancelable(true)
.autoDismiss(false)
.build()
.apply {
contentView?.apply {
setLineSpacing(0f, 1.2f)
setLines(3)
minLines = 3
maxLines = 9
ellipsize = TextUtils.TruncateAt.MIDDLE
gravity = Gravity.CENTER_VERTICAL
}
}
}
}
private fun restoreToOriginalPathWithConflictHandling(item: TrashEntities.TrashItem) {
private fun restoreToOriginalPathWithConflictHandling(item: TrashEntities.TrashItem, onRestore: (() -> Unit)? = null, onFinished: (() -> Unit)? = null) {
val dest = File(item.originalPath)
val parent = dest.parentFile ?: run {
ViewUtils.showToast(context, context.getString(R.string.error_invalid_path, item.originalPath), true)
return
}
restoreWithConflictHandling(item, parent, dest.name)
restoreWithConflictHandling(item, parent, dest.name, onRestore, onFinished)
}
@SuppressLint("CheckResult")
private fun chooseDirectoryThenRestore(item: TrashEntities.TrashItem, fileName: String) {
private fun chooseDirectoryThenRestore(item: TrashEntities.TrashItem, fileName: String, onRestore: (() -> Unit)? = null, onFinished: (() -> Unit)? = null) {
FileChooserDialogBuilder(context)
.title(R.string.dialog_button_choose_path)
.dir(INTERNAL_STORAGE_ROOT)
@@ -89,18 +138,18 @@ class TrashRestoreController(private val context: Context) {
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe({ dir ->
restoreWithConflictHandling(item, File(dir.path), fileName)
restoreWithConflictHandling(item, File(dir.path), fileName, onRestore, onFinished)
}, { e ->
e.printStackTrace()
ViewUtils.showToast(context, e.message, true)
})
}
private fun restoreWithConflictHandling(item: TrashEntities.TrashItem, destDir: File, fileName: String) {
private fun restoreWithConflictHandling(item: TrashEntities.TrashItem, destDir: File, fileName: String, onRestore: (() -> Unit)? = null, onFinished: (() -> Unit)? = null) {
val dest = File(destDir, fileName)
if (!dest.exists()) {
restoreNow(item, dest)
restoreNow(item, dest, onRestore, onFinished)
return
}
@@ -116,34 +165,99 @@ class TrashRestoreController(private val context: Context) {
.neutralColorRes(R.color.dialog_button_hint)
.onNeutral { _, _ ->
val newName = generateNextIndexedName(destDir, fileName)
restoreNow(item, File(destDir, newName))
restoreNow(item, File(destDir, newName), onRestore, onFinished)
}
.positiveText(R.string.text_overwrite)
.positiveColorRes(R.color.dialog_button_caution)
.onPositive { _, _ ->
restoreNow(item, dest)
restoreNow(item, dest, onRestore, onFinished)
}
.cancelable(true)
.build()
}
}
private fun restoreNow(item: TrashEntities.TrashItem, dest: File) {
private fun restoreNow(item: TrashEntities.TrashItem, dest: File, onRestore: (() -> Unit)? = null, onFinished: (() -> Unit)? = null) {
val destExistsBeforeRestore = dest.exists()
Schedulers.io().scheduleDirect {
runCatching {
TrashRepository(context.applicationContext).restoreTrashItemToPath(item, dest)
AndroidSchedulers.mainThread().scheduleDirect {
onRestore?.invoke()
}
withControllableProgressDialog { operationController ->
TrashRepository(context.applicationContext).restoreTrashItemToPath(item, dest, operationController)
// Notify Explorer to refresh the parent directory after restore.
// zh-CN: 恢复成功后通知 Explorer 刷新目标父目录.
notifyExplorerParentDirChanged(dest)
}
AndroidSchedulers.mainThread().scheduleDirect {
ViewUtils.showToast(context, context.getString(R.string.text_done), true)
onFinished?.invoke()
}
}.onFailure {
it.printStackTrace()
AndroidSchedulers.mainThread().scheduleDirect {
ViewUtils.showToast(context, it.message, true)
}.onFailure { e ->
when (e) {
is OperationAbortedException -> {
AndroidSchedulers.mainThread().scheduleDirect {
ViewUtils.showToast(context, context.getString(R.string.text_operation_aborted), false)
}
if (!destExistsBeforeRestore && dest.exists()) {
dest.deleteRecursively()
}
}
else -> {
e.printStackTrace()
AndroidSchedulers.mainThread().scheduleDirect {
ViewUtils.showToast(context, e.message, true)
}
}
}
}
}
}
@Throws(OperationAbortedException::class)
private fun withControllableProgressDialog(operation: (OperationController) -> Unit) {
val controller = OperationController()
val session = ProgressDialogSession(controller)
try {
val builder = MaterialDialog.Builder(context)
.title(R.string.text_restore)
.content(R.string.text_restoring)
.negativeText(R.string.dialog_button_abort)
.negativeColorRes(R.color.dialog_button_caution)
.onNegative { d, _ ->
controller.cancel()
}
.progress(true, 0)
.progressIndeterminateStyle(true)
.cancelable(false)
.canceledOnTouchOutside(false)
session.scheduleShow(builder)
controller.throwIfCancelled()
operation(controller)
controller.throwIfCancelled()
} catch (e: OperationAbortedException) {
throw e
} catch (e: Throwable) {
e.printStackTrace()
} finally {
session.dismissSafely()
}
}
private fun notifyExplorerParentDirChanged(dest: File) {
runCatching {
val parent = dest.parentFile ?: return
val page = ExplorerDirPage(ScriptFile(parent.path), null)
Explorers.workspace().notifyChildrenChanged(page)
}
}
/**
* Generate next indexed name by scanning existing "-n" siblings and taking the first missing n from 1.
* zh-CN: 通过扫描同级目录中已有的 "-n" 名称, 从 1 开始取第一个缺失的 n 作为新后缀.

View File

@@ -1,15 +1,21 @@
package org.autojs.autojs.storage.history
import android.app.Activity
import android.content.Context
import android.net.Uri
import com.afollestad.materialdialogs.MaterialDialog
import io.reactivex.android.schedulers.AndroidSchedulers
import io.reactivex.schedulers.Schedulers
import org.autojs.autojs.app.DialogUtils
import org.autojs.autojs.util.DialogUtils
import org.autojs.autojs.model.explorer.ExplorerDirPage
import org.autojs.autojs.model.explorer.Explorers
import org.autojs.autojs.model.script.ScriptFile
import org.autojs.autojs.model.script.Scripts
import org.autojs.autojs.pio.PFiles
import org.autojs.autojs.ui.common.NotAskAgainDialog
import org.autojs.autojs.util.MaterialDialogUtils.choiceWidgetThemeColor
import org.autojs.autojs.util.MaterialDialogUtils.widgetThemeColor
import org.autojs.autojs.ui.edit.EditActivity
import org.autojs.autojs.util.DialogUtils.choiceWidgetThemeColor
import org.autojs.autojs.util.DialogUtils.widgetThemeColor
import org.autojs.autojs.util.StringUtils
import org.autojs.autojs.util.StringUtils.key
import org.autojs.autojs.util.ViewUtils
@@ -28,6 +34,8 @@ import java.util.Locale
* zh-CN: 版本历史对话框控制器.
*
* Created by JetBrains AI Assistant (GPT-5.2) on Feb 4, 2026.
* Modified by SuperMonster003 as of Feb 6, 2026.
* Modified by JetBrains AI Assistant (GPT-5.2) as of Feb 7, 2026.
*/
class VersionHistoryController(private val context: Context) {
@@ -46,8 +54,10 @@ class VersionHistoryController(private val context: Context) {
return
}
loadAndShow(
logicalPath = logicalPath,
canonicalPath = logicalPath,
displayPath = runCatching { Uri.decode(logicalPath) }.getOrElse { logicalPath },
mode = Mode.RESTORE_TO_EDITOR,
showPreview = false,
onRestoreToEditor = onRestoreToEditor,
onRestoredUi = onRestoredUi,
)
@@ -57,15 +67,17 @@ class VersionHistoryController(private val context: Context) {
* Show version history for explorer (restore to disk, overwriting the file content).
* zh-CN: 为资源管理器显示版本历史 (恢复并写回磁盘, 覆盖文件内容).
*/
fun showForFilePath(path: String) {
fun showForFilePath(path: String, displayPath: String) {
val normalized = path.trimEnd('/')
if (!normalized.startsWith(INTERNAL_STORAGE_ROOT)) {
showNoHistoryDialog()
return
}
loadAndShow(
logicalPath = normalized,
canonicalPath = normalized,
displayPath = displayPath,
mode = Mode.RESTORE_TO_DISK,
showPreview = true,
onRestoreToEditor = null,
onRestoredUi = null,
)
@@ -84,8 +96,10 @@ class VersionHistoryController(private val context: Context) {
}
private fun loadAndShow(
logicalPath: String,
canonicalPath: String,
displayPath: String,
mode: Mode,
showPreview: Boolean = false,
onRestoreToEditor: ((String) -> Unit)?,
onRestoredUi: (() -> Unit)?,
) {
@@ -94,7 +108,7 @@ class VersionHistoryController(private val context: Context) {
val appCtx = context.applicationContext
val dao = HistoryDatabase.getInstance(appCtx).historyDao()
val fileEntry = dao.findFileByPath(logicalPath)
val fileEntry = dao.findFileByPath(canonicalPath)
val fileId = fileEntry?.fileId
val revs = if (fileId != null) {
@@ -120,36 +134,77 @@ class VersionHistoryController(private val context: Context) {
val selectedIndex = intArrayOf(0)
val optionDeleteSelected = MaterialDialog.OptionMenuItemSpec(
context.getString(R.string.text_delete)
) { dialog ->
val chosen = revs.getOrNull(selectedIndex[0]) ?: return@OptionMenuItemSpec
confirmAndDeleteSelectedRevision(
parentDialog = dialog,
rev = chosen,
canonicalPath = canonicalPath,
displayPath = displayPath,
mode = mode,
onRestoreToEditor = onRestoreToEditor,
onRestoredUi = onRestoredUi,
)
}
DialogUtils.buildAndShowAdaptive {
MaterialDialog.Builder(context)
.title(R.string.text_version_history)
.title(File(displayPath).name)
.options(listOf(optionDeleteSelected))
.items(items)
.itemsCallbackSingleChoice(0) { _, _, which, _ ->
selectedIndex[0] = which
true
}
.alwaysCallSingleChoiceCallback()
.choiceWidgetThemeColor()
.apply {
if (!showPreview) return@apply
neutralText(R.string.dialog_button_preview)
neutralColorRes(R.color.dialog_button_hint)
onNeutral { _, _ ->
val chosen = revs.getOrNull(selectedIndex[0]) ?: return@onNeutral
previewInReadOnlyEditor(chosen, displayPath)
}
}
.negativeText(R.string.dialog_button_cancel)
.negativeColorRes(R.color.dialog_button_default)
.positiveText(R.string.dialog_button_retrieve)
.onNegative { d, _ -> d.dismiss() }
.positiveText(R.string.dialog_button_restore)
.positiveColorRes(R.color.dialog_button_attraction)
.onPositive { _, _ ->
.onPositive { d, _ ->
val chosen = revs.getOrNull(selectedIndex[0]) ?: return@onPositive
when (mode) {
Mode.RESTORE_TO_EDITOR -> {
showRestoreConfirmDialogForEditor {
d.dismiss()
restoreToEditor(chosen, onRestoreToEditor, onRestoredUi)
}
}
Mode.RESTORE_TO_DISK -> {
showRestoreConfirmDialogForDisk(logicalPath) {
restoreToDisk(chosen, logicalPath)
showRestoreConfirmDialogForDisk(canonicalPath) {
d.dismiss()
restoreToDisk(chosen, canonicalPath)
}
}
}
}
.cancelable(true)
.autoDismiss(false)
.build()
.apply {
titleView.setOnLongClickListener {
when {
File(displayPath).exists() -> {
Scripts.edit(this@VersionHistoryController.context, displayPath)
true
}
else -> false
}
}
}
}
}
}.onFailure {
@@ -161,6 +216,36 @@ class VersionHistoryController(private val context: Context) {
}
}
/**
* Preview selected revision in a read-only editor.
* zh-CN: 在只读编辑器中预览所选 revision 内容.
*/
private fun previewInReadOnlyEditor(rev: HistoryEntities.Revision, displayPath: String) {
val appCtx = context.applicationContext
Schedulers.io().scheduleDirect {
runCatching {
val bytes = HistoryRepository(appCtx).readRevisionBytes(rev)
val restored = decodeRevisionBytes(bytes, rev.encoding, rev.hadBom)
val fmt = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault())
val timeText = fmt.format(Date(rev.createdAt))
val name = "[ ${context.getString(R.string.text_preview)} ] ${File(displayPath).name} @ $timeText"
AndroidSchedulers.mainThread().scheduleDirect {
val newTask = context !is Activity
EditActivity.viewContent(context, name, restored, newTask)
}
}.onFailure {
it.printStackTrace()
AndroidSchedulers.mainThread().scheduleDirect {
ViewUtils.showToast(context, it.message, true)
}
}
}
}
private fun restoreToEditor(
rev: HistoryEntities.Revision,
onRestoreToEditor: ((String) -> Unit)?,
@@ -190,6 +275,11 @@ class VersionHistoryController(private val context: Context) {
runCatching {
val bytes = HistoryRepository(appCtx).readRevisionBytes(rev)
writeBytesTransactional(File(targetPath), bytes)
// Notify Explorer to refresh the parent directory after restoring to disk.
// zh-CN: 写回磁盘恢复成功后通知 Explorer 刷新目标父目录.
notifyExplorerParentDirChanged(targetPath)
AndroidSchedulers.mainThread().scheduleDirect {
ViewUtils.showToast(context, context.getString(R.string.text_done), true)
}
@@ -202,6 +292,14 @@ class VersionHistoryController(private val context: Context) {
}
}
private fun notifyExplorerParentDirChanged(targetPath: String) {
runCatching {
val parent = File(targetPath).parentFile ?: return
val page = ExplorerDirPage(ScriptFile(parent.path), null)
Explorers.workspace().notifyChildrenChanged(page)
}
}
private fun writeBytesTransactional(dest: File, bytes: ByteArray) {
dest.parentFile?.mkdirs()
@@ -282,6 +380,72 @@ class VersionHistoryController(private val context: Context) {
return MessageDigest.getInstance("SHA-256").digest(bytes)
}
/**
* Confirm deleting selected revision.
* zh-CN: 确认删除所选 revision.
*/
private fun confirmAndDeleteSelectedRevision(
parentDialog: MaterialDialog,
rev: HistoryEntities.Revision,
canonicalPath: String,
displayPath: String,
mode: Mode,
onRestoreToEditor: ((String) -> Unit)?,
onRestoredUi: (() -> Unit)?,
) {
DialogUtils.buildAndShowAdaptive {
MaterialDialog.Builder(context)
.title(R.string.text_prompt)
.content(R.string.text_delete_revision_confirm)
.negativeText(R.string.dialog_button_cancel)
.negativeColorRes(R.color.dialog_button_default)
.positiveText(R.string.dialog_button_confirm)
.positiveColorRes(R.color.dialog_button_caution)
.onPositive { _, _ ->
parentDialog.dismiss()
deleteRevisionAndReloadDialog(rev, canonicalPath, displayPath, mode, onRestoreToEditor, onRestoredUi)
}
.cancelable(true)
.build()
}
}
/**
* Delete revision and reload dialog.
* zh-CN: 删除 revision 并重新加载对话框.
*/
private fun deleteRevisionAndReloadDialog(
rev: HistoryEntities.Revision,
canonicalPath: String,
displayPath: String,
mode: Mode,
onRestoreToEditor: ((String) -> Unit)?,
onRestoredUi: (() -> Unit)?,
) {
val appCtx = context.applicationContext
Schedulers.io().scheduleDirect {
runCatching {
HistoryRepository(appCtx).deleteRevision(rev)
}.onSuccess {
AndroidSchedulers.mainThread().scheduleDirect {
loadAndShow(
canonicalPath = canonicalPath,
displayPath = displayPath,
mode = mode,
showPreview = true,
onRestoreToEditor = onRestoreToEditor,
onRestoredUi = onRestoredUi,
)
}
}.onFailure {
it.printStackTrace()
AndroidSchedulers.mainThread().scheduleDirect {
ViewUtils.showToast(context, it.message, true)
}
}
}
}
private enum class Mode {
RESTORE_TO_EDITOR,
RESTORE_TO_DISK,

View File

@@ -48,7 +48,7 @@ interface ColorHistoryDao {
suspend fun hasDataByLibraryId(libraryId: Long): Boolean
@Delete
suspend fun delete(vararg histories: ColorHistory)
suspend fun delete(vararg history: ColorHistory)
@Query("DELETE FROM color_history")
suspend fun deleteAll()

View File

@@ -13,9 +13,9 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.autojs.autojs.app.DialogUtils.showAdaptive
import org.autojs.autojs.util.MaterialDialogUtils.makeTextCopyable
import org.autojs.autojs.util.MaterialDialogUtils.setCopyableText
import org.autojs.autojs.util.DialogUtils.showAdaptive
import org.autojs.autojs.util.DialogUtils.makeTextCopyable
import org.autojs.autojs.util.DialogUtils.setCopyableText
import org.autojs.autojs.runtime.api.augment.colors.Colors
import org.autojs.autojs.util.ColorUtils
import org.autojs.autojs6.R

View File

@@ -10,7 +10,7 @@ import org.autojs.autojs.theme.ThemeChangeNotifier
import org.autojs.autojs.theme.ThemeColorManager
import org.autojs.autojs.theme.app.ColorLibrariesActivity.Companion.PresetColorItem
import org.autojs.autojs.theme.app.ColorSelectBaseActivity.Companion.SELECT_NONE
import org.autojs.autojs.theme.app.ColorSelectBaseActivity.Companion.saveDatabaseForColorHistories
import org.autojs.autojs.theme.app.ColorSelectBaseActivity.Companion.saveDatabaseForColorHistory
import org.autojs.autojs.theme.app.ColorSelectBaseActivity.Companion.savePrefsForLegacy
import org.autojs.autojs.theme.app.ColorSelectBaseActivity.Companion.savePrefsForLibraries
import org.autojs.autojs6.R
@@ -68,7 +68,7 @@ class ColorItemAdapter(
private fun savePrefsAndDatabase(context: Context, item: PresetColorItem) {
savePrefsForLibraries(item)
savePrefsForLegacy(context, item)
saveDatabaseForColorHistories(
saveDatabaseForColorHistory(
applicationContext = context.applicationContext,
libraryId = item.libraryId,
itemId = item.itemId,

View File

@@ -289,8 +289,8 @@ class ColorItemsActivity : ColorSelectBaseActivity() {
locateCurrentThemeColor()
true
}
R.id.action_show_histories -> {
showColorHistories(mLibrary.id) { selectedHistoryItem ->
R.id.action_show_history -> {
showColorHistory(mLibrary.id) { selectedHistoryItem ->
when (val targetIndex = mAdapter.items.indexOfFirst { getColor(it.colorRes) == selectedHistoryItem.colorInt }) {
-1 -> ViewUtils.showToast(this, R.string.error_failed_to_apply_current_color_history, true)
else -> {

View File

@@ -126,22 +126,22 @@ class ColorLibrariesActivity : ColorSelectBaseActivity() {
showColorPicker()
true
}
R.id.action_show_all_histories -> {
showColorHistories { selectedHistoryItem ->
R.id.action_show_all_history_entries -> {
showColorHistory { selectedHistoryItem ->
val identifier = selectedHistoryItem.colorLibraryIdentifier ?: run {
ViewUtils.showToast(this@ColorLibrariesActivity, R.string.error_failed_to_apply_current_color_history, true)
return@showColorHistories
return@showColorHistory
}
if (identifier == getString(R.string.color_library_identifier_palette)) {
onColorPickerConfirmed(selectedHistoryItem.colorInt)
return@showColorHistories
return@showColorHistory
}
presetColorLibraries.find { getString(it.identifierRes) == identifier }?.let { library ->
library.colors.firstOrNull { getColor(it.colorRes) == selectedHistoryItem.colorInt }?.let { item ->
savePrefsForLibraries(item)
savePrefsForLegacy(this, item)
saveDatabaseForColorHistories(
saveDatabaseForColorHistory(
applicationContext = applicationContext,
libraryId = item.libraryId,
itemId = item.itemId,

View File

@@ -264,7 +264,7 @@ abstract class ColorSelectBaseActivity : BaseActivity() {
.setColor(initialColor ?: customColor)
.setOldColorPanelOnClickListener { v, dialog -> showColorDetails(v.tag as? Int, parentDialog = dialog) }
.setNewColorPanelOnClickListener { v, dialog -> showColorDetails(v.tag as? Int, parentDialog = dialog) }
.setColorHistoriesHandler { showColorPickerHistories(it) }
.setColorHistoryHandler { showColorPickerHistory(it) }
.create()
.setColorPickerDialogListener { _, color: Int ->
onColorPickerConfirmed(color)
@@ -276,7 +276,7 @@ abstract class ColorSelectBaseActivity : BaseActivity() {
savePrefsForLegacyPalette(color)
savePrefsForLibraries(COLOR_LIBRARY_ID_PALETTE, 0)
saveDatabaseForPaletteHistories(lifecycleScope, applicationContext, color)
saveDatabaseForPaletteHistory(lifecycleScope, applicationContext, color)
isForciblyEnableAppBarColorTransition = true
@@ -284,16 +284,16 @@ abstract class ColorSelectBaseActivity : BaseActivity() {
ThemeChangeNotifier.notifyThemeChanged()
}
private fun showColorPickerHistories(colorPickerDialog: ColorPickerDialog) {
private fun showColorPickerHistory(colorPickerDialog: ColorPickerDialog) {
lifecycleScope.launch {
var historiesDialog: MaterialDialog? = null
var historyDialog: MaterialDialog? = null
val dao = withContext(Dispatchers.IO) {
ColorHistoryDatabase.getInstance(applicationContext).paletteHistoryDao()
}
when {
withContext(Dispatchers.IO) { dao.hasData() } -> {
val adapter = PaletteHistoryItemAdapter(emptyList()) { selectedColor ->
historiesDialog?.dismiss()
historyDialog?.dismiss()
colorPickerDialog.onHistorySelected(selectedColor)
}
launch(Dispatchers.Main) {
@@ -303,15 +303,15 @@ abstract class ColorSelectBaseActivity : BaseActivity() {
it.layoutManager = LinearLayoutManager(context)
it.addItemDecoration(DividerItemDecoration(context, VERTICAL))
}
historiesDialog = MaterialDialog.Builder(context)
.title(R.string.text_histories)
historyDialog = MaterialDialog.Builder(context)
.title(R.string.text_history)
.customView(recyclerView, false)
.neutralText(R.string.dialog_button_clear_items)
.neutralColorRes(R.color.dialog_button_warn)
.onNeutral { parentDialog, _ ->
MaterialDialog.Builder(context)
.title(R.string.text_prompt)
.content(getString(R.string.text_confirm_to_clear_histories_of, getString(R.string.text_color_palette)))
.content(getString(R.string.text_confirm_to_clear_the_history_of, getString(R.string.text_color_palette)))
.negativeText(R.string.dialog_button_cancel)
.negativeColorRes(R.color.dialog_button_default)
.positiveText(R.string.dialog_button_confirm)
@@ -322,7 +322,7 @@ abstract class ColorSelectBaseActivity : BaseActivity() {
withContext(Dispatchers.Main) {
adapter.updateData(emptyList())
parentDialog.dismiss()
ViewUtils.showToast(context, R.string.text_all_histories_cleared, true)
ViewUtils.showToast(context, R.string.text_history_has_been_cleared, true)
}
}
}
@@ -334,17 +334,17 @@ abstract class ColorSelectBaseActivity : BaseActivity() {
.autoDismiss(false)
.show()
}
val histories = withContext(Dispatchers.IO) {
val history = withContext(Dispatchers.IO) {
dao.getAll().sortedByDescending { it.lastUsedTime }
}
launch(Dispatchers.Main) {
adapter.updateData(histories)
adapter.updateData(history)
}
}
else -> withContext(Dispatchers.Main) {
MaterialDialog.Builder(this@ColorSelectBaseActivity)
.title(R.string.text_histories)
.content(R.string.text_no_histories)
.title(R.string.text_history)
.content(R.string.text_no_history)
.positiveText(R.string.dialog_button_dismiss)
.positiveColorRes(R.color.dialog_button_default)
.show()
@@ -353,12 +353,12 @@ abstract class ColorSelectBaseActivity : BaseActivity() {
}
}
protected fun showColorHistories(libraryId: Int? = null, onHistorySelected: (selectedHistoryItem: ColorHistoryItem) -> Unit) {
protected fun showColorHistory(libraryId: Int? = null, onHistorySelected: (selectedHistoryItem: ColorHistoryItem) -> Unit) {
val isShowAllHistories = libraryId == null
val isShowAllHistoryEntries = libraryId == null
lifecycleScope.launch {
var historiesDialog: MaterialDialog? = null
var historyDialog: MaterialDialog? = null
var paletteHistoryDao: PaletteHistoryDao? = null
val colorHistoryDao = withContext(Dispatchers.IO) {
ColorHistoryDatabase.getInstance(applicationContext).colorHistoryDao()
@@ -372,8 +372,8 @@ abstract class ColorSelectBaseActivity : BaseActivity() {
if (!hasData) {
withContext(Dispatchers.Main) {
MaterialDialog.Builder(this@ColorSelectBaseActivity)
.title(R.string.text_histories)
.content(R.string.text_no_histories)
.title(R.string.text_history)
.content(R.string.text_no_history)
.positiveText(R.string.dialog_button_dismiss)
.positiveColorRes(R.color.dialog_button_default)
.show()
@@ -381,7 +381,7 @@ abstract class ColorSelectBaseActivity : BaseActivity() {
return@launch
}
val adapter = ColorHistoryItemAdapter(emptyList()) { selectedHistoryItem ->
historiesDialog?.dismiss()
historyDialog?.dismiss()
onHistorySelected(selectedHistoryItem)
}
launch(Dispatchers.Main) {
@@ -391,8 +391,8 @@ abstract class ColorSelectBaseActivity : BaseActivity() {
it.layoutManager = LinearLayoutManager(context)
it.addItemDecoration(DividerItemDecoration(context, VERTICAL))
}
historiesDialog = MaterialDialog.Builder(context)
.title(R.string.text_histories)
historyDialog = MaterialDialog.Builder(context)
.title(R.string.text_history)
.customView(recyclerView, false)
.neutralText(R.string.dialog_button_clear_items)
.neutralColorRes(R.color.dialog_button_warn)
@@ -401,12 +401,12 @@ abstract class ColorSelectBaseActivity : BaseActivity() {
.title(R.string.text_prompt)
.apply {
when {
isShowAllHistories -> {
content(getString(R.string.text_confirm_to_clear_histories_of_all_color_libraries))
isShowAllHistoryEntries -> {
content(getString(R.string.text_confirm_to_clear_the_history_of_all_color_libraries))
}
else -> presetColorLibraries.find { it.id == libraryId }?.let {
content(getString(R.string.text_confirm_to_clear_histories_of, getString(it.nameRes)))
} ?: content(getString(R.string.text_confirm_to_clear_all_histories))
content(getString(R.string.text_confirm_to_clear_the_history_of, getString(it.nameRes)))
} ?: content(getString(R.string.text_confirm_to_clear_the_history))
}
}
.negativeText(R.string.dialog_button_cancel)
@@ -415,7 +415,7 @@ abstract class ColorSelectBaseActivity : BaseActivity() {
.positiveColorRes(R.color.dialog_button_caution)
.onPositive { _, _ ->
lifecycleScope.launch(Dispatchers.IO) {
if (isShowAllHistories) {
if (isShowAllHistoryEntries) {
if (paletteHistoryDao == null) {
paletteHistoryDao = withContext(Dispatchers.IO) {
ColorHistoryDatabase.getInstance(applicationContext).paletteHistoryDao()
@@ -429,7 +429,7 @@ abstract class ColorSelectBaseActivity : BaseActivity() {
withContext(Dispatchers.Main) {
adapter.updateData(emptyList())
parentDialog.dismiss()
ViewUtils.showToast(context, R.string.text_all_histories_cleared, true)
ViewUtils.showToast(context, R.string.text_history_has_been_cleared, true)
}
}
}
@@ -442,14 +442,14 @@ abstract class ColorSelectBaseActivity : BaseActivity() {
.show()
}
val colorHistories = withContext(Dispatchers.IO) {
val colorHistory = withContext(Dispatchers.IO) {
when {
isShowAllHistories -> colorHistoryDao.getAll()
isShowAllHistoryEntries -> colorHistoryDao.getAll()
else -> colorHistoryDao.getAllByLibraryId(libraryId.toLong())
}
}
val historyItems = colorHistories.mapNotNull { history ->
val historyItems = colorHistory.mapNotNull { history ->
var colorInt: Int? = null
var colorName: String? = null
var colorLibraryIdentifier: String? = null
@@ -460,7 +460,7 @@ abstract class ColorSelectBaseActivity : BaseActivity() {
presetColorLibrary.colors.find { it.itemId == history.itemId }?.let { item ->
colorInt = getColor(item.colorRes)
colorName = getString(item.nameRes)
if (isShowAllHistories) {
if (isShowAllHistoryEntries) {
colorLibraryIdentifier = getString(presetColorLibrary.identifierRes)
}
}
@@ -480,7 +480,7 @@ abstract class ColorSelectBaseActivity : BaseActivity() {
return@mapNotNull null
}.toMutableList()
if (isShowAllHistories) {
if (isShowAllHistoryEntries) {
val paletteIdentifier = getString(R.string.color_library_identifier_palette)
if (paletteHistoryDao == null) {
paletteHistoryDao = withContext(Dispatchers.IO) {
@@ -778,22 +778,22 @@ abstract class ColorSelectBaseActivity : BaseActivity() {
}
@JvmStatic
fun saveDatabaseForPaletteHistories(dispatcherScope: CoroutineScope? = null, applicationContext: Context, color: Int) {
saveHistories(dispatcherScope) {
fun saveDatabaseForPaletteHistory(dispatcherScope: CoroutineScope? = null, applicationContext: Context, color: Int) {
saveHistory(dispatcherScope) {
var itemId by Delegates.notNull<Int>()
savePaletteHistories(applicationContext, color).also { itemId = it }
saveColorHistories(applicationContext, COLOR_LIBRARY_ID_PALETTE, itemId)
savePaletteHistory(applicationContext, color).also { itemId = it }
saveColorHistory(applicationContext, COLOR_LIBRARY_ID_PALETTE, itemId)
}
}
@JvmStatic
fun saveDatabaseForColorHistories(dispatcherScope: CoroutineScope? = null, applicationContext: Context, libraryId: Int, itemId: Int) {
saveHistories(dispatcherScope) {
saveColorHistories(applicationContext, libraryId, itemId)
fun saveDatabaseForColorHistory(dispatcherScope: CoroutineScope? = null, applicationContext: Context, libraryId: Int, itemId: Int) {
saveHistory(dispatcherScope) {
saveColorHistory(applicationContext, libraryId, itemId)
}
}
private fun saveHistories(
private fun saveHistory(
dispatcherScope: CoroutineScope? = null,
saveJob: suspend CoroutineScope.() -> Unit,
) {
@@ -803,13 +803,13 @@ abstract class ColorSelectBaseActivity : BaseActivity() {
}
}
private suspend fun savePaletteHistories(applicationContext: Context, color: Int): Int {
private suspend fun savePaletteHistory(applicationContext: Context, color: Int): Int {
val dao = ColorHistoryDatabase.getInstance(applicationContext).paletteHistoryDao()
val colorInfo = ColorInfo(ColorUtils.toHex(color, 6))
return dao.upsert(PaletteHistory(colorInfo = colorInfo).apply { setLastUsedTimeCurrent() }).toInt()
}
private suspend fun saveColorHistories(applicationContext: Context, libraryId: Int, itemId: Int) {
private suspend fun saveColorHistory(applicationContext: Context, libraryId: Int, itemId: Int) {
val dao = ColorHistoryDatabase.getInstance(applicationContext).colorHistoryDao()
dao.upsert(ColorHistory(libraryId = libraryId, itemId = itemId).apply { setLastUsedTimeCurrent() })
}

View File

@@ -31,8 +31,8 @@ import org.autojs.autojs.theme.app.ColorSelectBaseActivity.Companion.SELECT_NONE
import org.autojs.autojs.theme.app.ColorSelectBaseActivity.Companion.colorItemsLegacy
import org.autojs.autojs.theme.app.ColorSelectBaseActivity.Companion.customColorPosition
import org.autojs.autojs.theme.app.ColorSelectBaseActivity.Companion.defaultColorPosition
import org.autojs.autojs.theme.app.ColorSelectBaseActivity.Companion.saveDatabaseForColorHistories
import org.autojs.autojs.theme.app.ColorSelectBaseActivity.Companion.saveDatabaseForPaletteHistories
import org.autojs.autojs.theme.app.ColorSelectBaseActivity.Companion.saveDatabaseForColorHistory
import org.autojs.autojs.theme.app.ColorSelectBaseActivity.Companion.saveDatabaseForPaletteHistory
import org.autojs.autojs.util.ViewUtils
import org.autojs.autojs6.R
import org.autojs.autojs6.databinding.MtColorSettingRecyclerViewItemBinding
@@ -70,7 +70,7 @@ class ColorSettingRecyclerView : ThemeColorRecyclerView {
presetColorLibraries.find { it.isDefault }!!.colors.find { colorItem ->
context.getColor(colorItem.colorRes) == context.getColor(R.color.theme_color_default)
}?.let {
saveDatabaseForColorHistories(
saveDatabaseForColorHistory(
applicationContext = context.applicationContext,
libraryId = COLOR_LIBRARY_ID_DEFAULT,
itemId = it.itemId,
@@ -85,7 +85,7 @@ class ColorSettingRecyclerView : ThemeColorRecyclerView {
presetColorLibraries.find { it.isMaterial }!!.colors.find { colorItem ->
context.getColor(colorItem.colorRes) == c
}?.let {
saveDatabaseForColorHistories(
saveDatabaseForColorHistory(
applicationContext = context.applicationContext,
libraryId = COLOR_LIBRARY_ID_MATERIAL,
itemId = it.itemId,
@@ -174,7 +174,7 @@ class ColorSettingRecyclerView : ThemeColorRecyclerView {
val colorWithFullAlpha = color or Color.BLACK
Pref.putInt(ColorSelectBaseActivity.KEY_CUSTOM_COLOR, colorWithFullAlpha)
setSelectedPosition(customColorPosition)
saveDatabaseForPaletteHistories(applicationContext = context.applicationContext, color = colorWithFullAlpha)
saveDatabaseForPaletteHistory(applicationContext = context.applicationContext, color = colorWithFullAlpha)
mOnItemClickListener?.onItemClick(v, customColorPosition)
}
.show((context as FragmentActivity).supportFragmentManager, "ColorPickerTagForColorSelect")

View File

@@ -38,7 +38,7 @@ interface PaletteHistoryDao {
suspend fun hasData(): Boolean
@Delete
suspend fun delete(vararg histories: PaletteHistory): Int
suspend fun delete(vararg historyItem: PaletteHistory): Int
@Query("DELETE FROM palette_history")
suspend fun deleteAll()

View File

@@ -5,10 +5,10 @@ import android.os.Bundle
import android.text.TextUtils
import android.util.AttributeSet
import com.afollestad.materialdialogs.MaterialDialog
import org.autojs.autojs.app.DialogUtils.showAdaptive
import org.autojs.autojs.util.DialogUtils.showAdaptive
import org.autojs.autojs.core.pref.Pref
import org.autojs.autojs.util.MaterialDialogUtils.choiceWidgetThemeColor
import org.autojs.autojs.util.MaterialDialogUtils.widgetThemeColor
import org.autojs.autojs.util.DialogUtils.choiceWidgetThemeColor
import org.autojs.autojs.util.DialogUtils.widgetThemeColor
import org.autojs.autojs.ui.common.NotAskAgainDialog
import org.autojs.autojs6.R

View File

@@ -4,7 +4,7 @@ import android.content.Context
import android.content.res.TypedArray
import android.util.AttributeSet
import androidx.preference.PreferenceViewHolder
import org.autojs.autojs.app.DialogUtils
import org.autojs.autojs.util.DialogUtils
import org.autojs.autojs6.R
open class MaterialPreference : androidx.preference.Preference, LongClickablePreferenceLike {

View File

@@ -10,7 +10,7 @@ import android.widget.Switch
import androidx.appcompat.widget.SwitchCompat
import androidx.preference.PreferenceViewHolder
import androidx.preference.SwitchPreference
import org.autojs.autojs.app.DialogUtils
import org.autojs.autojs.util.DialogUtils
import org.autojs.autojs.theme.ThemeColor
import org.autojs.autojs.theme.ThemeColorHelper
import org.autojs.autojs.theme.ThemeColorManager

View File

@@ -7,8 +7,8 @@ import android.os.Build
import android.provider.Settings
import androidx.annotation.RequiresApi
import androidx.core.net.toUri
import org.autojs.autojs.app.DialogUtils.showAdaptive
import org.autojs.autojs.util.MaterialDialogUtils.widgetThemeColor
import org.autojs.autojs.util.DialogUtils.showAdaptive
import org.autojs.autojs.util.DialogUtils.widgetThemeColor
import org.autojs.autojs.ui.common.NotAskAgainDialog
import org.autojs.autojs.util.IntentUtils.startSafely
import org.autojs.autojs.util.StringUtils.key

View File

@@ -6,7 +6,7 @@ import android.view.Window;
import android.widget.EditText;
import android.widget.LinearLayout;
import com.afollestad.materialdialogs.MaterialDialog;
import org.autojs.autojs.app.DialogUtils;
import org.autojs.autojs.util.DialogUtils;
import org.autojs.autojs.model.script.ScriptFile;
import org.autojs.autojs.model.script.Scripts;
import org.autojs.autojs.util.ViewUtils;

View File

@@ -4,13 +4,11 @@ import android.annotation.SuppressLint;
import android.content.Context;
import android.content.Intent;
import android.os.Environment;
import android.os.SystemClock;
import android.text.Editable;
import android.text.InputType;
import android.util.Log;
import android.view.View;
import android.widget.EditText;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.core.content.pm.ShortcutManagerCompat;
@@ -21,7 +19,6 @@ import io.reactivex.android.schedulers.AndroidSchedulers;
import io.reactivex.internal.functions.ObjectHelper;
import io.reactivex.schedulers.Schedulers;
import io.reactivex.subjects.PublishSubject;
import org.autojs.autojs.app.DialogUtils;
import org.autojs.autojs.app.GlobalAppContext;
import org.autojs.autojs.core.pref.Pref;
import org.autojs.autojs.external.ScriptIntents;
@@ -40,14 +37,19 @@ import org.autojs.autojs.pio.PFiles;
import org.autojs.autojs.pio.UncheckedIOException;
import org.autojs.autojs.project.ProjectConfig;
import org.autojs.autojs.storage.file.TmpScriptFiles;
import org.autojs.autojs.storage.history.HistoryPrefs;
import org.autojs.autojs.storage.history.HistoryRepository;
import org.autojs.autojs.storage.history.TrashRepository;
import org.autojs.autojs.ui.filechooser.FileChooserDialogBuilder;
import org.autojs.autojs.ui.shortcut.ShortcutCreateActivity;
import org.autojs.autojs.ui.timing.TimedTaskSettingActivity;
import org.autojs.autojs.util.DialogUtils;
import org.autojs.autojs.util.DialogUtils.OperationAbortedException;
import org.autojs.autojs.util.DialogUtils.OperationController;
import org.autojs.autojs.util.DialogUtils.ProgressDialogSession;
import org.autojs.autojs.util.EnvironmentUtils;
import org.autojs.autojs.util.FileUtils;
import org.autojs.autojs.util.IntentUtils;
import org.autojs.autojs.util.MaterialDialogUtils;
import org.autojs.autojs.util.ShortcutUtils;
import org.autojs.autojs.util.ViewUtils;
import org.autojs.autojs.util.WorkingDirectoryUtils;
@@ -65,44 +67,27 @@ import java.util.List;
import java.util.Objects;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import static org.autojs.autojs.app.DialogUtils.fixCheckBoxGravity;
import static org.autojs.autojs.model.explorer.ExplorerFileItem.isInSampleDir;
import static org.autojs.autojs.util.DialogUtils.fixCheckBoxGravity;
import static org.autojs.autojs.util.FileUtils.TYPE.JAVASCRIPT;
import static org.autojs.autojs.util.RhinoUtils.isMainThread;
import static org.autojs.autojs.util.ThreadUtils.runOnMain;
/**
* Created by Stardust on Jul 31, 2017.
* Modified by JetBrains AI Assistant (GPT-5.2) as of Jan 31, 2026.
* Modified by SuperMonster003 as of Feb 1, 2026.
* Modified by JetBrains AI Assistant (GPT-5.2) as of Feb 6, 2026.
* Modified by SuperMonster003 as of Feb 7, 2026.
*/
@SuppressWarnings({"ResultOfMethodCallIgnored", "CodeBlock2Expr"})
@SuppressWarnings({"ResultOfMethodCallIgnored", "CodeBlock2Expr", "unused"})
@SuppressLint("CheckResult")
public class ScriptOperations {
private static final String LOG_TAG = "ScriptOperations";
// Minimum UI update interval for progress dialog, to reduce main-thread pressure.
// zh-CN: 进度对话框 UI 更新的最小间隔, 用于降低主线程压力.
private static final long PROGRESS_UPDATE_MIN_INTERVAL_MS = 50L;
// Delay before showing progress dialog to avoid flashing for fast operations.
// zh-CN: 显示进度对话框前的延迟, 用于避免快速操作导致的闪现.
private static final long PROGRESS_DIALOG_SHOW_DELAY_MS = 200L;
// Minimum duration to keep progress dialog visible once shown, to avoid flashing.
// zh-CN: 进度对话框一旦显示后的最短展示时长, 用于避免闪现.
private static final long PROGRESS_DIALOG_MIN_SHOW_MS = 300L;
// Progress max for large files (totalBytes > Integer.MAX_VALUE), higher means smoother progress.
// zh-CN: 大文件 (totalBytes > Integer.MAX_VALUE) 的进度最大值, 值越大进度越细腻.
private static final int PROGRESS_MAX_LARGE = 10_000;
// Suffix for partial copy output file.
// zh-CN: 复制过程的临时输出文件后缀.
private static final String PARTIAL_COPY_SUFFIX = ".partial";
@@ -168,7 +153,7 @@ public class ScriptOperations {
Scripts.edit(mContext, path);
}
} else {
showMessage(R.string.text_failed_to_create);
showMessage(R.string.error_failed_to_create);
}
}
@@ -225,12 +210,12 @@ public class ScriptOperations {
.positiveText(R.string.dialog_button_confirm)
.positiveColorRes(R.color.dialog_button_attraction)
.negativeText(R.string.dialog_button_back);
MaterialDialogUtils.choiceWidgetThemeColor(builderDefaultPrefix);
DialogUtils.choiceWidgetThemeColor(builderDefaultPrefix);
MaterialDialog dialogDefaultPrefix = builderDefaultPrefix.build();
DialogUtils.showAdaptive(dialogDefaultPrefix);
})
.autoDismiss(false);
MaterialDialogUtils.widgetThemeColor(builder);
DialogUtils.widgetThemeColor(builder);
MaterialDialog dialog = builder.build();
dialogRef.set(dialog);
@@ -327,7 +312,7 @@ public class ScriptOperations {
showMessage(R.string.text_already_created);
notifyFileCreated(new ScriptFile(newDir));
} else {
showMessage(R.string.text_failed_to_create);
showMessage(R.string.error_failed_to_create);
}
});
}
@@ -367,7 +352,7 @@ public class ScriptOperations {
input.onComplete();
})
.canceledOnTouchOutside(false);
MaterialDialogUtils.widgetThemeColor(builder);
DialogUtils.widgetThemeColor(builder);
DialogUtils.showAdaptive(builder.build());
return input;
}
@@ -395,7 +380,6 @@ public class ScriptOperations {
}
ExplorerFileItem newItem = item.rename(newName);
if (ObjectHelper.equals(newItem.toScriptFile(), item.toScriptFile())) {
showMessage(R.string.error_cannot_rename);
throw new IOException();
}
notifyFileChanged(item, newItem);
@@ -434,12 +418,6 @@ public class ScriptOperations {
// zh-CN: 用于中止处理与刷新.
boolean destTouched = false;
// Timestamp/progress/content state for throttling UI updates.
// zh-CN: 用于节流 UI 更新的时间戳/进度/文本状态.
final long[] lastProgressUpdateTime = new long[]{0L};
final long[] lastProgressValue = new long[]{-1L};
final long[] lastContentUpdateTime = new long[]{0L};
try {
long totalBytes = computeTotalBytes(source);
@@ -456,11 +434,11 @@ public class ScriptOperations {
totalBytes,
(copied, total) -> {
controller.throwIfCancelled();
session.setProgressThrottled(copied, totalBytes, lastProgressUpdateTime, lastProgressValue);
session.setProgressThrottled(copied, totalBytes);
},
(currentFile) -> {
controller.throwIfCancelled();
session.setContentThrottled(toContentList(source, dest, currentFile), lastContentUpdateTime);
session.setContentThrottled(toContentList(source, dest, currentFile));
},
controller
);
@@ -475,11 +453,11 @@ public class ScriptOperations {
totalBytes,
(copied, total) -> {
controller.throwIfCancelled();
session.setProgressThrottled(copied, totalBytes, lastProgressUpdateTime, lastProgressValue);
session.setProgressThrottled(copied, totalBytes);
},
(currentFile) -> {
controller.throwIfCancelled();
session.setContentThrottled(toContentList(source, dest, currentFile), lastContentUpdateTime);
session.setContentThrottled(toContentList(source, dest, currentFile));
},
controller
);
@@ -494,7 +472,7 @@ public class ScriptOperations {
// Cleanup and refresh when aborted.
// zh-CN: 中止时清理并刷新.
if (destTouched) {
notifyChildrenChangedAtItsParent(new ScriptFile(dest.getPath()));
notifyChildrenChangedAtItsParent(new ScriptFile(dest));
if (isMove) {
notifyChildrenChangedAtItsParent(source);
}
@@ -529,18 +507,15 @@ public class ScriptOperations {
? isMove ? R.string.text_move_folder : R.string.text_copy_folder
: isMove ? R.string.text_move_file : R.string.text_copy_file;
int contentRes = isMove ? R.string.text_moving : R.string.text_copying;
MaterialDialog.Builder builder = new MaterialDialog.Builder(mContext)
.title(titleRes)
.content(R.string.ellipsis_six)
.content(contentRes)
.negativeText(R.string.dialog_button_abort)
.negativeColorRes(R.color.dialog_button_caution)
.onNegative((dialog, which) -> {
controller.cancel();
try {
dialog.setContent(mContext.getString(R.string.text_aborting));
} catch (Throwable ignored) {
/* Ignored. */
}
})
.cancelable(false)
.canceledOnTouchOutside(false);
@@ -552,7 +527,7 @@ public class ScriptOperations {
} else if (totalBytes > 0) {
// Determinate progress in scaled units for huge files.
// zh-CN: 对于超大文件使用缩放单位的确定性进度.
builder.progress(false, PROGRESS_MAX_LARGE, true);
builder.progress(false, DialogUtils.PROGRESS_MAX_LARGE, true);
} else {
builder.progress(true, 0);
}
@@ -705,13 +680,13 @@ public class ScriptOperations {
@NonNull File src,
@NonNull File dst,
long totalBytes,
@NonNull ProgressCallback callback,
@NonNull ProgressCallback progressCallback,
@NonNull FileProgressCallback fileCallback,
@NonNull OperationController controller
) throws IOException {
controller.throwIfCancelled();
long[] copied = new long[]{0L};
copyFileOrDirWithProgressInternal(src, dst, totalBytes, copied, callback, fileCallback, controller);
copyFileOrDirWithProgressInternal(src, dst, totalBytes, copied, progressCallback, fileCallback, controller);
}
private void copyFileOrDirWithProgressInternal(
@@ -719,13 +694,13 @@ public class ScriptOperations {
@NonNull File dst,
long totalBytes,
@NonNull long[] copied,
@NonNull ProgressCallback callback,
@NonNull FileProgressCallback pathCallback,
@NonNull ProgressCallback progressCallback,
@NonNull FileProgressCallback fileCallback,
@NonNull OperationController controller
) throws IOException {
controller.throwIfCancelled();
pathCallback.onFile(dst);
fileCallback.onFile(dst);
if (src.isDirectory()) {
if (!dst.exists() && !dst.mkdirs()) {
@@ -736,12 +711,12 @@ public class ScriptOperations {
return;
}
for (File child : children) {
copyFileOrDirWithProgressInternal(child, new File(dst, child.getName()), totalBytes, copied, callback, pathCallback, controller);
copyFileOrDirWithProgressInternal(child, new File(dst, child.getName()), totalBytes, copied, progressCallback, fileCallback, controller);
}
return;
}
copyFileWithProgressAtomic(src, dst, totalBytes, copied, callback, pathCallback, controller);
copyFileWithProgressAtomic(src, dst, totalBytes, copied, progressCallback, fileCallback, controller);
}
// Copy file with an atomic "partial file then rename" strategy, supports abort cleanup.
@@ -751,8 +726,8 @@ public class ScriptOperations {
@NonNull File dst,
long totalBytes,
@NonNull long[] copied,
@NonNull ProgressCallback callback,
@NonNull FileProgressCallback pathCallback,
@NonNull ProgressCallback progressCallback,
@NonNull FileProgressCallback fileCallback,
@NonNull OperationController controller
) throws IOException {
controller.throwIfCancelled();
@@ -777,8 +752,8 @@ public class ScriptOperations {
fos.write(buffer, 0, read);
copied[0] += read;
pathCallback.onFile(dst);
callback.onProgress(copied[0], totalBytes);
fileCallback.onFile(dst);
progressCallback.onProgress(copied[0], totalBytes);
}
fos.getFD().sync();
}
@@ -821,7 +796,7 @@ public class ScriptOperations {
@NonNull File src,
@NonNull File dst,
long totalBytes,
@NonNull ProgressCallback callback,
@NonNull ProgressCallback progressCallback,
@NonNull FileProgressCallback fileCallback,
@NonNull OperationController controller
) throws IOException {
@@ -833,7 +808,7 @@ public class ScriptOperations {
return;
}
copyFileOrDirWithProgress(src, dst, totalBytes, callback, fileCallback, controller);
copyFileOrDirWithProgress(src, dst, totalBytes, progressCallback, fileCallback, controller);
controller.throwIfCancelled();
@@ -980,7 +955,7 @@ public class ScriptOperations {
.negativeText(R.string.dialog_button_cancel)
.positiveText(R.string.dialog_button_confirm)
.positiveColorRes(R.color.dialog_button_caution);
MaterialDialogUtils.choiceWidgetThemeColor(builder);
DialogUtils.choiceWidgetThemeColor(builder);
return builder.build();
});
}
@@ -1007,30 +982,22 @@ public class ScriptOperations {
private void moveToTrashWithProgress(final ScriptFile scriptFile) {
boolean isDir = scriptFile.isDirectory();
int titleRes = isDir ? R.string.text_delete_folder : R.string.text_delete_file;
int titleRes = isDir ? R.string.text_move_folder_to_trash : R.string.text_move_file_to_trash;
Observable.fromCallable(() -> {
OperationController controller = new OperationController();
ProgressDialogSession session = new ProgressDialogSession(controller);
try {
// Indeterminate progress is acceptable for trashing.
// zh-CN: 移入回收站使用不确定进度条即可.
MaterialDialog.Builder builder = new MaterialDialog.Builder(mContext)
.title(titleRes)
.content(R.string.ellipsis_six)
.content(R.string.text_moving_to_trash)
.negativeText(R.string.dialog_button_abort)
.negativeColorRes(R.color.dialog_button_caution)
.onNegative((dialog, which) -> {
controller.cancel();
try {
dialog.setContent(mContext.getString(R.string.text_aborting));
} catch (Throwable ignored) {
/* Ignored. */
}
})
.progress(true, 0)
.progressIndeterminateStyle(true)
.progress(false, DialogUtils.PROGRESS_MAX_LARGE, true)
.cancelable(false)
.canceledOnTouchOutside(false);
@@ -1041,7 +1008,20 @@ public class ScriptOperations {
// Move into trash (blob + db) then notify explorer.
// zh-CN: 移入回收站 (blob + db), 然后通知资源管理器.
new TrashRepository(mContext.getApplicationContext())
.moveToTrash(scriptFile.getPath());
.moveToTrashWithProgress(
scriptFile.getPath(),
(processed, total) -> {
controller.throwIfCancelled();
if (total > 0) {
session.setProgressThrottled(processed, total);
}
},
(currentFile) -> {
controller.throwIfCancelled();
session.setContentThrottled(toContentList(scriptFile, currentFile));
},
controller
);
return 1;
} catch (OperationAbortedException aborted) {
@@ -1056,6 +1036,13 @@ public class ScriptOperations {
switch (result) {
case 1 -> {
showMessage(R.string.text_moved_to_trash);
// Refresh explorer reliably for both file and directory.
// zh-CN: 对文件与目录均使用更可靠的刷新方式.
notifyChildrenChangedAtItsParent(scriptFile);
// Keep existing item removed notification as best-effort.
// zh-CN: 保留原有的 itemRemoved 通知, 作为尽力而为的补充.
notifyFileRemoved(ProjectConfig.isProject(scriptFile), scriptFile.isDirectory(), scriptFile);
}
case -1 -> showMessage(R.string.text_operation_aborted);
@@ -1064,6 +1051,17 @@ public class ScriptOperations {
}, e -> {
e.printStackTrace();
showMessage(R.string.text_failed_to_delete);
String msg = e.getClass().getSimpleName();
if (e.getMessage() != null && !e.getMessage().isBlank()) {
msg = msg + ": " + e.getMessage();
}
DialogUtils.buildAndShowAdaptive(new MaterialDialog.Builder(mContext)
.title(R.string.text_move_to_trash_failed)
.content(msg)
.positiveText(R.string.dialog_button_dismiss)
.positiveColorRes(R.color.dialog_button_default)
.cancelable(false)::build);
});
}
@@ -1077,23 +1075,16 @@ public class ScriptOperations {
OperationController controller = new OperationController();
ProgressDialogSession session = new ProgressDialogSession(controller);
final long[] lastContentUpdateTime = new long[]{0L};
try {
// For delete, indeterminate progress is acceptable and consistent.
// zh-CN: 删除操作使用不确定进度条即可, 行为更稳定.
MaterialDialog.Builder builder = new MaterialDialog.Builder(mContext)
.title(titleRes)
.content(R.string.ellipsis_six)
.content(R.string.text_deleting)
.negativeText(R.string.dialog_button_abort)
.negativeColorRes(R.color.dialog_button_caution)
.onNegative((dialog, which) -> {
controller.cancel();
try {
dialog.setContent(mContext.getString(R.string.text_aborting));
} catch (Throwable ignored) {
/* Ignored. */
}
})
.progress(true, 0)
.progressIndeterminateStyle(true)
@@ -1104,7 +1095,7 @@ public class ScriptOperations {
boolean deleted = deleteRecursivelyWithProgress(
scriptFile,
currentFile -> session.setContentThrottled(toContentList(scriptFile, currentFile), lastContentUpdateTime),
currentFile -> session.setContentThrottled(toContentList(scriptFile, currentFile)),
controller
);
@@ -1122,6 +1113,20 @@ public class ScriptOperations {
case 1 -> {
showMessage(R.string.text_already_deleted);
notifyFileRemoved(isProject, isDir, scriptFile);
if (HistoryPrefs.deletePermanentlyAlsoClearHistory()) {
var scriptFilePath = scriptFile.getPath();
if (scriptFilePath.startsWith(INTERNAL_STORAGE_ROOT)) {
Schedulers.io().scheduleDirect(() -> {
try {
new HistoryRepository(mContext.getApplicationContext())
.clearHistoryForPath(scriptFilePath);
} catch (Throwable ignored) {
/* Ignored. */
}
});
}
}
}
case 0 -> showMessage(R.string.text_failed_to_delete);
case -1 -> showMessage(R.string.text_operation_aborted);
@@ -1131,7 +1136,7 @@ public class ScriptOperations {
public void setAsWorkingDirNow(final ScriptFile scriptFile) {
WorkingDirectoryUtils.setPath(scriptFile.getPath());
WorkingDirectoryUtils.addIntoHistories(scriptFile.getPath());
WorkingDirectoryUtils.addIntoHistory(scriptFile.getPath());
showMessage(R.string.text_done);
}
@@ -1288,190 +1293,11 @@ public class ScriptOperations {
}
}
private static final class ProgressDialogSession {
private final OperationController controller;
private final AtomicReference<MaterialDialog> dialogRef = new AtomicReference<>();
// Whether the session has been closed (operation finished/aborted), used to prevent late dialog showing.
// zh-CN: 会话是否已关闭 (操作已完成/已中止), 用于防止延迟任务在结束后仍弹出对话框.
private final AtomicBoolean closed = new AtomicBoolean(false);
private final AtomicBoolean showScheduled = new AtomicBoolean(false);
private volatile boolean shown = false;
private long shownAtUptimeMs = 0L;
ProgressDialogSession(OperationController controller) {
this.controller = controller;
}
void scheduleShow(MaterialDialog.Builder builder) {
if (!showScheduled.compareAndSet(false, true)) {
return;
}
GlobalAppContext.postDelayed(() -> {
// Skip showing if operation already finished/aborted.
// zh-CN: 若操作已结束/已中止, 则不再显示对话框.
if (closed.get() || controller.cancelled.get()) {
return;
}
MaterialDialog dialog = DialogUtils.buildAndShowAdaptive(builder::build);
dialog.setProgressNumberFormat("...");
DialogUtils.applyProgressThemeColorTintLists(dialog);
// If closed becomes true right after building, dismiss immediately to avoid dangling dialog.
// zh-CN: 若 build 后立刻变为 closed, 则立即关闭, 避免对话框悬挂.
if (closed.get()) {
try {
if (dialog.isShowing()) {
dialog.dismiss();
}
} catch (Throwable ignored) {
/* Ignored. */
}
return;
}
dialogRef.set(dialog);
shown = true;
shownAtUptimeMs = SystemClock.uptimeMillis();
}, PROGRESS_DIALOG_SHOW_DELAY_MS);
}
void setContentThrottled(List<String> contentList, long[] lastUpdateTime) {
long now = SystemClock.uptimeMillis();
if (now - lastUpdateTime[0] < PROGRESS_UPDATE_MIN_INTERVAL_MS) {
return;
}
lastUpdateTime[0] = now;
MaterialDialog dialog = dialogRef.get();
if (dialog == null) {
return;
}
GlobalAppContext.post(() -> {
try {
TextView contentView = dialog.getContentView();
if (contentView != null) {
ViewUtils.setLinesEllipsizedIndividually(contentView, contentList, 1.5f);
}
} catch (Throwable ignored) {
/* Ignored. */
}
});
}
void setProgressThrottled(long progress, long totalBytes, long[] lastUpdateTime, long[] lastProgress) {
long now = SystemClock.uptimeMillis();
if (progress == lastProgress[0]) {
return;
}
if (now - lastUpdateTime[0] < PROGRESS_UPDATE_MIN_INTERVAL_MS) {
return;
}
lastUpdateTime[0] = now;
lastProgress[0] = progress;
MaterialDialog dialog = dialogRef.get();
if (dialog == null) {
return;
}
GlobalAppContext.post(() -> {
try {
DialogUtils.setProgressNumberFormatByBytes(dialog, progress, totalBytes, true);
int v;
int max;
if (totalBytes > 0 && totalBytes <= Integer.MAX_VALUE) {
max = (int) totalBytes;
v = (int) Math.max(0L, Math.min(progress, max));
} else if (totalBytes > 0) {
max = PROGRESS_MAX_LARGE;
double ratio = (double) progress / (double) totalBytes;
v = (int) Math.round(ratio * (double) max);
v = Math.max(0, Math.min(max, v));
} else {
// No total bytes, keep indeterminate behavior.
// zh-CN: 无总字节数, 保持不确定进度行为.
return;
}
dialog.setMaxProgress(max);
dialog.setProgress(v);
} catch (Throwable ignored) {
/* Ignored. */
}
});
}
void dismissSafely() {
// Mark closed first so delayed show won't create a dialog after completion.
// zh-CN: 先标记 closed, 防止延迟 show 在完成后创建对话框.
closed.set(true);
MaterialDialog dialog = dialogRef.get();
if (dialog == null) {
return;
}
long now = SystemClock.uptimeMillis();
long delay = 0L;
if (shown) {
long shownDuration = now - shownAtUptimeMs;
if (shownDuration < PROGRESS_DIALOG_MIN_SHOW_MS) {
delay = PROGRESS_DIALOG_MIN_SHOW_MS - shownDuration;
}
}
GlobalAppContext.postDelayed(() -> {
try {
if (dialog.isShowing()) {
dialog.dismiss();
}
} catch (Throwable ignored) {
/* Ignored. */
}
}, delay);
}
}
// Controller for cancellable operations (copy/move/delete).
// zh-CN: 用于可取消操作 (复制/移动/删除) 的控制器.
private static final class OperationController {
final AtomicBoolean cancelled = new AtomicBoolean(false);
void cancel() {
cancelled.set(true);
}
void throwIfCancelled() {
if (cancelled.get()) {
throw new OperationAbortedException();
}
}
}
private static final class OperationAbortedException extends RuntimeException {
OperationAbortedException() {
super("Operation aborted");
}
}
private interface FileProgressCallback {
public interface FileProgressCallback {
void onFile(@NonNull File currentFile);
}
private interface ProgressCallback {
void onProgress(long copiedBytes, long totalBytes);
public interface ProgressCallback {
void onProgress(long processed, long total);
}
}

View File

@@ -4,7 +4,7 @@ import android.content.Context;
import android.content.Intent;
import android.os.Bundle;
import com.afollestad.materialdialogs.MaterialDialog;
import org.autojs.autojs.util.MaterialDialogUtils;
import org.autojs.autojs.util.DialogUtils;
import org.autojs.autojs.ui.BaseActivity;
import org.autojs.autojs.util.ClipboardUtils;
import org.autojs.autojs.util.IntentUtils;
@@ -65,7 +65,7 @@ public class ErrorDialogActivity extends BaseActivity {
var materialDialog = builder.show();
MaterialDialogUtils.makeTextCopyable(materialDialog, materialDialog.getContentView());
DialogUtils.makeTextCopyable(materialDialog, materialDialog.getContentView());
}
/**

View File

@@ -19,11 +19,13 @@ import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.LinearSmoothScroller
import androidx.recyclerview.widget.RecyclerView
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
import com.afollestad.materialdialogs.MaterialDialog
import io.reactivex.Completable
import io.reactivex.Observable
import io.reactivex.android.schedulers.AndroidSchedulers
import io.reactivex.disposables.Disposable
import io.reactivex.schedulers.Schedulers
import org.autojs.autojs.util.DialogUtils
import org.autojs.autojs.core.pref.Pref.getInt
import org.autojs.autojs.core.pref.Pref.getLinkedList
import org.autojs.autojs.core.pref.Pref.getStringOrNull
@@ -62,6 +64,7 @@ import org.autojs.autojs.ui.viewmodel.ExplorerItemManager.Companion.SORT_TYPE_SI
import org.autojs.autojs.ui.viewmodel.ExplorerItemManager.Companion.SORT_TYPE_TYPE
import org.autojs.autojs.ui.widget.BindableViewHolder
import org.autojs.autojs.ui.widget.FirstCharView
import org.autojs.autojs.util.ClipboardUtils
import org.autojs.autojs.util.ColorUtils
import org.autojs.autojs.util.EnvironmentUtils.externalStoragePath
import org.autojs.autojs.util.FileUtils
@@ -130,7 +133,7 @@ open class ExplorerView : ThemeColorSwipeRefreshLayout, SwipeRefreshLayout.OnRef
private var mOnItemOperateListener: OnItemOperateListener? = null
private val mPageStateHistories = Stack<ExplorerPageState>()
private val mPageStateHistory = Stack<ExplorerPageState>()
private var mDirectorySpanSize = 2
// Keep only one refresh chain running to avoid concurrent updates.
@@ -248,7 +251,7 @@ open class ExplorerView : ThemeColorSwipeRefreshLayout, SwipeRefreshLayout.OnRef
override fun onDetachedFromWindow() {
super.onDetachedFromWindow()
mExplorer?.unregisterChangeListener(this)
mPageStateHistories.clear()
mPageStateHistory.clear()
binding = null
// Cancel pending refresh to avoid late UI updates and concurrent iterations.
@@ -269,17 +272,20 @@ open class ExplorerView : ThemeColorSwipeRefreshLayout, SwipeRefreshLayout.OnRef
R.id.action_move_to -> {
ScriptOperations(context, this@ExplorerView, currentPage)
.move(selectedItem!!.toScriptFile())
.subscribe(Observers.emptyObserver())
.ignoreElements()
.subscribe({}, { showErrorDialog(it, R.string.error_failed_to_move) })
}
R.id.action_copy_to -> {
ScriptOperations(context, this@ExplorerView, currentPage)
.copy(selectedItem!!.toScriptFile())
.subscribe(Observers.emptyObserver())
.ignoreElements()
.subscribe({}, { showErrorDialog(it, R.string.error_failed_to_copy) })
}
R.id.action_rename -> {
ScriptOperations(context, this@ExplorerView, currentPage)
.rename(selectedItem as ExplorerFileItem?)
.subscribe(Observers.emptyObserver())
.ignoreElements()
.subscribe({}, { showErrorDialog(it, R.string.error_failed_to_rename) })
}
R.id.action_delete -> {
ScriptOperations(context, this@ExplorerView, currentPage)
@@ -290,7 +296,7 @@ open class ExplorerView : ThemeColorSwipeRefreshLayout, SwipeRefreshLayout.OnRef
// Show version history for this file and restore to disk when chosen.
// zh-CN: 显示该文件的版本历史, 选择后写回磁盘恢复.
VersionHistoryController(context).showForFilePath(selected.path)
VersionHistoryController(context).showForFilePath(selected.path, selected.path)
notifyItemOperated()
mRequestHostDialogHide?.run()
@@ -348,6 +354,30 @@ open class ExplorerView : ThemeColorSwipeRefreshLayout, SwipeRefreshLayout.OnRef
return true
}
private fun showErrorDialog(t: Throwable, titleRes: Int) {
DialogUtils.buildAndShowAdaptive {
MaterialDialog.Builder(context)
.title(titleRes)
.content(t.message ?: context.getString(R.string.error_unknown_exception))
.apply {
t.message?.let { tMessage ->
neutralText(R.string.dialog_button_copy)
neutralColorRes(R.color.dialog_button_hint)
onNeutral { d, _ ->
ClipboardUtils.setClip(context, tMessage)
showSnack(d.view, R.string.text_already_copied_to_clip, false)
}
}
}
.positiveText(R.string.dialog_button_dismiss)
.positiveColorRes(R.color.dialog_button_default)
.onPositive { d, _ -> d.dismiss() }
.cancelable(false)
.autoDismiss(false)
.build()
}
}
protected open fun onCreateViewHolder(inflater: LayoutInflater, parent: ViewGroup?, viewType: Int): BindableViewHolder<Any> {
return when (viewType) {
VIEW_TYPE_ITEM -> ExplorerItemViewHolder(inflater.inflate(R.layout.explorer_file, parent, false))
@@ -447,11 +477,11 @@ open class ExplorerView : ThemeColorSwipeRefreshLayout, SwipeRefreshLayout.OnRef
}
fun canGoBack(): Boolean {
return !mPageStateHistories.empty()
return !mPageStateHistory.empty()
}
fun goBack() {
setCurrentPageState(mPageStateHistories.pop())
setCurrentPageState(mPageStateHistory.pop())
refreshCurrentPage()
}
@@ -508,19 +538,19 @@ open class ExplorerView : ThemeColorSwipeRefreshLayout, SwipeRefreshLayout.OnRef
val currentPath = currentPage.path
putString(getPrefKey("explorer_current"), currentPath)
val rootPath = if (mPageStateHistories.isEmpty()) path else mPageStateHistories.firstElement().page!!.path
val rootPath = if (mPageStateHistory.isEmpty()) path else mPageStateHistory.firstElement().page!!.path
putString(getPrefKey("explorer_root"), rootPath)
val histories = LinkedList<String>()
for (explorerPageState in mPageStateHistories) {
histories.add(
val historyItems = LinkedList<String>()
for (explorerPageState in mPageStateHistory) {
historyItems.add(
(explorerPageState.page!!.path
+ "," + explorerPageState.scrollY
+ "," + explorerPageState.dirsCollapsed
+ "," + explorerPageState.filesCollapsed)
)
}
putLinkedList(getPrefKey("explorer_histories"), histories)
putLinkedList(getPrefKey("explorer_history"), historyItems)
}
// TODO by SuperMonster003 on Apr 1, 2023.
@@ -535,11 +565,11 @@ open class ExplorerView : ThemeColorSwipeRefreshLayout, SwipeRefreshLayout.OnRef
setDefaultExplorer()
}
val storedHistories = getLinkedList(getPrefKey("explorer_histories"))
for (dataString in storedHistories) {
val storedHistory = getLinkedList(getPrefKey("explorer_history"))
for (dataString in storedHistory) {
val split = dataString.split(",".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray()
val (pagePath, scrollY, dirsCollapsed, filesCollapsed) = split
mPageStateHistories.push(ExplorerPageState(ExplorerDirPage.createRoot(pagePath)).also { pageState ->
mPageStateHistory.push(ExplorerPageState(ExplorerDirPage.createRoot(pagePath)).also { pageState ->
pageState.scrollY = scrollY.toInt()
pageState.dirsCollapsed = dirsCollapsed.toBoolean()
pageState.filesCollapsed = filesCollapsed.toBoolean()
@@ -564,7 +594,7 @@ open class ExplorerView : ThemeColorSwipeRefreshLayout, SwipeRefreshLayout.OnRef
}
fun saveCurrentPageIntoHistory() {
mPageStateHistories.push(currentPageState)
mPageStateHistory.push(currentPageState)
}
fun setDirectorySpanSize(directorySpanSize: Int) {
@@ -1013,6 +1043,9 @@ open class ExplorerView : ThemeColorSwipeRefreshLayout, SwipeRefreshLayout.OnRef
if (!mExplorerItem.canDelete()) {
menu.removeItem(R.id.action_delete)
}
if (!mExplorerItem.isTextEditable) {
menu.removeItem(R.id.action_version_history)
}
if (!mExplorerItem.canBuildApk()) {
menu.removeItem(R.id.action_build_apk)
}
@@ -1101,15 +1134,18 @@ open class ExplorerView : ThemeColorSwipeRefreshLayout, SwipeRefreshLayout.OnRef
R.id.action_copy_to ->
ScriptOperations(context, explorerView, currentPage)
.copy(selectedItem.toScriptFile())
.subscribe(Observers.emptyObserver())
.ignoreElements()
.subscribe({}, { showErrorDialog(it, R.string.error_failed_to_copy) })
R.id.action_move_to ->
ScriptOperations(context, explorerView, currentPage)
.move(selectedItem.toScriptFile())
.subscribe(Observers.emptyObserver())
.ignoreElements()
.subscribe({}, { showErrorDialog(it, R.string.error_failed_to_move) })
R.id.action_rename ->
ScriptOperations(context, explorerView, currentPage)
.rename(selectedItem as? ExplorerFileItem)
.subscribe(Observers.emptyObserver())
.ignoreElements()
.subscribe({}, { showErrorDialog(it, R.string.error_failed_to_rename) })
R.id.action_delete ->
ScriptOperations(context, explorerView, currentPage)
.delete(selectedItem.toScriptFile())
@@ -1290,7 +1326,7 @@ open class ExplorerView : ThemeColorSwipeRefreshLayout, SwipeRefreshLayout.OnRef
remove(getPrefKey("page_state"))
remove(getPrefKey("explorer_current"))
remove(getPrefKey("explorer_root"))
remove(getPrefKey("explorer_histories"))
remove(getPrefKey("explorer_history"))
}
fun getPrefKey(key: String): String = ExplorerView::class.java.simpleName + '.' + key

View File

@@ -7,7 +7,7 @@ import androidx.annotation.StringRes;
import com.afollestad.materialdialogs.MaterialDialog;
import io.reactivex.Observable;
import io.reactivex.subjects.PublishSubject;
import org.autojs.autojs.app.DialogUtils;
import org.autojs.autojs.util.DialogUtils;
import org.autojs.autojs.model.explorer.Explorer;
import org.autojs.autojs.model.explorer.ExplorerDirPage;
import org.autojs.autojs.model.explorer.ExplorerFileProvider;

View File

@@ -14,7 +14,7 @@ import com.makeramen.roundedimageview.RoundedImageView;
import org.autojs.autojs.AutoJs;
import org.autojs.autojs.app.AppLevelThemeDialogBuilder;
import org.autojs.autojs.app.CircularMenuOperationDialogBuilder;
import org.autojs.autojs.app.DialogUtils;
import org.autojs.autojs.util.DialogUtils;
import org.autojs.autojs.app.tool.PointerLocationTool;
import org.autojs.autojs.core.accessibility.AccessibilityTool;
import org.autojs.autojs.core.accessibility.Capture;

View File

@@ -20,7 +20,7 @@ import com.bignerdranch.expandablerecyclerview.ParentViewHolder;
import com.bignerdranch.expandablerecyclerview.model.Parent;
import org.autojs.autojs.app.AppLevelThemeDialogBuilder;
import org.autojs.autojs.app.DialogUtils;
import org.autojs.autojs.util.DialogUtils;
import org.autojs.autojs.codegeneration.CodeGenerator;
import org.autojs.autojs.core.accessibility.NodeInfo;
import org.autojs.autojs.theme.util.ListBuilder;

View File

@@ -5,7 +5,7 @@ import android.view.ContextThemeWrapper
import android.view.View
import android.view.ViewGroup
import org.autojs.autojs.app.AppLevelThemeDialogBuilder
import org.autojs.autojs.app.DialogUtils.showAdaptive
import org.autojs.autojs.util.DialogUtils.showAdaptive
import org.autojs.autojs.core.accessibility.Capture
import org.autojs.autojs.core.accessibility.NodeInfo
import org.autojs.autojs.core.accessibility.WindowInfo

View File

@@ -13,6 +13,7 @@ import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import io.reactivex.Observable
import io.reactivex.android.schedulers.AndroidSchedulers
import io.reactivex.disposables.CompositeDisposable
import org.autojs.autojs.AutoJs
import org.autojs.autojs.app.tool.FloatingButtonTool
import org.autojs.autojs.app.tool.JsonSocketClientTool
@@ -21,6 +22,7 @@ import org.autojs.autojs.app.tool.PointerLocationTool
import org.autojs.autojs.core.accessibility.AccessibilityTool
import org.autojs.autojs.core.plugin.center.PluginCenterActivity
import org.autojs.autojs.core.pref.Pref
import org.autojs.autojs.core.pref.PrefRx
import org.autojs.autojs.external.foreground.AppForegroundService
import org.autojs.autojs.permission.AllFilesAccessPermission
import org.autojs.autojs.permission.DisplayOverOtherAppsPermission
@@ -36,10 +38,13 @@ import org.autojs.autojs.permission.XiaomiBackgroundPopupPermission
import org.autojs.autojs.pluginclient.DevPluginService
import org.autojs.autojs.pluginclient.JsonSocketClient
import org.autojs.autojs.pluginclient.JsonSocketServer
import org.autojs.autojs.pio.PFiles
import org.autojs.autojs.runtime.api.WrappedShizuku
import org.autojs.autojs.service.AccessibilityService
import org.autojs.autojs.service.ForegroundService
import org.autojs.autojs.service.NotificationService
import org.autojs.autojs.storage.history.HistoryDatabase
import org.autojs.autojs.storage.history.HistoryPrefs
import org.autojs.autojs.theme.ThemeChangeNotifier
import org.autojs.autojs.theme.app.ColorSelectBaseActivity
import org.autojs.autojs.ui.floating.CircularMenu
@@ -66,18 +71,26 @@ import org.greenrobot.eventbus.EventBus
import org.greenrobot.eventbus.Subscribe
import rikka.shizuku.Shizuku
import java.lang.ref.WeakReference
import java.util.Locale
import kotlin.math.roundToInt
import org.autojs.autojs.util.App as UtilApp
/**
* Created by Stardust on Jan 30, 2017.
* Modified by SuperMonster003 as of Jan 9, 2026.
* Transformed by SuperMonster003 on Sep 19, 2022.
* Modified by SuperMonster003 as of Jan 9, 2026.
* Modified by JetBrains AI Assistant (GPT-5.2) as of Feb 5, 2026.
*/
open class DrawerFragment : Fragment() {
private val binding by viewBinding(FragmentDrawerBinding::bind)
/**
* Rx disposables for drawer subtitle observing.
* zh-CN: 抽屉子标题监听的 Rx 订阅管理器.
*/
private val drawerStatsDisposables: CompositeDisposable = CompositeDisposable()
private var privateDrawerMenu: RecyclerView? = null
private var privateContext: WeakReference<Context?>? = null
private var privateActivity: WeakReference<MainActivity?>? = null
@@ -632,8 +645,88 @@ open class DrawerFragment : Fragment() {
initMenuItems()
initMenuItemStates()
setupListeners()
// Start observing stats for drawer subtitles.
// zh-CN: 开始监听抽屉子标题所需的统计信息.
observeTrashAndHistoryDrawerSubtitles()
}
private fun observeTrashAndHistoryDrawerSubtitles() {
val appCtx = mContext.applicationContext
val dao = HistoryDatabase.getInstance(appCtx).historyDao()
// Trash: observe DB stats + observe limit preference.
// zh-CN: 回收站: 监听 DB 统计 + 监听容量上限偏好.
val trashLimitFlow = PrefRx.observeLong(
keyRes = R.string.key_trash_max_total_bytes,
defaultValue = HistoryPrefs.DEFAULT_TRASH_MAX_TOTAL_BYTES,
)
val trashDisposable = Observable
.combineLatest(
dao.observeTrashStats().toObservable(),
trashLimitFlow.toObservable(),
) { stats, limitBytes ->
val count = stats.count.coerceAtLeast(0L)
val totalBytes = (stats.totalBytes ?: 0L).coerceAtLeast(0L)
val limit = limitBytes.coerceAtLeast(0L)
val sizeText = PFiles.formatSizeWithUnit(totalBytes)
val percentText = if (limit > 0L) {
val ratio = (totalBytes.toDouble() / limit.toDouble()).coerceAtLeast(0.0)
String.format(Locale.getDefault(), "%.1f%%", ratio * 100.0)
} else {
String.format(Locale.getDefault(), "%.1f%%", 0.0)
}
"$count | $sizeText | $percentText"
}
.observeOn(AndroidSchedulers.mainThread())
.subscribe { subtitle ->
mTrashItem.subtitle = subtitle
(mDrawerMenu.adapter as? DrawerMenuAdapter)?.notifyItemChanged(mTrashItem)
}
drawerStatsDisposables.add(trashDisposable)
// Version history: observe DB stats + observe limit preference.
// zh-CN: 版本历史: 监听 DB 统计 + 监听容量上限偏好.
val historyLimitFlow = PrefRx.observeLong(
keyRes = R.string.key_history_max_total_bytes,
defaultValue = HistoryPrefs.DEFAULT_HISTORY_MAX_TOTAL_BYTES,
)
val historyDisposable = Observable
.combineLatest(
dao.observeVersionHistoryStats().toObservable(),
historyLimitFlow.toObservable(),
) { stats, limitBytes ->
val count = stats.fileCount.coerceAtLeast(0L)
val totalBytes = (stats.totalBytes ?: 0L).coerceAtLeast(0L)
val limit = limitBytes.coerceAtLeast(0L)
val sizeText = PFiles.formatSizeWithUnit(totalBytes)
val percentText = if (limit > 0L) {
val ratio = (totalBytes.toDouble() / limit.toDouble()).coerceAtLeast(0.0)
String.format(Locale.getDefault(), "%.1f%%", ratio * 100.0)
} else {
String.format(Locale.getDefault(), "%.1f%%", 0.0)
}
"$count | $sizeText | $percentText"
}
.observeOn(AndroidSchedulers.mainThread())
.subscribe { subtitle ->
mVersionHistoryItem.subtitle = subtitle
(mDrawerMenu.adapter as? DrawerMenuAdapter)?.notifyItemChanged(mVersionHistoryItem)
}
drawerStatsDisposables.add(historyDisposable)
}
private fun configureDrawerWidth() {
val screenWidthDp = resources.configuration.screenWidthDp
val targetWidthPx = 288f.coerceIn(
@@ -659,7 +752,13 @@ open class DrawerFragment : Fragment() {
super.onDestroy()
mClientModeItem.dispose()
mServerModeItem.dispose()
// mActivity.unregisterReceiver(mReceiver)
// Dispose drawer subtitle observers.
// zh-CN: 释放抽屉子标题监听订阅.
drawerStatsDisposables.clear()
EventBus.getDefault().unregister(this)
}

View File

@@ -8,7 +8,7 @@ import io.reactivex.android.schedulers.AndroidSchedulers
import io.reactivex.schedulers.Schedulers
import org.autojs.autojs.core.pref.Pref
import org.autojs.autojs.ui.common.NotAskAgainDialog
import org.autojs.autojs.util.MaterialDialogUtils.widgetThemeColor
import org.autojs.autojs.util.DialogUtils.widgetThemeColor
import org.autojs.autojs.util.ViewUtils
import org.autojs.autojs6.R

View File

@@ -25,10 +25,10 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.selects.select
import kotlinx.coroutines.withContext
import net.dongliu.apk.parser.ApkFile
import org.autojs.autojs.app.DialogUtils.showAdaptive
import org.autojs.autojs.util.MaterialDialogUtils.makeSettingsLaunchable
import org.autojs.autojs.util.MaterialDialogUtils.makeTextCopyable
import org.autojs.autojs.util.MaterialDialogUtils.setCopyableTextIfAbsent
import org.autojs.autojs.util.DialogUtils.showAdaptive
import org.autojs.autojs.util.DialogUtils.makeSettingsLaunchable
import org.autojs.autojs.util.DialogUtils.makeTextCopyable
import org.autojs.autojs.util.DialogUtils.setCopyableTextIfAbsent
import org.autojs.autojs.external.fileprovider.AppFileProvider
import org.autojs.autojs.pio.PFiles
import org.autojs.autojs.runtime.api.AppUtils

View File

@@ -13,9 +13,9 @@ import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.autojs.autojs.app.DialogUtils.showAdaptive
import org.autojs.autojs.util.MaterialDialogUtils.makeTextCopyable
import org.autojs.autojs.util.MaterialDialogUtils.setCopyableTextIfAbsent
import org.autojs.autojs.util.DialogUtils.showAdaptive
import org.autojs.autojs.util.DialogUtils.makeTextCopyable
import org.autojs.autojs.util.DialogUtils.setCopyableTextIfAbsent
import org.autojs.autojs.external.fileprovider.AppFileProvider
import org.autojs.autojs.pio.PFiles
import org.autojs.autojs.runtime.api.Mime
@@ -38,6 +38,8 @@ object EditableFileInfoDialogManager {
MaterialDialog.Builder(context)
.title(R.string.text_failed)
.content(R.string.file_not_exist_or_readable)
.positiveText(R.string.dialog_button_dismiss)
.positiveColorRes(R.color.dialog_button_default)
.showAdaptive()
return
}

View File

@@ -17,9 +17,9 @@ import kotlinx.coroutines.async
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.autojs.autojs.app.DialogUtils.showAdaptive
import org.autojs.autojs.util.MaterialDialogUtils.makeTextCopyable
import org.autojs.autojs.util.MaterialDialogUtils.setCopyableText
import org.autojs.autojs.util.DialogUtils.showAdaptive
import org.autojs.autojs.util.DialogUtils.makeTextCopyable
import org.autojs.autojs.util.DialogUtils.setCopyableText
import org.autojs.autojs.model.explorer.ExplorerItem
import org.autojs.autojs.util.ViewUtils
import org.autojs.autojs6.R

View File

@@ -35,7 +35,7 @@ import net.dongliu.apk.parser.bean.ApkMeta;
import org.autojs.autojs.apkbuilder.ApkBuilder;
import org.autojs.autojs.apkbuilder.keystore.KeyStore;
import org.autojs.autojs.core.pref.Language;
import org.autojs.autojs.util.MaterialDialogUtils;
import org.autojs.autojs.util.DialogUtils;
import org.autojs.autojs.external.fileprovider.AppFileProvider;
import org.autojs.autojs.model.explorer.Explorers;
import org.autojs.autojs.model.script.ScriptFile;
@@ -422,7 +422,7 @@ public class BuildActivity extends BaseActivity implements ApkBuilder.ProgressCa
getString(R.string.uri_autojs6_download_link));
builder.positiveText(R.string.dialog_button_dismiss);
builder.positiveColorRes(R.color.dialog_button_hint);
MaterialDialogUtils.widgetThemeColor(builder);
DialogUtils.widgetThemeColor(builder);
MaterialDialog dialog = builder.show();
if (dialog != null) {
TextView contentView = dialog.getContentView();

View File

@@ -11,7 +11,7 @@ import com.afollestad.materialdialogs.MaterialDialog
import com.jaredrummler.android.widget.AnimatedSvgView
import de.psdev.licensesdialog.LicenseResolver
import de.psdev.licensesdialog.LicensesDialog
import org.autojs.autojs.util.MaterialDialogUtils.widgetThemeColor
import org.autojs.autojs.util.DialogUtils.widgetThemeColor
import org.autojs.autojs.network.UpdateChecker
import org.autojs.autojs.network.UpdateChecker.PromptMode
import org.autojs.autojs.ui.BaseActivity
@@ -66,7 +66,7 @@ open class AboutActivity : BaseActivity() {
functionsButtonsBinding.aboutFunctionsButtonLicenses.setOnClickListener { showLicensesDialog() }
functionsButtonsBinding.aboutFunctionsButtonUpdate.setOnClickListener { checkForUpdates() }
functionsButtonsBinding.aboutFunctionsButtonVersionHistories.setOnClickListener { showVersionHistories() }
functionsButtonsBinding.aboutFunctionsButtonReleaseHistory.setOnClickListener { showReleaseHistory() }
functionsButtonsBinding.aboutFunctionsButtonFeedback.setOnClickListener { startFeedbackActivity() }
}
@@ -168,8 +168,8 @@ open class AboutActivity : BaseActivity() {
.build().checkNow()
}
private fun showVersionHistories() {
DisplayVersionHistoriesActivity.launch(this)
private fun showReleaseHistory() {
DisplayReleaseHistoryActivity.launch(this)
}
private fun startFeedbackActivity() {

View File

@@ -31,25 +31,25 @@ import org.autojs.autojs.core.pref.Language.Companion.getPrefLanguageOrNull
import org.autojs.autojs.theme.ThemeColorHelper
import org.autojs.autojs.theme.widget.ThemeColorToolbar
import org.autojs.autojs.ui.BaseActivity
import org.autojs.autojs.ui.settings.VersionHistoryRepository.Companion.Category
import org.autojs.autojs.ui.settings.VersionHistoryRepository.Companion.DEFAULT_FILTER
import org.autojs.autojs.ui.settings.VersionHistoryRepository.Companion.DEFAULT_VERSION_NAME
import org.autojs.autojs.ui.settings.ReleaseHistoryRepository.Companion.Category
import org.autojs.autojs.ui.settings.ReleaseHistoryRepository.Companion.DEFAULT_FILTER
import org.autojs.autojs.ui.settings.ReleaseHistoryRepository.Companion.DEFAULT_VERSION_NAME
import org.autojs.autojs.util.IntentUtils.startSafely
import org.autojs.autojs.util.MaterialDialogUtils.choiceWidgetThemeColor
import org.autojs.autojs.util.DialogUtils.choiceWidgetThemeColor
import org.autojs.autojs.util.ProcessLogger
import org.autojs.autojs.util.ViewUtils.excludePaddingClippableViewFromBottomNavigationBar
import org.autojs.autojs.util.ViewUtils.setMenuIconsColorByThemeColorLuminance
import org.autojs.autojs.util.ViewUtils.setNavigationIconColorByThemeColorLuminance
import org.autojs.autojs6.R
import org.autojs.autojs6.databinding.ActivityDisplayVersionHistoriesBinding
import org.autojs.autojs6.databinding.ActivityDisplayReleaseHistoryBinding
class DisplayVersionHistoriesActivity : BaseActivity() {
class DisplayReleaseHistoryActivity : BaseActivity() {
private val repo by lazy { VersionHistoryRepository() }
private val repo by lazy { ReleaseHistoryRepository() }
private lateinit var binding: ActivityDisplayVersionHistoriesBinding
private lateinit var binding: ActivityDisplayReleaseHistoryBinding
private lateinit var mAdapter: VersionHistoryAdapter
private lateinit var mAdapter: ReleaseHistoryAdapter
private lateinit var mToolbar: ThemeColorToolbar
private var mAllDataHandled = false
@@ -58,13 +58,13 @@ class DisplayVersionHistoriesActivity : BaseActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val binding = ActivityDisplayVersionHistoriesBinding.inflate(layoutInflater).also {
val binding = ActivityDisplayReleaseHistoryBinding.inflate(layoutInflater).also {
binding = it
mToolbar = binding.toolbar
setContentView(it.root)
}
val adapter = VersionHistoryAdapter(this, Markwon.create(this)).also {
val adapter = ReleaseHistoryAdapter(this, Markwon.create(this)).also {
mAdapter = it
}
@@ -86,8 +86,8 @@ class DisplayVersionHistoriesActivity : BaseActivity() {
val urlBlob = "https://github.com/SuperMonster003/AutoJs6/blob/master/$urlSuffix"
val localItems = withContext(Dispatchers.IO) {
VersionHistoryRepository.readBestLocalSample(
context = this@DisplayVersionHistoriesActivity,
ReleaseHistoryRepository.readBestLocalSample(
context = this@DisplayReleaseHistoryActivity,
languageTag = languageTag,
)
}
@@ -103,8 +103,8 @@ class DisplayVersionHistoriesActivity : BaseActivity() {
var onlineItemLatestVersion: String? = null
var shouldContinueCollect = true
repo.loadVersionHistoriesFlow(
context = this@DisplayVersionHistoriesActivity,
repo.loadReleaseHistoryFlow(
context = this@DisplayReleaseHistoryActivity,
languageTag = languageTag,
urlRaw = urlRaw,
urlBlob = urlBlob
@@ -115,7 +115,7 @@ class DisplayVersionHistoriesActivity : BaseActivity() {
}
if (onlineItemLatestVersion == null) {
onlineItemLatestVersion = onlineItem.version
if (VersionHistoryRepository.compareVersion(localItemLatestVersion, onlineItemLatestVersion) > 0) {
if (ReleaseHistoryRepository.compareVersion(localItemLatestVersion, onlineItemLatestVersion) > 0) {
shouldContinueCollect = false
ProcessLogger.i(getString(R.string.logger_ver_history_local_data_newer_stop_online))
return@collectLatest
@@ -128,7 +128,7 @@ class DisplayVersionHistoriesActivity : BaseActivity() {
mAllDataHandled = true
}
setToolbarAsBack(R.string.text_version_histories)
setToolbarAsBack(R.string.text_release_history)
}
private suspend fun hideLoadingTextContainerIfNeeded() {
@@ -144,7 +144,7 @@ class DisplayVersionHistoriesActivity : BaseActivity() {
}
override fun onCreateOptionsMenu(menu: Menu): Boolean {
menuInflater.inflate(R.menu.menu_display_version_histories, menu)
menuInflater.inflate(R.menu.menu_display_release_history, menu)
setUpToolbarColors()
return super.onCreateOptionsMenu(menu)
}
@@ -253,7 +253,7 @@ class DisplayVersionHistoriesActivity : BaseActivity() {
@JvmStatic
fun launch(context: Context) {
Intent(context, DisplayVersionHistoriesActivity::class.java)
Intent(context, DisplayReleaseHistoryActivity::class.java)
.startSafely(context)
}

View File

@@ -13,7 +13,7 @@ import android.util.AttributeSet
import androidx.preference.PreferenceManager
import com.afollestad.materialdialogs.MaterialDialog
import org.autojs.autojs.core.pref.Pref
import org.autojs.autojs.util.MaterialDialogUtils.widgetThemeColor
import org.autojs.autojs.util.DialogUtils.widgetThemeColor
import org.autojs.autojs.external.shortcut.ShortcutActivity
import org.autojs.autojs.theme.preference.MaterialListPreference
import org.autojs.autojs.ui.common.NotAskAgainDialog

View File

@@ -11,33 +11,33 @@ import android.widget.TextView
import androidx.core.view.isVisible
import androidx.recyclerview.widget.RecyclerView
import io.noties.markwon.Markwon
import org.autojs.autojs.ui.settings.VersionHistoryAdapter.VersionHistoryViewHolder
import org.autojs.autojs.ui.settings.VersionHistoryRepository.Companion.Category
import org.autojs.autojs.ui.settings.VersionHistoryRepository.Companion.DEFAULT_FILTER
import org.autojs.autojs.ui.settings.VersionHistoryRepository.Companion.compareVersion
import org.autojs.autojs.ui.settings.ReleaseHistoryAdapter.ReleaseHistoryViewHolder
import org.autojs.autojs.ui.settings.ReleaseHistoryRepository.Companion.Category
import org.autojs.autojs.ui.settings.ReleaseHistoryRepository.Companion.DEFAULT_FILTER
import org.autojs.autojs.ui.settings.ReleaseHistoryRepository.Companion.compareVersion
import org.autojs.autojs.util.ProcessLogger
import org.autojs.autojs6.R
import org.autojs.autojs6.databinding.ItemVersionHistoryBinding
import org.autojs.autojs6.databinding.ItemReleaseHistoryBinding
@SuppressLint("NotifyDataSetChanged")
class VersionHistoryAdapter(private val context: Context, private val markwon: Markwon) : RecyclerView.Adapter<VersionHistoryViewHolder>() {
class ReleaseHistoryAdapter(private val context: Context, private val markwon: Markwon) : RecyclerView.Adapter<ReleaseHistoryViewHolder>() {
private val mData = mutableListOf<VersionHistoryItem>()
private val mData = mutableListOf<ReleaseHistoryItem>()
private var mLayoutManager: RecyclerView.LayoutManager? = null
private val mCategoryFilter: MutableSet<Category> = DEFAULT_FILTER.clone()
fun submit(list: MutableList<VersionHistoryItem>) {
fun submit(list: MutableList<ReleaseHistoryItem>) {
mData.clear()
mData += list
notifyDataSetChanged()
}
fun add(item: VersionHistoryItem) {
fun add(item: ReleaseHistoryItem) {
mData += item
notifyItemInserted(mData.lastIndex)
}
fun addAll(list: List<VersionHistoryItem>) {
fun addAll(list: List<ReleaseHistoryItem>) {
val start = mData.size
mData += list
notifyItemRangeInserted(start, list.size)
@@ -49,7 +49,7 @@ class VersionHistoryAdapter(private val context: Context, private val markwon: M
notifyDataSetChanged()
}
fun addOrUpdate(item: VersionHistoryItem) {
fun addOrUpdate(item: ReleaseHistoryItem) {
when (val idx = mData.indexOfFirst { compareVersion(it.version, item.version) == 0 }) {
-1 -> {
ProcessLogger.i("${item.version}: ${context.getString(R.string.logger_ver_history_insert_new_entries)}")
@@ -60,8 +60,8 @@ class VersionHistoryAdapter(private val context: Context, private val markwon: M
mLayoutManager?.scrollToPosition(0)
}
else -> {
fun sameDate(local: VersionHistoryItem) = local.date == item.date
fun sameLines(local: VersionHistoryItem) = local.lines.joinToString(",") { it.trim() } == item.lines.joinToString(",") { it.trim() }
fun sameDate(local: ReleaseHistoryItem) = local.date == item.date
fun sameLines(local: ReleaseHistoryItem) = local.lines.joinToString(",") { it.trim() } == item.lines.joinToString(",") { it.trim() }
val local = mData[idx]
if (sameDate(local) && sameLines(local)) {
ProcessLogger.i("${item.version}: ${context.getString(R.string.logger_ver_history_no_processing_needed)}")
@@ -88,18 +88,18 @@ class VersionHistoryAdapter(private val context: Context, private val markwon: M
mLayoutManager = recyclerView.layoutManager
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VersionHistoryViewHolder {
val binding = ItemVersionHistoryBinding.inflate(LayoutInflater.from(parent.context), parent, false)
return VersionHistoryViewHolder(parent.context, binding)
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ReleaseHistoryViewHolder {
val binding = ItemReleaseHistoryBinding.inflate(LayoutInflater.from(parent.context), parent, false)
return ReleaseHistoryViewHolder(parent.context, binding)
}
override fun getItemCount() = mData.size
override fun onBindViewHolder(holder: VersionHistoryViewHolder, position: Int) {
override fun onBindViewHolder(holder: ReleaseHistoryViewHolder, position: Int) {
holder.bind(mData[position], animate = false)
}
override fun onBindViewHolder(holder: VersionHistoryViewHolder, position: Int, payloads: MutableList<Any>) {
override fun onBindViewHolder(holder: ReleaseHistoryViewHolder, position: Int, payloads: MutableList<Any>) {
val animate = payloads.contains(PAYLOAD_EXPAND_STATE_CHANGED)
holder.bind(mData[position], animate)
}
@@ -114,7 +114,7 @@ class VersionHistoryAdapter(private val context: Context, private val markwon: M
notifyItemRangeChanged(0, itemCount, PAYLOAD_EXPAND_STATE_CHANGED)
}
inner class VersionHistoryViewHolder(context: Context, binding: ItemVersionHistoryBinding) : RecyclerView.ViewHolder(binding.root) {
inner class ReleaseHistoryViewHolder(context: Context, binding: ItemReleaseHistoryBinding) : RecyclerView.ViewHolder(binding.root) {
private val chevron = binding.chevron
@@ -140,7 +140,7 @@ class VersionHistoryAdapter(private val context: Context, private val markwon: M
private val changelogLabelImprovement = "`${context.getString(R.string.changelog_label_improvement)}`"
private val changelogLabelDependency = "`${context.getString(R.string.changelog_label_dependency)}`"
fun bind(item: VersionHistoryItem, animate: Boolean) {
fun bind(item: ReleaseHistoryItem, animate: Boolean) {
val rotationAngle = if (item.expanded) 180f else 0f
chevron.animate().cancel()
when (animate) {
@@ -205,7 +205,7 @@ class VersionHistoryAdapter(private val context: Context, private val markwon: M
companion object {
private val TAG = VersionHistoryAdapter::class.java.simpleName
private val TAG = ReleaseHistoryAdapter::class.java.simpleName
private const val PAYLOAD_EXPAND_STATE_CHANGED = 1

View File

@@ -1,6 +1,6 @@
package org.autojs.autojs.ui.settings
data class VersionHistoryItem(
data class ReleaseHistoryItem(
val version: String,
val date: String,
val lines: List<String>,

View File

@@ -7,7 +7,7 @@ import org.autojs.autojs.theme.preference.MaterialPreference
/**
* Created by SuperMonster003 on May 1, 2025.
*/
class VersionHistoryPreference : MaterialPreference {
class ReleaseHistoryPreference : MaterialPreference {
constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int, defStyleRes: Int) : super(context, attrs, defStyleAttr, defStyleRes)
@@ -17,6 +17,6 @@ class VersionHistoryPreference : MaterialPreference {
constructor(context: Context) : super(context)
override fun onClick() = DisplayVersionHistoriesActivity.launch(context)
override fun onClick() = DisplayReleaseHistoryActivity.launch(context)
}

View File

@@ -1,7 +1,6 @@
package org.autojs.autojs.ui.settings
import android.content.Context
import androidx.annotation.StringRes
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.channelFlow
@@ -18,16 +17,16 @@ import java.io.File
import org.autojs.autojs6.R
import java.util.EnumSet
class VersionHistoryRepository {
class ReleaseHistoryRepository {
private val mOkHttpClient by lazy { OkHttpClient() }
fun loadVersionHistoriesFlow(
fun loadReleaseHistoryFlow(
context: Context,
languageTag: String,
urlRaw: String,
urlBlob: String,
): Flow<VersionHistoryItem> = channelFlow {
): Flow<ReleaseHistoryItem> = channelFlow {
runCatching {
ProcessLogger.i(context.getString(R.string.logger_ver_history_start_raw_thread))
ProcessLogger.i("URL: $urlRaw")
@@ -107,7 +106,7 @@ class VersionHistoryRepository {
return "CHANGELOG-$this.cache.md"
}
suspend fun readBestLocalSample(context: Context, languageTag: String): List<VersionHistoryItem> {
suspend fun readBestLocalSample(context: Context, languageTag: String): List<ReleaseHistoryItem> {
val assetStr = runCatching {
context.assets.open("doc/${languageTag.toChangelogMarkdownFile()}").bufferedReader().use { it.readText() }
}.getOrNull()
@@ -162,14 +161,14 @@ class VersionHistoryRepository {
File(context.filesDir, languageTag.toChangelogMarkdownCacheFile()).writeText(markdown)
}
private fun parseMarkdownFlow(md: String): Flow<VersionHistoryItem> = channelFlow {
private fun parseMarkdownFlow(md: String): Flow<ReleaseHistoryItem> = channelFlow {
var curTitle = ""
var curDate = ""
val bodyLines = mutableListOf<String>()
fun flush(isClose: Boolean = false) {
if (curTitle.isNotBlank()) {
trySend(VersionHistoryItem(curTitle, curDate, bodyLines.toList()))
trySend(ReleaseHistoryItem(curTitle, curDate, bodyLines.toList()))
bodyLines.clear()
}
if (isClose) close()
@@ -206,7 +205,7 @@ class VersionHistoryRepository {
val lines = ul.getElementsByTag("li").map { li ->
TextUtils.htmlToMarkdown(li.outerHtml()).trim()
}
send(VersionHistoryItem(version, date, lines))
send(ReleaseHistoryItem(version, date, lines))
}
}
}
@@ -225,4 +224,4 @@ class VersionHistoryRepository {
}
}
}

View File

@@ -16,8 +16,8 @@ import com.afollestad.materialdialogs.MaterialDialog
import io.reactivex.Observable
import io.reactivex.android.schedulers.AndroidSchedulers
import io.reactivex.schedulers.Schedulers
import org.autojs.autojs.app.DialogUtils
import org.autojs.autojs.app.DialogUtils.showAdaptive
import org.autojs.autojs.util.DialogUtils
import org.autojs.autojs.util.DialogUtils.showAdaptive
import org.autojs.autojs.storage.file.FileObservable
import org.autojs.autojs.theme.ThemeColorHelper
import org.autojs.autojs.theme.preference.MaterialPreference
@@ -25,7 +25,7 @@ import org.autojs.autojs.tool.SimpleObserver
import org.autojs.autojs.ui.filechooser.FileChooserDialogBuilder
import org.autojs.autojs.ui.main.MainActivity
import org.autojs.autojs.util.EnvironmentUtils
import org.autojs.autojs.util.MaterialDialogUtils.choiceWidgetThemeColor
import org.autojs.autojs.util.DialogUtils.choiceWidgetThemeColor
import org.autojs.autojs.util.ViewUtils
import org.autojs.autojs.util.WorkingDirectoryUtils
import org.autojs.autojs6.R
@@ -103,14 +103,14 @@ class WorkingDirectoryPreference : MaterialPreference {
.neutralColorRes(R.color.dialog_button_hint)
.onNeutral { _, _ ->
MaterialDialog.Builder(prefContext)
.title(R.string.text_histories)
.content(R.string.text_no_histories)
.items(WorkingDirectoryUtils.histories)
.itemsCallback { dHistories, _, _, text ->
dHistories.dismiss()
.title(R.string.text_history)
.content(R.string.text_no_history)
.items(WorkingDirectoryUtils.history)
.itemsCallback { dHistory, _, _, text ->
dHistory.dismiss()
mContentView.setText(text)
}
.itemsLongCallback { dHistories, _, _, text ->
.itemsLongCallback { dHistory, _, _, text ->
false.also {
MaterialDialog.Builder(prefContext)
.title(R.string.text_prompt)
@@ -120,11 +120,11 @@ class WorkingDirectoryPreference : MaterialPreference {
.positiveColorRes(R.color.dialog_button_caution)
.onPositive { ds, _ ->
ds.dismiss()
WorkingDirectoryUtils.removeFromHistories(text)
dHistories.items?.let {
WorkingDirectoryUtils.removeFromHistory(text)
dHistory.items?.let {
it.remove(text)
dHistories.notifyItemsChanged()
DialogUtils.toggleContentViewByItems(dHistories)
dHistory.notifyItemsChanged()
DialogUtils.toggleContentViewByItems(dHistory)
}
}
.showAdaptive()
@@ -133,7 +133,7 @@ class WorkingDirectoryPreference : MaterialPreference {
.choiceWidgetThemeColor()
.negativeText(R.string.dialog_button_back)
.negativeColorRes(R.color.dialog_button_default)
.onNegative { dHistories, _ -> dHistories.dismiss() }
.onNegative { dHistory, _ -> dHistory.dismiss() }
.autoDismiss(false)
.showAdaptive()
.also { DialogUtils.toggleContentViewByItems(it) }
@@ -144,7 +144,7 @@ class WorkingDirectoryPreference : MaterialPreference {
.positiveColorRes(R.color.dialog_button_attraction)
.onPositive { dialog, _ ->
val inputPath = mContentView.text.toString()
WorkingDirectoryUtils.addIntoHistories(inputPath)
WorkingDirectoryUtils.addIntoHistory(inputPath)
if (isDirPathChanged(inputPath)) {
if (mSwitchView.isChecked) {
transfer(mPrefFullPath, inputPath)

View File

@@ -0,0 +1,305 @@
package org.autojs.autojs.ui.storage
import android.os.Bundle
import android.view.Menu
import android.view.MenuItem
import android.view.View
import android.widget.TextView
import androidx.annotation.MenuRes
import androidx.appcompat.widget.SearchView
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.DividerItemDecoration.VERTICAL
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import androidx.viewbinding.ViewBinding
import com.afollestad.materialdialogs.MaterialDialog
import io.reactivex.android.schedulers.AndroidSchedulers
import io.reactivex.schedulers.Schedulers
import org.autojs.autojs.util.DialogUtils
import org.autojs.autojs.theme.widget.ThemeColorSwipeRefreshLayout
import org.autojs.autojs.theme.widget.ThemeColorToolbar
import org.autojs.autojs.ui.BaseActivity
import org.autojs.autojs.util.ViewUtils.excludePaddingClippableViewFromBottomNavigationBar
import org.autojs.autojs.util.ViewUtils.onceGlobalLayout
import org.autojs.autojs.util.ViewUtils.setColorsByThemeColorLuminance
import org.autojs.autojs.util.ViewUtils.setMenuIconsColorByThemeColorLuminance
import org.autojs.autojs.util.ViewUtils.setNavigationIconColorByThemeColorLuminance
import org.autojs.autojs.util.ViewUtils.setTitlesTextColorByThemeColorLuminance
import org.autojs.autojs6.R
import java.util.Locale
/**
* Base activity for a list page that supports swipe refresh, search, sort, and empty hint.
* zh-CN: 支持下拉刷新/搜索/排序/空态提示的列表页面基类.
*
* Notes.
* zh-CN: 说明.
* 1) This class inflates ViewBinding by subclass factory method.
* zh-CN: 1) 本类通过子类提供的工厂方法创建并持有 ViewBinding.
* 2) Subclass provides view references (toolbar/recycler/swipe/hint) via a holder.
* zh-CN: 2) 子类通过 holder 提供 toolbar/recycler/swipe/hint 等引用.
*
* Created by JetBrains AI Assistant (GPT-5.2) on Feb 5, 2026.
*/
abstract class BaseSearchSortListActivity<VB : ViewBinding, T> : BaseActivity() {
protected data class ListPageViews(
val toolbar: ThemeColorToolbar,
val recyclerView: RecyclerView,
val swipeRefreshLayout: ThemeColorSwipeRefreshLayout,
val emptyHint: TextView,
)
protected lateinit var binding: VB
protected lateinit var toolbar: ThemeColorToolbar
protected lateinit var recyclerView: RecyclerView
protected lateinit var swipeRefreshLayout: ThemeColorSwipeRefreshLayout
protected lateinit var hint: TextView
protected var searchView: SearchView? = null
protected var allItems: List<T> = emptyList()
protected var queryText: String = ""
protected var internalSortMode: CommonSortMode = CommonSortMode.TIME_DESC
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = createBinding()
setContentView(binding.root)
val views = provideViews(binding)
toolbar = views.toolbar
recyclerView = views.recyclerView
swipeRefreshLayout = views.swipeRefreshLayout
hint = views.emptyHint
recyclerView.apply {
layoutManager = LinearLayoutManager(this@BaseSearchSortListActivity)
adapter = createRecyclerAdapter()
addItemDecoration(DividerItemDecoration(context, VERTICAL))
excludePaddingClippableViewFromBottomNavigationBar()
}
swipeRefreshLayout.setOnRefreshListener { load() }
setToolbarAsBack(getToolbarTitleText())
load()
}
override fun initThemeColors() {
super.initThemeColors()
updateToolbarColors()
updateSearchViewColors()
}
override fun onCreateOptionsMenu(menu: Menu): Boolean {
menuInflater.inflate(menuResId(), menu)
setUpSearchMenuItem(menu)
updateToolbarColors()
updateSearchViewColors()
return true
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
val mappedSortMode = sortModeFromMenuItemIdOrNull(item.itemId)
if (mappedSortMode != null) {
setSortMode(mappedSortMode)
return true
}
if (onExtraMenuItemSelected(item)) {
return true
}
return super.onOptionsItemSelected(item)
}
protected open fun setSortMode(mode: CommonSortMode) {
internalSortMode = mode
applyFilterAndSort()
}
protected open fun applyFilterAndSort() {
val q = queryText.trim()
val filtered = if (q.isBlank()) {
allItems
} else {
val qLower = q.lowercase(Locale.getDefault())
allItems.filter { matchesQuery(it, qLower) }
}
val sorted = sortItems(filtered, internalSortMode)
updateHintVisibility(sorted)
swipeRefreshLayout.isRefreshing = false
submitToAdapter(sorted)
updateStats(allItems = allItems, shownItems = sorted)
}
protected fun load() {
Schedulers.io().scheduleDirect {
runCatching {
loadAllItemsInBackground()
}.onSuccess { items ->
AndroidSchedulers.mainThread().scheduleDirect {
if (isFinishing || isDestroyed) return@scheduleDirect
allItems = items
// Hook after items loaded (before filter/sort/submit).
// zh-CN: 数据加载完成后的钩子 (在过滤/排序/提交之前调用).
onItemsLoaded(items)
applyFilterAndSort()
}
}.onFailure { t ->
t.printStackTrace()
AndroidSchedulers.mainThread().scheduleDirect {
if (isFinishing || isDestroyed) return@scheduleDirect
swipeRefreshLayout.isRefreshing = false
DialogUtils.buildAndShowAdaptive {
MaterialDialog.Builder(this)
.title(R.string.text_prompt)
.content(t.message ?: t.toString())
.positiveText(R.string.dialog_button_dismiss)
.positiveColorRes(R.color.dialog_button_default)
.cancelable(true)
.build()
}
}
}
}
}
/**
* Called when allItems is updated on main thread, before applyFilterAndSort.
* zh-CN: 当 allItems 在主线程更新后, 且在调用 applyFilterAndSort 之前触发.
*/
protected open fun onItemsLoaded(items: List<T>) = Unit
protected fun updateHintVisibility(items: List<T>) {
hint.visibility = if (items.isEmpty()) View.VISIBLE else View.GONE
}
protected fun setUpSearchMenuItem(menu: Menu) {
val searchItem = menu.findItem(R.id.action_search) ?: return
searchItem.setOnActionExpandListener(object : MenuItem.OnActionExpandListener {
override fun onMenuItemActionExpand(item: MenuItem) = true.also {
toolbar.onceGlobalLayout { updateToolbarColors() }
}
override fun onMenuItemActionCollapse(item: MenuItem) = true
})
val sv = searchItem.actionView as? SearchView
searchView = sv
sv?.queryHint = getString(R.string.text_search)
sv?.setOnQueryTextListener(object : SearchView.OnQueryTextListener {
override fun onQueryTextSubmit(query: String?): Boolean {
queryText = query.orEmpty()
applyFilterAndSort()
return true
}
override fun onQueryTextChange(newText: String?): Boolean {
queryText = newText.orEmpty()
applyFilterAndSort()
return true
}
})
}
protected fun updateToolbarColors() {
toolbar.setMenuIconsColorByThemeColorLuminance(this)
toolbar.setNavigationIconColorByThemeColorLuminance(this)
toolbar.setTitlesTextColorByThemeColorLuminance(this)
}
protected fun updateSearchViewColors() {
searchView?.setColorsByThemeColorLuminance(this)
}
/**
* Create view binding instance.
* zh-CN: 创建 ViewBinding 实例.
*/
protected abstract fun createBinding(): VB
/**
* Provide view references for list page.
* zh-CN: 提供列表页所需的 view 引用.
*/
protected abstract fun provideViews(binding: VB): ListPageViews
/**
* Title text for toolbar back mode.
* zh-CN: 返回模式下 toolbar 的标题文本.
*/
protected abstract fun getToolbarTitleText(): String
/**
* Menu resource id.
* zh-CN: 菜单资源 ID.
*/
@MenuRes
protected abstract fun menuResId(): Int
/**
* Create RecyclerView adapter.
* zh-CN: 创建 RecyclerView adapter.
*/
protected abstract fun createRecyclerAdapter(): RecyclerView.Adapter<*>
/**
* Submit items to adapter.
* zh-CN: 提交列表数据到 adapter.
*/
protected abstract fun submitToAdapter(items: List<T>)
/**
* Load all items on background thread.
* zh-CN: 在后台线程加载全量数据.
*/
protected abstract fun loadAllItemsInBackground(): List<T>
/**
* Query predicate.
* zh-CN: 查询匹配规则.
*/
protected abstract fun matchesQuery(item: T, qLower: String): Boolean
/**
* Sort items.
* zh-CN: 对条目排序.
*/
protected abstract fun sortItems(items: List<T>, sortMode: CommonSortMode): List<T>
/**
* Update toolbar subtitle stats.
* zh-CN: 更新 toolbar 子标题统计信息.
*/
protected abstract fun updateStats(allItems: List<T>, shownItems: List<T>)
/**
* Map menu item id to sort mode (or null if not a sort item).
* zh-CN: 将菜单项 ID 映射为排序模式 (如果不是排序项则返回 null).
*/
protected open fun sortModeFromMenuItemIdOrNull(itemId: Int): CommonSortMode? = when (itemId) {
R.id.action_sort_time_desc -> CommonSortMode.TIME_DESC
R.id.action_sort_time_asc -> CommonSortMode.TIME_ASC
R.id.action_sort_size_desc -> CommonSortMode.SIZE_DESC
R.id.action_sort_size_asc -> CommonSortMode.SIZE_ASC
R.id.action_sort_name_asc -> CommonSortMode.NAME_ASC
R.id.action_sort_path_asc -> CommonSortMode.PATH_ASC
else -> null
}
/**
* Handle extra menu items (non-search/non-sort).
* zh-CN: 处理额外菜单项 (非搜索/非排序).
*/
protected open fun onExtraMenuItemSelected(item: MenuItem): Boolean = false
}

View File

@@ -0,0 +1,16 @@
package org.autojs.autojs.ui.storage
/**
* Common sort mode for searchable/sortable list pages.
* zh-CN: 可搜索/可排序列表页面的通用排序模式.
*
* Created by JetBrains AI Assistant (GPT-5.2) on Feb 5, 2026.
*/
enum class CommonSortMode {
TIME_DESC,
TIME_ASC,
SIZE_DESC,
SIZE_ASC,
NAME_ASC,
PATH_ASC,
}

View File

@@ -0,0 +1,84 @@
package org.autojs.autojs.ui.storage
import android.content.Context
import org.autojs.autojs.pio.PFiles
import org.autojs.autojs6.R
import java.util.Locale
/**
* Storage usage summary formatter for header/subtitle.
* zh-CN: header/subtitle 的容量摘要格式化工具.
*
* Created by JetBrains AI Assistant (GPT-5.2) on Feb 5, 2026.
*/
object StorageUsageSummaryFormatter {
private const val HEADER_PIPE: String = " | "
private const val SUBTITLE_PIPE: String = " | "
/**
* Format percent text from bytes and limit.
* zh-CN: 根据已用字节与上限字节格式化百分比文本.
*/
fun formatPercentText(bytes: Long, limitBytes: Long, locale: Locale = Locale.getDefault()): String {
val used = bytes.coerceAtLeast(0L)
val limit = limitBytes.coerceAtLeast(0L)
return if (limit > 0L) {
val ratio = (used.toDouble() / limit.toDouble()).coerceAtLeast(0.0)
String.format(locale, "%.1f%%", ratio * 100.0)
} else {
String.format(locale, "%.1f%%", 0.0)
}
}
/**
* Format header subtitle text.
* zh-CN: 格式化 header 的子标题文本.
*/
fun formatHeader(
context: Context,
count: Long,
totalBytes: Long,
limitBytes: Long,
): String {
val countSafe = count.coerceAtLeast(0L)
val bytesSafe = totalBytes.coerceAtLeast(0L)
val limitSafe = limitBytes.coerceAtLeast(0L)
val itemsPart = context.getString(R.string.text_items_count_colon_value, countSafe)
val sizeText = PFiles.formatSizeWithUnit(bytesSafe)
val percentText = formatPercentText(bytesSafe, limitSafe)
var sizePart = context.getString(R.string.text_total_size_colon_value, sizeText)
if (limitSafe > 0L) {
sizePart += " ($percentText)"
}
return listOf(itemsPart, sizePart).joinToString(HEADER_PIPE)
}
/**
* Format drawer subtitle text (compact).
* zh-CN: 格式化抽屉子标题文本 (紧凑).
*/
fun formatDrawerSubtitle(
count: Long,
totalBytes: Long,
limitBytes: Long,
locale: Locale = Locale.getDefault(),
): String {
val countSafe = count.coerceAtLeast(0L)
val bytesSafe = totalBytes.coerceAtLeast(0L)
val limitSafe = limitBytes.coerceAtLeast(0L)
val sizeText = PFiles.formatSizeWithUnit(bytesSafe)
val percentText = formatPercentText(bytesSafe, limitSafe, locale)
return listOf(
countSafe.toString(),
sizeText,
percentText,
).joinToString(SUBTITLE_PIPE)
}
}

View File

@@ -2,26 +2,24 @@ package org.autojs.autojs.ui.storage
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.view.Gravity
import android.view.View
import android.view.ViewGroup
import android.widget.LinearLayout
import android.widget.TextView
import androidx.recyclerview.widget.LinearLayoutManager
import android.view.MenuItem
import androidx.recyclerview.widget.RecyclerView
import com.afollestad.materialdialogs.MaterialDialog
import io.reactivex.Observable
import io.reactivex.android.schedulers.AndroidSchedulers
import io.reactivex.disposables.CompositeDisposable
import io.reactivex.schedulers.Schedulers
import org.autojs.autojs.app.DialogUtils
import org.autojs.autojs.util.DialogUtils
import org.autojs.autojs.core.pref.PrefRx
import org.autojs.autojs.storage.history.HistoryDatabase
import org.autojs.autojs.storage.history.HistoryPrefs
import org.autojs.autojs.storage.history.TrashEntities
import org.autojs.autojs.storage.history.TrashRepository
import org.autojs.autojs.storage.history.TrashRestoreController
import org.autojs.autojs.ui.BaseActivity
import org.autojs.autojs.util.ViewUtils
import org.autojs.autojs6.R
import org.autojs.autojs6.databinding.ActivityTrashBinding
import java.io.File
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
/**
@@ -29,68 +27,216 @@ import java.util.Locale
* zh-CN: 回收站页面.
*
* Created by JetBrains AI Assistant (GPT-5.2) on Feb 4, 2026.
* Modified by JetBrains AI Assistant (GPT-5.2) as of Feb 5, 2026.
* Modified by SuperMonster003 as of Feb 7, 2026.
*/
class TrashActivity : BaseActivity() {
private lateinit var recyclerView: RecyclerView
class TrashActivity : BaseSearchSortListActivity<ActivityTrashBinding, TrashEntities.TrashItem>() {
private val adapter = TrashAdapter(
onClick = { item ->
TrashRestoreController(this).startRestoreFlow(item)
}
val optionMenuItemRemove = MaterialDialog.OptionMenuItemSpec(getString(R.string.text_remove_from_trash)) { d ->
confirmAndRemoveFromTrash(item, d)
}
TrashRestoreController(this).startRestoreFlow(item, listOf(optionMenuItemRemove)) {
load()
}
},
onLongClick = { item ->
confirmAndRemoveFromTrash(item)
},
)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
/**
* Disposables for header observing.
* zh-CN: header 监听订阅管理器.
*/
private val headerDisposables: CompositeDisposable = CompositeDisposable()
title = getString(R.string.text_trash)
/**
* Latest computed subtitle from observers.
* zh-CN: 来自监听器的最新 subtitle 文本.
*/
private var observedSubtitle: String? = null
recyclerView = RecyclerView(this).apply {
layoutManager = LinearLayoutManager(this@TrashActivity)
adapter = this@TrashActivity.adapter
layoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT,
)
}
setContentView(recyclerView)
load()
override fun onStart() {
super.onStart()
startObserveHeaderSubtitleIfNeeded()
}
private fun load() {
override fun onStop() {
super.onStop()
// Avoid leaking Activity via subscriptions.
// zh-CN: 避免订阅持有 Activity 导致泄露.
headerDisposables.clear()
}
private fun startObserveHeaderSubtitleIfNeeded() {
headerDisposables.clear()
val dao = HistoryDatabase.getInstance(applicationContext).historyDao()
val limitBytesFlow = PrefRx.observeLong(
keyRes = R.string.key_trash_max_total_bytes,
defaultValue = HistoryPrefs.DEFAULT_TRASH_MAX_TOTAL_BYTES,
)
val disposable = Observable
.combineLatest(
dao.observeTrashStats().toObservable(),
limitBytesFlow.toObservable(),
) { stats, limitBytes ->
StorageUsageSummaryFormatter.formatHeader(
context = this,
count = stats.count,
totalBytes = (stats.totalBytes ?: 0L),
limitBytes = limitBytes,
)
}
.observeOn(AndroidSchedulers.mainThread())
.subscribe { subtitle ->
observedSubtitle = subtitle
toolbar.subtitle = subtitle
}
headerDisposables.add(disposable)
}
override fun createBinding(): ActivityTrashBinding {
return ActivityTrashBinding.inflate(layoutInflater)
}
override fun provideViews(binding: ActivityTrashBinding): ListPageViews {
return ListPageViews(
toolbar = binding.toolbar,
recyclerView = binding.recyclerView,
swipeRefreshLayout = binding.swipeRefreshLayout,
emptyHint = binding.emptyHint,
)
}
override fun getToolbarTitleText(): String {
return getString(R.string.text_trash)
}
override fun menuResId(): Int {
return R.menu.menu_trash
}
override fun createRecyclerAdapter(): RecyclerView.Adapter<*> {
return adapter
}
override fun submitToAdapter(items: List<TrashEntities.TrashItem>) {
adapter.submit(items)
}
override fun loadAllItemsInBackground(): List<TrashEntities.TrashItem> {
val dao = HistoryDatabase.getInstance(applicationContext).historyDao()
return dao.listTrashItemsDesc()
}
override fun matchesQuery(item: TrashEntities.TrashItem, qLower: String): Boolean {
val path = item.originalPath
val name = File(path).name
val locale = Locale.getDefault()
return path.lowercase(locale).contains(qLower) || name.lowercase(locale).contains(qLower)
}
override fun sortItems(
items: List<TrashEntities.TrashItem>,
sortMode: CommonSortMode,
): List<TrashEntities.TrashItem> {
val locale = Locale.getDefault()
return when (sortMode) {
CommonSortMode.TIME_DESC -> items.sortedByDescending { it.trashedAt }
CommonSortMode.TIME_ASC -> items.sortedBy { it.trashedAt }
CommonSortMode.SIZE_DESC -> items.sortedByDescending { it.sizeBytes }
CommonSortMode.SIZE_ASC -> items.sortedBy { it.sizeBytes }
CommonSortMode.NAME_ASC -> items.sortedBy { File(it.originalPath).name.lowercase(locale) }
CommonSortMode.PATH_ASC -> items.sortedBy { it.originalPath.lowercase(locale) }
}
}
override fun updateStats(
allItems: List<TrashEntities.TrashItem>,
shownItems: List<TrashEntities.TrashItem>,
) {
// Header subtitle is driven by Room observe + Pref observe.
// zh-CN: header subtitle 由 Room 监听 + Pref 监听驱动.
observedSubtitle?.let { toolbar.subtitle = it }
}
override fun onExtraMenuItemSelected(item: MenuItem): Boolean {
return when (item.itemId) {
R.id.action_clear_trash -> true.also { confirmAndClearTrash() }
else -> false
}
}
private fun confirmAndRemoveFromTrash(item: TrashEntities.TrashItem, parentDialog: MaterialDialog? = null) {
DialogUtils.buildAndShowAdaptive {
MaterialDialog.Builder(this)
.title(R.string.text_prompt)
.content(R.string.text_remove_from_trash_confirm)
.negativeText(R.string.dialog_button_cancel)
.negativeColorRes(R.color.dialog_button_default)
.positiveText(R.string.dialog_button_confirm)
.positiveColorRes(R.color.dialog_button_caution)
.onPositive { _, _ ->
parentDialog?.dismiss()
removeFromTrash(item)
}
.cancelable(true)
.build()
}
}
private fun removeFromTrash(item: TrashEntities.TrashItem) {
Schedulers.io().scheduleDirect {
runCatching {
val dao = HistoryDatabase.getInstance(applicationContext).historyDao()
val items = dao.listTrashItemsDesc()
TrashRepository(applicationContext).deleteTrashItem(item)
AndroidSchedulers.mainThread().scheduleDirect {
adapter.submit(items)
if (items.isEmpty()) {
DialogUtils.buildAndShowAdaptive {
MaterialDialog.Builder(this)
.title(R.string.text_prompt)
.content(R.string.text_no_data)
.positiveText(R.string.dialog_button_dismiss)
.positiveColorRes(R.color.dialog_button_default)
.cancelable(true)
.build()
}
}
ViewUtils.showToast(this, getString(R.string.text_already_deleted))
load()
}
}.onFailure {
it.printStackTrace()
AndroidSchedulers.mainThread().scheduleDirect {
DialogUtils.buildAndShowAdaptive {
MaterialDialog.Builder(this)
.title(R.string.text_prompt)
.content(it.message ?: it.toString())
.positiveText(R.string.dialog_button_dismiss)
.positiveColorRes(R.color.dialog_button_default)
.cancelable(true)
.build()
}
ViewUtils.showToast(this, it.message, true)
}
}
}
}
private fun confirmAndClearTrash() {
DialogUtils.buildAndShowAdaptive {
MaterialDialog.Builder(this)
.title(R.string.text_prompt)
.content(R.string.text_clear_trash_confirm)
.negativeText(R.string.dialog_button_cancel)
.negativeColorRes(R.color.dialog_button_default)
.positiveText(R.string.dialog_button_confirm)
.positiveColorRes(R.color.dialog_button_caution)
.onPositive { _, _ -> clearTrash() }
.cancelable(true)
.build()
}
}
private fun clearTrash() {
Schedulers.io().scheduleDirect {
runCatching {
TrashRepository(applicationContext).clearTrash()
AndroidSchedulers.mainThread().scheduleDirect {
ViewUtils.showSnack(recyclerView, getString(R.string.text_done), true)
load()
}
}.onFailure {
it.printStackTrace()
AndroidSchedulers.mainThread().scheduleDirect {
ViewUtils.showSnack(recyclerView, it.message, true)
}
}
}
@@ -104,81 +250,3 @@ class TrashActivity : BaseActivity() {
}
}
}
private class TrashAdapter(
private val onClick: (TrashEntities.TrashItem) -> Unit,
) : RecyclerView.Adapter<TrashViewHolder>() {
private var items: List<TrashEntities.TrashItem> = emptyList()
fun submit(newItems: List<TrashEntities.TrashItem>) {
items = newItems
notifyDataSetChanged()
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): TrashViewHolder {
return TrashViewHolder.create(parent, onClick)
}
override fun onBindViewHolder(holder: TrashViewHolder, position: Int) {
holder.bind(items[position])
}
override fun getItemCount(): Int = items.size
}
private class TrashViewHolder(
itemView: View,
private val onClick: (TrashEntities.TrashItem) -> Unit,
) : RecyclerView.ViewHolder(itemView) {
private val titleView: TextView = (itemView as LinearLayout).getChildAt(0) as TextView
private val subtitleView: TextView = (itemView as LinearLayout).getChildAt(1) as TextView
private var boundItem: TrashEntities.TrashItem? = null
fun bind(item: TrashEntities.TrashItem) {
boundItem = item
val name = File(item.originalPath).name
titleView.text = name
val fmt = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault())
val timeText = fmt.format(Date(item.trashedAt))
subtitleView.text = "${item.originalPath}\n$timeText"
itemView.setOnClickListener {
boundItem?.let(onClick)
}
}
companion object {
fun create(parent: ViewGroup, onClick: (TrashEntities.TrashItem) -> Unit): TrashViewHolder {
val ctx = parent.context
val root = LinearLayout(ctx).apply {
orientation = LinearLayout.VERTICAL
setPadding(32, 24, 32, 24)
layoutParams = RecyclerView.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT,
)
gravity = Gravity.CENTER_VERTICAL
}
val title = TextView(ctx).apply {
textSize = 16f
}
val subtitle = TextView(ctx).apply {
textSize = 12f
alpha = 0.75f
}
root.addView(title)
root.addView(subtitle)
return TrashViewHolder(root, onClick)
}
}
}

View File

@@ -0,0 +1,34 @@
package org.autojs.autojs.ui.storage
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import org.autojs.autojs.storage.history.TrashEntities
import org.autojs.autojs6.databinding.ItemTrashBinding
/**
* Created by JetBrains AI Assistant (GPT-5.2) on Feb 4, 2026.
*/
internal class TrashAdapter(
private val onClick: (TrashEntities.TrashItem) -> Unit,
private val onLongClick: (TrashEntities.TrashItem) -> Unit,
) : RecyclerView.Adapter<TrashViewHolder>() {
private var items: List<TrashEntities.TrashItem> = emptyList()
fun submit(newItems: List<TrashEntities.TrashItem>) {
items = newItems
notifyDataSetChanged()
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): TrashViewHolder {
val binding = ItemTrashBinding.inflate(LayoutInflater.from(parent.context), parent, false)
return TrashViewHolder(binding, onClick, onLongClick)
}
override fun onBindViewHolder(holder: TrashViewHolder, position: Int) {
holder.bind(items[position])
}
override fun getItemCount(): Int = items.size
}

View File

@@ -0,0 +1,57 @@
package org.autojs.autojs.ui.storage
import android.widget.ImageView
import android.widget.TextView
import androidx.recyclerview.widget.RecyclerView
import org.autojs.autojs.pio.PFiles
import org.autojs.autojs.storage.history.TrashEntities
import org.autojs.autojs.util.StringUtils.normalizeTrailingSlash
import org.autojs.autojs6.R
import org.autojs.autojs6.databinding.ItemTrashBinding
import java.io.File
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
/**
* Created by JetBrains AI Assistant (GPT-5.2) on Feb 4, 2026.
*/
internal class TrashViewHolder(
binding: ItemTrashBinding,
private val onClick: (TrashEntities.TrashItem) -> Unit,
private val onLongClick: (TrashEntities.TrashItem) -> Unit,
) : RecyclerView.ViewHolder(binding.root) {
private val iconView: ImageView = binding.icon
private val titleView: TextView = binding.title
private val subtitleView: TextView = binding.subtitle
private var boundItem: TrashEntities.TrashItem? = null
fun bind(item: TrashEntities.TrashItem) {
boundItem = item
iconView.setImageResource(if (item.isDirectory) R.drawable.ic_simple_folder else R.drawable.ic_simple_file)
titleView.text = File(item.originalPath).name
val fmt = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault())
val timeText = fmt.format(Date(item.trashedAt))
val sizeText = PFiles.formatSizeWithUnit(item.sizeBytes)
subtitleView.text = buildString {
append(timeText)
append(" | ")
append(sizeText)
append("\n")
append(item.originalPath.normalizeTrailingSlash(item.isDirectory))
}
itemView.setOnClickListener {
boundItem?.let(onClick)
}
itemView.setOnLongClickListener {
boundItem?.let(onLongClick)
true
}
}
}

View File

@@ -2,94 +2,389 @@ package org.autojs.autojs.ui.storage
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.view.Gravity
import android.view.View
import android.view.ViewGroup
import android.widget.LinearLayout
import android.widget.TextView
import androidx.recyclerview.widget.LinearLayoutManager
import android.view.MenuItem
import androidx.recyclerview.widget.RecyclerView
import com.afollestad.materialdialogs.MaterialDialog
import io.reactivex.Observable
import io.reactivex.android.schedulers.AndroidSchedulers
import io.reactivex.disposables.CompositeDisposable
import io.reactivex.schedulers.Schedulers
import org.autojs.autojs.app.DialogUtils
import org.autojs.autojs.util.DialogUtils
import org.autojs.autojs.core.pref.PrefRx
import org.autojs.autojs.storage.history.HistoryDatabase
import org.autojs.autojs.storage.history.HistoryEntities
import org.autojs.autojs.storage.history.HistoryPrefs
import org.autojs.autojs.storage.history.HistoryRepository
import org.autojs.autojs.storage.history.VersionHistoryController
import org.autojs.autojs.ui.BaseActivity
import org.autojs.autojs.util.ViewUtils
import org.autojs.autojs6.R
import java.text.SimpleDateFormat
import java.util.Date
import org.autojs.autojs6.databinding.ActivityVersionHistoryBinding
import java.io.File
import java.util.Locale
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicBoolean
/**
* Version history page (all files).
* zh-CN: 版本历史页面 (全部文件).
*
* Created by JetBrains AI Assistant (GPT-5.2) on Feb 4, 2026.
* Modified by JetBrains AI Assistant (GPT-5.2) as of Feb 6, 2026.
*/
class VersionHistoryActivity : BaseActivity() {
class VersionHistoryActivity : BaseSearchSortListActivity<ActivityVersionHistoryBinding, HistoryEntities.FileEntry>() {
private lateinit var recyclerView: RecyclerView
/**
* Cached aggregated bytes for fileId.
* zh-CN: fileId 对应的聚合字节数缓存.
*/
private val sizeCache: ConcurrentHashMap<String, Long> = ConcurrentHashMap()
/**
* Cached revision count for fileId.
* zh-CN: fileId 对应的 revision 数量缓存.
*/
private val countCache: ConcurrentHashMap<String, Long> = ConcurrentHashMap()
/**
* Whether caches have been populated once.
* zh-CN: 缓存是否已至少完成一次填充.
*/
private val sizeReady: AtomicBoolean = AtomicBoolean(false)
/**
* Prevent duplicated async loading.
* zh-CN: 防止重复触发异步加载.
*/
private val sizeLoading: AtomicBoolean = AtomicBoolean(false)
private val adapter = VersionHistoryFileAdapter(
onClick = { entry ->
VersionHistoryController(this).showForFilePath(entry.logicalPath)
}
VersionHistoryController(this).showForFilePath(entry.canonicalPath, entry.displayPath)
},
onLongClick = { entry ->
confirmAndClearHistoryForEntry(entry)
},
getSizeBytesOrNull = { fileId ->
sizeCache[fileId]
},
getRevisionCountOrNull = { fileId ->
countCache[fileId]
},
)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
/**
* Disposables for header observing.
* zh-CN: header 监听订阅管理器.
*/
private val headerDisposables: CompositeDisposable = CompositeDisposable()
title = getString(R.string.text_version_history)
/**
* Latest computed subtitle from observers.
* zh-CN: 来自监听器的最新 subtitle 文本.
*/
private var observedSubtitle: String? = null
recyclerView = RecyclerView(this).apply {
layoutManager = LinearLayoutManager(this@VersionHistoryActivity)
adapter = this@VersionHistoryActivity.adapter
layoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT,
)
}
setContentView(recyclerView)
load()
override fun onStart() {
super.onStart()
startObserveHeaderSubtitleIfNeeded()
}
private fun load() {
override fun onStop() {
super.onStop()
// Avoid leaking Activity via subscriptions.
// zh-CN: 避免订阅持有 Activity 导致泄露.
headerDisposables.clear()
}
private fun startObserveHeaderSubtitleIfNeeded() {
headerDisposables.clear()
val dao = HistoryDatabase.getInstance(applicationContext).historyDao()
val limitBytesFlow = PrefRx.observeLong(
keyRes = R.string.key_history_max_total_bytes,
defaultValue = HistoryPrefs.DEFAULT_HISTORY_MAX_TOTAL_BYTES,
)
val disposable = Observable
.combineLatest(
dao.observeVersionHistoryStats().toObservable(),
limitBytesFlow.toObservable(),
) { stats, limitBytes ->
StorageUsageSummaryFormatter.formatHeader(
context = this,
count = stats.fileCount,
totalBytes = (stats.totalBytes ?: 0L),
limitBytes = limitBytes,
)
}
.observeOn(AndroidSchedulers.mainThread())
.subscribe { subtitle ->
observedSubtitle = subtitle
toolbar.subtitle = subtitle
}
headerDisposables.add(disposable)
}
override fun createBinding(): ActivityVersionHistoryBinding {
return ActivityVersionHistoryBinding.inflate(layoutInflater)
}
override fun provideViews(binding: ActivityVersionHistoryBinding): ListPageViews {
return ListPageViews(
toolbar = binding.toolbar,
recyclerView = binding.recyclerView,
swipeRefreshLayout = binding.swipeRefreshLayout,
emptyHint = binding.emptyHint,
)
}
override fun getToolbarTitleText(): String {
return getString(R.string.text_version_history)
}
override fun menuResId(): Int {
return R.menu.menu_version_history
}
override fun createRecyclerAdapter(): RecyclerView.Adapter<*> {
return adapter
}
override fun submitToAdapter(items: List<HistoryEntities.FileEntry>) {
adapter.submit(items)
}
override fun loadAllItemsInBackground(): List<HistoryEntities.FileEntry> {
val dao = HistoryDatabase.getInstance(applicationContext).historyDao()
// Default sorting: latest seen first.
// zh-CN: 默认排序: 最近访问优先.
return dao.listAllFiles().sortedByDescending { it.lastSeenAt }
}
override fun onItemsLoaded(items: List<HistoryEntities.FileEntry>) {
// Recompute stats on each load (first enter / swipe refresh).
// zh-CN: 每次 load 后都重新计算聚合统计 (首次进入/下拉刷新).
sizeCache.clear()
countCache.clear()
sizeReady.set(false)
sizeLoading.set(false)
// Refresh per-row display immediately (show "Calculating...").
// zh-CN: 立即刷新每行显示 (展示 "Calculating...").
adapter.notifyDataSetChanged()
ensureStatsLoadedAsync(forceRecompute = true)
}
override fun onExtraMenuItemSelected(item: MenuItem): Boolean {
return when (item.itemId) {
R.id.action_clear_version_history -> true.also { confirmAndClearAllHistory() }
else -> false
}
}
override fun setSortMode(mode: CommonSortMode) {
internalSortMode = mode
val needSize = mode == CommonSortMode.SIZE_DESC || mode == CommonSortMode.SIZE_ASC
if (needSize && !sizeReady.get()) {
// Strict mode: wait for real size before sorting by size.
// zh-CN: 严格模式: size 未就绪时, 切到按大小排序需等待真实 size 后再排序.
swipeRefreshLayout.isRefreshing = true
ensureStatsLoadedAsync(forceRecompute = false)
updateStats(allItems = allItems, shownItems = emptyList())
return
}
applyFilterAndSort()
}
/**
* Ensure aggregated stats (size + count) loaded.
* zh-CN: 确保聚合统计 (size + count) 已加载.
*/
private fun ensureStatsLoadedAsync(forceRecompute: Boolean) {
if (!forceRecompute && sizeReady.get()) return
if (!sizeLoading.compareAndSet(false, true)) return
Schedulers.io().scheduleDirect {
runCatching {
val dao = HistoryDatabase.getInstance(applicationContext).historyDao()
val files = dao.listAllFiles().sortedByDescending { it.lastSeenAt }
val fileIds = allItems.map { it.fileId }
val rows = if (fileIds.isEmpty()) emptyList() else dao.aggStatsByFileIds(fileIds)
val sizeMap = HashMap<String, Long>(rows.size)
val countMap = HashMap<String, Long>(rows.size)
for (row in rows) {
sizeMap[row.fileId] = (row.totalBytes ?: 0L).coerceAtLeast(0L)
countMap[row.fileId] = row.revisionCount.coerceAtLeast(0L)
}
Pair(sizeMap, countMap)
}.onSuccess { (sizeMap, countMap) ->
AndroidSchedulers.mainThread().scheduleDirect {
adapter.submit(files)
if (isFinishing || isDestroyed) return@scheduleDirect
if (files.isEmpty()) {
DialogUtils.buildAndShowAdaptive {
MaterialDialog.Builder(this)
.title(R.string.text_prompt)
.content(R.string.text_no_data)
.positiveText(R.string.dialog_button_dismiss)
.positiveColorRes(R.color.dialog_button_default)
.cancelable(true)
.build()
}
// Replace caches with newly computed values.
// zh-CN: 使用新计算结果替换缓存.
sizeCache.clear()
sizeCache.putAll(sizeMap)
countCache.clear()
countCache.putAll(countMap)
sizeReady.set(true)
sizeLoading.set(false)
// Refresh per-row display.
// zh-CN: 刷新每行显示.
adapter.notifyDataSetChanged()
// If current mode is size sort, now apply strict sorting.
// zh-CN: 若当前为按大小排序, 则此时才允许应用严格排序.
val needSize = internalSortMode == CommonSortMode.SIZE_DESC || internalSortMode == CommonSortMode.SIZE_ASC
if (needSize) {
applyFilterAndSort()
} else {
swipeRefreshLayout.isRefreshing = false
updateStats(allItems = allItems, shownItems = emptyList())
}
}
}.onFailure { t ->
t.printStackTrace()
AndroidSchedulers.mainThread().scheduleDirect {
sizeLoading.set(false)
swipeRefreshLayout.isRefreshing = false
// Keep UI usable even if stats calculation failed.
// zh-CN: 统计计算失败时仍保持 UI 可用.
applyFilterAndSort()
}
}
}
}
override fun matchesQuery(item: HistoryEntities.FileEntry, qLower: String): Boolean {
val path = item.displayPath
val name = File(path).name
val locale = Locale.getDefault()
return path.lowercase(locale).contains(qLower) || name.lowercase(locale).contains(qLower)
}
override fun sortItems(
items: List<HistoryEntities.FileEntry>,
sortMode: CommonSortMode,
): List<HistoryEntities.FileEntry> {
val locale = Locale.getDefault()
return when (sortMode) {
CommonSortMode.TIME_DESC -> items.sortedByDescending { it.lastSeenAt }
CommonSortMode.TIME_ASC -> items.sortedBy { it.lastSeenAt }
CommonSortMode.SIZE_DESC -> items.sortedWith(
compareByDescending<HistoryEntities.FileEntry> { sizeCache[it.fileId] ?: 0L }
.thenBy { it.canonicalPath.lowercase(locale) }
)
CommonSortMode.SIZE_ASC -> items.sortedWith(
compareBy<HistoryEntities.FileEntry> { sizeCache[it.fileId] ?: 0L }
.thenBy { it.canonicalPath.lowercase(locale) }
)
CommonSortMode.NAME_ASC -> items.sortedBy { File(it.canonicalPath).name.lowercase(locale) }
CommonSortMode.PATH_ASC -> items.sortedBy { it.canonicalPath.lowercase(locale) }
}
}
override fun updateStats(
allItems: List<HistoryEntities.FileEntry>,
shownItems: List<HistoryEntities.FileEntry>,
) {
// Header subtitle is driven by Room observe + Pref observe.
// zh-CN: header subtitle 由 Room 监听 + Pref 监听驱动.
observedSubtitle?.let { toolbar.subtitle = it }
}
/**
* Confirm clearing all history.
* zh-CN: 确认清空全部历史记录.
*/
private fun confirmAndClearAllHistory() {
DialogUtils.buildAndShowAdaptive {
MaterialDialog.Builder(this)
.title(R.string.text_prompt)
.content(R.string.text_clear_version_history_confirm)
.negativeText(R.string.dialog_button_cancel)
.negativeColorRes(R.color.dialog_button_default)
.positiveText(R.string.dialog_button_confirm)
.positiveColorRes(R.color.dialog_button_caution)
.onPositive { _, _ -> clearAllHistory() }
.cancelable(true)
.build()
}
}
/**
* Clear all history in background.
* zh-CN: 在后台线程清空全部历史记录.
*/
private fun clearAllHistory() {
Schedulers.io().scheduleDirect {
runCatching {
HistoryRepository(applicationContext).clearAllHistory()
}.onSuccess {
AndroidSchedulers.mainThread().scheduleDirect {
ViewUtils.showToast(this, getString(R.string.text_history_has_been_cleared), true)
load()
}
}.onFailure {
it.printStackTrace()
AndroidSchedulers.mainThread().scheduleDirect {
DialogUtils.buildAndShowAdaptive {
MaterialDialog.Builder(this)
.title(R.string.text_prompt)
.content(it.message ?: it.toString())
.positiveText(R.string.dialog_button_dismiss)
.positiveColorRes(R.color.dialog_button_default)
.cancelable(true)
.build()
}
ViewUtils.showToast(this, it.message, true)
}
}
}
}
/**
* Confirm clearing history for one file entry (all revisions).
* zh-CN: 确认清除单个条目对应的历史记录 (全部 revisions).
*/
private fun confirmAndClearHistoryForEntry(entry: HistoryEntities.FileEntry) {
DialogUtils.buildAndShowAdaptive {
MaterialDialog.Builder(this)
.title(R.string.text_prompt)
.content(getString(R.string.text_confirm_to_clear_the_history_of, entry.canonicalPath))
.negativeText(R.string.dialog_button_cancel)
.negativeColorRes(R.color.dialog_button_default)
.positiveText(R.string.dialog_button_confirm)
.positiveColorRes(R.color.dialog_button_caution)
.onPositive { _, _ -> clearHistoryForEntry(entry) }
.cancelable(true)
.build()
}
}
/**
* Clear history for one entry in background.
* zh-CN: 在后台线程清除单个条目的历史记录.
*/
private fun clearHistoryForEntry(entry: HistoryEntities.FileEntry) {
Schedulers.io().scheduleDirect {
runCatching {
HistoryRepository(applicationContext).clearHistoryForPath(entry.canonicalPath)
}.onSuccess {
AndroidSchedulers.mainThread().scheduleDirect {
ViewUtils.showToast(this, getString(R.string.text_history_has_been_cleared), true)
load()
}
}.onFailure {
it.printStackTrace()
AndroidSchedulers.mainThread().scheduleDirect {
ViewUtils.showToast(this, it.message, true)
}
}
}
@@ -103,80 +398,3 @@ class VersionHistoryActivity : BaseActivity() {
}
}
}
private class VersionHistoryFileAdapter(
private val onClick: (HistoryEntities.FileEntry) -> Unit,
) : RecyclerView.Adapter<VersionHistoryFileViewHolder>() {
private var items: List<HistoryEntities.FileEntry> = emptyList()
fun submit(newItems: List<HistoryEntities.FileEntry>) {
items = newItems
notifyDataSetChanged()
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VersionHistoryFileViewHolder {
return VersionHistoryFileViewHolder.create(parent, onClick)
}
override fun onBindViewHolder(holder: VersionHistoryFileViewHolder, position: Int) {
holder.bind(items[position])
}
override fun getItemCount(): Int = items.size
}
private class VersionHistoryFileViewHolder(
itemView: View,
private val onClick: (HistoryEntities.FileEntry) -> Unit,
) : RecyclerView.ViewHolder(itemView) {
private val titleView: TextView = (itemView as LinearLayout).getChildAt(0) as TextView
private val subtitleView: TextView = (itemView as LinearLayout).getChildAt(1) as TextView
private var boundItem: HistoryEntities.FileEntry? = null
fun bind(item: HistoryEntities.FileEntry) {
boundItem = item
titleView.text = item.logicalPath
val fmt = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault())
val timeText = fmt.format(Date(item.lastSeenAt))
subtitleView.text = "${item.latestFingerprint}\n$timeText"
itemView.setOnClickListener {
boundItem?.let(onClick)
}
}
companion object {
fun create(parent: ViewGroup, onClick: (HistoryEntities.FileEntry) -> Unit): VersionHistoryFileViewHolder {
val ctx = parent.context
val root = LinearLayout(ctx).apply {
orientation = LinearLayout.VERTICAL
setPadding(32, 24, 32, 24)
layoutParams = RecyclerView.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT,
)
gravity = Gravity.CENTER_VERTICAL
}
val title = TextView(ctx).apply {
textSize = 15f
}
val subtitle = TextView(ctx).apply {
textSize = 11f
alpha = 0.75f
}
root.addView(title)
root.addView(subtitle)
return VersionHistoryFileViewHolder(root, onClick)
}
}
}

View File

@@ -0,0 +1,38 @@
package org.autojs.autojs.ui.storage
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import org.autojs.autojs.storage.history.HistoryEntities
/**
* Created by JetBrains AI Assistant (GPT-5.2) on Feb 5, 2026.
*/
internal class VersionHistoryFileAdapter(
private val onClick: (HistoryEntities.FileEntry) -> Unit,
private val onLongClick: (HistoryEntities.FileEntry) -> Unit,
private val getSizeBytesOrNull: (fileId: String) -> Long?,
private val getRevisionCountOrNull: (fileId: String) -> Long?,
) : RecyclerView.Adapter<VersionHistoryFileViewHolder>() {
private var items: List<HistoryEntities.FileEntry> = emptyList()
fun submit(newItems: List<HistoryEntities.FileEntry>) {
items = newItems
notifyDataSetChanged()
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VersionHistoryFileViewHolder {
return VersionHistoryFileViewHolder.create(parent, onClick, onLongClick)
}
override fun onBindViewHolder(holder: VersionHistoryFileViewHolder, position: Int) {
val item = items[position]
holder.bind(
item = item,
totalSizeBytesOrNull = getSizeBytesOrNull(item.fileId),
revisionCountOrNull = getRevisionCountOrNull(item.fileId),
)
}
override fun getItemCount(): Int = items.size
}

View File

@@ -0,0 +1,123 @@
package org.autojs.autojs.ui.storage
import android.util.TypedValue
import android.view.Gravity
import android.view.View
import android.view.ViewGroup
import android.widget.LinearLayout
import android.widget.TextView
import androidx.recyclerview.widget.RecyclerView
import org.autojs.autojs.pio.PFiles
import org.autojs.autojs.storage.history.HistoryEntities
import org.autojs.autojs6.R
import java.io.File
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
/**
* Created by JetBrains AI Assistant (GPT-5.2) on Feb 5, 2026.
* Modified by JetBrains AI Assistant (GPT-5.2) as of Feb 6, 2026.
* Modified by SuperMonster003 as of Feb 7, 2026.
*/
internal class VersionHistoryFileViewHolder(
itemView: View,
private val onClick: (HistoryEntities.FileEntry) -> Unit,
private val onLongClick: (HistoryEntities.FileEntry) -> Unit,
) : RecyclerView.ViewHolder(itemView) {
private val titleView: TextView = (itemView as LinearLayout).getChildAt(0) as TextView
private val subtitleView: TextView = (itemView as LinearLayout).getChildAt(1) as TextView
private var boundItem: HistoryEntities.FileEntry? = null
fun bind(
item: HistoryEntities.FileEntry,
totalSizeBytesOrNull: Long?,
revisionCountOrNull: Long?,
) {
boundItem = item
titleView.text = File(item.displayPath).name
val context = itemView.context
val fmt = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault())
val timeText = fmt.format(Date(item.lastSeenAt))
val sizeText = totalSizeBytesOrNull
?.coerceAtLeast(0L)?.let { PFiles.formatSizeWithUnit(it) }
?: context.getString(R.string.text_half_ellipsis)
val countText = revisionCountOrNull
?.coerceAtLeast(0L)?.toString()
?: context.getString(R.string.text_half_ellipsis)
subtitleView.text = buildString {
append(timeText)
append(" | ")
append("< $countText >")
append(" | ")
append(sizeText)
append("\n")
append(item.displayPath)
}
itemView.setOnClickListener {
boundItem?.let(onClick)
}
itemView.setOnLongClickListener {
boundItem?.let(onLongClick)
true
}
}
companion object {
fun create(
parent: ViewGroup,
onClick: (HistoryEntities.FileEntry) -> Unit,
onLongClick: (HistoryEntities.FileEntry) -> Unit,
): VersionHistoryFileViewHolder {
val ctx = parent.context
val root = LinearLayout(ctx).apply {
orientation = LinearLayout.VERTICAL
setPadding(32, 24, 32, 24)
layoutParams = RecyclerView.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT,
)
gravity = Gravity.CENTER_VERTICAL
// Use theme selectable background to show press/ripple feedback.
// zh-CN: 使用主题的可选择背景, 显示按压态或 ripple 点击反馈.
with(ctx.theme) {
val typedValue = TypedValue()
resolveAttribute(android.R.attr.selectableItemBackground, typedValue, true)
setBackgroundResource(typedValue.resourceId)
}
// Ensure this view can show pressed state.
// zh-CN: 确保该 view 能显示 pressed 状态.
isClickable = true
isFocusable = true
isLongClickable = true
}
val title = TextView(ctx).apply {
textSize = 16f
}
val subtitle = TextView(ctx).apply {
textSize = 12f
alpha = 0.75f
}
root.addView(title)
root.addView(subtitle)
return VersionHistoryFileViewHolder(root, onClick, onLongClick)
}
}
}

View File

@@ -1,6 +1,4 @@
@file:Suppress("unused")
package org.autojs.autojs.app
package org.autojs.autojs.util
import android.app.Activity
import android.content.Context
@@ -9,6 +7,7 @@ import android.content.DialogInterface
import android.content.res.ColorStateList
import android.os.Build
import android.os.Looper
import android.os.SystemClock
import android.provider.Settings
import android.text.util.Linkify
import android.util.Log
@@ -16,35 +15,62 @@ import android.view.Gravity
import android.view.View
import android.view.WindowManager
import android.widget.CheckBox
import android.widget.TextView
import androidx.preference.PreferenceViewHolder
import com.afollestad.materialdialogs.DialogAction
import com.afollestad.materialdialogs.MaterialDialog
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.autojs.autojs.annotation.ReservedForCompatibility
import org.autojs.autojs.app.GlobalAppContext
import org.autojs.autojs.event.BackCompat
import org.autojs.autojs.theme.ThemeColorHelper
import org.autojs.autojs.theme.ThemeColorManager
import org.autojs.autojs.theme.preference.LongClickablePreferenceLike
import org.autojs.autojs.ui.explorer.ExplorerView
import org.autojs.autojs.util.ColorUtils
import org.autojs.autojs.util.ThreadUtils.runOnMain
import org.autojs.autojs.util.ViewUtils.showSnack
import org.autojs.autojs.util.ViewUtils.setLinesEllipsizedIndividually
import org.autojs.autojs6.R
import java.util.Locale
import java.util.concurrent.Callable
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicLong
import java.util.concurrent.atomic.AtomicReference
import kotlin.concurrent.Volatile
import kotlin.math.max
import kotlin.math.min
import kotlin.math.roundToInt
/**
* Created by Stardust on Aug 4, 2017.
* Transformed by SuperMonster003 on Oct 19, 2022.
* Modified by OpenAI ChatGPT (GPT-5.2 Thinking) as of Jan 20, 2026.
* Modified by JetBrains AI Assistant (GPT-5.2) as of Feb 1, 2026.
* Modified by SuperMonster003 as of Feb 3, 2026.
* Modified by SuperMonster003 as of Feb 7, 2026.
*/
object DialogUtils {
private const val TAG = "DialogUtils"
// Minimum UI update interval for progress dialog, to reduce main-thread pressure.
// zh-CN: 进度对话框 UI 更新的最小间隔, 用于降低主线程压力.
private const val PROGRESS_UPDATE_MIN_INTERVAL_MS: Long = 50L
// Delay before showing progress dialog to avoid flashing for fast operations.
// zh-CN: 显示进度对话框前的延迟, 用于避免快速操作导致的闪现.
private const val PROGRESS_DIALOG_SHOW_DELAY_MS: Long = 200L
// Minimum duration to keep progress dialog visible once shown, to avoid flashing.
// zh-CN: 进度对话框一旦显示后的最短展示时长, 用于避免闪现.
private const val PROGRESS_DIALOG_MIN_SHOW_MS: Long = 300L
// Progress max for large files (totalBytes > Integer.MAX_VALUE), higher means smoother progress.
// zh-CN: 大文件 (totalBytes > Integer.MAX_VALUE) 的进度最大值, 值越大进度越细腻.
const val PROGRESS_MAX_LARGE: Int = 10000
@JvmStatic
fun <T : MaterialDialog.Builder> T.showAdaptive(): MaterialDialog = build().showAdaptive()
@@ -60,7 +86,7 @@ object DialogUtils {
*
* Behavior:
* 1) Always performs window operations and `show()` on the main thread.
* 2) If the dialog uses an [Activity] context, it is shown normally.
* 2) If the dialog uses an [android.app.Activity] context, it is shown normally.
* 3) Otherwise, it tries to show as an overlay window:
* - `TYPE_APPLICATION_OVERLAY` on Android O+.
* - `TYPE_PHONE` on pre-O devices.
@@ -72,7 +98,7 @@ object DialogUtils {
*
* 行为说明:
* 1) 所有 Window 参数操作和 `show()` 都保证在主线程执行.
* 2) 若对话框基于 [Activity] Context, 则按常规方式显示.
* 2) 若对话框基于 [android.app.Activity] Context, 则按常规方式显示.
* 3) 否则尝试以 overlay 窗口显示:
* - Android O+ 使用 `TYPE_APPLICATION_OVERLAY`.
* - Android O 以下使用 `TYPE_PHONE`.
@@ -96,7 +122,7 @@ object DialogUtils {
@Deprecated("Use showAdaptive instead.", ReplaceWith("showAdaptive(dialog, focusable)"))
@ReservedForCompatibility
fun <T : MaterialDialog> showDialog(dialog: T, focusable: Boolean = true): T {
runOnMain {
ThreadUtils.runOnMain {
// Prevent duplicated show.
// zh-CN: 防止重复 show().
if (dialog.isShowing) return@runOnMain
@@ -300,13 +326,90 @@ object DialogUtils {
}
}
fun MaterialDialog.makeSettingsLaunchable(viewGetter: (dialog: MaterialDialog) -> View?, packageName: String?) {
viewGetter(this)?.setOnClickListener {
packageName?.let { pkg ->
IntentUtils.launchAppDetailsSettings(this.context, pkg)
} ?: ViewUtils.showSnack(this.view, R.string.error_app_not_installed)
}
}
@JvmStatic
fun MaterialDialog.makeTextCopyable(textViewGetter: (dialog: MaterialDialog) -> TextView?) {
makeTextCopyable(textViewGetter(this))
}
@JvmStatic
@JvmOverloads
fun MaterialDialog.makeTextCopyable(textView: TextView?, textValue: String? = textView?.text?.toString()) {
if (!textValue.isNullOrBlank()) {
val context = this.context
textView?.setOnClickListener {
ClipboardUtils.setClip(context, textValue)
ViewUtils.showSnack(this.view, "${context.getString(R.string.text_already_copied_to_clip)}: $textValue")
}
}
}
fun MaterialDialog.setCopyableTextIfAbsent(textView: TextView, textValuePair: Pair<String?, String?>) {
setCopyableTextIfAbsent(textView, textValuePair.first, textValuePair.second)
}
fun MaterialDialog.setCopyableTextIfAbsent(textView: TextView, textValue: String?, suffix: String? = null) {
when {
textValue.isNullOrBlank() -> {
if (textView.isNullOrBlank() || textView.isEllipsisSix()) {
textView.setUnknown()
}
}
textView.isNullOrBlank() || textView.isEllipsisSix() || textView.isUnknown() -> {
textView.text = textValue + (suffix ?: "")
this.makeTextCopyable(textView, textValue)
}
}
}
fun MaterialDialog.setCopyableTextIfAbsent(textView: TextView, scope: CoroutineScope, f: () -> String?) {
setCopyableTextIfAbsent(textView, scope, f, suffix = null)
}
fun MaterialDialog.setCopyableTextIfAbsent(textView: TextView, scope: CoroutineScope, f: () -> String?, suffix: String?) {
scope.launch(Dispatchers.IO) {
val textValue = f.invoke()
withContext(Dispatchers.Main) {
setCopyableTextIfAbsent(textView, textValue, suffix)
}
}
}
fun MaterialDialog.setCopyableText(textView: TextView, textValue: String?) {
when {
textValue.isNullOrBlank() -> {
textView.setUnknown()
}
else -> {
textView.text = textValue
this.makeTextCopyable(textView, textValue)
}
}
}
@JvmStatic
fun MaterialDialog.Builder.choiceWidgetThemeColor() = also {
it.choiceWidgetColor(ThemeColorHelper.getThemeColorStateList(context))
}
@JvmStatic
fun MaterialDialog.Builder.widgetThemeColor() = also {
it.widgetColor(ThemeColorHelper.getThemeColorStateList(context))
}
@JvmStatic
fun fixCheckBoxGravity(dialog: MaterialDialog): MaterialDialog = dialog.also {
it.view.findViewById<CheckBox>(com.afollestad.materialdialogs.R.id.md_promptCheckbox)?.gravity = Gravity.CENTER_VERTICAL
}
@JvmStatic
fun isActivityContext(context: Context?): Boolean {
private fun isActivityContext(context: Context?): Boolean {
return context is Activity || (context is ContextWrapper && isActivityContext(context.baseContext))
}
@@ -345,7 +448,7 @@ object DialogUtils {
val now = System.currentTimeMillis()
if (now - time.lastPressed >= time.minPressInterval) {
time.lastPressed = now
showSnack(explorerView, R.string.text_press_again_to_dismiss_dialog)
ViewUtils.showSnack(explorerView, R.string.text_press_again_to_dismiss_dialog)
return@installDialogBackHandler true
}
di.dismiss()
@@ -496,4 +599,212 @@ object DialogUtils {
fun MaterialDialog.setActionButtonText(actionButton: DialogAction, string: String) {
getActionButton(actionButton).text = string
}
}
class ProgressDialogSession internal constructor(private val controller: OperationController) {
private val dialogRef = AtomicReference<MaterialDialog?>()
// Whether the session has been closed (operation finished/aborted), used to prevent late dialog showing.
// zh-CN: 会话是否已关闭 (操作已完成/已中止), 用于防止延迟任务在结束后仍弹出对话框.
private val closed = AtomicBoolean(false)
private val showScheduled = AtomicBoolean(false)
@Volatile
private var shown = false
private var shownAtUptimeMs = 0L
// Pending UI state before dialog is actually shown.
// zh-CN: 对话框真正 show 之前的挂起 UI 状态, 用于避免延迟显示导致的首次更新丢失.
private val pendingContentRef: AtomicReference<List<String>?> = AtomicReference(null)
private val pendingProcessedRef: AtomicLong = AtomicLong(-1L)
private val pendingTotalRef: AtomicLong = AtomicLong(0L)
// Timestamp/progress/content state for throttling UI updates.
// zh-CN: 用于节流 UI 更新的时间戳/进度/文本状态.
private val lastProgressUpdateTime: LongArray = longArrayOf(0L)
private val lastProgressValue: LongArray = longArrayOf(-1L)
private val lastContentUpdateTime: LongArray = longArrayOf(0L)
fun scheduleShow(builder: MaterialDialog.Builder) {
if (!showScheduled.compareAndSet(false, true)) {
return
}
GlobalAppContext.postDelayed(Runnable {
// Skip showing if operation already finished/aborted.
// zh-CN: 若操作已结束/已中止, 则不再显示对话框.
if (closed.get() || controller.cancelled.get()) {
return@Runnable
}
val dialog: MaterialDialog = buildAndShowAdaptive { builder.build() }
dialog.setProgressNumberFormat("...")
dialog.applyProgressThemeColorTintLists()
// If closed becomes true right after building, dismiss immediately to avoid dangling dialog.
// zh-CN: 若 build 后立刻变为 closed, 则立即关闭, 避免对话框悬挂.
if (closed.get()) {
try {
if (dialog.isShowing) {
dialog.dismiss()
}
} catch (_: Throwable) {
/* Ignored. */
}
return@Runnable
}
dialogRef.set(dialog)
shown = true
shownAtUptimeMs = SystemClock.uptimeMillis()
// Apply pending content/progress immediately after dialog is ready.
// zh-CN: 对话框就绪后立即应用挂起的 content/progress, 避免首次更新被延迟显示吞掉.
pendingContentRef.getAndSet(null)?.let { pending ->
GlobalAppContext.post {
try {
dialog.contentView?.setLinesEllipsizedIndividually(pending, 1.5f)
} catch (_: Throwable) {
/* Ignored. */
}
}
}
val pendingProcessed = pendingProcessedRef.getAndSet(-1L)
if (pendingProcessed >= 0L) {
val pendingTotal = pendingTotalRef.get()
setProgressThrottled(pendingProcessed, pendingTotal)
}
}, PROGRESS_DIALOG_SHOW_DELAY_MS)
}
fun setContentThrottled(contentList: MutableList<String>) {
val dialog = dialogRef.get()
if (dialog == null) {
// Cache the latest content if dialog is not shown yet.
// zh-CN: 若对话框尚未显示, 缓存最新 content, 等显示后回放.
pendingContentRef.set(contentList.toList())
return
}
val now = SystemClock.uptimeMillis()
if (now - lastContentUpdateTime[0] < PROGRESS_UPDATE_MIN_INTERVAL_MS) {
return
}
lastContentUpdateTime[0] = now
GlobalAppContext.post {
try {
dialog.contentView?.setLinesEllipsizedIndividually(contentList.toList(), 1.5f)
} catch (_: Throwable) {
/* Ignored. */
}
}
}
fun setProgressThrottled(processed: Long, total: Long) {
val dialog = dialogRef.get()
if (dialog == null) {
// Cache the latest progress if dialog is not shown yet.
// zh-CN: 若对话框尚未显示, 缓存最新进度, 等显示后回放.
pendingProcessedRef.set(processed)
pendingTotalRef.set(total)
return
}
val now = SystemClock.uptimeMillis()
if (processed == lastProgressValue[0]) {
return
}
if (now - lastProgressUpdateTime[0] < PROGRESS_UPDATE_MIN_INTERVAL_MS) {
return
}
lastProgressUpdateTime[0] = now
lastProgressValue[0] = processed
GlobalAppContext.post(Runnable {
try {
dialog.setProgressNumberFormatByBytes(processed, total, true)
var v: Int
val max: Int
if (total > 0 && total <= Int.MAX_VALUE) {
max = total.toInt()
v = max(0L, min(processed, max.toLong())).toInt()
} else if (total > 0) {
max = PROGRESS_MAX_LARGE
val ratio = processed.toDouble() / total.toDouble()
v = (ratio * max.toDouble()).roundToInt()
v = max(0, min(max, v))
} else {
// No total bytes, keep indeterminate behavior.
// zh-CN: 无总字节数, 保持不确定进度行为.
return@Runnable
}
dialog.setMaxProgress(max)
dialog.setProgress(v)
} catch (_: Throwable) {
/* Ignored. */
}
})
}
fun dismissSafely() {
// Mark closed first so delayed show won't create a dialog after completion.
// zh-CN: 先标记 closed, 防止延迟 show 在完成后创建对话框.
closed.set(true)
val dialog = dialogRef.get() ?: return
val now = SystemClock.uptimeMillis()
var delay = 0L
if (shown) {
val shownDuration = now - shownAtUptimeMs
if (shownDuration < PROGRESS_DIALOG_MIN_SHOW_MS) {
delay = PROGRESS_DIALOG_MIN_SHOW_MS - shownDuration
}
}
GlobalAppContext.postDelayed({
try {
if (dialog.isShowing) {
dialog.dismiss()
}
} catch (_: Throwable) {
/* Ignored. */
}
}, delay)
}
}
// Controller for cancellable operations (copy/move/delete).
// zh-CN: 用于可取消操作 (复制/移动/删除) 的控制器.
class OperationController {
val cancelled: AtomicBoolean = AtomicBoolean(false)
fun cancel() {
cancelled.set(true)
}
fun throwIfCancelled() {
if (cancelled.get()) {
throw OperationAbortedException()
}
}
}
class OperationAbortedException internal constructor() : RuntimeException("Operation aborted")
private fun TextView.setUnknown() = setText(R.string.text_unknown)
private fun TextView.isUnknown() = text == context.getString(R.string.text_unknown)
private fun TextView.isEllipsisSix() = text == context.getString(R.string.ellipsis_six)
private fun TextView.isNullOrBlank() = text.isNullOrBlank()
}

View File

@@ -38,7 +38,7 @@ import org.autojs.autojs.runtime.api.WrappedShizuku
import org.autojs.autojs.ui.enhancedfloaty.FloatyService
import org.autojs.autojs.ui.floating.FloatyWindowManger
import org.autojs.autojs.util.ContextUtils.findActivity
import org.autojs.autojs.util.MaterialDialogUtils.choiceWidgetThemeColor
import org.autojs.autojs.util.DialogUtils.choiceWidgetThemeColor
import org.autojs.autojs.util.StringUtils.key
import org.autojs.autojs6.R
import java.io.File

View File

@@ -1,99 +0,0 @@
package org.autojs.autojs.util
import android.view.View
import android.widget.TextView
import com.afollestad.materialdialogs.MaterialDialog
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.autojs.autojs.theme.ThemeColorHelper
import org.autojs.autojs6.R
import kotlin.text.isNullOrBlank
object MaterialDialogUtils {
fun MaterialDialog.makeSettingsLaunchable(viewGetter: (dialog: MaterialDialog) -> View?, packageName: String?) {
viewGetter(this)?.setOnClickListener {
packageName?.let { pkg ->
IntentUtils.launchAppDetailsSettings(this.context, pkg)
} ?: ViewUtils.showSnack(this.view, R.string.error_app_not_installed)
}
}
@JvmStatic
fun MaterialDialog.makeTextCopyable(textViewGetter: (dialog: MaterialDialog) -> TextView?) {
makeTextCopyable(textViewGetter(this))
}
@JvmStatic
@JvmOverloads
fun MaterialDialog.makeTextCopyable(textView: TextView?, textValue: String? = textView?.text?.toString()) {
if (!textValue.isNullOrBlank()) {
val context = this.context
textView?.setOnClickListener {
ClipboardUtils.setClip(context, textValue)
ViewUtils.showSnack(this.view, "${context.getString(R.string.text_already_copied_to_clip)}: $textValue")
}
}
}
fun MaterialDialog.setCopyableTextIfAbsent(textView: TextView, textValuePair: Pair<String?, String?>) {
setCopyableTextIfAbsent(textView, textValuePair.first, textValuePair.second)
}
fun MaterialDialog.setCopyableTextIfAbsent(textView: TextView, textValue: String?, suffix: String? = null) {
when {
textValue.isNullOrBlank() -> {
if (textView.isNullOrBlank() || textView.isEllipsisSix()) {
textView.setUnknown()
}
}
textView.isNullOrBlank() || textView.isEllipsisSix() || textView.isUnknown() -> {
textView.text = textValue + (suffix ?: "")
this.makeTextCopyable(textView, textValue)
}
}
}
fun MaterialDialog.setCopyableTextIfAbsent(textView: TextView, scope: CoroutineScope, f: () -> String?) {
setCopyableTextIfAbsent(textView, scope, f, suffix = null)
}
fun MaterialDialog.setCopyableTextIfAbsent(textView: TextView, scope: CoroutineScope, f: () -> String?, suffix: String?) {
scope.launch(Dispatchers.IO) {
val textValue = f.invoke()
withContext(Dispatchers.Main) {
setCopyableTextIfAbsent(textView, textValue, suffix)
}
}
}
fun MaterialDialog.setCopyableText(textView: TextView, textValue: String?) {
when {
textValue.isNullOrBlank() -> {
textView.setUnknown()
}
else -> {
textView.text = textValue
this.makeTextCopyable(textView, textValue)
}
}
}
@JvmStatic
fun MaterialDialog.Builder.choiceWidgetThemeColor() = also {
it.choiceWidgetColor(ThemeColorHelper.getThemeColorStateList(context))
}
@JvmStatic
fun MaterialDialog.Builder.widgetThemeColor() = also {
it.widgetColor(ThemeColorHelper.getThemeColorStateList(context))
}
private fun TextView.setUnknown() = setText(R.string.text_unknown)
private fun TextView.isUnknown() = text == context.getString(R.string.text_unknown)
private fun TextView.isEllipsisSix() = text == context.getString(R.string.ellipsis_six)
private fun TextView.isNullOrBlank() = text.isNullOrBlank()
}

View File

@@ -20,7 +20,7 @@ import kotlin.math.pow
/**
* Created by Stardust on May 3, 2017.
* Modified by SuperMonster003 as of Jul 6, 2022.
* Modified by SuperMonster003 as of Feb 8, 2026.
*/
object StringUtils {
@@ -364,6 +364,14 @@ object StringUtils {
fun String.isLooselyIn(list: Iterable<String>): Boolean =
list.any { this.equalsLoosely(it) }
@JvmStatic
fun String.normalizeTrailingSlash(isDir: Boolean): String {
if (this == "/") return "/"
val noTail = this.trimEnd('/')
return if (isDir) "$noTail/" else noTail
}
class CharsetMatchWrapper(private val charsetMatch: CharsetMatch?) {
val name: String? by lazy { charsetMatch?.name }

View File

@@ -4,8 +4,7 @@ import android.content.Context
import androidx.appcompat.app.AppCompatActivity
import com.afollestad.materialdialogs.DialogAction
import com.afollestad.materialdialogs.MaterialDialog
import org.autojs.autojs.app.DialogUtils
import org.autojs.autojs.app.DialogUtils.showAdaptive
import org.autojs.autojs.util.DialogUtils.showAdaptive
import org.autojs.autojs.core.pref.Pref
import org.autojs.autojs.core.pref.Pref.isAutoCheckForUpdatesEnabled
import org.autojs.autojs.core.pref.Pref.lastNoNewerUpdatesTimestamp
@@ -14,7 +13,7 @@ import org.autojs.autojs.core.pref.Pref.lastUpdatesPostponedTimestamp
import org.autojs.autojs.network.UpdateChecker
import org.autojs.autojs.network.UpdateChecker.PromptMode
import org.autojs.autojs.network.entity.VersionInfo
import org.autojs.autojs.util.MaterialDialogUtils.choiceWidgetThemeColor
import org.autojs.autojs.util.DialogUtils.choiceWidgetThemeColor
import org.autojs.autojs6.R
/**

View File

@@ -38,7 +38,6 @@ import android.view.ViewGroup
import android.view.ViewGroup.LayoutParams.MATCH_PARENT
import android.view.ViewTreeObserver
import android.view.Window
import android.view.WindowInsets
import android.view.WindowManager
import android.view.inputmethod.InputMethodManager
import android.widget.EditText
@@ -64,6 +63,7 @@ import androidx.core.view.WindowInsetsControllerCompat
import androidx.core.view.children
import androidx.core.view.get
import androidx.core.view.size
import androidx.core.view.updateLayoutParams
import com.google.android.material.floatingactionbutton.FloatingActionButton
import com.google.android.material.snackbar.Snackbar
import org.autojs.autojs.app.GlobalAppContext
@@ -80,11 +80,13 @@ import android.text.TextUtils as AndroidTextUtils
/**
* Created by Stardust on Jan 24, 2017.
* Modified by SuperMonster003 as of Feb 2, 2026.
* Modified by OpenAI ChatGPT (GPT-5.2 Thinking) as of Feb 7, 2026.
*/
@Suppress("unused")
object ViewUtils {
private const val TAG_STATUS_BAR_SCRIM = "status_bar_scrim"
private const val STATUS_BAR_SCRIM_TAG = "autojs6:status_bar_scrim"
private const val NAV_BAR_SCRIM_TAG = "autojs6:nav_bar_scrim"
@JvmStatic
var isKeepScreenOnWhenInForegroundEnabled
@@ -747,9 +749,9 @@ object ViewUtils {
private fun installOrUpdateScrim(activity: Activity, @ColorInt color: Int) {
val decor = activity.window.decorView as ViewGroup
val scrim = decor.children.find { it.tag == TAG_STATUS_BAR_SCRIM }
val scrim = decor.children.find { it.tag == STATUS_BAR_SCRIM_TAG }
?: FrameLayout(activity).also {
it.tag = TAG_STATUS_BAR_SCRIM
it.tag = STATUS_BAR_SCRIM_TAG
// Height will be set by Insets later.
// zh-CN: 高度稍后由 Insets 赋值.
val lp = FrameLayout.LayoutParams(MATCH_PARENT, 0, Gravity.TOP)
@@ -789,7 +791,6 @@ object ViewUtils {
fun setNavigationBarBackgroundColor(activity: Activity, color: Int) {
val window = activity.window
val decorView = window.decorView
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.VANILLA_ICE_CREAM) {
@Suppress("DEPRECATION")
@@ -797,18 +798,66 @@ object ViewUtils {
return
}
decorView.setOnApplyWindowInsetsListener { view, insets ->
val navBarInsets = insets.getInsets(WindowInsets.Type.navigationBars())
val contentView: ViewGroup? = activity.findViewById(android.R.id.content)
val navBarOverlay = View(activity).apply {
val content = activity.findViewById<ViewGroup>(android.R.id.content) ?: return
val scrim = (content.findViewWithTag(NAV_BAR_SCRIM_TAG) ?: run {
View(activity).apply {
tag = NAV_BAR_SCRIM_TAG
// Avoid intercepting touch/accessibility focus.
// zh-CN: 避免拦截触摸/无障碍焦点.
isClickable = false
isFocusable = false
importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO
layoutParams = FrameLayout.LayoutParams(
MATCH_PARENT,
navBarInsets.bottom, // Navigation bar height. (zh-CN: 导航栏高度.)
).apply { gravity = Gravity.BOTTOM }
setBackgroundColor(color)
0,
).apply {
gravity = Gravity.BOTTOM
}
// Key: listener is attached to scrim itself, not touching decorView.
// zh-CN: 关键: listener 挂在 scrim 自己身上, 不去动 decorView.
ViewCompat.setOnApplyWindowInsetsListener(this) { v, insets ->
val nav = insets.getInsets(WindowInsetsCompat.Type.navigationBars())
val tap = insets.getInsets(WindowInsetsCompat.Type.tappableElement())
val bottom = maxOf(nav.bottom, tap.bottom)
v.updateLayoutParams<FrameLayout.LayoutParams> {
height = bottom
gravity = Gravity.BOTTOM
}
insets
}
}.also { created ->
content.addView(created)
created.requestApplyInsetsWhenAttached()
}
contentView?.addView(navBarOverlay)
view.onApplyWindowInsets(insets)
})
// Update color without depending on insets callback (avoid "probabilistic ineffectiveness").
// zh-CN: 更新颜色不依赖 insets 回调 (避免 "概率不生效").
scrim.setBackgroundColor(color)
scrim.bringToFront()
}
// If requestApplyInsets() is called when view is not attached, it will be discarded.
// Need to wait until attached and request again to reliably trigger insets dispatch.
// zh-CN:
// 如果 view 未 attach 时调用 requestApplyInsets() 会被丢弃.
// 需要等 attach 后再请求一次, 才能稳定触发 insets 分发.
private fun View.requestApplyInsetsWhenAttached() {
if (ViewCompat.isAttachedToWindow(this)) {
ViewCompat.requestApplyInsets(this)
} else {
addOnAttachStateChangeListener(object : View.OnAttachStateChangeListener {
override fun onViewAttachedToWindow(v: View) {
v.removeOnAttachStateChangeListener(this)
ViewCompat.requestApplyInsets(v)
}
override fun onViewDetachedFromWindow(v: View) = Unit
})
}
}
@@ -1075,7 +1124,7 @@ object ViewUtils {
val desaturate = ColorMatrix().apply { setSaturation(0f) }
// Construct the alpha scaling matrix.
// zh-CN: 构造透明度缩放矩阵.
val alphaMatrix = ColorMatrix().apply { setScale(1f, 1f, 1f, 0.5f) }
val alphaMatrix = ColorMatrix().apply { setScale(1f, 1f, 1f, alpha) }
// Concatenate: first desaturate, then apply alpha.
// zh-CN: 叠加: 先灰度, 再透明度.
desaturate.postConcat(alphaMatrix)
@@ -1149,18 +1198,17 @@ object ViewUtils {
fun showToast(context: Context, stringRes: Int, isLong: Boolean) = showToast(context, context.getString(stringRes), isLong)
@JvmStatic
fun showToast(context: Context, string: String?) = showToast(context, string, false)
fun showToast(context: Context, string: CharSequence?) = showToast(context, string, false)
@JvmStatic
fun showToast(context: Context, string: String?, isLong: Boolean) {
string?.let {
when {
Looper.getMainLooper() == Looper.myLooper() -> {
Toast.makeText(context, it, if (isLong) Toast.LENGTH_LONG else Toast.LENGTH_SHORT).show()
}
else -> GlobalAppContext.post {
Toast.makeText(context, it, if (isLong) Toast.LENGTH_LONG else Toast.LENGTH_SHORT).show()
}
fun showToast(context: Context, string: CharSequence?, isLong: Boolean) {
string ?: return
when {
Looper.getMainLooper() == Looper.myLooper() -> {
Toast.makeText(context, string, if (isLong) Toast.LENGTH_LONG else Toast.LENGTH_SHORT).show()
}
else -> GlobalAppContext.post {
Toast.makeText(context, string, if (isLong) Toast.LENGTH_LONG else Toast.LENGTH_SHORT).show()
}
}
}
@@ -1178,10 +1226,11 @@ object ViewUtils {
}
@JvmStatic
fun showSnack(view: View, string: CharSequence) = showSnack(view, string, false)
fun showSnack(view: View, string: CharSequence?) = showSnack(view, string, false)
@JvmStatic
fun showSnack(view: View, string: CharSequence, isLong: Boolean) {
fun showSnack(view: View, string: CharSequence?, isLong: Boolean) {
string ?: return
try {
Snackbar.make(view, string, if (isLong) Snackbar.LENGTH_LONG else Snackbar.LENGTH_SHORT).show()
} catch (e: IllegalStateException) {
@@ -1199,7 +1248,8 @@ object ViewUtils {
}
@JvmStatic
fun showSnack(view: View, string: CharSequence, duration: Int) {
fun showSnack(view: View, string: CharSequence?, duration: Int) {
string ?: return
try {
Snackbar.make(view, string, duration).show()
} catch (e: IllegalStateException) {
@@ -1329,7 +1379,7 @@ object ViewUtils {
fun TextView.setLinesEllipsizedIndividually(
lines: List<CharSequence>,
lineSpacing: Float = 0f,
where: AndroidTextUtils.TruncateAt = AndroidTextUtils.TruncateAt.MIDDLE
where: AndroidTextUtils.TruncateAt = AndroidTextUtils.TruncateAt.MIDDLE,
) {
post {
val avail = width - paddingLeft - paddingRight

View File

@@ -12,9 +12,9 @@ import java.io.File
object WorkingDirectoryUtils {
var histories: LinkedHashSet<String>
get() = Pref.getLinkedHashSet(R.string.key_working_directory_histories)
private set(value) = Pref.putLinkedHashSet(R.string.key_working_directory_histories, value)
var history: LinkedHashSet<String>
get() = Pref.getLinkedHashSet(R.string.key_working_directory_history)
private set(value) = Pref.putLinkedHashSet(R.string.key_working_directory_history, value)
@JvmStatic
var path: String
@@ -86,12 +86,12 @@ object WorkingDirectoryUtils {
}
@JvmStatic
fun addIntoHistories(history: CharSequence) {
histories = linkedSetOf(toRelativePath(history.toString())).apply { addAll(histories) }
fun addIntoHistory(item: CharSequence) {
history = linkedSetOf(toRelativePath(item.toString())).apply { addAll(history) }
}
fun removeFromHistories(history: CharSequence) {
histories = histories.apply { remove(toRelativePath(history.toString())) }
fun removeFromHistory(item: CharSequence) {
history = history.apply { remove(toRelativePath(item.toString())) }
}
}

View File

Before

Width:  |  Height:  |  Size: 7.0 KiB

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 913 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Some files were not shown because too many files have changed in this diff Show More