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 >= 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
android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="32" />
@@ -132,10 +138,6 @@
android:protectionLevel="signature"
tools:ignore="ProtectedPermissions" />
<uses-permission
android:name="org.autojs.permission.PLUGIN"
android:protectionLevel="normal" />
<!-- 非 AutoJs6 运行必需, 不会主动申请, 脚本可自行申请 -->
<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.Intent
import android.content.IntentFilter
import android.net.Uri
import android.os.Bundle
import android.view.View
import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.net.toUri
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import androidx.lifecycle.lifecycleScope
@@ -15,6 +14,8 @@ import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.DividerItemDecoration.VERTICAL
import androidx.recyclerview.widget.LinearLayoutManager
import com.afollestad.materialdialogs.MaterialDialog
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
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 contextRef: Context
private val uninstallLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
vm.load(requireContext())
}
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?) {
super.onViewCreated(view, savedInstanceState)
_binding = FragmentPluginCenterBinding.bind(view)
@@ -57,9 +60,9 @@ class PluginCenterFragment : Fragment(R.layout.fragment_plugin_center) {
.positiveText(R.string.dialog_button_confirm)
.positiveColorRes(R.color.dialog_button_caution)
.onPositive { _, _ ->
val uri = Uri.parse("package:${item.packageName}")
val uri = "package:${item.packageName}".toUri()
val intent = Intent(Intent.ACTION_DELETE, uri)
uninstallLauncher.launch(intent)
startActivity(intent)
}
.show()
}
@@ -94,23 +97,106 @@ class PluginCenterFragment : Fragment(R.layout.fragment_plugin_center) {
excludePaddingClippableViewFromBottomNavigationBar()
}
// Load data.
// zh-CN: 加载数据.
vm.load(context)
// Pull-to-refresh: force refresh index every time (still respects backoff window).
// zh-CN: 下拉刷新: 每次都强制刷新索引 (仍遵守退避窗口).
binding.pluginCenterSwipeRefresh.setOnRefreshListener {
vm.load(contextRef, forceRefreshIndex = true)
}
// Subscribe data updates.
// zh-CN: 订阅数据更新.
// First entry: local priority + index async.
// zh-CN: 首次进入: 本地优先 + 索引异步.
vm.load(context, forceRefreshIndex = false)
// Subscribe to list data.
// zh-CN: 订阅列表数据.
viewLifecycleOwner.lifecycleScope.launch {
vm.items.collectLatest { 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() {
super.onStart()
run registerPackageReceiver@{
pkgReceiver ?: return@registerPackageReceiver
pkgReceiver ?: run registerPackageReceiver@{
pkgReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
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.
// zh-CN: 替换安装 (更新) 不记录 "最近安装", 但会刷新.
if (!replacing) PluginRecentStore.setLastInstalled(packageName)
vm.load(context)
vm.load(context, forceRefreshIndex = false)
}
Intent.ACTION_PACKAGE_REMOVED -> {
// Package uninstallation (pre-update phase) does not record "Recently uninstalled" when replacing.
// zh-CN: 替换卸载 (更新前阶段) 不记录 "最近卸载".
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() {
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: 回到页面时刷新一次, 覆盖安装/卸载后的状态.
if (::contextRef.isInitialized) {
vm.load(contextRef)
if (!isFirstEnter && ::contextRef.isInitialized) {
vm.load(contextRef, forceRefreshIndex = false)
}
}
override fun onDestroyView() {
super.onDestroyView()
emptyHintJob?.cancel()
emptyHintJob = null
_binding = null
}

View File

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

View File

@@ -68,10 +68,14 @@ class PluginCenterItemViewHolder(
switchView.setOnCheckedChangeListener(null)
switchView.isChecked = item.isEnabled
titleView.text = item.title
versionInfoView.text = item.versionSummary
authorView.text = item.author
descriptionView.text = item.description
titleView.text = item.title.takeUnless { it.isBlank() }
?: "[ ${context.getString(R.string.text_unknown_title_for_plugin)} ]"
versionInfoView.text = item.versionSummary.takeUnless { it.isBlank() }
?: "[ ${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) {
btnDeleteView.setButtonState(true) {

View File

@@ -1,10 +1,12 @@
package org.autojs.autojs.core.plugin.center
import android.content.Context
import android.util.Log
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.async
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import org.autojs.autojs6.R
@@ -13,7 +15,19 @@ import org.autojs.autojs6.R
* Loads both index plugins and locally installed plugins,
* 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() {
@@ -24,33 +38,91 @@ class PluginCenterViewModel : ViewModel() {
private val _items = MutableStateFlow<List<PluginCenterItem>>(emptyList())
val items: StateFlow<List<PluginCenterItem>> = _items
fun load(context: Context) {
viewModelScope.launch {
val idxDeferred = async { runCatching { indexRepo.fetchOfficialIndex(context) }.getOrElse { emptyList() } }
val insDeferred = async { runCatching { installedRepo.discoverInstalled(context) }.getOrElse { emptyList() } }
// Whether index loading is completed (includes "success/failure/empty" results, only means "no longer loading").
// zh-CN: 索引加载是否已完成 (包含 "成功/失败/空" 三种结果, 仅代表 "不再加载").
private val _indexLoaded = MutableStateFlow(false)
val indexLoaded: StateFlow<Boolean> = _indexLoaded
val indexEntries = idxDeferred.await()
val installed = insDeferred.await()
// Fatal exception discovered locally (requires user notification and Activity exit).
// 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 }
// Use index to drive UI, to display installable and updatable items.
// zh-CN: 用索引驱动 UI, 展示可安装及可更新的项.
val fromIndex = indexEntries.map { e ->
// Use index to drive UI, showing installable/updatable.
// zh-CN: 用索引驱动 UI, 展示 installable/updatable.
val fromIndex = indexEntries.mapNotNull { e ->
val local = installedByPkg[e.packageName]
toPluginCenterItem(context, index = e, local = local)
}
// Add items that "exist locally but not in index" (third-party or not indexed yet).
// zh-CN: 补充 "本地存在但索引里暂时没有" 的 (第三方暂未入索引).
// Supplement plugins that "exist locally but not in index" (third-party/not yet in index).
// zh-CN: 补充 "本地但索引没有" 的插件 (第三方/暂未入索引).
val extraLocals = installed
.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
_indexLoaded.value = true
// Refresh dialog if showing when returning to the page.
// zh-CN: 返回页面时, 对话框如果正在显示则刷新.
// When returning to the page, refresh if the details dialog is being displayed.
// zh-CN: 返回页面时, 若详情对话框正在显示则刷新.
PluginInfoDialogManager.refreshIfShowing(context, _items.value)
}
}
@@ -63,10 +135,12 @@ class PluginCenterViewModel : ViewModel() {
PluginInfoDialogManager.refreshIfShowing(context, _items.value)
}
private fun toPluginCenterItem(context: Context, index: PluginIndexEntry?, local: InstalledPluginRepository.InstalledPlugin?): PluginCenterItem {
val packageName = local?.packageName ?: index?.packageName.orEmpty()
private fun toPluginCenterItem(context: Context, index: PluginIndexEntry?, local: InstalledPluginRepository.InstalledPlugin?): PluginCenterItem? {
val packageName = local?.packageName ?: index?.packageName
if (packageName.isNullOrBlank()) return null
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 collaborators = index?.collaborators ?: emptyList()
@@ -74,10 +148,10 @@ class PluginCenterViewModel : ViewModel() {
val versionCodeLocal = local?.versionCode
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: 仅当 "已安装且索引版本更高" 时, 标记可更新, 并填充可更新目标信息.
val (updatableName, updatableCode, updatableDate) = run {
val defaultVersionInfo = Triple(null, null, null)
val defaultVersionInfo = Triple<String?, Long?, String?>(null, null, null)
versionCodeLocal ?: return@run defaultVersionInfo
val versionCodeIndex = index?.versionCode ?: return@run defaultVersionInfo
if (versionCodeIndex <= versionCodeLocal) return@run defaultVersionInfo
@@ -112,4 +186,9 @@ class PluginCenterViewModel : ViewModel() {
settings = null,
)
}
}
companion object {
private const val TAG = "PluginCenterViewModel"
}
}

View File

@@ -1,35 +1,282 @@
package org.autojs.autojs.core.plugin.center
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 {
suspend fun fetchOfficialIndex(context: Context): List<PluginIndexEntry> {
// TODO M1 先预置 1 条官方样例 "Paddle OCR (PP-OCRv5)", 便于与本地已安装合并显示.
return listOf(
PluginIndexEntry(
packageName = "io.github.supermonster003.autojs6.plugin.paddleocr.v5",
title = "Paddle OCR (PP-OCRv5)",
description = "百度飞桨光学字符识别插件",
author = "SuperMonster003",
collaborators = emptyList(),
versionName = "0.1.5",
versionCode = 15L,
versionDate = "2025-11-25",
// TODO M1 若索引的下载地址/哈希/尺寸未知, 则先设置为 null.
apkUrl = null,
apkSha256 = null,
apkSizeBytes = null,
// TODO M1 暂不拉网图标, 使用应用图标或默认图标.
iconUrl = null,
tags = listOf("official", "ocr", "paddle", "v5"),
engine = "paddle-ocr",
variant = "v5",
engineId = "paddle-ocr-pp-ocrv5",
),
)
companion object {
private const val TAG = "PluginIndexRepository"
private const val INDEX_URL = "https://raw.githubusercontent.com/SuperMonster005/autojs6-plugin-index/main/index.json"
private const val SP = "plugin_center_index"
private const val KEY_ETAG = "etag"
private const val KEY_LAST_SUCCESS_TS = "last_success_ts"
private const val KEY_LAST_FAILURE_TS = "last_failure_ts"
private const val KEY_RETRY_ATTEMPTS = "retry_attempts"
private const val CACHE_FILE_NAME = "plugin_center_index_cache.json"
private const val MIN_RETRY_INTERVAL_MS = 30_000L // 30 sec
private const val MAX_RETRY_INTERVAL_MS = 10 * 60_000L // 10 min
private const val MIN_REFRESH_INTERVAL_MS = 1 * 60_000L // 1 min
}
@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
if (!dialog.isShowing) return
val target = allItems.firstOrNull { it.packageName == pkg } ?: return
dialog.dismiss()
showPluginInfoDialog(context, target)
dialog.dismiss()
}
@JvmStatic
@@ -128,7 +128,7 @@ object PluginInfoDialogManager {
}
else -> {
d.dismiss()
CoroutineScope(Dispatchers.IO).launch {
CoroutineScope(Dispatchers.Main).launch {
PluginInstaller.installFromUrlWithPrompt(context, url, info.sha256)
}
}
@@ -154,7 +154,7 @@ object PluginInfoDialogManager {
}
else -> {
d.dismiss()
CoroutineScope(Dispatchers.IO).launch {
CoroutineScope(Dispatchers.Main).launch {
PluginInstaller.installFromUrlWithPrompt(context, url, info.sha256)
}
}
@@ -315,7 +315,7 @@ object PluginInfoDialogManager {
val version: String
val author: String?
val collaborators: List<String>
val description: String
val description: String?
val packageSize: Long
val apkUrl: String?
val sha256: String?
@@ -328,7 +328,7 @@ object PluginInfoDialogManager {
override val version: String,
override val author: String?,
override val collaborators: List<String>,
override val description: String,
override val description: String?,
override val packageSize: Long,
override val apkUrl: String?,
override val sha256: String?,
@@ -343,7 +343,7 @@ object PluginInfoDialogManager {
override val version: String,
override val author: String?,
override val collaborators: List<String>,
override val description: String,
override val description: String?,
override val packageSize: Long,
override val apkUrl: String?,
override val sha256: String?,

View File

@@ -9,13 +9,16 @@ import com.afollestad.materialdialogs.DialogAction
import com.afollestad.materialdialogs.MaterialDialog
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withContext
import org.autojs.autojs.network.download.DownloadManager
import org.autojs.autojs.runtime.api.Mime
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.toCacheFile
import org.autojs.autojs.util.UpdateUtils
import org.autojs.autojs.util.ViewUtils
import org.autojs.autojs6.R
import java.io.EOFException
import java.io.File
@@ -83,27 +86,20 @@ object PluginInstaller {
}
suspend fun installFromUrlWithPrompt(context: Context, url: String, expectedSha256: String? = null) {
var attempt = 0
while (true) {
when (val result = downloadWithProgress(context, url, expectedSha256)) {
is DownloadResult.Success -> {
installFromFileUriWithPrompt(context, result.uri)
return
}
is DownloadResult.Cancelled -> {
// User cancelled, no need to prompt.
// zh-CN: 用户取消, 无需提示.
return
}
is DownloadResult.Failure -> {
val retry = showRetryDialog(context, result)
if (retry) {
attempt++
// TODO M1: 不做自动退避, 交给用户控制重试节奏; M2 可引入指数退避/网络可用性判断
continue
} else {
return
}
when (val result = downloadWithProgress(context, url, expectedSha256)) {
is DownloadResult.Success -> {
installFromFileUriWithPrompt(context, result.uri)
}
is DownloadResult.Cancelled -> {
// User cancelled, no need to prompt.
// zh-CN: 用户取消, 无需提示.
}
is DownloadResult.Failure -> {
// Wait for user action in the failure dialog (retry/quit).
// zh-CN: 等待用户在失败对话框中的操作 (重试/放弃).
val wantRetry = showFailureDialogAndAwaitDecision(context, result)
if (wantRetry) {
installFromUrlWithPrompt(context, url, expectedSha256)
}
}
}
@@ -114,46 +110,21 @@ object PluginInstaller {
is DownloadResult.Success -> {
installFromFileUri(context, result.uri)
}
is DownloadResult.Cancelled -> {
// User cancelled, no need to prompt.
// zh-CN: 用户取消, 无需提示.
}
is DownloadResult.Failure -> {
MaterialDialog.Builder(context)
.title(result.titleRes)
.content(result.message)
.positiveText(R.string.dialog_button_dismiss)
.show()
// Wait for user action in the failure dialog (retry/quit).
// zh-CN: 等待用户在失败对话框中的操作 (重试/放弃).
val wantRetry = showFailureDialogAndAwaitDecision(context, result)
if (wantRetry) {
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).
// zh-CN: 探测 URL 大小 (HEAD).
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"
}
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 ChecksumMismatchException(val expected: String, val actual: String) : RuntimeException("sha256 mismatch")

View File

@@ -182,7 +182,7 @@ object PaddleOcrPluginHost {
cont.resumeWith(
Result.failure(
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) {
is Number -> coerceNumber(version, 0).let { num ->
when {
num.toInt() == 6 -> {
num.toInt() in 2..9 -> {
requiresAutojsVersion(scriptRuntime, arrayOf(num.toString()))
}
else -> {
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()) {
"AutoJs6 应用版本号需不低于 ${num.jsString}"
globalContext.getString(R.string.error_autojs6_version_number_must_not_be_lower_than_num, num.jsString)
}
}
}
}
else -> coerceString(version).let { 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"?>
<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_height="match_parent">
<androidx.recyclerview.widget.ThemeColorRecyclerView
android:id="@+id/plugin_center_recycler_view"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fadeScrollbars="true"
android:scrollbarDefaultDelayBeforeFade="600"
android:scrollbarFadeDuration="500"
android:scrollbars="vertical" />
<androidx.recyclerview.widget.ThemeColorRecyclerView
android:id="@+id/plugin_center_recycler_view"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fadeScrollbars="true"
android:scrollbarDefaultDelayBeforeFade="600"
android:scrollbarFadeDuration="500"
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_confirm_to_uninstall">هل تريد بالتأكيد إلغاء التثبيت؟</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>

View File

@@ -1116,5 +1116,15 @@
<string name="text_failed_to_update">Failed to update</string>
<string name="text_confirm_to_uninstall">Are you sure to uninstall?</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>

View File

@@ -1119,5 +1119,15 @@
<string name="text_failed_to_update">Error al actualizar</string>
<string name="text_confirm_to_uninstall">¿Seguro que deseas desinstalar?</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>

View File

@@ -1119,5 +1119,15 @@
<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="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>

View File

@@ -1120,5 +1120,15 @@
<string name="text_failed_to_update">更新に失敗しました</string>
<string name="text_confirm_to_uninstall">アンインストールしてもよろしいですか?</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>

View File

@@ -1121,5 +1121,15 @@
<string name="text_failed_to_update">업데이트 실패</string>
<string name="text_confirm_to_uninstall">정말 제거하시겠습니까?</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>

View File

@@ -1119,5 +1119,15 @@
<string name="text_failed_to_update">Не удалось обновить</string>
<string name="text_confirm_to_uninstall">Вы действительно хотите удалить?</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>

View File

@@ -1117,5 +1117,15 @@
<string name="text_failed_to_update">更新失敗</string>
<string name="text_confirm_to_uninstall">是否確定卸載</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>

View File

@@ -1117,5 +1117,15 @@
<string name="text_failed_to_update">更新失敗</string>
<string name="text_confirm_to_uninstall">是否確定解除安裝</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>

View File

@@ -1117,5 +1117,15 @@
<string name="text_failed_to_update">更新失败</string>
<string name="text_confirm_to_uninstall">是否确定卸载</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>

View File

@@ -1372,5 +1372,15 @@
<string name="text_failed_to_update">Failed to update</string>
<string name="text_confirm_to_uninstall">Are you sure to uninstall?</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>