6.7.0 - Alpha12 - 插件中心 M2 - 插件中心更新支持 ETag/缓存/退避; WorkManager 自动检查更新
This commit is contained in:
@@ -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" />
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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?,
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user