6.7.0 - Alpha12 - 插件中心 M2 - 插件中心更新支持 ETag/缓存/退避; WorkManager 自动检查更新

This commit is contained in:
SuperMonster003
2025-12-12 11:58:01 +08:00
parent b4a87e6639
commit 9864d2010c
25 changed files with 802 additions and 164 deletions

View File

@@ -27,6 +27,12 @@
<!-- ! Target API == 29: WRT + [ MSV<29> ] / WRT + [ MSV<28> ] + LGC --> <!-- ! Target API == 29: WRT + [ MSV<29> ] / WRT + [ MSV<28> ] + LGC -->
<!-- ! Target API >= 30: WRT + [ MSV<29> ] + LGC + MAN --> <!-- ! Target API >= 30: WRT + [ MSV<29> ] + LGC + MAN -->
<permission
android:name="org.autojs.permission.PLUGIN"
android:protectionLevel="signature" />
<uses-permission android:name="org.autojs.permission.PLUGIN" />
<uses-permission <uses-permission
android:name="android.permission.READ_EXTERNAL_STORAGE" android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="32" /> android:maxSdkVersion="32" />
@@ -132,10 +138,6 @@
android:protectionLevel="signature" android:protectionLevel="signature"
tools:ignore="ProtectedPermissions" /> tools:ignore="ProtectedPermissions" />
<uses-permission
android:name="org.autojs.permission.PLUGIN"
android:protectionLevel="normal" />
<!-- 非 AutoJs6 运行必需, 不会主动申请, 脚本可自行申请 --> <!-- 非 AutoJs6 运行必需, 不会主动申请, 脚本可自行申请 -->
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" /> <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />

View File

