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 >= 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" />
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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
|
||||
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?,
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user