mirror of
https://github.com/gkd-kit/gkd.git
synced 2024-11-16 03:32:38 +08:00
feat: position
This commit is contained in:
parent
c711ccfd06
commit
a726e2b22b
|
@ -216,4 +216,6 @@ dependencies {
|
||||||
|
|
||||||
implementation(libs.coil.compose)
|
implementation(libs.coil.compose)
|
||||||
implementation(libs.coil.gif)
|
implementation(libs.coil.gif)
|
||||||
|
|
||||||
|
implementation(libs.exp4j)
|
||||||
}
|
}
|
|
@ -10,7 +10,7 @@ import com.blankj.utilcode.util.ScreenUtils
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
|
||||||
typealias ActionFc = (context: AccessibilityService, node: AccessibilityNodeInfo) -> ActionResult
|
typealias ActionFc = (context: AccessibilityService, node: AccessibilityNodeInfo, position: RawSubscription.Position?) -> ActionResult
|
||||||
|
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
|
@ -18,6 +18,7 @@ data class GkdAction(
|
||||||
val selector: String,
|
val selector: String,
|
||||||
val quickFind: Boolean = false,
|
val quickFind: Boolean = false,
|
||||||
val action: String? = null,
|
val action: String? = null,
|
||||||
|
val position: RawSubscription.Position? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
|
@ -26,19 +27,21 @@ data class ActionResult(
|
||||||
val result: Boolean,
|
val result: Boolean,
|
||||||
)
|
)
|
||||||
|
|
||||||
val clickNode: ActionFc = { _, node ->
|
val clickNode: ActionFc = { _, node, _ ->
|
||||||
ActionResult(
|
ActionResult(
|
||||||
action = "clickNode", result = node.performAction(AccessibilityNodeInfo.ACTION_CLICK)
|
action = "clickNode", result = node.performAction(AccessibilityNodeInfo.ACTION_CLICK)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
val clickCenter: ActionFc = { context, node ->
|
val clickCenter: ActionFc = { context, node, position ->
|
||||||
val react = Rect()
|
val rect = Rect()
|
||||||
node.getBoundsInScreen(react)
|
node.getBoundsInScreen(rect)
|
||||||
val x = (react.right + react.left) / 2f
|
val p = position?.calc(rect)
|
||||||
val y = (react.bottom + react.top) / 2f
|
val x = p?.first ?: ((rect.right + rect.left) / 2f)
|
||||||
|
val y = p?.second ?: ((rect.bottom + rect.top) / 2f)
|
||||||
ActionResult(
|
ActionResult(
|
||||||
action = "clickCenter",
|
action = "clickCenter",
|
||||||
|
// TODO 在分屏/小窗模式下会点击到应用界面外部导致误触其它应用
|
||||||
result = if (0 <= x && 0 <= y && x <= ScreenUtils.getScreenWidth() && y <= ScreenUtils.getScreenHeight()) {
|
result = if (0 <= x && 0 <= y && x <= ScreenUtils.getScreenWidth() && y <= ScreenUtils.getScreenHeight()) {
|
||||||
val gestureDescription = GestureDescription.Builder()
|
val gestureDescription = GestureDescription.Builder()
|
||||||
val path = Path()
|
val path = Path()
|
||||||
|
@ -56,31 +59,32 @@ val clickCenter: ActionFc = { context, node ->
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
val click: ActionFc = { context, node ->
|
val click: ActionFc = { context, node, position ->
|
||||||
if (node.isClickable) {
|
if (node.isClickable) {
|
||||||
val result = clickNode(context, node)
|
val result = clickNode(context, node, position)
|
||||||
if (result.result) {
|
if (result.result) {
|
||||||
result
|
result
|
||||||
} else {
|
} else {
|
||||||
clickCenter(context, node)
|
clickCenter(context, node, position)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
clickCenter(context, node)
|
clickCenter(context, node, position)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val longClickNode: ActionFc = { _, node ->
|
val longClickNode: ActionFc = { _, node, _ ->
|
||||||
ActionResult(
|
ActionResult(
|
||||||
action = "longClickNode",
|
action = "longClickNode",
|
||||||
result = node.performAction(AccessibilityNodeInfo.ACTION_LONG_CLICK)
|
result = node.performAction(AccessibilityNodeInfo.ACTION_LONG_CLICK)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
val longClickCenter: ActionFc = { context, node ->
|
val longClickCenter: ActionFc = { context, node, position ->
|
||||||
val react = Rect()
|
val rect = Rect()
|
||||||
node.getBoundsInScreen(react)
|
node.getBoundsInScreen(rect)
|
||||||
val x = (react.right + react.left) / 2f
|
val p = position?.calc(rect)
|
||||||
val y = (react.bottom + react.top) / 2f
|
val x = p?.first ?: ((rect.right + rect.left) / 2f)
|
||||||
|
val y = p?.second ?: ((rect.bottom + rect.top) / 2f)
|
||||||
// 内部的 DEFAULT_LONG_PRESS_TIMEOUT 常量是 400
|
// 内部的 DEFAULT_LONG_PRESS_TIMEOUT 常量是 400
|
||||||
// 而 ViewConfiguration.getLongPressTimeout() 返回 300, 这将导致触发普通的 click 事件
|
// 而 ViewConfiguration.getLongPressTimeout() 返回 300, 这将导致触发普通的 click 事件
|
||||||
ActionResult(
|
ActionResult(
|
||||||
|
@ -104,20 +108,20 @@ val longClickCenter: ActionFc = { context, node ->
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
val longClick: ActionFc = { context, node ->
|
val longClick: ActionFc = { context, node, position ->
|
||||||
if (node.isLongClickable) {
|
if (node.isLongClickable) {
|
||||||
val result = longClickNode(context, node)
|
val result = longClickNode(context, node, position)
|
||||||
if (result.result) {
|
if (result.result) {
|
||||||
result
|
result
|
||||||
} else {
|
} else {
|
||||||
longClickCenter(context, node)
|
longClickCenter(context, node, position)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
longClickCenter(context, node)
|
longClickCenter(context, node, position)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val backFc: ActionFc = { context, _ ->
|
val backFc: ActionFc = { context, _, _ ->
|
||||||
ActionResult(
|
ActionResult(
|
||||||
action = "back",
|
action = "back",
|
||||||
result = context.performGlobalAction(AccessibilityService.GLOBAL_ACTION_BACK)
|
result = context.performGlobalAction(AccessibilityService.GLOBAL_ACTION_BACK)
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
package li.songe.gkd.data
|
package li.songe.gkd.data
|
||||||
|
|
||||||
|
import android.graphics.Rect
|
||||||
import androidx.compose.runtime.Immutable
|
import androidx.compose.runtime.Immutable
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
import kotlinx.serialization.json.JsonArray
|
import kotlinx.serialization.json.JsonArray
|
||||||
|
@ -11,11 +12,14 @@ import kotlinx.serialization.json.boolean
|
||||||
import kotlinx.serialization.json.int
|
import kotlinx.serialization.json.int
|
||||||
import kotlinx.serialization.json.jsonArray
|
import kotlinx.serialization.json.jsonArray
|
||||||
import kotlinx.serialization.json.jsonObject
|
import kotlinx.serialization.json.jsonObject
|
||||||
|
import kotlinx.serialization.json.jsonPrimitive
|
||||||
import kotlinx.serialization.json.long
|
import kotlinx.serialization.json.long
|
||||||
import li.songe.gkd.service.allowPropertyNames
|
import li.songe.gkd.service.allowPropertyNames
|
||||||
import li.songe.gkd.util.json
|
import li.songe.gkd.util.json
|
||||||
import li.songe.gkd.util.json5ToJson
|
import li.songe.gkd.util.json5ToJson
|
||||||
import li.songe.selector.Selector
|
import li.songe.selector.Selector
|
||||||
|
import net.objecthunter.exp4j.Expression
|
||||||
|
import net.objecthunter.exp4j.ExpressionBuilder
|
||||||
|
|
||||||
@Immutable
|
@Immutable
|
||||||
@Serializable
|
@Serializable
|
||||||
|
@ -90,6 +94,61 @@ data class RawSubscription(
|
||||||
@Serializable
|
@Serializable
|
||||||
data class RawCategory(val key: Int, val name: String, val enable: Boolean?)
|
data class RawCategory(val key: Int, val name: String, val enable: Boolean?)
|
||||||
|
|
||||||
|
@Immutable
|
||||||
|
@Serializable
|
||||||
|
data class Position(
|
||||||
|
val left: String?,
|
||||||
|
val top: String?,
|
||||||
|
val right: String?,
|
||||||
|
val bottom: String?
|
||||||
|
) {
|
||||||
|
private val leftExp by lazy { getExpression(left) }
|
||||||
|
private val topExp by lazy { getExpression(top) }
|
||||||
|
private val rightExp by lazy { getExpression(right) }
|
||||||
|
private val bottomExp by lazy { getExpression(bottom) }
|
||||||
|
|
||||||
|
val isValid by lazy {
|
||||||
|
((leftExp != null && (topExp != null || bottomExp != null)) || (rightExp != null && (topExp != null || bottomExp != null)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* return (x, y)
|
||||||
|
*/
|
||||||
|
fun calc(rect: Rect): Pair<Float, Float>? {
|
||||||
|
if (!isValid) return null
|
||||||
|
arrayOf(
|
||||||
|
leftExp,
|
||||||
|
topExp,
|
||||||
|
rightExp,
|
||||||
|
bottomExp
|
||||||
|
).forEach { exp ->
|
||||||
|
if (exp != null) {
|
||||||
|
setVariables(exp, rect)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (leftExp != null) {
|
||||||
|
if (topExp != null) {
|
||||||
|
return (rect.left + leftExp!!.evaluate()
|
||||||
|
.toFloat()) to (rect.top + topExp!!.evaluate().toFloat())
|
||||||
|
}
|
||||||
|
if (bottomExp != null) {
|
||||||
|
return (rect.left + leftExp!!.evaluate()
|
||||||
|
.toFloat()) to (rect.bottom - bottomExp!!.evaluate().toFloat())
|
||||||
|
}
|
||||||
|
} else if (rightExp != null) {
|
||||||
|
if (topExp != null) {
|
||||||
|
return (rect.right - rightExp!!.evaluate()
|
||||||
|
.toFloat()) to (rect.top + topExp!!.evaluate().toFloat())
|
||||||
|
}
|
||||||
|
if (bottomExp != null) {
|
||||||
|
return (rect.right - rightExp!!.evaluate()
|
||||||
|
.toFloat()) to (rect.bottom - bottomExp!!.evaluate().toFloat())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
interface RawCommonProps {
|
interface RawCommonProps {
|
||||||
val actionCd: Long?
|
val actionCd: Long?
|
||||||
|
@ -111,6 +170,7 @@ data class RawSubscription(
|
||||||
val key: Int?
|
val key: Int?
|
||||||
val preKeys: List<Int>?
|
val preKeys: List<Int>?
|
||||||
val action: String?
|
val action: String?
|
||||||
|
val position: Position?
|
||||||
val matches: List<String>?
|
val matches: List<String>?
|
||||||
val excludeMatches: List<String>?
|
val excludeMatches: List<String>?
|
||||||
}
|
}
|
||||||
|
@ -208,6 +268,9 @@ data class RawSubscription(
|
||||||
unknownPropertyNames.forEach { n ->
|
unknownPropertyNames.forEach { n ->
|
||||||
return@lazy "非法属性名:${n}"
|
return@lazy "非法属性名:${n}"
|
||||||
}
|
}
|
||||||
|
if (rules.any { r -> r.position?.isValid == false }) {
|
||||||
|
return@lazy "非法位置"
|
||||||
|
}
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -239,6 +302,7 @@ data class RawSubscription(
|
||||||
override val key: Int?,
|
override val key: Int?,
|
||||||
override val preKeys: List<Int>?,
|
override val preKeys: List<Int>?,
|
||||||
override val action: String?,
|
override val action: String?,
|
||||||
|
override val position: Position?,
|
||||||
override val matches: List<String>,
|
override val matches: List<String>,
|
||||||
override val excludeMatches: List<String>?,
|
override val excludeMatches: List<String>?,
|
||||||
override val matchAnyApp: Boolean?,
|
override val matchAnyApp: Boolean?,
|
||||||
|
@ -318,6 +382,7 @@ data class RawSubscription(
|
||||||
override val key: Int?,
|
override val key: Int?,
|
||||||
override val preKeys: List<Int>?,
|
override val preKeys: List<Int>?,
|
||||||
override val action: String?,
|
override val action: String?,
|
||||||
|
override val position: Position?,
|
||||||
override val matches: List<String>?,
|
override val matches: List<String>?,
|
||||||
override val excludeMatches: List<String>?,
|
override val excludeMatches: List<String>?,
|
||||||
|
|
||||||
|
@ -345,11 +410,69 @@ data class RawSubscription(
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|
||||||
|
private val expVars = arrayOf(
|
||||||
|
"left",
|
||||||
|
"top",
|
||||||
|
"right",
|
||||||
|
"bottom",
|
||||||
|
"width",
|
||||||
|
"height"
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun setVariables(exp: Expression, rect: Rect) {
|
||||||
|
exp.setVariable("left", rect.left.toDouble())
|
||||||
|
exp.setVariable("top", rect.top.toDouble())
|
||||||
|
exp.setVariable("right", rect.right.toDouble())
|
||||||
|
exp.setVariable("bottom", rect.bottom.toDouble())
|
||||||
|
exp.setVariable("width", rect.width().toDouble())
|
||||||
|
exp.setVariable("height", rect.height().toDouble())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getExpression(value: String?): Expression? {
|
||||||
|
return if (value != null) {
|
||||||
|
try {
|
||||||
|
ExpressionBuilder(value).variables(*expVars).build().apply {
|
||||||
|
expVars.forEach { v ->
|
||||||
|
// 预填充作 validate
|
||||||
|
setVariable(v, 0.0)
|
||||||
|
}
|
||||||
|
}.let { e ->
|
||||||
|
if (e.validate().isValid) {
|
||||||
|
e
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e.printStackTrace()
|
||||||
|
null
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getPosition(jsonObject: JsonObject? = null): Position? {
|
||||||
|
return when (val element = jsonObject?.get("position")) {
|
||||||
|
JsonNull, null -> null
|
||||||
|
is JsonObject -> {
|
||||||
|
Position(
|
||||||
|
left = element["left"]?.jsonPrimitive?.content,
|
||||||
|
bottom = element["bottom"]?.jsonPrimitive?.content,
|
||||||
|
top = element["top"]?.jsonPrimitive?.content,
|
||||||
|
right = element["right"]?.jsonPrimitive?.content,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun getStringIArray(
|
private fun getStringIArray(
|
||||||
json: JsonObject? = null,
|
jsonObject: JsonObject? = null,
|
||||||
name: String
|
name: String
|
||||||
): List<String>? {
|
): List<String>? {
|
||||||
return when (val element = json?.get(name)) {
|
return when (val element = jsonObject?.get(name)) {
|
||||||
JsonNull, null -> null
|
JsonNull, null -> null
|
||||||
is JsonObject -> error("Element ${this::class} can not be object")
|
is JsonObject -> error("Element ${this::class} can not be object")
|
||||||
is JsonArray -> element.map {
|
is JsonArray -> element.map {
|
||||||
|
@ -363,8 +486,8 @@ data class RawSubscription(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getIntIArray(json: JsonObject? = null, name: String): List<Int>? {
|
private fun getIntIArray(jsonObject: JsonObject? = null, name: String): List<Int>? {
|
||||||
return when (val element = json?.get(name)) {
|
return when (val element = jsonObject?.get(name)) {
|
||||||
JsonNull, null -> null
|
JsonNull, null -> null
|
||||||
is JsonArray -> element.map {
|
is JsonArray -> element.map {
|
||||||
when (it) {
|
when (it) {
|
||||||
|
@ -378,8 +501,8 @@ data class RawSubscription(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getLongIArray(json: JsonObject? = null, name: String): List<Long>? {
|
private fun getLongIArray(jsonObject: JsonObject? = null, name: String): List<Long>? {
|
||||||
return when (val element = json?.get(name)) {
|
return when (val element = jsonObject?.get(name)) {
|
||||||
JsonNull, null -> null
|
JsonNull, null -> null
|
||||||
is JsonArray -> element.map {
|
is JsonArray -> element.map {
|
||||||
when (it) {
|
when (it) {
|
||||||
|
@ -393,8 +516,8 @@ data class RawSubscription(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getString(json: JsonObject? = null, key: String): String? =
|
private fun getString(jsonObject: JsonObject? = null, key: String): String? =
|
||||||
when (val p = json?.get(key)) {
|
when (val p = jsonObject?.get(key)) {
|
||||||
JsonNull, null -> null
|
JsonNull, null -> null
|
||||||
is JsonPrimitive -> {
|
is JsonPrimitive -> {
|
||||||
if (p.isString) {
|
if (p.isString) {
|
||||||
|
@ -407,8 +530,8 @@ data class RawSubscription(
|
||||||
else -> error("Element $p is not a string")
|
else -> error("Element $p is not a string")
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getLong(json: JsonObject? = null, key: String): Long? =
|
private fun getLong(jsonObject: JsonObject? = null, key: String): Long? =
|
||||||
when (val p = json?.get(key)) {
|
when (val p = jsonObject?.get(key)) {
|
||||||
JsonNull, null -> null
|
JsonNull, null -> null
|
||||||
is JsonPrimitive -> {
|
is JsonPrimitive -> {
|
||||||
p.long
|
p.long
|
||||||
|
@ -417,8 +540,8 @@ data class RawSubscription(
|
||||||
else -> error("Element $p is not a long")
|
else -> error("Element $p is not a long")
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getInt(json: JsonObject? = null, key: String): Int? =
|
private fun getInt(jsonObject: JsonObject? = null, key: String): Int? =
|
||||||
when (val p = json?.get(key)) {
|
when (val p = jsonObject?.get(key)) {
|
||||||
JsonNull, null -> null
|
JsonNull, null -> null
|
||||||
is JsonPrimitive -> {
|
is JsonPrimitive -> {
|
||||||
p.int
|
p.int
|
||||||
|
@ -427,8 +550,8 @@ data class RawSubscription(
|
||||||
else -> error("Element $p is not a int")
|
else -> error("Element $p is not a int")
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getBoolean(json: JsonObject? = null, key: String): Boolean? =
|
private fun getBoolean(jsonObject: JsonObject? = null, key: String): Boolean? =
|
||||||
when (val p = json?.get(key)) {
|
when (val p = jsonObject?.get(key)) {
|
||||||
JsonNull, null -> null
|
JsonNull, null -> null
|
||||||
is JsonPrimitive -> {
|
is JsonPrimitive -> {
|
||||||
p.boolean
|
p.boolean
|
||||||
|
@ -468,6 +591,7 @@ data class RawSubscription(
|
||||||
excludeVersionCodes = getLongIArray(jsonObject, "excludeVersionCodes"),
|
excludeVersionCodes = getLongIArray(jsonObject, "excludeVersionCodes"),
|
||||||
versionNames = getStringIArray(jsonObject, "versionNames"),
|
versionNames = getStringIArray(jsonObject, "versionNames"),
|
||||||
excludeVersionNames = getStringIArray(jsonObject, "excludeVersionNames"),
|
excludeVersionNames = getStringIArray(jsonObject, "excludeVersionNames"),
|
||||||
|
position = getPosition(jsonObject),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -568,6 +692,7 @@ data class RawSubscription(
|
||||||
excludeMatches = getStringIArray(jsonObject, "excludeMatches"),
|
excludeMatches = getStringIArray(jsonObject, "excludeMatches"),
|
||||||
matches = getStringIArray(jsonObject, "matches") ?: error("miss matches"),
|
matches = getStringIArray(jsonObject, "matches") ?: error("miss matches"),
|
||||||
order = getInt(jsonObject, "order"),
|
order = getInt(jsonObject, "order"),
|
||||||
|
position = getPosition(jsonObject),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
package li.songe.gkd.data
|
package li.songe.gkd.data
|
||||||
|
|
||||||
|
import android.accessibilityservice.AccessibilityService
|
||||||
import android.view.accessibility.AccessibilityNodeInfo
|
import android.view.accessibility.AccessibilityNodeInfo
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import li.songe.gkd.service.lastTriggerRule
|
import li.songe.gkd.service.lastTriggerRule
|
||||||
|
@ -130,7 +131,10 @@ sealed class ResolvedRule(
|
||||||
return target
|
return target
|
||||||
}
|
}
|
||||||
|
|
||||||
val performAction = getActionFc(rule.action)
|
private val fc = getActionFc(rule.action)
|
||||||
|
fun performAction(context: AccessibilityService, node: AccessibilityNodeInfo): ActionResult {
|
||||||
|
return fc(context, node, rule.position)
|
||||||
|
}
|
||||||
|
|
||||||
var matchDelayJob: Job? = null
|
var matchDelayJob: Job? = null
|
||||||
|
|
||||||
|
|
|
@ -533,7 +533,7 @@ class GkdAbService : CompositionAbService({
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return getActionFc(gkdAction.action)(serviceVal, targetNode)
|
return getActionFc(gkdAction.action)(serviceVal, targetNode, gkdAction.position)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -204,6 +204,9 @@ dependencyResolutionManagement {
|
||||||
|
|
||||||
// https://github.com/Calvin-LL/Reorderable
|
// https://github.com/Calvin-LL/Reorderable
|
||||||
library("others.reorderable", "sh.calvin.reorderable:reorderable:1.3.1")
|
library("others.reorderable", "sh.calvin.reorderable:reorderable:1.3.1")
|
||||||
|
|
||||||
|
// https://www.objecthunter.net/exp4j/
|
||||||
|
library("exp4j", "net.objecthunter:exp4j:0.4.8")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue
Block a user