mirror of
https://github.com/topjohnwu/Magisk.git
synced 2025-03-20 06:20:52 +00:00
Add preliminary shared UID app support
This commit is contained in:
parent
f2c15c7701
commit
9f1740cc4f
@ -0,0 +1,8 @@
|
||||
// IRootUtils.aidl
|
||||
package com.topjohnwu.magisk.core.utils;
|
||||
|
||||
// Declare any non-default types here with import statements
|
||||
|
||||
interface IRootUtils {
|
||||
android.app.ActivityManager.RunningAppProcessInfo getAppProcess(int pid);
|
||||
}
|
@ -1,28 +0,0 @@
|
||||
package com.topjohnwu.magisk.core.magiskdb
|
||||
|
||||
import androidx.annotation.StringDef
|
||||
|
||||
abstract class BaseDao {
|
||||
|
||||
object Table {
|
||||
const val POLICY = "policies"
|
||||
const val LOG = "logs"
|
||||
const val SETTINGS = "settings"
|
||||
const val STRINGS = "strings"
|
||||
}
|
||||
|
||||
@StringDef(Table.POLICY, Table.LOG, Table.SETTINGS, Table.STRINGS)
|
||||
@Retention(AnnotationRetention.SOURCE)
|
||||
annotation class TableStrict
|
||||
|
||||
@TableStrict
|
||||
abstract val table: String
|
||||
|
||||
inline fun <reified Builder : Query.Builder> buildQuery(builder: Builder.() -> Unit = {}) =
|
||||
Builder::class.java.newInstance()
|
||||
.apply { table = this@BaseDao.table }
|
||||
.apply(builder)
|
||||
.toString()
|
||||
.let { Query(it) }
|
||||
|
||||
}
|
@ -0,0 +1,47 @@
|
||||
package com.topjohnwu.magisk.core.magiskdb
|
||||
|
||||
import com.topjohnwu.magisk.ktx.await
|
||||
import com.topjohnwu.superuser.Shell
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
open class MagiskDB {
|
||||
|
||||
suspend fun <R> exec(
|
||||
query: String,
|
||||
mapper: suspend (Map<String, String>) -> R
|
||||
): List<R> {
|
||||
return withContext(Dispatchers.IO) {
|
||||
val out = Shell.cmd("magisk --sqlite '$query'").await().out
|
||||
out.map { line ->
|
||||
line.split("\\|".toRegex())
|
||||
.map { it.split("=", limit = 2) }
|
||||
.filter { it.size == 2 }
|
||||
.associate { it[0] to it[1] }
|
||||
.let { mapper(it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend inline fun exec(query: String) {
|
||||
exec(query) {}
|
||||
}
|
||||
|
||||
fun Map<String, Any>.toQuery(): String {
|
||||
val keys = this.keys.joinToString(",")
|
||||
val values = this.values.joinToString(",") {
|
||||
when (it) {
|
||||
is Boolean -> if (it) "1" else "0"
|
||||
is Number -> it.toString()
|
||||
else -> "\"$it\""
|
||||
}
|
||||
}
|
||||
return "($keys) VALUES($values)"
|
||||
}
|
||||
|
||||
object Table {
|
||||
const val POLICY = "policies"
|
||||
const val SETTINGS = "settings"
|
||||
const val STRINGS = "strings"
|
||||
}
|
||||
}
|
@ -2,61 +2,48 @@ package com.topjohnwu.magisk.core.magiskdb
|
||||
|
||||
import com.topjohnwu.magisk.core.Const
|
||||
import com.topjohnwu.magisk.core.model.su.SuPolicy
|
||||
import com.topjohnwu.magisk.core.model.su.toPolicy
|
||||
import com.topjohnwu.magisk.core.model.su.createPolicy
|
||||
import com.topjohnwu.magisk.di.AppContext
|
||||
import com.topjohnwu.magisk.ktx.now
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.launch
|
||||
import timber.log.Timber
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class PolicyDao : MagiskDB() {
|
||||
|
||||
class PolicyDao : BaseDao() {
|
||||
|
||||
override val table: String = Table.POLICY
|
||||
|
||||
suspend fun deleteOutdated() = buildQuery<Delete> {
|
||||
condition {
|
||||
greaterThan("until", "0")
|
||||
and {
|
||||
lessThan("until", TimeUnit.MILLISECONDS.toSeconds(now).toString())
|
||||
}
|
||||
or {
|
||||
lessThan("until", "0")
|
||||
}
|
||||
}
|
||||
}.commit()
|
||||
|
||||
suspend fun delete(uid: Int) = buildQuery<Delete> {
|
||||
condition {
|
||||
equals("uid", uid)
|
||||
}
|
||||
}.commit()
|
||||
|
||||
suspend fun fetch(uid: Int) = buildQuery<Select> {
|
||||
condition {
|
||||
equals("uid", uid)
|
||||
}
|
||||
}.query().first().toPolicyOrNull()
|
||||
|
||||
suspend fun update(policy: SuPolicy) = buildQuery<Replace> {
|
||||
values(policy.toMap())
|
||||
}.commit()
|
||||
|
||||
suspend fun <R: Any> fetchAll(mapper: (SuPolicy) -> R) = buildQuery<Select> {
|
||||
condition {
|
||||
equals("uid/100000", Const.USER_ID)
|
||||
}
|
||||
}.query {
|
||||
it.toPolicyOrNull()?.let(mapper)
|
||||
suspend fun deleteOutdated() {
|
||||
val nowSeconds = TimeUnit.MILLISECONDS.toSeconds(System.currentTimeMillis())
|
||||
val query = "DELETE FROM ${Table.POLICY} WHERE " +
|
||||
"(until > 0 AND until < $nowSeconds) OR until < 0"
|
||||
exec(query)
|
||||
}
|
||||
|
||||
private fun Map<String, String>.toPolicyOrNull(): SuPolicy? {
|
||||
return runCatching { toPolicy(AppContext.packageManager) }.getOrElse {
|
||||
Timber.w(it)
|
||||
val uid = getOrElse("uid") { return null }
|
||||
GlobalScope.launch { delete(uid.toInt()) }
|
||||
null
|
||||
suspend fun delete(uid: Int) {
|
||||
val query = "DELETE FROM ${Table.POLICY} WHERE uid == $uid"
|
||||
exec(query)
|
||||
}
|
||||
|
||||
suspend fun fetch(uid: Int): SuPolicy? {
|
||||
val query = "SELECT * FROM ${Table.POLICY} WHERE uid == $uid LIMIT = 1"
|
||||
return exec(query) { it.toPolicyOrNull() }.firstOrNull()
|
||||
}
|
||||
|
||||
suspend fun update(policy: SuPolicy) {
|
||||
val query = "REPLACE INTO ${Table.POLICY} ${policy.toMap().toQuery()}"
|
||||
exec(query)
|
||||
}
|
||||
|
||||
suspend fun fetchAll(): List<SuPolicy> {
|
||||
val query = "SELECT * FROM ${Table.POLICY} WHERE uid/100000 == ${Const.USER_ID}"
|
||||
return exec(query) { it.toPolicyOrNull() }.filterNotNull()
|
||||
}
|
||||
|
||||
private suspend fun Map<String, String>.toPolicyOrNull(): SuPolicy? {
|
||||
try {
|
||||
return AppContext.packageManager.createPolicy(this)
|
||||
} catch (e: Exception) {
|
||||
Timber.w(e)
|
||||
val uid = get("uid") ?: return null
|
||||
delete(uid.toInt())
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -1,161 +0,0 @@
|
||||
package com.topjohnwu.magisk.core.magiskdb
|
||||
|
||||
import androidx.annotation.StringDef
|
||||
import com.topjohnwu.magisk.ktx.await
|
||||
import com.topjohnwu.superuser.Shell
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
class Query(private val _query: String) {
|
||||
val query get() = "magisk --sqlite '$_query'"
|
||||
|
||||
interface Builder {
|
||||
val requestType: String
|
||||
var table: String
|
||||
}
|
||||
|
||||
suspend inline fun <R : Any> query(crossinline mapper: (Map<String, String>) -> R?): List<R> =
|
||||
withContext(Dispatchers.Default) {
|
||||
Shell.cmd(query).await().out.map { line ->
|
||||
async {
|
||||
line.split("\\|".toRegex())
|
||||
.map { it.split("=", limit = 2) }
|
||||
.filter { it.size == 2 }
|
||||
.map { it[0] to it[1] }
|
||||
.toMap()
|
||||
.let(mapper)
|
||||
}
|
||||
}.awaitAll().filterNotNull()
|
||||
}
|
||||
|
||||
suspend inline fun query() = query { it }
|
||||
|
||||
suspend inline fun commit() = Shell.cmd(query).to(null).await()
|
||||
}
|
||||
|
||||
class Delete : Query.Builder {
|
||||
override val requestType: String = "DELETE FROM"
|
||||
override var table = ""
|
||||
|
||||
private var condition = ""
|
||||
|
||||
fun condition(builder: Condition.() -> Unit) {
|
||||
condition = Condition().apply(builder).toString()
|
||||
}
|
||||
|
||||
override fun toString(): String {
|
||||
return listOf(requestType, table, condition).joinToString(" ")
|
||||
}
|
||||
}
|
||||
|
||||
class Select : Query.Builder {
|
||||
override val requestType: String get() = "SELECT $fields FROM"
|
||||
override lateinit var table: String
|
||||
|
||||
private var fields = "*"
|
||||
private var condition = ""
|
||||
private var orderField = ""
|
||||
|
||||
fun fields(vararg newFields: String) {
|
||||
if (newFields.isEmpty()) {
|
||||
fields = "*"
|
||||
return
|
||||
}
|
||||
fields = newFields.joinToString(", ")
|
||||
}
|
||||
|
||||
fun condition(builder: Condition.() -> Unit) {
|
||||
condition = Condition().apply(builder).toString()
|
||||
}
|
||||
|
||||
fun orderBy(field: String, @OrderStrict order: String) {
|
||||
orderField = "ORDER BY $field $order"
|
||||
}
|
||||
|
||||
override fun toString(): String {
|
||||
return listOf(requestType, table, condition, orderField).joinToString(" ")
|
||||
}
|
||||
}
|
||||
|
||||
class Replace : Insert() {
|
||||
override val requestType: String = "REPLACE INTO"
|
||||
}
|
||||
|
||||
open class Insert : Query.Builder {
|
||||
override val requestType: String = "INSERT INTO"
|
||||
override lateinit var table: String
|
||||
|
||||
private val keys get() = _values.keys.joinToString(",")
|
||||
private val values get() = _values.values.joinToString(",") {
|
||||
when (it) {
|
||||
is Boolean -> if (it) "1" else "0"
|
||||
is Number -> it.toString()
|
||||
else -> "\"$it\""
|
||||
}
|
||||
}
|
||||
private var _values: Map<String, Any> = mapOf()
|
||||
|
||||
fun values(vararg pairs: Pair<String, Any>) {
|
||||
_values = pairs.toMap()
|
||||
}
|
||||
|
||||
fun values(values: Map<String, Any>) {
|
||||
_values = values
|
||||
}
|
||||
|
||||
override fun toString(): String {
|
||||
return listOf(requestType, table, "($keys) VALUES($values)").joinToString(" ")
|
||||
}
|
||||
}
|
||||
|
||||
class Condition {
|
||||
|
||||
private val conditionWord = "WHERE %s"
|
||||
private var condition: String = ""
|
||||
|
||||
fun equals(field: String, value: Any) {
|
||||
condition = when (value) {
|
||||
is String -> "$field=\"$value\""
|
||||
else -> "$field=$value"
|
||||
}
|
||||
}
|
||||
|
||||
fun greaterThan(field: String, value: String) {
|
||||
condition = "$field > $value"
|
||||
}
|
||||
|
||||
fun lessThan(field: String, value: String) {
|
||||
condition = "$field < $value"
|
||||
}
|
||||
|
||||
fun greaterOrEqualTo(field: String, value: String) {
|
||||
condition = "$field >= $value"
|
||||
}
|
||||
|
||||
fun lessOrEqualTo(field: String, value: String) {
|
||||
condition = "$field <= $value"
|
||||
}
|
||||
|
||||
fun and(builder: Condition.() -> Unit) {
|
||||
condition = "($condition AND ${Condition().apply(builder).condition})"
|
||||
}
|
||||
|
||||
fun or(builder: Condition.() -> Unit) {
|
||||
condition = "($condition OR ${Condition().apply(builder).condition})"
|
||||
}
|
||||
|
||||
override fun toString(): String {
|
||||
return conditionWord.format(condition)
|
||||
}
|
||||
}
|
||||
|
||||
object Order {
|
||||
const val ASC = "ASC"
|
||||
const val DESC = "DESC"
|
||||
}
|
||||
|
||||
@StringDef(Order.ASC, Order.DESC)
|
||||
@Retention(AnnotationRetention.SOURCE)
|
||||
annotation class OrderStrict
|
@ -1,22 +1,20 @@
|
||||
package com.topjohnwu.magisk.core.magiskdb
|
||||
|
||||
class SettingsDao : BaseDao() {
|
||||
class SettingsDao : MagiskDB() {
|
||||
|
||||
override val table = Table.SETTINGS
|
||||
suspend fun delete(key: String) {
|
||||
val query = "DELETE FROM ${Table.SETTINGS} WHERE key == \"$key\""
|
||||
exec(query)
|
||||
}
|
||||
|
||||
suspend fun delete(key: String) = buildQuery<Delete> {
|
||||
condition { equals("key", key) }
|
||||
}.commit()
|
||||
|
||||
suspend fun put(key: String, value: Int) = buildQuery<Replace> {
|
||||
values("key" to key, "value" to value)
|
||||
}.commit()
|
||||
|
||||
suspend fun fetch(key: String, default: Int = -1) = buildQuery<Select> {
|
||||
fields("value")
|
||||
condition { equals("key", key) }
|
||||
}.query {
|
||||
it["value"]?.toIntOrNull()
|
||||
}.firstOrNull() ?: default
|
||||
suspend fun put(key: String, value: Int) {
|
||||
val kv = mapOf("key" to key, "value" to value)
|
||||
val query = "REPLACE INTO ${Table.SETTINGS} ${kv.toQuery()}"
|
||||
exec(query)
|
||||
}
|
||||
|
||||
suspend fun fetch(key: String, default: Int = -1): Int {
|
||||
val query = "SELECT value FROM ${Table.SETTINGS} WHERE key == \"$key\" LIMIT = 1"
|
||||
return exec(query) { it["value"]?.toInt() }.firstOrNull() ?: default
|
||||
}
|
||||
}
|
||||
|
@ -1,22 +1,20 @@
|
||||
package com.topjohnwu.magisk.core.magiskdb
|
||||
|
||||
class StringDao : BaseDao() {
|
||||
class StringDao : MagiskDB() {
|
||||
|
||||
override val table = Table.STRINGS
|
||||
suspend fun delete(key: String) {
|
||||
val query = "DELETE FROM ${Table.STRINGS} WHERE key == \"$key\""
|
||||
exec(query)
|
||||
}
|
||||
|
||||
suspend fun delete(key: String) = buildQuery<Delete> {
|
||||
condition { equals("key", key) }
|
||||
}.commit()
|
||||
|
||||
suspend fun put(key: String, value: String) = buildQuery<Replace> {
|
||||
values("key" to key, "value" to value)
|
||||
}.commit()
|
||||
|
||||
suspend fun fetch(key: String, default: String = "") = buildQuery<Select> {
|
||||
fields("value")
|
||||
condition { equals("key", key) }
|
||||
}.query {
|
||||
it["value"]
|
||||
}.firstOrNull() ?: default
|
||||
suspend fun put(key: String, value: String) {
|
||||
val kv = mapOf("key" to key, "value" to value)
|
||||
val query = "REPLACE INTO ${Table.STRINGS} ${kv.toQuery()}"
|
||||
exec(query)
|
||||
}
|
||||
|
||||
suspend fun fetch(key: String, default: String = ""): String {
|
||||
val query = "SELECT value FROM ${Table.STRINGS} WHERE key == \"$key\" LIMIT = 1"
|
||||
return exec(query) { it["value"] }.firstOrNull() ?: default
|
||||
}
|
||||
}
|
||||
|
@ -1,11 +1,13 @@
|
||||
package com.topjohnwu.magisk.core.model.su
|
||||
|
||||
import android.content.pm.PackageInfo
|
||||
import android.content.pm.PackageManager
|
||||
import androidx.room.Entity
|
||||
import androidx.room.PrimaryKey
|
||||
import com.topjohnwu.magisk.ktx.now
|
||||
import com.topjohnwu.magisk.ktx.getLabel
|
||||
|
||||
@Entity(tableName = "logs")
|
||||
data class SuLog(
|
||||
class SuLog(
|
||||
val fromUid: Int,
|
||||
val toUid: Int,
|
||||
val fromPid: Int,
|
||||
@ -13,7 +15,44 @@ data class SuLog(
|
||||
val appName: String,
|
||||
val command: String,
|
||||
val action: Boolean,
|
||||
val time: Long = now
|
||||
val time: Long = System.currentTimeMillis()
|
||||
) {
|
||||
@PrimaryKey(autoGenerate = true) var id: Int = 0
|
||||
}
|
||||
|
||||
fun PackageManager.createSuLog(
|
||||
info: PackageInfo,
|
||||
toUid: Int,
|
||||
fromPid: Int,
|
||||
command: String,
|
||||
policy: Int
|
||||
): SuLog {
|
||||
val appInfo = info.applicationInfo
|
||||
return SuLog(
|
||||
fromUid = appInfo.uid,
|
||||
toUid = toUid,
|
||||
fromPid = fromPid,
|
||||
packageName = getNameForUid(appInfo.uid)!!,
|
||||
appName = appInfo.getLabel(this),
|
||||
command = command,
|
||||
action = policy == SuPolicy.ALLOW
|
||||
)
|
||||
}
|
||||
|
||||
fun createSuLog(
|
||||
fromUid: Int,
|
||||
toUid: Int,
|
||||
fromPid: Int,
|
||||
command: String,
|
||||
policy: Int
|
||||
): SuLog {
|
||||
return SuLog(
|
||||
fromUid = fromUid,
|
||||
toUid = toUid,
|
||||
fromPid = fromPid,
|
||||
packageName = "[UID] $fromUid",
|
||||
appName = "[UID] $fromUid",
|
||||
command = command,
|
||||
action = policy == SuPolicy.ALLOW
|
||||
)
|
||||
}
|
||||
|
@ -1,22 +1,20 @@
|
||||
@file:SuppressLint("InlinedApi")
|
||||
|
||||
package com.topjohnwu.magisk.core.model.su
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.pm.PackageInfo
|
||||
import android.content.pm.PackageManager
|
||||
import android.graphics.drawable.Drawable
|
||||
import com.topjohnwu.magisk.core.model.su.SuPolicy.Companion.INTERACTIVE
|
||||
import com.topjohnwu.magisk.ktx.getLabel
|
||||
import com.topjohnwu.magisk.ktx.getPackageInfo
|
||||
|
||||
data class SuPolicy(
|
||||
class SuPolicy(
|
||||
val uid: Int,
|
||||
val packageName: String,
|
||||
val appName: String,
|
||||
val icon: Drawable,
|
||||
var policy: Int = INTERACTIVE,
|
||||
var until: Long = -1L,
|
||||
val logging: Boolean = true,
|
||||
val notification: Boolean = true
|
||||
var logging: Boolean = true,
|
||||
var notification: Boolean = true
|
||||
) {
|
||||
|
||||
companion object {
|
||||
@ -25,10 +23,6 @@ data class SuPolicy(
|
||||
const val ALLOW = 2
|
||||
}
|
||||
|
||||
fun toLog(toUid: Int, fromPid: Int, command: String) = SuLog(
|
||||
uid, toUid, fromPid, packageName, appName,
|
||||
command, policy == ALLOW)
|
||||
|
||||
fun toMap() = mapOf(
|
||||
"uid" to uid,
|
||||
"package_name" to packageName,
|
||||
@ -39,47 +33,43 @@ data class SuPolicy(
|
||||
)
|
||||
}
|
||||
|
||||
@Throws(PackageManager.NameNotFoundException::class)
|
||||
fun Map<String, String>.toPolicy(pm: PackageManager): SuPolicy {
|
||||
val uid = get("uid")?.toIntOrNull() ?: -1
|
||||
val packageName = get("package_name").orEmpty()
|
||||
val info = pm.getApplicationInfo(packageName, PackageManager.MATCH_UNINSTALLED_PACKAGES)
|
||||
|
||||
if (info.uid != uid)
|
||||
throw PackageManager.NameNotFoundException()
|
||||
|
||||
fun PackageManager.createPolicy(info: PackageInfo): SuPolicy {
|
||||
val appInfo = info.applicationInfo
|
||||
val prefix = if (info.sharedUserId == null) "" else "[SharedUID] "
|
||||
return SuPolicy(
|
||||
uid = uid,
|
||||
packageName = packageName,
|
||||
appName = info.getLabel(pm),
|
||||
icon = info.loadIcon(pm),
|
||||
policy = get("policy")?.toIntOrNull() ?: INTERACTIVE,
|
||||
until = get("until")?.toLongOrNull() ?: -1L,
|
||||
logging = get("logging")?.toIntOrNull() != 0,
|
||||
notification = get("notification")?.toIntOrNull() != 0
|
||||
uid = appInfo.uid,
|
||||
packageName = getNameForUid(appInfo.uid)!!,
|
||||
appName = "$prefix${appInfo.getLabel(this)}",
|
||||
icon = appInfo.loadIcon(this),
|
||||
)
|
||||
}
|
||||
|
||||
@Throws(PackageManager.NameNotFoundException::class)
|
||||
fun Int.toPolicy(pm: PackageManager, policy: Int = INTERACTIVE): SuPolicy {
|
||||
val pkg = pm.getPackagesForUid(this)?.firstOrNull()
|
||||
?: throw PackageManager.NameNotFoundException()
|
||||
val info = pm.getApplicationInfo(pkg, PackageManager.MATCH_UNINSTALLED_PACKAGES)
|
||||
return SuPolicy(
|
||||
uid = info.uid,
|
||||
packageName = pkg,
|
||||
appName = info.getLabel(pm),
|
||||
icon = info.loadIcon(pm),
|
||||
policy = policy
|
||||
)
|
||||
fun PackageManager.createPolicy(uid: Int): SuPolicy {
|
||||
val info = getPackageInfo(uid, -1)
|
||||
return if (info == null) {
|
||||
// We can assert getNameForUid does not return null because
|
||||
// getPackageInfo will already throw if UID does not exist
|
||||
val name = getNameForUid(uid)!!
|
||||
SuPolicy(
|
||||
uid = uid,
|
||||
packageName = name,
|
||||
appName = "[SharedUID] $name",
|
||||
icon = defaultActivityIcon,
|
||||
)
|
||||
} else {
|
||||
createPolicy(info)
|
||||
}
|
||||
}
|
||||
|
||||
fun Int.toUidPolicy(pm: PackageManager, policy: Int): SuPolicy {
|
||||
return SuPolicy(
|
||||
uid = this,
|
||||
packageName = "[UID] $this",
|
||||
appName = "[UID] $this",
|
||||
icon = pm.defaultActivityIcon,
|
||||
policy = policy
|
||||
)
|
||||
@Throws(PackageManager.NameNotFoundException::class)
|
||||
fun PackageManager.createPolicy(map: Map<String, String>): SuPolicy {
|
||||
val uid = map["uid"]?.toIntOrNull() ?: throw IllegalArgumentException()
|
||||
val policy = createPolicy(uid)
|
||||
|
||||
map["policy"]?.toInt()?.let { policy.policy = it }
|
||||
map["until"]?.toLong()?.let { policy.until = it }
|
||||
map["logging"]?.toInt()?.let { policy.logging = it != 0 }
|
||||
map["notification"]?.toInt()?.let { policy.notification = it != 0 }
|
||||
return policy
|
||||
}
|
||||
|
@ -7,12 +7,12 @@ import com.topjohnwu.magisk.BuildConfig
|
||||
import com.topjohnwu.magisk.R
|
||||
import com.topjohnwu.magisk.core.Config
|
||||
import com.topjohnwu.magisk.core.model.su.SuPolicy
|
||||
import com.topjohnwu.magisk.core.model.su.toPolicy
|
||||
import com.topjohnwu.magisk.core.model.su.toUidPolicy
|
||||
import com.topjohnwu.magisk.core.model.su.createSuLog
|
||||
import com.topjohnwu.magisk.di.ServiceLocator
|
||||
import com.topjohnwu.magisk.ktx.getLabel
|
||||
import com.topjohnwu.magisk.ktx.getPackageInfo
|
||||
import com.topjohnwu.magisk.utils.Utils
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import timber.log.Timber
|
||||
|
||||
object SuCallbackHandler {
|
||||
@ -53,59 +53,47 @@ object SuCallbackHandler {
|
||||
private fun handleLogging(context: Context, data: Bundle) {
|
||||
val fromUid = data.getIntComp("from.uid", -1)
|
||||
val notify = data.getBoolean("notify", true)
|
||||
val allow = data.getIntComp("policy", SuPolicy.ALLOW)
|
||||
val policy = data.getIntComp("policy", SuPolicy.ALLOW)
|
||||
val toUid = data.getIntComp("to.uid", -1)
|
||||
val pid = data.getIntComp("pid", -1)
|
||||
val command = data.getString("command", "")
|
||||
|
||||
val pm = context.packageManager
|
||||
|
||||
val policy = runCatching {
|
||||
fromUid.toPolicy(pm, allow)
|
||||
}.getOrElse {
|
||||
GlobalScope.launch { ServiceLocator.policyDB.delete(fromUid) }
|
||||
fromUid.toUidPolicy(pm, allow)
|
||||
}
|
||||
val log = runCatching {
|
||||
pm.getPackageInfo(fromUid, pid)?.let {
|
||||
pm.createSuLog(it, toUid, pid, command, policy)
|
||||
}
|
||||
}.getOrNull() ?: createSuLog(fromUid, toUid, pid, command, policy)
|
||||
|
||||
if (notify)
|
||||
notify(context, policy)
|
||||
notify(context, log.action, log.appName)
|
||||
|
||||
val toUid = data.getIntComp("to.uid", -1)
|
||||
val pid = data.getIntComp("pid", -1)
|
||||
|
||||
val command = data.getString("command", "")
|
||||
val log = policy.toLog(
|
||||
toUid = toUid,
|
||||
fromPid = pid,
|
||||
command = command
|
||||
)
|
||||
|
||||
GlobalScope.launch {
|
||||
ServiceLocator.logRepo.insert(log)
|
||||
}
|
||||
runBlocking { ServiceLocator.logRepo.insert(log) }
|
||||
}
|
||||
|
||||
private fun handleNotify(context: Context, data: Bundle) {
|
||||
val fromUid = data.getIntComp("from.uid", -1)
|
||||
val allow = data.getIntComp("policy", SuPolicy.ALLOW)
|
||||
val uid = data.getIntComp("from.uid", -1)
|
||||
val pid = data.getIntComp("pid", -1)
|
||||
val policy = data.getIntComp("policy", SuPolicy.ALLOW)
|
||||
|
||||
val pm = context.packageManager
|
||||
|
||||
val appName = runCatching {
|
||||
pm.getPackageInfo(uid, pid)?.applicationInfo?.getLabel(pm)
|
||||
}.getOrNull() ?: "[UID] $uid"
|
||||
|
||||
val policy = runCatching {
|
||||
fromUid.toPolicy(pm, allow)
|
||||
}.getOrElse {
|
||||
GlobalScope.launch { ServiceLocator.policyDB.delete(fromUid) }
|
||||
fromUid.toUidPolicy(pm, allow)
|
||||
}
|
||||
notify(context, policy)
|
||||
notify(context, policy == SuPolicy.ALLOW, appName)
|
||||
}
|
||||
|
||||
private fun notify(context: Context, policy: SuPolicy) {
|
||||
private fun notify(context: Context, granted: Boolean, appName: String) {
|
||||
if (Config.suNotification == Config.Value.NOTIFICATION_TOAST) {
|
||||
val resId = if (policy.policy == SuPolicy.ALLOW)
|
||||
val resId = if (granted)
|
||||
R.string.su_allow_toast
|
||||
else
|
||||
R.string.su_deny_toast
|
||||
|
||||
Utils.toast(context.getString(resId, policy.appName), Toast.LENGTH_SHORT)
|
||||
Utils.toast(context.getString(resId, appName), Toast.LENGTH_SHORT)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -6,7 +6,8 @@ import com.topjohnwu.magisk.BuildConfig
|
||||
import com.topjohnwu.magisk.core.Config
|
||||
import com.topjohnwu.magisk.core.magiskdb.PolicyDao
|
||||
import com.topjohnwu.magisk.core.model.su.SuPolicy
|
||||
import com.topjohnwu.magisk.core.model.su.toPolicy
|
||||
import com.topjohnwu.magisk.core.model.su.createPolicy
|
||||
import com.topjohnwu.magisk.ktx.getPackageInfo
|
||||
import com.topjohnwu.superuser.Shell
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
@ -60,10 +61,12 @@ class SuRequestHandler(
|
||||
|
||||
private suspend fun init(intent: Intent) = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val name = intent.getStringExtra("fifo") ?: throw SuRequestError()
|
||||
val fifo = intent.getStringExtra("fifo") ?: throw SuRequestError()
|
||||
val uid = intent.getIntExtra("uid", -1).also { if (it < 0) throw SuRequestError() }
|
||||
output = DataOutputStream(FileOutputStream(name).buffered())
|
||||
policy = uid.toPolicy(pm)
|
||||
val pid = intent.getIntExtra("pid", -1)
|
||||
val info = pm.getPackageInfo(uid, pid) ?: throw SuRequestError()
|
||||
output = DataOutputStream(FileOutputStream(fifo).buffered())
|
||||
policy = pm.createPolicy(info)
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
when (e) {
|
||||
|
@ -1,30 +1,64 @@
|
||||
package com.topjohnwu.magisk.core.utils
|
||||
|
||||
import android.app.ActivityManager
|
||||
import android.content.ComponentName
|
||||
import android.content.Intent
|
||||
import android.content.ServiceConnection
|
||||
import android.os.Binder
|
||||
import android.os.IBinder
|
||||
import androidx.core.content.getSystemService
|
||||
import com.topjohnwu.superuser.Shell
|
||||
import com.topjohnwu.superuser.ShellUtils
|
||||
import com.topjohnwu.superuser.ipc.RootService
|
||||
import timber.log.Timber
|
||||
import java.io.File
|
||||
import java.util.concurrent.locks.AbstractQueuedSynchronizer
|
||||
|
||||
class RootUtils(stub: Any?) : RootService() {
|
||||
|
||||
private val className: String = stub?.javaClass?.name ?: javaClass.name
|
||||
private lateinit var am: ActivityManager
|
||||
|
||||
constructor() : this(null) {
|
||||
Timber.plant(Timber.DebugTree())
|
||||
}
|
||||
|
||||
override fun onCreate() {
|
||||
am = getSystemService()!!
|
||||
}
|
||||
|
||||
override fun getComponentName(): ComponentName {
|
||||
return ComponentName(packageName, className)
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent): IBinder {
|
||||
return Binder()
|
||||
return object : IRootUtils.Stub() {
|
||||
override fun getAppProcess(pid: Int) = safe(null) { getAppProcessImpl(pid) }
|
||||
}
|
||||
}
|
||||
|
||||
private inline fun <T> safe(default: T, block: () -> T): T {
|
||||
return try {
|
||||
block()
|
||||
} catch (e: Throwable) {
|
||||
Timber.e(e)
|
||||
default
|
||||
}
|
||||
}
|
||||
|
||||
private fun getAppProcessImpl(_pid: Int): ActivityManager.RunningAppProcessInfo? {
|
||||
val procList = am.runningAppProcesses
|
||||
var pid = _pid
|
||||
while (pid > 1) {
|
||||
val proc = procList.find { it.pid == pid }
|
||||
if (proc != null)
|
||||
return proc
|
||||
// Find PPID
|
||||
File("/proc/$pid/status").useLines {
|
||||
val s = it.find { line -> line.startsWith("PPid:") }
|
||||
pid = s?.substring(5)?.trim()?.toInt() ?: -1
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
object Connection : AbstractQueuedSynchronizer(), ServiceConnection {
|
||||
|
@ -7,10 +7,10 @@ import android.content.Context
|
||||
import android.content.ContextWrapper
|
||||
import android.content.Intent
|
||||
import android.content.pm.ApplicationInfo
|
||||
import android.content.pm.PackageInfo
|
||||
import android.content.pm.PackageManager
|
||||
import android.content.pm.PackageManager.*
|
||||
import android.content.pm.PackageManager.PERMISSION_GRANTED
|
||||
import android.content.res.Configuration
|
||||
import android.content.res.Resources
|
||||
import android.database.Cursor
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Canvas
|
||||
@ -19,40 +19,26 @@ import android.graphics.drawable.BitmapDrawable
|
||||
import android.graphics.drawable.LayerDrawable
|
||||
import android.net.Uri
|
||||
import android.os.Build.VERSION.SDK_INT
|
||||
import android.text.PrecomputedText
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.view.ViewTreeObserver
|
||||
import android.view.inputmethod.InputMethodManager
|
||||
import android.widget.TextView
|
||||
import androidx.annotation.ColorRes
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.appcompat.content.res.AppCompatResources
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.content.getSystemService
|
||||
import androidx.core.net.toUri
|
||||
import androidx.core.text.PrecomputedTextCompat
|
||||
import androidx.core.view.isGone
|
||||
import androidx.core.widget.TextViewCompat
|
||||
import androidx.databinding.BindingAdapter
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.interpolator.view.animation.FastOutSlowInInterpolator
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.transition.AutoTransition
|
||||
import androidx.transition.TransitionManager
|
||||
import com.topjohnwu.magisk.R
|
||||
import com.topjohnwu.magisk.core.Const
|
||||
import com.topjohnwu.magisk.core.base.BaseActivity
|
||||
import com.topjohnwu.magisk.core.utils.RootUtils
|
||||
import com.topjohnwu.magisk.core.utils.currentLocale
|
||||
import com.topjohnwu.magisk.di.AppContext
|
||||
import com.topjohnwu.magisk.utils.DynamicClassLoader
|
||||
import com.topjohnwu.magisk.utils.Utils
|
||||
import com.topjohnwu.superuser.Shell
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.File
|
||||
import kotlin.Array
|
||||
import kotlin.String
|
||||
import java.lang.reflect.Array as JArray
|
||||
|
||||
fun Context.rawResource(id: Int) = resources.openRawResource(id)
|
||||
@ -79,8 +65,6 @@ val Context.deviceProtectedContext: Context get() =
|
||||
createDeviceProtectedStorageContext()
|
||||
} else { this }
|
||||
|
||||
fun Intent.startActivity(context: Context) = context.startActivity(this)
|
||||
|
||||
fun Intent.startActivityWithRoot() {
|
||||
val args = mutableListOf("am", "start", "--user", Const.USER_ID.toString())
|
||||
val cmd = toCommand(args).joinToString(" ")
|
||||
@ -185,8 +169,6 @@ fun Intent.toCommand(args: MutableList<String> = mutableListOf()): MutableList<S
|
||||
return args
|
||||
}
|
||||
|
||||
fun Intent.chooser(title: String = "Pick an app") = Intent.createChooser(this, title)
|
||||
|
||||
fun Context.cachedFile(name: String) = File(cacheDir, name)
|
||||
|
||||
fun <Result> Cursor.toList(transformer: (Cursor) -> Result): List<Result> {
|
||||
@ -209,34 +191,6 @@ fun ApplicationInfo.getLabel(pm: PackageManager): String {
|
||||
return loadLabel(pm).toString()
|
||||
}
|
||||
|
||||
fun Intent.exists(packageManager: PackageManager) = resolveActivity(packageManager) != null
|
||||
|
||||
fun Context.colorCompat(@ColorRes id: Int) = try {
|
||||
ContextCompat.getColor(this, id)
|
||||
} catch (e: Resources.NotFoundException) {
|
||||
null
|
||||
}
|
||||
|
||||
fun Context.colorStateListCompat(@ColorRes id: Int) = try {
|
||||
ContextCompat.getColorStateList(this, id)
|
||||
} catch (e: Resources.NotFoundException) {
|
||||
null
|
||||
}
|
||||
|
||||
fun Context.drawableCompat(@DrawableRes id: Int) = AppCompatResources.getDrawable(this, id)
|
||||
/**
|
||||
* Pass [start] and [end] dimensions, function will return left and right
|
||||
* with respect to RTL layout direction
|
||||
*/
|
||||
fun Context.startEndToLeftRight(start: Int, end: Int): Pair<Int, Int> {
|
||||
if (resources.configuration.layoutDirection == View.LAYOUT_DIRECTION_RTL) {
|
||||
return end to start
|
||||
}
|
||||
return start to end
|
||||
}
|
||||
|
||||
fun Context.openUrl(url: String) = Utils.openLink(this, url.toUri())
|
||||
|
||||
inline fun <reified T> T.createClassLoader(apk: File) =
|
||||
DynamicClassLoader(apk, T::class.java.classLoader)
|
||||
|
||||
@ -308,3 +262,20 @@ fun getProperty(key: String, def: String): String {
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
@SuppressLint("InlinedApi")
|
||||
@Throws(PackageManager.NameNotFoundException::class)
|
||||
fun PackageManager.getPackageInfo(uid: Int, pid: Int): PackageInfo? {
|
||||
val flag = PackageManager.MATCH_UNINSTALLED_PACKAGES
|
||||
val pkgs = getPackagesForUid(uid) ?: throw PackageManager.NameNotFoundException()
|
||||
return if (pkgs.size > 1) {
|
||||
if (pid <= 0)
|
||||
return null
|
||||
// Try to find package name from PID
|
||||
val proc = RootUtils.obj?.getAppProcess(pid) ?: return null
|
||||
val pkg = proc.pkgList[0]
|
||||
getPackageInfo(pkg, flag)
|
||||
} else {
|
||||
getPackageInfo(pkgs[0], flag)
|
||||
}
|
||||
}
|
||||
|
@ -20,34 +20,28 @@ class PolicyRvItem(
|
||||
var isExpanded = false
|
||||
set(value) = set(value, field, { field = it }, BR.expanded)
|
||||
|
||||
// This property hosts the policy state
|
||||
var policyState = item.policy == SuPolicy.ALLOW
|
||||
set(value) = set(value, field, { field = it }, BR.enabled)
|
||||
|
||||
// This property binds with the UI state
|
||||
@get:Bindable
|
||||
var isEnabled
|
||||
get() = policyState
|
||||
set(value) = set(value, policyState, { viewModel.togglePolicy(this, it) }, BR.enabled)
|
||||
|
||||
@get:Bindable
|
||||
var shouldNotify = item.notification
|
||||
set(value) = set(value, field, { field = it }, BR.shouldNotify) {
|
||||
viewModel.updatePolicy(updatedPolicy, isLogging = false)
|
||||
get() = item.policy == SuPolicy.ALLOW
|
||||
set(value) {
|
||||
if (value != isEnabled)
|
||||
viewModel.togglePolicy(this, value)
|
||||
}
|
||||
|
||||
@get:Bindable
|
||||
var shouldLog = item.logging
|
||||
set(value) = set(value, field, { field = it }, BR.shouldLog) {
|
||||
viewModel.updatePolicy(updatedPolicy, isLogging = true)
|
||||
var shouldNotify
|
||||
get() = item.notification
|
||||
set(value) = set(value, shouldNotify, { item.notification = it }, BR.shouldNotify) {
|
||||
viewModel.updatePolicy(item, isLogging = false)
|
||||
}
|
||||
|
||||
private val updatedPolicy
|
||||
get() = item.copy(
|
||||
policy = if (policyState) SuPolicy.ALLOW else SuPolicy.DENY,
|
||||
notification = shouldNotify,
|
||||
logging = shouldLog
|
||||
)
|
||||
@get:Bindable
|
||||
var shouldLog
|
||||
get() = item.logging
|
||||
set(value) = set(value, shouldLog, { item.logging = it }, BR.shouldLog) {
|
||||
viewModel.updatePolicy(item, isLogging = true)
|
||||
}
|
||||
|
||||
fun toggleExpand() {
|
||||
isExpanded = !isExpanded
|
||||
|
@ -47,12 +47,12 @@ class SuperuserViewModel(
|
||||
return@launch
|
||||
}
|
||||
state = State.LOADING
|
||||
val (policies, diff) = withContext(Dispatchers.Default) {
|
||||
val (policies, diff) = withContext(Dispatchers.IO) {
|
||||
db.deleteOutdated()
|
||||
val policies = db.fetchAll {
|
||||
val policies = db.fetchAll().map {
|
||||
PolicyRvItem(it, it.icon, this@SuperuserViewModel)
|
||||
}.sortedWith(compareBy(
|
||||
{ it.item.appName.toLowerCase(currentLocale) },
|
||||
{ it.item.appName.lowercase(currentLocale) },
|
||||
{ it.item.packageName }
|
||||
))
|
||||
policies to itemsPolicies.calculateDiff(policies)
|
||||
@ -107,14 +107,13 @@ class SuperuserViewModel(
|
||||
|
||||
fun togglePolicy(item: PolicyRvItem, enable: Boolean) {
|
||||
fun updateState() {
|
||||
item.policyState = enable
|
||||
|
||||
val policy = if (enable) SuPolicy.ALLOW else SuPolicy.DENY
|
||||
val app = item.item.copy(policy = policy)
|
||||
item.item.policy = policy
|
||||
item.notifyPropertyChanged(BR.enabled)
|
||||
|
||||
viewModelScope.launch {
|
||||
db.update(app)
|
||||
val res = if (app.policy == SuPolicy.ALLOW) R.string.su_snack_grant
|
||||
db.update(item.item)
|
||||
val res = if (item.item.policy == SuPolicy.ALLOW) R.string.su_snack_grant
|
||||
else R.string.su_snack_deny
|
||||
SnackbarEvent(res.asText(item.item.appName)).publish()
|
||||
}
|
||||
|
@ -7,6 +7,7 @@ import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.view.Window
|
||||
import android.view.WindowManager
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.topjohnwu.magisk.R
|
||||
import com.topjohnwu.magisk.arch.UIActivity
|
||||
import com.topjohnwu.magisk.core.su.SuCallbackHandler
|
||||
@ -14,6 +15,9 @@ import com.topjohnwu.magisk.core.su.SuCallbackHandler.REQUEST
|
||||
import com.topjohnwu.magisk.databinding.ActivityRequestBinding
|
||||
import com.topjohnwu.magisk.di.viewModel
|
||||
import com.topjohnwu.magisk.ui.theme.Theme
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
open class SuRequestActivity : UIActivity<ActivityRequestBinding>() {
|
||||
|
||||
@ -40,8 +44,12 @@ open class SuRequestActivity : UIActivity<ActivityRequestBinding>() {
|
||||
if (action == REQUEST) {
|
||||
viewModel.handleRequest(intent)
|
||||
} else {
|
||||
SuCallbackHandler.run(this, action, intent.extras)
|
||||
finish()
|
||||
lifecycleScope.launch {
|
||||
withContext(Dispatchers.IO) {
|
||||
SuCallbackHandler.run(this@SuRequestActivity, action, intent.extras)
|
||||
}
|
||||
finish()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
finish()
|
||||
|
@ -191,6 +191,7 @@ void app_notify(const su_context &ctx) {
|
||||
vector<Extra> extras;
|
||||
extras.reserve(2);
|
||||
extras.emplace_back("from.uid", ctx.info->uid);
|
||||
extras.emplace_back("pid", ctx.pid);
|
||||
extras.emplace_back("policy", ctx.info->access.policy);
|
||||
|
||||
exec_cmd("notify", extras, ctx.info);
|
||||
@ -198,21 +199,22 @@ void app_notify(const su_context &ctx) {
|
||||
}
|
||||
}
|
||||
|
||||
int app_request(const shared_ptr<su_info> &info) {
|
||||
int app_request(const su_context &ctx) {
|
||||
// Create FIFO
|
||||
char fifo[64];
|
||||
strcpy(fifo, "/dev/socket/");
|
||||
gen_rand_str(fifo + 12, 32, true);
|
||||
mkfifo(fifo, 0600);
|
||||
chown(fifo, info->mgr_st.st_uid, info->mgr_st.st_gid);
|
||||
chown(fifo, ctx.info->mgr_st.st_uid, ctx.info->mgr_st.st_gid);
|
||||
setfilecon(fifo, "u:object_r:" SEPOL_FILE_TYPE ":s0");
|
||||
|
||||
// Send request
|
||||
vector<Extra> extras;
|
||||
extras.reserve(2);
|
||||
extras.emplace_back("fifo", fifo);
|
||||
extras.emplace_back("uid", info->eval_uid);
|
||||
exec_cmd("request", extras, info, false);
|
||||
extras.emplace_back("uid", ctx.info->eval_uid);
|
||||
extras.emplace_back("pid", ctx.pid);
|
||||
exec_cmd("request", extras, ctx.info, false);
|
||||
|
||||
// Wait for data input for at most 70 seconds
|
||||
int fd = xopen(fifo, O_RDONLY | O_CLOEXEC | O_NONBLOCK);
|
||||
|
@ -60,4 +60,4 @@ struct su_context {
|
||||
|
||||
void app_log(const su_context &ctx);
|
||||
void app_notify(const su_context &ctx);
|
||||
int app_request(const std::shared_ptr<su_info> &info);
|
||||
int app_request(const su_context &ctx);
|
||||
|
@ -193,20 +193,7 @@ static shared_ptr<su_info> get_su_info(unsigned uid) {
|
||||
info->access = NO_SU_ACCESS;
|
||||
return info;
|
||||
}
|
||||
} else {
|
||||
return info;
|
||||
}
|
||||
|
||||
// If still not determined, ask manager
|
||||
int fd = app_request(info);
|
||||
if (fd < 0) {
|
||||
info->access.policy = DENY;
|
||||
} else {
|
||||
int ret = read_int_be(fd);
|
||||
info->access.policy = ret < 0 ? DENY : static_cast<policy_t>(ret);
|
||||
close(fd);
|
||||
}
|
||||
|
||||
return info;
|
||||
}
|
||||
|
||||
@ -237,6 +224,18 @@ void su_daemon_handler(int client, const sock_cred *cred) {
|
||||
read_string(client, ctx.req.shell);
|
||||
read_string(client, ctx.req.command);
|
||||
|
||||
// If still not determined, ask manager
|
||||
if (ctx.info->access.policy == QUERY) {
|
||||
int fd = app_request(ctx);
|
||||
if (fd < 0) {
|
||||
ctx.info->access.policy = DENY;
|
||||
} else {
|
||||
int ret = read_int_be(fd);
|
||||
ctx.info->access.policy = ret < 0 ? DENY : static_cast<policy_t>(ret);
|
||||
close(fd);
|
||||
}
|
||||
}
|
||||
|
||||
if (ctx.info->access.log)
|
||||
app_log(ctx);
|
||||
else if (ctx.info->access.notify)
|
||||
|
Loading…
x
Reference in New Issue
Block a user