@@ -4,10 +4,9 @@ import android.content.BroadcastReceiver
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.IntentFilter import android.content.IntentFilter
import android.net.Uri
import android.os.Bundle import android.os.Bundle
import android.view.View import android.view.View
import androidx.activity.result.contract.ActivityResultContracts import androidx.core.net.toUri
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels import androidx.fragment.app.viewModels
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
@@ -15,6 +14,8 @@ import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.DividerItemDecoration.VERTICAL import androidx.recyclerview.widget.DividerItemDecoration.VERTICAL
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import com.afollestad.materialdialogs.MaterialDialog import com.afollestad.materialdialogs.MaterialDialog
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.autojs.autojs.util.ViewUtils.excludePaddingClippableViewFromBottomNavigationBar import org.autojs.autojs.util.ViewUtils.excludePaddingClippableViewFromBottomNavigationBar
@@ -30,12 +31,14 @@ class PluginCenterFragment : Fragment(R.layout.fragment_plugin_center) {
private lateinit var adapter: PluginCenterItemAdapter private lateinit var adapter: PluginCenterItemAdapter
private lateinit var contextRef: Context private lateinit var contextRef: Context
private val uninstallLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
vm.load(requireContext())
}
private var pkgReceiver: BroadcastReceiver? = null private var pkgReceiver: BroadcastReceiver? = null
// Job for delaying the display of empty state hint.
// zh-CN: 用于延迟显示空态提示的 Job.
private var emptyHintJob: Job? = null
private var isFirstEnter: Boolean = true
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
_binding = FragmentPluginCenterBinding.bind(view) _binding = FragmentPluginCenterBinding.bind(view)
@@ -57,9 +60,9 @@ class PluginCenterFragment : Fragment(R.layout.fragment_plugin_center) {
.positiveText(R.string.dialog_button_confirm) .positiveText(R.string.dialog_button_confirm)
.positiveColorRes(R.color.dialog_button_caution) .positiveColorRes(R.color.dialog_button_caution)
.onPositive { _, _ -> .onPositive { _, _ ->
val uri = Uri.parse("package:${item.packageName}") val uri = "package:${item.packageName}".toUri()
val intent = Intent(Intent.ACTION_DELETE, uri) val intent = Intent(Intent.ACTION_DELETE, uri)
uninstallLauncher.launch(intent) startActivity(intent)
} }
.show() .show()
} }
@@ -94,23 +97,106 @@ class PluginCenterFragment : Fragment(R.layout.fragment_plugin_center) {
excludePaddingClippableViewFromBottomNavigationBar() excludePaddingClippableViewFromBottomNavigationBar()
} }
// Load data. // Pull-to-refresh: force refresh index every time (still respects backoff window).
// zh-CN: 加载数据. // zh-CN: 下拉刷新: 每次都强制刷新索引 (仍遵守退避窗口).
vm.load(context) binding.pluginCenterSwipeRefresh.setOnRefreshListener {
vm.load(contextRef, forceRefreshIndex = true)
}
// Subscribe data updates. // First entry: local priority + index async.
// zh-CN: 订阅数据更新. // zh-CN: 首次进入: 本地优先 + 索引异步.
vm.load(context, forceRefreshIndex = false)
// Subscribe to list data.
// zh-CN: 订阅列表数据.
viewLifecycleOwner.lifecycleScope.launch { viewLifecycleOwner.lifecycleScope.launch {
vm.items.collectLatest { list -> vm.items.collectLatest { list ->
adapter.updateData(list) adapter.updateData(list)
updateEmptyHint(list, vm.indexLoaded.value)
}
}
// Subscribe to index loading completion status, used to update pull-to-refresh animation and empty state text.
// zh-CN: 订阅索引加载完成状态, 用于更新下拉刷新动画与空态文案.
viewLifecycleOwner.lifecycleScope.launch {
vm.indexLoaded.collectLatest { loaded ->
binding.pluginCenterSwipeRefresh.isRefreshing = false
updateEmptyHint(adapter.items(), loaded)
}
}
// Subscribe to locally discovered fatal exceptions.
// zh-CN: 订阅本地发现致命异常.
viewLifecycleOwner.lifecycleScope.launch {
vm.fatalError.collectLatest { msg ->
MaterialDialog.Builder(contextRef)
.title(R.string.text_error)
.content(msg)
.positiveText(R.string.dialog_button_dismiss)
.onPositive { _, _ -> requireActivity().finish() }
.cancelable(false)
.show()
}
}
}
private fun updateEmptyHint(items: List<PluginCenterItem>, indexLoaded: Boolean) {
val hintView = binding.pluginCenterEmptyHint
when {
items.isNotEmpty() -> {
// Has data: hide hint immediately and cancel any waiting tasks.
// zh-CN: 有数据: 立即隐藏提示, 并取消任何等待任务.
emptyHintJob?.cancel()
emptyHintJob = null
hintView.visibility = View.GONE
isFirstEnter = false
}
indexLoaded -> {
// Local and index stages have ended, list is still empty, immediately show "no plugins" hint.
// zh-CN: 本地与索引阶段已结束, 列表仍为空, 立即显示"没有插件"提示.
emptyHintJob?.cancel()
emptyHintJob = null
hintView.visibility = View.VISIBLE
hintView.setText(R.string.text_no_plugins_installed_hint)
isFirstEnter = false
}
else -> {
// Local is empty, index still being fetched.
// zh-CN: 本地为空, 索引仍在获取.
hintView.visibility = View.GONE
if (isFirstEnter) {
// First entry: wait at most 0.5 seconds, if still no data then show "retrieving..." hint.
// zh-CN: 首次进入: 最多等待 0.5 秒, 若仍无数据再显示 "正在获取..." 提示.
if (emptyHintJob == null || emptyHintJob?.isCancelled == true) {
emptyHintJob = viewLifecycleOwner.lifecycleScope.launch {
delay(500L)
if (isAdded && _binding != null) {
val latestItems = adapter.items()
val latestIndexLoaded = vm.indexLoaded.value
if (latestItems.isEmpty() && !latestIndexLoaded) {
hintView.visibility = View.VISIBLE
hintView.setText(R.string.text_retrieving_plugin_list_data)
}
}
}
}
} else {
// Not first time (including pull-to-refresh/auto refresh when returning to page): show loading hint immediately without delay.
// zh-CN: 非首次 (包括下拉刷新/返回页面自动刷新): 立即显示加载提示, 不做延迟.
emptyHintJob?.cancel()
emptyHintJob = null
hintView.visibility = View.VISIBLE
hintView.setText(R.string.text_retrieving_plugin_list_data)
}
} }
} }
} }
override fun onStart() { override fun onStart() {
super.onStart() super.onStart()
run registerPackageReceiver@{ pkgReceiver ?: run registerPackageReceiver@{
pkgReceiver ?: return@registerPackageReceiver
pkgReceiver = object : BroadcastReceiver() { pkgReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) { override fun onReceive(context: Context, intent: Intent) {
val data = intent.data ?: return val data = intent.data ?: return
@@ -121,13 +207,13 @@ class PluginCenterFragment : Fragment(R.layout.fragment_plugin_center) {
// Package installation (update) does not record "Recently installed" when replacing, but will refresh. // Package installation (update) does not record "Recently installed" when replacing, but will refresh.
// zh-CN: 替换安装 (更新) 不记录 "最近安装", 但会刷新. // zh-CN: 替换安装 (更新) 不记录 "最近安装", 但会刷新.
if (!replacing) PluginRecentStore.setLastInstalled(packageName) if (!replacing) PluginRecentStore.setLastInstalled(packageName)
vm.load(context) vm.load(context, forceRefreshIndex = false)
} }
Intent.ACTION_PACKAGE_REMOVED -> { Intent.ACTION_PACKAGE_REMOVED -> {
// Package uninstallation (pre-update phase) does not record "Recently uninstalled" when replacing. // Package uninstallation (pre-update phase) does not record "Recently uninstalled" when replacing.
// zh-CN: 替换卸载 (更新前阶段) 不记录 "最近卸载". // zh-CN: 替换卸载 (更新前阶段) 不记录 "最近卸载".
if (!replacing) PluginRecentStore.setLastUninstalled(packageName) if (!replacing) PluginRecentStore.setLastUninstalled(packageName)
vm.load(context) vm.load(context, forceRefreshIndex = false)
} }
} }
} }
@@ -149,15 +235,17 @@ class PluginCenterFragment : Fragment(R.layout.fragment_plugin_center) {
override fun onResume() { override fun onResume() {
super.onResume() super.onResume()
// Refresh once when returning to the page to update install/uninstall status. // Refresh once when returning to page, to cover state after installation/uninstallation.
// zh-CN: 回到页面时刷新一次, 覆盖安装/卸载后的状态. // zh-CN: 回到页面时刷新一次, 覆盖安装/卸载后的状态.
if (::contextRef.isInitialized) { if (!isFirstEnter && ::contextRef.isInitialized) {
vm.load(contextRef) vm.load(contextRef, forceRefreshIndex = false)
} }
} }
override fun onDestroyView() { override fun onDestroyView() {
super.onDestroyView() super.onDestroyView()
emptyHintJob?.cancel()
emptyHintJob = null
_binding = null _binding = null
} }

View File

@@ -20,7 +20,7 @@ data class PluginCenterItem(
var updatableVersionDate: String? = null, var updatableVersionDate: String? = null,
val author: String? = null, val author: String? = null,
val collaborators: List<String> = emptyList(), val collaborators: List<String> = emptyList(),
val description: String, val description: String? = null,
// Size of installed package (aggregated base + splits), 0 for uninstalled. // Size of installed package (aggregated base + splits), 0 for uninstalled.
// zh-CN: 已安装包大小 (聚合 base + splits), 未安装为 0. // zh-CN: 已安装包大小 (聚合 base + splits), 未安装为 0.
@@ -42,12 +42,15 @@ data class PluginCenterItem(
val versionSummary: String val versionSummary: String
get() = formatVersionInfo(versionName, versionCode, versionDate) get() = formatVersionInfo(versionName, versionCode, versionDate)
val updatableVersionSummary: String val updatableVersionSummary: String?
get() = formatVersionInfo( get() = when {
updatableVersionName ?: versionName, isUpdatable -> formatVersionInfo(
updatableVersionCode ?: versionCode, updatableVersionName ?: versionName,
updatableVersionDate, updatableVersionCode ?: versionCode,
) updatableVersionDate,
)
else -> null
}
val isUpdatable: Boolean val isUpdatable: Boolean
get() = updatableVersionName != null get() = updatableVersionName != null

View File

@@ -68,10 +68,14 @@ class PluginCenterItemViewHolder(
switchView.setOnCheckedChangeListener(null) switchView.setOnCheckedChangeListener(null)
switchView.isChecked = item.isEnabled switchView.isChecked = item.isEnabled
titleView.text = item.title titleView.text = item.title.takeUnless { it.isBlank() }
versionInfoView.text = item.versionSummary ?: "[ ${context.getString(R.string.text_unknown_title_for_plugin)} ]"
authorView.text = item.author versionInfoView.text = item.versionSummary.takeUnless { it.isBlank() }
descriptionView.text = item.description ?: "[ ${context.getString(R.string.text_unknown_version_for_plugin)} ]"
authorView.text = item.author.takeUnless { it.isNullOrBlank() }
?: "[ ${context.getString(R.string.text_unknown_author_for_plugin)} ]"
descriptionView.text = item.description.takeUnless { it.isNullOrBlank() }
?: "[ ${context.getString(R.string.text_unknown_description_for_plugin)} ]"
if (item.isInstalled) { if (item.isInstalled) {
btnDeleteView.setButtonState(true) { btnDeleteView.setButtonState(true) {

View File

@@ -1,10 +1,12 @@
package org.autojs.autojs.core.plugin.center package org.autojs.autojs.core.plugin.center
import android.content.Context import android.content.Context
import android.util.Log
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.async import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.autojs.autojs6.R import org.autojs.autojs6.R
@@ -13,7 +15,19 @@ import org.autojs.autojs6.R
* Loads both index plugins and locally installed plugins, * Loads both index plugins and locally installed plugins,
* merging them into a PluginCenterItem list. * merging them into a PluginCenterItem list.
* *
* zh-CN: 统一加载索引插件与本地已安装插件, 合并为 PluginCenterItem 列表. * Behavior strategy:
* - First load local plugins and immediately push the list (local priority).
* - Then load the index in the background, merge with local plugins and push again after success.
* - If local discovery fails, notify UI to show a dialog and exit Activity through fatalError.
*
* zh-CN:
*
* 统一加载索引插件与本地已安装插件, 合并为 PluginCenterItem 列表.
*
* 行为策略:
* - 先加载本地插件并立即推送列表 (本地优先).
* - 紧接着在后台加载索引, 成功后与本地合并再推送一次.
* - 若本地发现失败, 通过 fatalError 通知 UI 弹窗并退出 Activity.
*/ */
class PluginCenterViewModel : ViewModel() { class PluginCenterViewModel : ViewModel() {
@@ -24,33 +38,91 @@ class PluginCenterViewModel : ViewModel() {
private val _items = MutableStateFlow<List<PluginCenterItem>>(emptyList()) private val _items = MutableStateFlow<List<PluginCenterItem>>(emptyList())
val items: StateFlow<List<PluginCenterItem>> = _items val items: StateFlow<List<PluginCenterItem>> = _items
fun load(context: Context) { // Whether index loading is completed (includes "success/failure/empty" results, only means "no longer loading").
viewModelScope.launch { // zh-CN: 索引加载是否已完成 (包含 "成功/失败/空" 三种结果, 仅代表 "不再加载").
val idxDeferred = async { runCatching { indexRepo.fetchOfficialIndex(context) }.getOrElse { emptyList() } } private val _indexLoaded = MutableStateFlow(false)
val insDeferred = async { runCatching { installedRepo.discoverInstalled(context) }.getOrElse { emptyList() } } val indexLoaded: StateFlow<Boolean> = _indexLoaded
val indexEntries = idxDeferred.await() // Fatal exception discovered locally (requires user notification and Activity exit).
val installed = insDeferred.await() // zh-CN: 本地发现的致命异常 (需要提示用户并退出 Activity).
private val _fatalError = MutableSharedFlow<String>(extraBufferCapacity = 1)
val fatalError: SharedFlow<String> = _fatalError
/**
* Load plugin list.
*
* zh-CN: 加载插件列表.
*
* @param forceRefreshIndex Whether to force refresh index.
* - false: Use 1 minute throttling + ETag/backoff (normal page entry).
* - true: Attempt to refresh index every time (still respects backoff window, for pull-to-refresh usage).
*
* zh-CN: 是否强制刷新索引.
* - false: 使用 1 分钟节流 + ETag/退避 (普通进入页面).
* - true: 每次都尝试刷新索引 (仍遵守退避窗口, 供下拉刷新使用).
*/
fun load(context: Context, forceRefreshIndex: Boolean = false) {
Log.d(TAG, "load: forceRefreshIndex = $forceRefreshIndex")
viewModelScope.launch {
_indexLoaded.value = false
// First load local plugins (local priority).
// zh-CN: 先加载本地插件 (本地优先).
val installed = runCatching {
installedRepo.discoverInstalled(context)
}.onFailure { e ->
// Treat "local discovery" failure as a serious exception.
// zh-CN: 将 "本地发现" 失败视为严重异常.
val message = context.getString(
R.string.error_failed_to_load_plugins_with_reason,
e.message ?: e.toString(),
)
_fatalError.tryEmit(message)
}.getOrNull() ?: run {
// Do not continue subsequent logic when unable to obtain local list.
// zh-CN: 无法获取本地列表时不再继续后续逻辑.
_items.value = emptyList()
_indexLoaded.value = true
return@launch
}
// Render list using "local only".
// zh-CN: 使用 "仅本地" 渲染列表.
val onlyLocalItems = installed.mapNotNull { local ->
toPluginCenterItem(context, index = null, local = local)
}
_items.value = onlyLocalItems
// Asynchronously load index and merge.
// zh-CN: 异步加载索引并合并.
val indexEntries = runCatching {
indexRepo.fetchOfficialIndex(context, forceRefresh = forceRefreshIndex)
}.onFailure {
// Index fetch exception is not fatal, just log it.
// zh-CN: 索引获取异常不算致命, 使用日志记录即可.
Log.w(TAG, "fetchOfficialIndex failed: ${it.message}")
}.getOrElse { emptyList() }
val installedByPkg = installed.associateBy { it.packageName } val installedByPkg = installed.associateBy { it.packageName }
// Use index to drive UI, to display installable and updatable items. // Use index to drive UI, showing installable/updatable.
// zh-CN: 用索引驱动 UI, 展示可安装及可更新的项. // zh-CN: 用索引驱动 UI, 展示 installable/updatable.
val fromIndex = indexEntries.map { e -> val fromIndex = indexEntries.mapNotNull { e ->
val local = installedByPkg[e.packageName] val local = installedByPkg[e.packageName]
toPluginCenterItem(context, index = e, local = local) toPluginCenterItem(context, index = e, local = local)
} }
// Add items that "exist locally but not in index" (third-party or not indexed yet). // Supplement plugins that "exist locally but not in index" (third-party/not yet in index).
// zh-CN: 补充 "本地存在但索引里暂时没有" 的 (第三方暂未入索引). // zh-CN: 补充 "本地但索引没有" 的插件 (第三方/暂未入索引).
val extraLocals = installed val extraLocals = installed
.filter { ins -> indexEntries.none { it.packageName == ins.packageName } } .filter { ins -> indexEntries.none { it.packageName == ins.packageName } }
.map { local -> toPluginCenterItem(context, index = null, local = local) } .mapNotNull { local -> toPluginCenterItem(context, index = null, local = local) }
_items.value = fromIndex + extraLocals _items.value = fromIndex + extraLocals
_indexLoaded.value = true
// Refresh dialog if showing when returning to the page. // When returning to the page, refresh if the details dialog is being displayed.
// zh-CN: 返回页面时, 对话框如果正在显示则刷新. // zh-CN: 返回页面时, 若详情对话框正在显示则刷新.
PluginInfoDialogManager.refreshIfShowing(context, _items.value) PluginInfoDialogManager.refreshIfShowing(context, _items.value)
} }
} }
@@ -63,10 +135,12 @@ class PluginCenterViewModel : ViewModel() {
PluginInfoDialogManager.refreshIfShowing(context, _items.value) PluginInfoDialogManager.refreshIfShowing(context, _items.value)
} }
private fun toPluginCenterItem(context: Context, index: PluginIndexEntry?, local: InstalledPluginRepository.InstalledPlugin?): PluginCenterItem { private fun toPluginCenterItem(context: Context, index: PluginIndexEntry?, local: InstalledPluginRepository.InstalledPlugin?): PluginCenterItem? {
val packageName = local?.packageName ?: index?.packageName.orEmpty() val packageName = local?.packageName ?: index?.packageName
if (packageName.isNullOrBlank()) return null
val title = local?.title ?: index?.title ?: packageName val title = local?.title ?: index?.title ?: packageName
val description = local?.description ?: index?.description.orEmpty() val description = local?.description ?: index?.description
val author = local?.author ?: index?.author val author = local?.author ?: index?.author
val collaborators = index?.collaborators ?: emptyList() val collaborators = index?.collaborators ?: emptyList()
@@ -74,10 +148,10 @@ class PluginCenterViewModel : ViewModel() {
val versionCodeLocal = local?.versionCode val versionCodeLocal = local?.versionCode
val isInstalled = local != null val isInstalled = local != null
// Mark as updatable and populate update target information only when "installed and index version is higher". // Only mark as updatable when "installed and index version is higher", and fill in updatable target information.
// zh-CN: 仅当 "已安装且索引版本更高" 时, 标记可更新, 并填充可更新目标信息. // zh-CN: 仅当 "已安装且索引版本更高" 时, 标记可更新, 并填充可更新目标信息.
val (updatableName, updatableCode, updatableDate) = run { val (updatableName, updatableCode, updatableDate) = run {
val defaultVersionInfo = Triple(null, null, null) val defaultVersionInfo = Triple<String?, Long?, String?>(null, null, null)
versionCodeLocal ?: return@run defaultVersionInfo versionCodeLocal ?: return@run defaultVersionInfo
val versionCodeIndex = index?.versionCode ?: return@run defaultVersionInfo val versionCodeIndex = index?.versionCode ?: return@run defaultVersionInfo
if (versionCodeIndex <= versionCodeLocal) return@run defaultVersionInfo if (versionCodeIndex <= versionCodeLocal) return@run defaultVersionInfo
@@ -112,4 +186,9 @@ class PluginCenterViewModel : ViewModel() {
settings = null, settings = null,
) )
} }
}
companion object {
private const val TAG = "PluginCenterViewModel"
}
}

View File

@@ -1,35 +1,282 @@
package org.autojs.autojs.core.plugin.center package org.autojs.autojs.core.plugin.center
import android.content.Context import android.content.Context
import android.util.Log
import androidx.core.content.edit
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.autojs.autojs6.BuildConfig
import org.autojs.autojs6.R
import org.json.JSONArray
import org.json.JSONObject
import java.io.File
import java.net.ConnectException
import java.net.HttpURLConnection
import java.net.NoRouteToHostException
import java.net.URL
import java.net.UnknownHostException
import kotlin.math.min
/**
* 官方插件索引仓库 (M1: 内置静态; M2 再接入网络/ETag/缓存).
*/
class PluginIndexRepository { class PluginIndexRepository {
suspend fun fetchOfficialIndex(context: Context): List<PluginIndexEntry> { companion object {
// TODO M1 先预置 1 条官方样例 "Paddle OCR (PP-OCRv5)", 便于与本地已安装合并显示. private const val TAG = "PluginIndexRepository"
return listOf(
PluginIndexEntry( private const val INDEX_URL = "https://raw.githubusercontent.com/SuperMonster005/autojs6-plugin-index/main/index.json"
packageName = "io.github.supermonster003.autojs6.plugin.paddleocr.v5",
title = "Paddle OCR (PP-OCRv5)", private const val SP = "plugin_center_index"
description = "百度飞桨光学字符识别插件", private const val KEY_ETAG = "etag"
author = "SuperMonster003", private const val KEY_LAST_SUCCESS_TS = "last_success_ts"
collaborators = emptyList(), private const val KEY_LAST_FAILURE_TS = "last_failure_ts"
versionName = "0.1.5", private const val KEY_RETRY_ATTEMPTS = "retry_attempts"
versionCode = 15L,
versionDate = "2025-11-25", private const val CACHE_FILE_NAME = "plugin_center_index_cache.json"
// TODO M1 若索引的下载地址/哈希/尺寸未知, 则先设置为 null.
apkUrl = null, private const val MIN_RETRY_INTERVAL_MS = 30_000L // 30 sec
apkSha256 = null, private const val MAX_RETRY_INTERVAL_MS = 10 * 60_000L // 10 min
apkSizeBytes = null, private const val MIN_REFRESH_INTERVAL_MS = 1 * 60_000L // 1 min
// TODO M1 暂不拉网图标, 使用应用图标或默认图标.
iconUrl = null,
tags = listOf("official", "ocr", "paddle", "v5"),
engine = "paddle-ocr",
variant = "v5",
engineId = "paddle-ocr-pp-ocrv5",
),
)
} }
@Volatile
private var memoryCache: List<PluginIndexEntry>? = null
suspend fun fetchOfficialIndex(context: Context, forceRefresh: Boolean = false): List<PluginIndexEntry> =
withContext(Dispatchers.IO) {
memoryCache?.let { return@withContext it }
val sp = context.getSharedPreferences(SP, Context.MODE_PRIVATE)
val cacheFile = File(context.cacheDir, CACHE_FILE_NAME)
val now = System.currentTimeMillis()
val lastFailureTs = sp.getLong(KEY_LAST_FAILURE_TS, 0L)
val retryAttempts = sp.getInt(KEY_RETRY_ATTEMPTS, 0)
val lastSuccessTs = sp.getLong(KEY_LAST_SUCCESS_TS, 0L)
val cachedEntries: List<PluginIndexEntry>? = readCacheSafely(cacheFile)
// Throttle for MIN_REFRESH_INTERVAL_MS time.
// zh-CN: 节流 MIN_REFRESH_INTERVAL_MS 时间.
if (!forceRefresh && lastSuccessTs > 0 && now - lastSuccessTs < MIN_REFRESH_INTERVAL_MS) {
cachedEntries?.let {
memoryCache = it
Log.i(TAG, "Using cached index (within 1 minutes).")
return@withContext it
}
}
val withinBackoffWindow = !forceRefresh && isWithinBackoffWindow(now, lastFailureTs, retryAttempts)
if (withinBackoffWindow) {
cachedEntries?.let { fromCache ->
memoryCache = fromCache
Log.i(TAG, "Within backoff window, skip network and use cache.")
return@withContext fromCache
}
}
val etag = sp.getString(KEY_ETAG, null)
val netResult = runCatching { fetchFromNetwork(context, etag) }
when (val r = netResult.getOrNull()) {
is NetResult.NotModified -> {
resetFailureState(sp, now)
cachedEntries?.let { fromCache ->
memoryCache = fromCache
Log.i(TAG, "Index not modified, use cached.")
return@withContext fromCache
}
// Server returned 304, but no local cache file exists.
// In this case, need to re-initiate a complete request without ETag to fetch the latest index data.
// zh-CN:
// 服务器返回 304, 但本地无缓存文件.
// 此时需要重新发起一次无 ETag 的完整请求, 拉取最新索引数据.
Log.w(TAG, "Index not modified, but no cache found. Force fetching fresh data without ETag.")
val freshResult = runCatching { fetchFromNetwork(context, null) }
when (val fr = freshResult.getOrNull()) {
is NetResult.Fresh -> {
cacheFile.writeText(fr.body)
sp.edit {
putString(KEY_ETAG, fr.etag)
putLong(KEY_LAST_SUCCESS_TS, now)
}
val parsed = parseIndexJson(fr.body)
memoryCache = parsed
Log.i(TAG, "Fetched fresh index after missing cache on 304.")
return@withContext parsed
}
is NetResult.NotModified -> {
Log.w(TAG, "Got 304 again even without ETag, fall back to empty list.")
}
null -> {
val th = freshResult.exceptionOrNull()
Log.w(TAG, "Force fetch after missing cache failed: ${th?.message}")
}
}
}
is NetResult.Fresh -> {
resetFailureState(sp, now)
cacheFile.writeText(r.body)
sp.edit {
putString(KEY_ETAG, r.etag)
putLong(KEY_LAST_SUCCESS_TS, now)
}
val parsed = parseIndexJson(r.body)
memoryCache = parsed
Log.i(TAG, "Fetched fresh index from network.")
return@withContext parsed
}
null -> {
val throwable = netResult.exceptionOrNull()
Log.w(TAG, "fetchOfficialIndex network failed: ${throwable?.message}")
// Check if it is a "no network connection" type exception,
// if so, do not increment the backoff counter.
// zh-CN: 判断是否为 "无网络连接" 类型异常, 若是则不累加退避计数.
// @formatter:off
val isOffline = throwable is UnknownHostException
|| throwable is ConnectException
|| throwable is NoRouteToHostException
// @formatter:on
if (!isOffline) {
increaseFailureState(sp, now, retryAttempts)
} else {
Log.i(TAG, "Detected offline state, skip increasing backoff counters.")
}
cachedEntries?.let { fallback ->
memoryCache = fallback
return@withContext fallback
}
}
}
return@withContext emptyList()
}
private sealed interface NetResult {
data class Fresh(val body: String, val etag: String?) : NetResult
data object NotModified : NetResult
}
private fun fetchFromNetwork(context: Context, etag: String?): NetResult {
val conn = (URL(INDEX_URL).openConnection() as HttpURLConnection).apply {
connectTimeout = 10_000
readTimeout = 15_000
requestMethod = "GET"
etag?.let { setRequestProperty("If-None-Match", it) }
setRequestProperty("Accept", "application/json")
setRequestProperty("User-Agent", "${context.getString(R.string.app_name)}/${BuildConfig.VERSION_NAME} (PluginCenter)")
}
return try {
conn.connect()
when (val code = conn.responseCode) {
HttpURLConnection.HTTP_NOT_MODIFIED -> NetResult.NotModified
HttpURLConnection.HTTP_OK -> {
val body = conn.inputStream.bufferedReader().use { it.readText() }
val newEtag = conn.getHeaderField("ETag")
NetResult.Fresh(body, newEtag)
}
else -> throw IllegalStateException("HTTP $code: ${conn.responseMessage}")
}
} finally {
conn.disconnect()
}
}
private fun readCacheSafely(file: File): List<PluginIndexEntry>? =
runCatching {
if (!file.exists() || !file.canRead()) return null
val text = file.readText()
parseIndexJson(text)
}.getOrNull()
private fun isWithinBackoffWindow(now: Long, lastFailureTs: Long, attempts: Int): Boolean {
if (lastFailureTs <= 0 || attempts <= 0) return false
// Avoid overflow.
// zh-CN: 避免溢出.
val exp = min(attempts - 1, 6)
val base = MIN_RETRY_INTERVAL_MS
// Backoff duration = MIN_RETRY_INTERVAL * 2^(attempts-1), capped at MAX_RETRY_INTERVAL.
// zh-CN: 退避时长 = MIN_RETRY_INTERVAL * 2^(attempts-1), 最大不超过 MAX_RETRY_INTERVAL.
val delay = min(base shl exp, MAX_RETRY_INTERVAL_MS)
return now - lastFailureTs < delay
}
private fun resetFailureState(sp: android.content.SharedPreferences, now: Long) {
sp.edit {
putLong(KEY_LAST_FAILURE_TS, 0L)
putInt(KEY_RETRY_ATTEMPTS, 0)
putLong(KEY_LAST_SUCCESS_TS, now)
}
}
private fun increaseFailureState(sp: android.content.SharedPreferences, now: Long, prevAttempts: Int) {
val attempts = (prevAttempts + 1).coerceAtMost(10)
sp.edit {
putLong(KEY_LAST_FAILURE_TS, now)
putInt(KEY_RETRY_ATTEMPTS, attempts)
}
}
private fun parseIndexJson(json: String): List<PluginIndexEntry> {
val root = JSONObject(json)
val list = mutableListOf<PluginIndexEntry>()
// The index structure is { "plugins/items": PluginIndexEntry[] }.
// zh-CN: 索引结构为 { "plugins/items": PluginIndexEntry[] }.
val arr: JSONArray = when {
root.has("plugins") -> root.getJSONArray("plugins")
root.has("items") -> root.getJSONArray("items")
else -> JSONArray().also {
Log.w(TAG, "Invalid index JSON: missing plugins/items field.")
}
}
for (i in 0 until arr.length()) {
val obj = arr.optJSONObject(i) ?: continue
val pkg = obj.optString("packageName").takeIf { it.isNotBlank() } ?: continue
val title = obj.optString("title", pkg)
val desc = obj.optString("description", "")
val author = obj.optString("author").takeIf { it.isNotBlank() }
val collaborators = obj.optJSONArray("collaborators")?.let { ja ->
List(ja.length()) { idx -> ja.optString(idx) }.filter { it.isNotBlank() }
} ?: emptyList()
val engine = obj.optString("engine").takeIf { it.isNotBlank() }
val variant = obj.optString("variant").takeIf { it.isNotBlank() }
val engineId = obj.optString("engineId").takeIf { it.isNotBlank() }
val versionName = obj.optString("versionName", "0.0.0")
val versionCode = obj.optLong("versionCode", -1L).takeIf { it > 0 }
val versionDate = obj.optString("versionDate").takeIf { it.isNotBlank() }
val apkUrl = obj.optString("apkUrl").takeIf { it.isNotBlank() }
val apkSha256 = obj.optString("apkSha256").takeIf { it.isNotBlank() }
val apkSize = obj.optLong("apkSizeBytes", -1L).takeIf { it > 0 }
list += PluginIndexEntry(
packageName = pkg,
// TODO M2: 若索引提供 iconUrl 再解析为 Uri.
iconUrl = null,
title = title,
description = desc,
author = author,
collaborators = collaborators,
engine = engine,
variant = variant,
engineId = engineId,
versionName = versionName,
versionCode = versionCode,
versionDate = versionDate,
apkUrl = apkUrl,
apkSha256 = apkSha256,
apkSizeBytes = apkSize,
tags = emptyList(),
)
}
return list
}
} }

View File

@@ -0,0 +1,38 @@
package org.autojs.autojs.core.plugin.center
import android.content.Context
import androidx.work.Constraints
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.NetworkType
import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager
import java.util.concurrent.TimeUnit
object PluginIndexSyncScheduler {
private const val UNIQUE_WORK_NAME = "plugin_index_auto_sync"
fun schedulePeriodicSync(context: Context) {
val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()
val request = PeriodicWorkRequestBuilder<PluginIndexSyncWorker>(
repeatInterval = 24,
repeatIntervalTimeUnit = TimeUnit.HOURS,
).setConstraints(constraints)
.build()
WorkManager.getInstance(context).enqueueUniquePeriodicWork(
UNIQUE_WORK_NAME,
ExistingPeriodicWorkPolicy.KEEP,
request,
)
}
fun cancelPeriodicSync(context: Context) {
WorkManager.getInstance(context)
.cancelUniqueWork(UNIQUE_WORK_NAME)
}
}

View File

@@ -0,0 +1,29 @@
package org.autojs.autojs.core.plugin.center
import android.content.Context
import android.util.Log
import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters
class PluginIndexSyncWorker(
appContext: Context,
params: WorkerParameters,
) : CoroutineWorker(appContext, params) {
private val repo = PluginIndexRepository()
override suspend fun doWork(): Result =
try {
repo.fetchOfficialIndex(applicationContext, forceRefresh = true)
Log.i(TAG, "Index sync succeeded.")
Result.success()
} catch (e: Exception) {
Log.w(TAG, "Index sync failed: ${e.message}")
Result.retry()
}
companion object {
private const val TAG = "PluginIndexSyncWorker"
}
}

View File

@@ -43,8 +43,8 @@ object PluginInfoDialogManager {
val pkg = currentPackageName ?: return val pkg = currentPackageName ?: return
if (!dialog.isShowing) return if (!dialog.isShowing) return
val target = allItems.firstOrNull { it.packageName == pkg } ?: return val target = allItems.firstOrNull { it.packageName == pkg } ?: return
dialog.dismiss()
showPluginInfoDialog(context, target) showPluginInfoDialog(context, target)
dialog.dismiss()
} }
@JvmStatic @JvmStatic
@@ -128,7 +128,7 @@ object PluginInfoDialogManager {
} }
else -> { else -> {
d.dismiss() d.dismiss()
CoroutineScope(Dispatchers.IO).launch { CoroutineScope(Dispatchers.Main).launch {
PluginInstaller.installFromUrlWithPrompt(context, url, info.sha256) PluginInstaller.installFromUrlWithPrompt(context, url, info.sha256)
} }
} }
@@ -154,7 +154,7 @@ object PluginInfoDialogManager {
} }
else -> { else -> {
d.dismiss() d.dismiss()
CoroutineScope(Dispatchers.IO).launch { CoroutineScope(Dispatchers.Main).launch {
PluginInstaller.installFromUrlWithPrompt(context, url, info.sha256) PluginInstaller.installFromUrlWithPrompt(context, url, info.sha256)
} }
} }
@@ -315,7 +315,7 @@ object PluginInfoDialogManager {
val version: String val version: String
val author: String? val author: String?
val collaborators: List<String> val collaborators: List<String>
val description: String val description: String?
val packageSize: Long val packageSize: Long
val apkUrl: String? val apkUrl: String?
val sha256: String? val sha256: String?
@@ -328,7 +328,7 @@ object PluginInfoDialogManager {
override val version: String, override val version: String,
override val author: String?, override val author: String?,
override val collaborators: List<String>, override val collaborators: List<String>,
override val description: String, override val description: String?,
override val packageSize: Long, override val packageSize: Long,
override val apkUrl: String?, override val apkUrl: String?,
override val sha256: String?, override val sha256: String?,
@@ -343,7 +343,7 @@ object PluginInfoDialogManager {
override val version: String, override val version: String,
override val author: String?, override val author: String?,
override val collaborators: List<String>, override val collaborators: List<String>,
override val description: String, override val description: String?,
override val packageSize: Long, override val packageSize: Long,
override val apkUrl: String?, override val apkUrl: String?,
override val sha256: String?, override val sha256: String?,

View File

@@ -9,13 +9,16 @@ import com.afollestad.materialdialogs.DialogAction
import com.afollestad.materialdialogs.MaterialDialog import com.afollestad.materialdialogs.MaterialDialog
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.autojs.autojs.network.download.DownloadManager import org.autojs.autojs.network.download.DownloadManager
import org.autojs.autojs.runtime.api.Mime import org.autojs.autojs.runtime.api.Mime
import org.autojs.autojs.ui.main.scripts.ApkInfoDialogManager import org.autojs.autojs.ui.main.scripts.ApkInfoDialogManager
import org.autojs.autojs.util.ClipboardUtils
import org.autojs.autojs.util.FileUtils import org.autojs.autojs.util.FileUtils
import org.autojs.autojs.util.FileUtils.toCacheFile import org.autojs.autojs.util.FileUtils.toCacheFile
import org.autojs.autojs.util.UpdateUtils import org.autojs.autojs.util.UpdateUtils
import org.autojs.autojs.util.ViewUtils
import org.autojs.autojs6.R import org.autojs.autojs6.R
import java.io.EOFException import java.io.EOFException
import java.io.File import java.io.File
@@ -83,27 +86,20 @@ object PluginInstaller {
} }
suspend fun installFromUrlWithPrompt(context: Context, url: String, expectedSha256: String? = null) { suspend fun installFromUrlWithPrompt(context: Context, url: String, expectedSha256: String? = null) {
var attempt = 0 when (val result = downloadWithProgress(context, url, expectedSha256)) {
while (true) { is DownloadResult.Success -> {
when (val result = downloadWithProgress(context, url, expectedSha256)) { installFromFileUriWithPrompt(context, result.uri)
is DownloadResult.Success -> { }
installFromFileUriWithPrompt(context, result.uri) is DownloadResult.Cancelled -> {
return // User cancelled, no need to prompt.
} // zh-CN: 用户取消, 无需提示.
is DownloadResult.Cancelled -> { }
// User cancelled, no need to prompt. is DownloadResult.Failure -> {
// zh-CN: 用户取消, 无需提示. // Wait for user action in the failure dialog (retry/quit).
return // zh-CN: 等待用户在失败对话框中的操作 (重试/放弃).
} val wantRetry = showFailureDialogAndAwaitDecision(context, result)
is DownloadResult.Failure -> { if (wantRetry) {
val retry = showRetryDialog(context, result) installFromUrlWithPrompt(context, url, expectedSha256)
if (retry) {
attempt++
// TODO M1: 不做自动退避, 交给用户控制重试节奏; M2 可引入指数退避/网络可用性判断
continue
} else {
return
}
} }
} }
} }
@@ -114,46 +110,21 @@ object PluginInstaller {
is DownloadResult.Success -> { is DownloadResult.Success -> {
installFromFileUri(context, result.uri) installFromFileUri(context, result.uri)
} }
is DownloadResult.Cancelled -> {
// User cancelled, no need to prompt.
// zh-CN: 用户取消, 无需提示.
}
is DownloadResult.Failure -> { is DownloadResult.Failure -> {
MaterialDialog.Builder(context) // Wait for user action in the failure dialog (retry/quit).
.title(result.titleRes) // zh-CN: 等待用户在失败对话框中的操作 (重试/放弃).
.content(result.message) val wantRetry = showFailureDialogAndAwaitDecision(context, result)
.positiveText(R.string.dialog_button_dismiss) if (wantRetry) {
.show() installFromUrl(context, url, expectedSha256)
}
} }
else -> Unit
} }
} }
private fun showRetryDialog(context: Context, failure: DownloadResult.Failure): Boolean {
var wantRetry = false
MaterialDialog.Builder(context)
.title(failure.titleRes)
.content(failure.message)
.neutralText(R.string.dialog_button_exception_details)
.neutralColorRes(R.color.dialog_button_hint)
.onNeutral { _, _ ->
MaterialDialog.Builder(context)
.title(failure.titleRes)
.content(failure.message)
.positiveText(R.string.dialog_button_dismiss)
.show()
}
.negativeText(R.string.dialog_button_quit)
.negativeColorRes(R.color.dialog_button_default)
.onNegative { d, _ -> d.dismiss() }
.positiveText(R.string.dialog_button_retry)
.positiveColorRes(R.color.dialog_button_attraction)
.onPositive { d, _ ->
wantRetry = true
d.dismiss()
}
.cancelable(false)
.autoDismiss(true)
.show()
return wantRetry
}
// Probe URL size (HEAD). // Probe URL size (HEAD).
// zh-CN: 探测 URL 大小 (HEAD). // zh-CN: 探测 URL 大小 (HEAD).
suspend fun probeContentLength(url: String): Long? = withContext(Dispatchers.IO) { suspend fun probeContentLength(url: String): Long? = withContext(Dispatchers.IO) {
@@ -322,6 +293,48 @@ object PluginInstaller {
return if (last.endsWith(".apk", ignoreCase = true)) last else "$last.apk" return if (last.endsWith(".apk", ignoreCase = true)) last else "$last.apk"
} }
private suspend fun showFailureDialogAndAwaitDecision(
context: Context,
result: DownloadResult.Failure,
): Boolean = withContext(Dispatchers.Main) {
suspendCancellableCoroutine { cont ->
var resumed = false
fun tryResume(value: Boolean) {
if (resumed) return
resumed = true
if (cont.isActive) {
cont.resume(value) { _, _, _ -> }
}
}
MaterialDialog.Builder(context)
.title(result.titleRes)
.content(result.message)
.neutralText(R.string.text_copy_all)
.neutralColorRes(R.color.dialog_button_hint)
.onNeutral { d, _ ->
ClipboardUtils.setClip(context, result.message)
ViewUtils.showSnack(d.view, R.string.text_already_copied_to_clip, false)
}
.negativeText(R.string.dialog_button_quit)
.negativeColorRes(R.color.dialog_button_default)
.onNegative { d, _ ->
d.dismiss()
tryResume(false)
}
.positiveText(R.string.dialog_button_retry)
.positiveColorRes(R.color.dialog_button_attraction)
.onPositive { d, _ ->
d.dismiss()
tryResume(true)
}
.cancelable(false)
.autoDismiss(false)
.show()
}
}
private data class HttpStatusException(val code: Int, override val message: String) : RuntimeException(message) private data class HttpStatusException(val code: Int, override val message: String) : RuntimeException(message)
private data class ChecksumMismatchException(val expected: String, val actual: String) : RuntimeException("sha256 mismatch") private data class ChecksumMismatchException(val expected: String, val actual: String) : RuntimeException("sha256 mismatch")

View File

@@ -182,7 +182,7 @@ object PaddleOcrPluginHost {
cont.resumeWith( cont.resumeWith(
Result.failure( Result.failure(
IllegalStateException( IllegalStateException(
"bindService SecurityException: $cn. Please make sure the plugin declares <permission android:name=\"org.autojs.permission.PLUGIN\"/> and the Service uses this permission.", se "bindService SecurityException: $cn. Please make sure the plugin declares <uses-permission android:name=\"org.autojs.permission.PLUGIN\"/> and the Service uses this permission.", se
) )
) )
) )

View File

@@ -500,22 +500,22 @@ class Global(private val scriptRuntime: ScriptRuntime) : Augmentable(scriptRunti
when (version) { when (version) {
is Number -> coerceNumber(version, 0).let { num -> is Number -> coerceNumber(version, 0).let { num ->
when { when {
num.toInt() == 6 -> { num.toInt() in 2..9 -> {
requiresAutojsVersion(scriptRuntime, arrayOf(num.toString())) requiresAutojsVersion(scriptRuntime, arrayOf(num.toString()))
} }
else -> { else -> {
require(num.toInt() >= 461) { require(num.toInt() >= 461) {
"指定的 AutoJs6 应用版本号需大于 461" globalContext.getString(R.string.error_specified_autojs6_version_number_must_be_greater_than_461)
} }
require(BuildConfig.VERSION_CODE >= num.toInt()) { require(BuildConfig.VERSION_CODE >= num.toInt()) {
"AutoJs6 应用版本号需不低于 ${num.jsString}" globalContext.getString(R.string.error_autojs6_version_number_must_not_be_lower_than_num, num.jsString)
} }
} }
} }
} }
else -> coerceString(version).let { ver -> else -> coerceString(version).let { ver ->
require(Version(BuildConfig.VERSION_NAME).isAtLeast(Version(ver))) { require(Version(BuildConfig.VERSION_NAME).isAtLeast(Version(ver))) {
"AutoJs6 应用版本需不低于 $ver" globalContext.getString(R.string.error_autojs6_version_must_not_be_lower_than_ver, ver)
} }
} }
} }

View File

@@ -1,15 +1,40 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android" <FrameLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/plugin_center_root"
android:layout_width="match_parent"
android:layout_height="match_parent">
<org.autojs.autojs.theme.widget.ThemeColorSwipeRefreshLayout
android:id="@+id/plugin_center_swipe_refresh"
tools:visibility="gone"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent"> android:layout_height="match_parent">
<androidx.recyclerview.widget.ThemeColorRecyclerView <androidx.recyclerview.widget.ThemeColorRecyclerView
android:id="@+id/plugin_center_recycler_view" android:id="@+id/plugin_center_recycler_view"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:fadeScrollbars="true" android:fadeScrollbars="true"
android:scrollbarDefaultDelayBeforeFade="600" android:scrollbarDefaultDelayBeforeFade="600"
android:scrollbarFadeDuration="500" android:scrollbarFadeDuration="500"
android:scrollbars="vertical" /> android:scrollbars="vertical" />
</FrameLayout> </org.autojs.autojs.theme.widget.ThemeColorSwipeRefreshLayout>
<TextView
android:id="@+id/plugin_center_empty_hint"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:gravity="center"
android:textColor="@color/text_color_primary_alpha_70"
android:textSize="14sp"
android:lineSpacingMultiplier="1.5"
android:layout_gravity="center"
android:paddingHorizontal="24dp"
android:visibility="gone"
tools:visibility="visible"
tools:text="@string/text_retrieving_plugin_list_data" />
</FrameLayout>

View File

@@ -1121,5 +1121,15 @@
<string name="text_failed_to_update">فشل التحديث</string> <string name="text_failed_to_update">فشل التحديث</string>
<string name="text_confirm_to_uninstall">هل تريد بالتأكيد إلغاء التثبيت؟</string> <string name="text_confirm_to_uninstall">هل تريد بالتأكيد إلغاء التثبيت؟</string>
<string name="dialog_button_exception_details">تفاصيل</string> <string name="dialog_button_exception_details">تفاصيل</string>
<string name="error_failed_to_load_plugins_with_reason">فشل تحميل المكونات الإضافية.\nالسبب: %1$s.</string>
<string name="text_retrieving_plugin_list_data" tools:ignore="TypographyEllipsis">جارٍ جلب بيانات قائمة المكونات الإضافية...</string>
<string name="text_no_plugins_installed_hint">لا توجد حاليًا أي مكونات إضافية مثبتة.\nانقر على زر \"+\" لتثبيت المكونات الإضافية.</string>
<string name="text_unknown_author_for_plugin">مؤلف غير معروف</string>
<string name="text_unknown_description_for_plugin">وصف غير معروف</string>
<string name="text_unknown_version_for_plugin">إصدار غير معروف</string>
<string name="text_unknown_title_for_plugin">عنوان غير معروف</string>
<string name="error_specified_autojs6_version_number_must_be_greater_than_461">يجب أن يكون رقم إصدار AutoJs6 المحدد أكبر من 461</string>
<string name="error_autojs6_version_number_must_not_be_lower_than_num">يجب ألا يكون رقم إصدار AutoJs6 أقل من %1$s</string>
<string name="error_autojs6_version_must_not_be_lower_than_ver">يجب ألا يكون إصدار AutoJs6 أقل من %1$s</string>
</resources> </resources>

View File

@@ -1116,5 +1116,15 @@
<string name="text_failed_to_update">Failed to update</string> <string name="text_failed_to_update">Failed to update</string>
<string name="text_confirm_to_uninstall">Are you sure to uninstall?</string> <string name="text_confirm_to_uninstall">Are you sure to uninstall?</string>
<string name="dialog_button_exception_details">Details</string> <string name="dialog_button_exception_details">Details</string>
<string name="error_failed_to_load_plugins_with_reason">Failed to load plugins.\nReason: %1$s.</string>
<string name="text_retrieving_plugin_list_data" tools:ignore="TypographyEllipsis">Retrieving plugin list data...</string>
<string name="text_no_plugins_installed_hint">No plugins currently installed.\nClick the \"+\" button to install plugins.</string>
<string name="text_unknown_author_for_plugin">Unknown Author</string>
<string name="text_unknown_description_for_plugin">Unknown Description</string>
<string name="text_unknown_version_for_plugin">Unknown Version</string>
<string name="text_unknown_title_for_plugin">Unknown Title</string>
<string name="error_specified_autojs6_version_number_must_be_greater_than_461">Specified AutoJs6 version number must be greater than 461</string>
<string name="error_autojs6_version_number_must_not_be_lower_than_num">AutoJs6 version number must not be lower than %1$s</string>
<string name="error_autojs6_version_must_not_be_lower_than_ver">AutoJs6 version must not be lower than %1$s</string>
</resources> </resources>

View File

@@ -1119,5 +1119,15 @@
<string name="text_failed_to_update">Error al actualizar</string> <string name="text_failed_to_update">Error al actualizar</string>
<string name="text_confirm_to_uninstall">¿Seguro que deseas desinstalar?</string> <string name="text_confirm_to_uninstall">¿Seguro que deseas desinstalar?</string>
<string name="dialog_button_exception_details">Detalles</string> <string name="dialog_button_exception_details">Detalles</string>
<string name="error_failed_to_load_plugins_with_reason">Error al cargar los complementos.\nMotivo: %1$s.</string>
<string name="text_retrieving_plugin_list_data" tools:ignore="TypographyEllipsis">Obteniendo datos de la lista de complementos...</string>
<string name="text_no_plugins_installed_hint">Actualmente no hay complementos instalados.\nPulsa el botón \"+\" para instalar complementos.</string>
<string name="text_unknown_author_for_plugin">Autor Desconocido</string>
<string name="text_unknown_description_for_plugin">Descripción Desconocida</string>
<string name="text_unknown_version_for_plugin">Versión Desconocida</string>
<string name="text_unknown_title_for_plugin">Título Desconocido</string>
<string name="error_specified_autojs6_version_number_must_be_greater_than_461">El número de versión especificado de AutoJs6 debe ser mayor que 461</string>
<string name="error_autojs6_version_number_must_not_be_lower_than_num">El número de versión de AutoJs6 no debe ser inferior a %1$s</string>
<string name="error_autojs6_version_must_not_be_lower_than_ver">La versión de AutoJs6 no debe ser inferior a %1$s</string>
</resources> </resources>

View File

@@ -1119,5 +1119,15 @@
<string name="text_failed_to_update">Échec de la mise à jour</string> <string name="text_failed_to_update">Échec de la mise à jour</string>
<string name="text_confirm_to_uninstall">Voulez-vous vraiment désinstaller ?</string> <string name="text_confirm_to_uninstall">Voulez-vous vraiment désinstaller ?</string>
<string name="dialog_button_exception_details">Détails</string> <string name="dialog_button_exception_details">Détails</string>
<string name="error_failed_to_load_plugins_with_reason">Échec du chargement des plugins.\nRaison : %1$s.</string>
<string name="text_retrieving_plugin_list_data" tools:ignore="TypographyEllipsis">Récupération des données de la liste des plugins...</string>
<string name="text_no_plugins_installed_hint">Aucun plugin n\'est actuellement installé.\nCliquez sur le bouton \"+\" pour installer des plugins.</string>
<string name="text_unknown_author_for_plugin">Auteur Inconnu</string>
<string name="text_unknown_description_for_plugin">Description Inconnue</string>
<string name="text_unknown_version_for_plugin">Version Inconnue</string>
<string name="text_unknown_title_for_plugin">Titre Inconnu</string>
<string name="error_specified_autojs6_version_number_must_be_greater_than_461">Le numéro de version AutoJs6 spécifié doit être supérieur à 461</string>
<string name="error_autojs6_version_number_must_not_be_lower_than_num">Le numéro de version d\'AutoJs6 ne doit pas être inférieur à %1$s</string>
<string name="error_autojs6_version_must_not_be_lower_than_ver">La version d\'AutoJs6 ne doit pas être inférieure à %1$s</string>
</resources> </resources>

View File

@@ -1120,5 +1120,15 @@
<string name="text_failed_to_update">更新に失敗しました</string> <string name="text_failed_to_update">更新に失敗しました</string>
<string name="text_confirm_to_uninstall">アンインストールしてもよろしいですか?</string> <string name="text_confirm_to_uninstall">アンインストールしてもよろしいですか?</string>
<string name="dialog_button_exception_details">詳細</string> <string name="dialog_button_exception_details">詳細</string>
<string name="error_failed_to_load_plugins_with_reason">プラグインの読み込みに失敗しました.\n理由: %1$s.</string>
<string name="text_retrieving_plugin_list_data" tools:ignore="TypographyEllipsis">プラグイン一覧のデータを取得しています...</string>
<string name="text_no_plugins_installed_hint">現在インストールされているプラグインはありません.\n\"+\" ボタンをタップしてプラグインをインストールしてください.</string>
<string name="text_unknown_author_for_plugin">不明な作者</string>
<string name="text_unknown_description_for_plugin">不明な説明</string>
<string name="text_unknown_version_for_plugin">不明なバージョン</string>
<string name="text_unknown_title_for_plugin">不明なタイトル</string>
<string name="error_specified_autojs6_version_number_must_be_greater_than_461">指定された AutoJs6 のバージョン番号は 461 より大きくなければなりません</string>
<string name="error_autojs6_version_number_must_not_be_lower_than_num">AutoJs6 のバージョン番号は %1$s 未満であってはなりません</string>
<string name="error_autojs6_version_must_not_be_lower_than_ver">AutoJs6 のバージョンは %1$s 未満であってはなりません</string>
</resources> </resources>

View File

@@ -1121,5 +1121,15 @@
<string name="text_failed_to_update">업데이트 실패</string> <string name="text_failed_to_update">업데이트 실패</string>
<string name="text_confirm_to_uninstall">정말 제거하시겠습니까?</string> <string name="text_confirm_to_uninstall">정말 제거하시겠습니까?</string>
<string name="dialog_button_exception_details">자세히</string> <string name="dialog_button_exception_details">자세히</string>
<string name="error_failed_to_load_plugins_with_reason">플러그인을 불러오지 못했습니다.\n사유: %1$s.</string>
<string name="text_retrieving_plugin_list_data" tools:ignore="TypographyEllipsis">플러그인 목록 데이터를 가져오는 중입니다...</string>
<string name="text_no_plugins_installed_hint">현재 설치된 플러그인이 없습니다.\n\"+\" 버튼을 눌러 플러그인을 설치하세요.</string>
<string name="text_unknown_author_for_plugin">알 수 없는 작성자</string>
<string name="text_unknown_description_for_plugin">알 수 없는 설명</string>
<string name="text_unknown_version_for_plugin">알 수 없는 버전</string>
<string name="text_unknown_title_for_plugin">알 수 없는 제목</string>
<string name="error_specified_autojs6_version_number_must_be_greater_than_461">지정된 AutoJs6 버전 번호는 461보다 커야 합니다</string>
<string name="error_autojs6_version_number_must_not_be_lower_than_num">AutoJs6 버전 번호는 %1$s보다 낮아서는 안 됩니다</string>
<string name="error_autojs6_version_must_not_be_lower_than_ver">AutoJs6 버전은 %1$s보다 낮아서는 안 됩니다</string>
</resources> </resources>

View File

@@ -1119,5 +1119,15 @@
<string name="text_failed_to_update">Не удалось обновить</string> <string name="text_failed_to_update">Не удалось обновить</string>
<string name="text_confirm_to_uninstall">Вы действительно хотите удалить?</string> <string name="text_confirm_to_uninstall">Вы действительно хотите удалить?</string>
<string name="dialog_button_exception_details">Детали</string> <string name="dialog_button_exception_details">Детали</string>
<string name="error_failed_to_load_plugins_with_reason">Не удалось загрузить плагины.\nПричина: %1$s.</string>
<string name="text_retrieving_plugin_list_data" tools:ignore="TypographyEllipsis">Получение данных списка плагинов...</string>
<string name="text_no_plugins_installed_hint">В настоящее время плагины не установлены.\nНажмите кнопку \"+\" чтобы установить плагины.</string>
<string name="text_unknown_author_for_plugin">Неизвестный Автор</string>
<string name="text_unknown_description_for_plugin">Неизвестное Описание</string>
<string name="text_unknown_version_for_plugin">Неизвестная Версия</string>
<string name="text_unknown_title_for_plugin">Неизвестный Заголовок</string>
<string name="error_specified_autojs6_version_number_must_be_greater_than_461">Указанный номер версии AutoJs6 должен быть больше 461</string>
<string name="error_autojs6_version_number_must_not_be_lower_than_num">Номер версии AutoJs6 не должен быть ниже %1$s</string>
<string name="error_autojs6_version_must_not_be_lower_than_ver">Версия AutoJs6 не должна быть ниже %1$s</string>
</resources> </resources>

View File

@@ -1117,5 +1117,15 @@
<string name="text_failed_to_update">更新失敗</string> <string name="text_failed_to_update">更新失敗</string>
<string name="text_confirm_to_uninstall">是否確定卸載</string> <string name="text_confirm_to_uninstall">是否確定卸載</string>
<string name="dialog_button_exception_details">異常詳情</string> <string name="dialog_button_exception_details">異常詳情</string>
<string name="error_failed_to_load_plugins_with_reason">插件讀取失敗.\n原因: %1$s.</string>
<string name="text_retrieving_plugin_list_data" tools:ignore="TypographyEllipsis">正在獲取插件列表數據...</string>
<string name="text_no_plugins_installed_hint">當前沒有已安裝的插件.\n可點擊 \"+\" 按鈕安裝插件.</string>
<string name="text_unknown_author_for_plugin">未知開發者</string>
<string name="text_unknown_description_for_plugin">未知描述</string>
<string name="text_unknown_version_for_plugin">未知版本</string>
<string name="text_unknown_title_for_plugin">未知標題</string>
<string name="error_specified_autojs6_version_number_must_be_greater_than_461">指定的 AutoJs6 應用版本號需大於 461</string>
<string name="error_autojs6_version_number_must_not_be_lower_than_num">AutoJs6 應用版本號需不低於 %1$s</string>
<string name="error_autojs6_version_must_not_be_lower_than_ver">AutoJs6 應用版本需不低於 %1$s</string>
</resources> </resources>

View File

@@ -1117,5 +1117,15 @@
<string name="text_failed_to_update">更新失敗</string> <string name="text_failed_to_update">更新失敗</string>
<string name="text_confirm_to_uninstall">是否確定解除安裝</string> <string name="text_confirm_to_uninstall">是否確定解除安裝</string>
<string name="dialog_button_exception_details">異常詳情</string> <string name="dialog_button_exception_details">異常詳情</string>
<string name="error_failed_to_load_plugins_with_reason">外掛讀取失敗.\n原因: %1$s.</string>
<string name="text_retrieving_plugin_list_data" tools:ignore="TypographyEllipsis">正在獲取外掛列表資料...</string>
<string name="text_no_plugins_installed_hint">當前沒有已安裝的外掛.\n可點選 \"+\" 按鈕安裝外掛.</string>
<string name="text_unknown_author_for_plugin">未知開發者</string>
<string name="text_unknown_description_for_plugin">未知描述</string>
<string name="text_unknown_version_for_plugin">未知版本</string>
<string name="text_unknown_title_for_plugin">未知標題</string>
<string name="error_specified_autojs6_version_number_must_be_greater_than_461">指定的 AutoJs6 應用版本號需大於 461</string>
<string name="error_autojs6_version_number_must_not_be_lower_than_num">AutoJs6 應用版本號需不低於 %1$s</string>
<string name="error_autojs6_version_must_not_be_lower_than_ver">AutoJs6 應用版本需不低於 %1$s</string>
</resources> </resources>

View File

@@ -1117,5 +1117,15 @@
<string name="text_failed_to_update">更新失败</string> <string name="text_failed_to_update">更新失败</string>
<string name="text_confirm_to_uninstall">是否确定卸载</string> <string name="text_confirm_to_uninstall">是否确定卸载</string>
<string name="dialog_button_exception_details">异常详情</string> <string name="dialog_button_exception_details">异常详情</string>
<string name="error_failed_to_load_plugins_with_reason">插件读取失败.\n原因: %1$s.</string>
<string name="text_retrieving_plugin_list_data" tools:ignore="TypographyEllipsis">正在获取插件列表数据...</string>
<string name="text_no_plugins_installed_hint">当前没有已安装的插件.\n可点击 \"+\" 按钮安装插件.</string>
<string name="text_unknown_author_for_plugin">未知开发者</string>
<string name="text_unknown_description_for_plugin">未知描述</string>
<string name="text_unknown_version_for_plugin">未知版本</string>
<string name="text_unknown_title_for_plugin">未知标题</string>
<string name="error_specified_autojs6_version_number_must_be_greater_than_461">指定的 AutoJs6 应用版本号需大于 461</string>
<string name="error_autojs6_version_number_must_not_be_lower_than_num">AutoJs6 应用版本号需不低于 %1$s</string>
<string name="error_autojs6_version_must_not_be_lower_than_ver">AutoJs6 应用版本需不低于 %1$s</string>
</resources> </resources>

View File

@@ -1372,5 +1372,15 @@
<string name="text_failed_to_update">Failed to update</string> <string name="text_failed_to_update">Failed to update</string>
<string name="text_confirm_to_uninstall">Are you sure to uninstall?</string> <string name="text_confirm_to_uninstall">Are you sure to uninstall?</string>
<string name="dialog_button_exception_details">Details</string> <string name="dialog_button_exception_details">Details</string>
<string name="error_failed_to_load_plugins_with_reason">Failed to load plugins.\nReason: %1$s.</string>
<string name="text_retrieving_plugin_list_data" tools:ignore="TypographyEllipsis">Retrieving plugin list data...</string>
<string name="text_no_plugins_installed_hint">No plugins currently installed.\nClick the \"+\" button to install plugins.</string>
<string name="text_unknown_author_for_plugin">Unknown Author</string>
<string name="text_unknown_description_for_plugin">Unknown Description</string>
<string name="text_unknown_version_for_plugin">Unknown Version</string>
<string name="text_unknown_title_for_plugin">Unknown Title</string>
<string name="error_specified_autojs6_version_number_must_be_greater_than_461">Specified AutoJs6 version number must be greater than 461</string>
<string name="error_autojs6_version_number_must_not_be_lower_than_num">AutoJs6 version number must not be lower than %1$s</string>
<string name="error_autojs6_version_must_not_be_lower_than_ver">AutoJs6 version must not be lower than %1$s</string>
</resources> </resources>

View File

@@ -1,5 +1,5 @@
#Tue Dec 09 12:09:37 CST 2025 #Thu Dec 11 10:33:50 CST 2025
BUILD_TIME=1765253377144 BUILD_TIME=1765420430709
COMPILE_SDK_VERSION=36 COMPILE_SDK_VERSION=36
IMAGE_QUANT_CMAKE_VERSION=3.22.1 IMAGE_QUANT_CMAKE_VERSION=3.22.1
IMAGE_QUANT_NDK_VERSION=26.1.10909125 IMAGE_QUANT_NDK_VERSION=26.1.10909125
@@ -27,6 +27,6 @@ RAPID_OCR_OPENCV_MOBILE_LABEL_VERSION=13
RAPID_OCR_OPENCV_MOBILE_VERSION=4.5.3 RAPID_OCR_OPENCV_MOBILE_VERSION=4.5.3
TARGET_SDK_VERSION=36 TARGET_SDK_VERSION=36
TARGET_SDK_VERSION_INRT=29 TARGET_SDK_VERSION_INRT=29
VERSION_BUILD=3530 VERSION_BUILD=3539
VERSION_NAME=6.7.0 Alpha12 VERSION_NAME=6.7.0 Alpha12
VSCODE_EXT_REQUIRED_VERSION=1.0.8 VSCODE_EXT_REQUIRED_VERSION=1.0.8