6.7.0 - Alpha19 - 版本历史 M4 - 支持排序, 搜索, 摘要显示, 删除, 清空等; 优化 UI 显示
@@ -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 وصفحة الوثائق بواسطة شريط التنقل",
|
||||
"في بعض الصفحات قد يؤدي النقر على أزرار شريط الأدوات إلى تفعيل حدث النقر على العنوان بالخطأ",
|
||||
"السطور الفارغة في محرّر الشيفرة تظهر كرموز مربّعة على بعض الأجهزة",
|
||||
"السطور الفارغة في محرّر الشيفرة تظهر كرموز مربّعة على بعض الأجهزة (حل مؤقت)",
|
||||
"مربع حوار لوحة الألوان في إعداد لون السمة قد يتراكم بلا حدود",
|
||||
"زر رفع الصوت لا يوقف جميع السكربتات عند تعطيل خدمة الوصول",
|
||||
"تداخل لوحة المفاتيح عند تحرير بث مخصّص في صفحة المهام المجدولة",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 が入力エリアを覆う問題を修正",
|
||||
|
||||
@@ -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가 입력 영역을 덮는 문제 수정",
|
||||
|
||||
@@ -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 перекрывал ввод при редактировании пользовательского широковещательного сообщения в расписании задач",
|
||||
|
||||
@@ -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 点击按钮时可能导致标题点击事件误触发的问题",
|
||||
"部分设备代码编辑器空行显示方框字符的问题",
|
||||
"部分设备代码编辑器空行显示方框字符的问题 (试修)",
|
||||
"主题色设置页面调色盘对话框可能无限叠加的问题",
|
||||
"无障碍服务关闭时音量加键停止所有脚本功能失效的问题",
|
||||
"定时任务页面编辑自定义广播内容时出现的输入法遮挡问题",
|
||||
|
||||
@@ -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 點擊按鈕時可能導致標題點擊事件誤觸發的問題",
|
||||
"部分設備代碼編輯器空行顯示方框字符的問題",
|
||||
"部分設備代碼編輯器空行顯示方框字符的問題 (試修)",
|
||||
"主題色設置頁面調色盤對話框可能無限疊加的問題",
|
||||
"無障礙服務關閉時音量加鍵停止所有腳本功能失效的問題",
|
||||
"定時任務頁面編輯自定義廣播內容時出現的輸入法遮擋問題",
|
||||
|
||||
@@ -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 點選按鈕時可能導致標題點選事件誤觸發的問題",
|
||||
"部分裝置程式碼編輯器空行顯示方框字元的問題",
|
||||
"部分裝置程式碼編輯器空行顯示方框字元的問題 (試修)",
|
||||
"主題色設定頁面調色盤對話方塊可能無限疊加的問題",
|
||||
"無障礙服務關閉時音量加鍵停止所有指令碼功能失效的問題",
|
||||
"定時任務頁面編輯自定義廣播內容時出現的輸入法遮擋問題",
|
||||
|
||||
@@ -229,6 +229,7 @@ dependencies /* Unclassified */ {
|
||||
// Room
|
||||
implementation(libs.room.runtime)
|
||||
implementation(libs.room.ktx)
|
||||
implementation(libs.room.rxjava2)
|
||||
ksp(libs.room.compiler)
|
||||
|
||||
// ApkSig
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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?,
|
||||
)
|
||||
|
||||
@@ -61,4 +61,4 @@ abstract class HistoryDatabase : RoomDatabase() {
|
||||
}.build().also { instance = it }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 作为新后缀.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 -> {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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() })
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>,
|
||||
@@ -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)
|
||||
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
}
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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())) }
|
||||
}
|
||||
|
||||
}
|
||||
|
Before Width: | Height: | Size: 7.0 KiB After Width: | Height: | Size: 7.0 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
BIN
app/src/main/res/drawable/ic_simple_file.png
Normal file
|
After Width: | Height: | Size: 913 B |
BIN
app/src/main/res/drawable/ic_simple_folder.png
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 17 KiB |