Streamline encrypted settings and logging
This commit is contained in:
+12
-39
@@ -1,63 +1,36 @@
|
|||||||
package se.ajpanton.notificationsmaster.alerts
|
package se.ajpanton.notificationsmaster.alerts
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.util.AtomicFile
|
|
||||||
import org.json.JSONArray
|
import org.json.JSONArray
|
||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
import se.ajpanton.notificationsmaster.data.AesGcmCipher
|
import se.ajpanton.notificationsmaster.data.EncryptedAtomicFile
|
||||||
import se.ajpanton.notificationsmaster.data.EncryptedPayload
|
|
||||||
import se.ajpanton.notificationsmaster.data.LogEncryptionKeyProvider
|
|
||||||
import se.ajpanton.notificationsmaster.settings.NotificationListenerComponentController
|
import se.ajpanton.notificationsmaster.settings.NotificationListenerComponentController
|
||||||
import se.ajpanton.notificationsmaster.module.AlertPolicySync
|
import se.ajpanton.notificationsmaster.module.AlertPolicySync
|
||||||
import java.io.BufferedInputStream
|
|
||||||
import java.io.BufferedOutputStream
|
|
||||||
import java.io.DataInputStream
|
|
||||||
import java.io.DataOutputStream
|
|
||||||
import java.io.File
|
|
||||||
import java.io.FileNotFoundException
|
|
||||||
|
|
||||||
/** Atomic, encrypted storage for alert settings. An unsupported format fails closed. */
|
/** Atomic, encrypted storage for alert settings. An unsupported format fails closed. */
|
||||||
class AlertConfigurationStore(context: Context) {
|
class AlertConfigurationStore(context: Context) {
|
||||||
private val context = context.applicationContext
|
private val context = context.applicationContext
|
||||||
private val file = AtomicFile(File(this.context.filesDir, FILE_NAME))
|
private val file = EncryptedAtomicFile(this.context, FILE_NAME)
|
||||||
private val cipher = AesGcmCipher(LogEncryptionKeyProvider().getOrCreate())
|
|
||||||
|
|
||||||
@Synchronized
|
fun load(): AlertConfiguration = synchronized(lock) {
|
||||||
fun load(): AlertConfiguration = try {
|
if (!file.exists) cached = null
|
||||||
DataInputStream(BufferedInputStream(file.openRead())).use { input ->
|
cached ?: file.read()?.let(AlertConfigurationJson::decode)?.also { cached = it }
|
||||||
val iv = ByteArray(input.readInt().also { require(it in 1..32) })
|
?: AlertConfiguration()
|
||||||
input.readFully(iv)
|
|
||||||
val encrypted = ByteArray(input.readInt().also { require(it in 1..MAX_CIPHER_TEXT_BYTES) })
|
|
||||||
input.readFully(encrypted)
|
|
||||||
AlertConfigurationJson.decode(cipher.decrypt(EncryptedPayload(iv, encrypted)))
|
|
||||||
}
|
|
||||||
} catch (_: FileNotFoundException) {
|
|
||||||
AlertConfiguration()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Synchronized
|
|
||||||
fun save(configuration: AlertConfiguration) {
|
fun save(configuration: AlertConfiguration) {
|
||||||
val payload = cipher.encrypt(AlertConfigurationJson.encode(configuration))
|
synchronized(lock) {
|
||||||
val output = file.startWrite()
|
file.write(AlertConfigurationJson.encode(configuration))
|
||||||
try {
|
cached = configuration
|
||||||
val stream = DataOutputStream(BufferedOutputStream(output))
|
}
|
||||||
stream.writeInt(payload.initializationVector.size)
|
|
||||||
stream.write(payload.initializationVector)
|
|
||||||
stream.writeInt(payload.cipherText.size)
|
|
||||||
stream.write(payload.cipherText)
|
|
||||||
stream.flush()
|
|
||||||
file.finishWrite(output)
|
|
||||||
NotificationListenerComponentController.synchronize(context)
|
NotificationListenerComponentController.synchronize(context)
|
||||||
AlertPolicySync.publish(context, configuration)
|
AlertPolicySync.publish(context, configuration)
|
||||||
} catch (error: Exception) {
|
|
||||||
file.failWrite(output)
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private companion object {
|
private companion object {
|
||||||
const val FILE_NAME = "alert-configuration.bin"
|
const val FILE_NAME = "alert-configuration.bin"
|
||||||
const val MAX_CIPHER_TEXT_BYTES = 1024 * 1024
|
val lock = Any()
|
||||||
|
var cached: AlertConfiguration? = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+15
-13
@@ -133,9 +133,13 @@ class NotificationCaptureService : NotificationListenerService() {
|
|||||||
imageBytes: (() -> ByteArray?)? = null,
|
imageBytes: (() -> ByteArray?)? = null,
|
||||||
) {
|
) {
|
||||||
if (!GroupSummaryPolicy.shouldLog(snapshot, action, captureSettings.logGroupSummaries)) return
|
if (!GroupSummaryPolicy.shouldLog(snapshot, action, captureSettings.logGroupSummaries)) return
|
||||||
if (!allows(loggingType, snapshot.packageName)) return
|
if (!loggingEnabled(loggingType, snapshot.packageName) ||
|
||||||
|
!NotificationRuleEvaluator.allows(appFilterStore.load(), snapshot.packageName)
|
||||||
|
) return
|
||||||
val appName = appName(snapshot.packageName)
|
val appName = appName(snapshot.packageName)
|
||||||
val retainImage = includeContents && snapshot.hasImage && allows(LoggingType.IMAGE_CONTENT, snapshot.packageName)
|
val textEnabled = includeContents && loggingEnabled(LoggingType.TEXT_CONTENT, snapshot.packageName)
|
||||||
|
val imageEnabled = includeContents && loggingEnabled(LoggingType.IMAGE_CONTENT, snapshot.packageName)
|
||||||
|
val retainImage = snapshot.hasImage && imageEnabled
|
||||||
val retainedImageBytes = if (retainImage) imageBytes?.invoke() else null
|
val retainedImageBytes = if (retainImage) imageBytes?.invoke() else null
|
||||||
val entry = NotificationLogEntry(
|
val entry = NotificationLogEntry(
|
||||||
recordedAtEpochMillis = System.currentTimeMillis(),
|
recordedAtEpochMillis = System.currentTimeMillis(),
|
||||||
@@ -143,8 +147,8 @@ class NotificationCaptureService : NotificationListenerService() {
|
|||||||
packageName = snapshot.packageName,
|
packageName = snapshot.packageName,
|
||||||
appName = appName,
|
appName = appName,
|
||||||
action = action,
|
action = action,
|
||||||
contents = if (includeContents) visibleContents(snapshot) else null,
|
contents = if (includeContents) visibleContents(snapshot, textEnabled, imageEnabled) else null,
|
||||||
previousContents = previousSnapshot?.let(::visibleContents),
|
previousContents = previousSnapshot?.let { visibleContents(it, textEnabled, imageEnabled) },
|
||||||
imageId = if (retainedImageBytes != null) java.util.UUID.randomUUID().toString() else null,
|
imageId = if (retainedImageBytes != null) java.util.UUID.randomUUID().toString() else null,
|
||||||
)
|
)
|
||||||
writeExecutor.execute {
|
writeExecutor.execute {
|
||||||
@@ -166,25 +170,23 @@ class NotificationCaptureService : NotificationListenerService() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun visibleContents(snapshot: NotificationSnapshot): String? {
|
private fun visibleContents(snapshot: NotificationSnapshot, textEnabled: Boolean, imageEnabled: Boolean): String? {
|
||||||
val text = snapshot.textContents?.takeIf {
|
return listOfNotNull(
|
||||||
allows(LoggingType.TEXT_CONTENT, snapshot.packageName)
|
snapshot.textContents?.takeIf { textEnabled },
|
||||||
}
|
if (snapshot.hasImage && imageEnabled) "[image]" else null,
|
||||||
val image = snapshot.hasImage && allows(LoggingType.IMAGE_CONTENT, snapshot.packageName)
|
)
|
||||||
return listOfNotNull(text, if (image) "[image]" else null)
|
|
||||||
.joinToString("\n")
|
.joinToString("\n")
|
||||||
.take(MAX_CONTENT_CHARACTERS)
|
.take(MAX_CONTENT_CHARACTERS)
|
||||||
.ifEmpty { null }
|
.ifEmpty { null }
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun allows(type: LoggingType, packageName: String): Boolean {
|
private fun loggingEnabled(type: LoggingType, packageName: String): Boolean {
|
||||||
val globalEnabled = ruleStore.ruleFor(type).enabled
|
val globalEnabled = ruleStore.ruleFor(type).enabled
|
||||||
val eventEnabled = if (type in LoggingType.eventTypes) {
|
return if (type in LoggingType.eventTypes) {
|
||||||
perAppEventSettings.isEnabled(packageName, type, globalEnabled)
|
perAppEventSettings.isEnabled(packageName, type, globalEnabled)
|
||||||
} else {
|
} else {
|
||||||
globalEnabled
|
globalEnabled
|
||||||
}
|
}
|
||||||
return eventEnabled && NotificationRuleEvaluator.allows(appFilterStore.load(), packageName)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun dispatchAlert(snapshot: NotificationSnapshot, source: AlertSource) {
|
private fun dispatchAlert(snapshot: NotificationSnapshot, source: AlertSource) {
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ object SeenApps {
|
|||||||
private fun resetAfterBoot(preferences: android.content.SharedPreferences) {
|
private fun resetAfterBoot(preferences: android.content.SharedPreferences) {
|
||||||
val bootEpoch = System.currentTimeMillis() - SystemClock.elapsedRealtime()
|
val bootEpoch = System.currentTimeMillis() - SystemClock.elapsedRealtime()
|
||||||
if (abs(preferences.getLong(KEY_BOOT_EPOCH, -1) - bootEpoch) > 5_000) {
|
if (abs(preferences.getLong(KEY_BOOT_EPOCH, -1) - bootEpoch) > 5_000) {
|
||||||
preferences.edit().putLong(KEY_BOOT_EPOCH, bootEpoch).remove(KEY_PACKAGES).commit()
|
preferences.edit().putLong(KEY_BOOT_EPOCH, bootEpoch).remove(KEY_PACKAGES).apply()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
package se.ajpanton.notificationsmaster.data
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.util.AtomicFile
|
||||||
|
import java.io.BufferedInputStream
|
||||||
|
import java.io.BufferedOutputStream
|
||||||
|
import java.io.DataInputStream
|
||||||
|
import java.io.DataOutputStream
|
||||||
|
import java.io.File
|
||||||
|
import java.io.FileNotFoundException
|
||||||
|
|
||||||
|
internal class EncryptedAtomicFile(context: Context, name: String) {
|
||||||
|
private val file = AtomicFile(File(context.filesDir, name))
|
||||||
|
private val cipher = AesGcmCipher(LogEncryptionKeyProvider().getOrCreate())
|
||||||
|
|
||||||
|
val exists get() = file.baseFile.isFile
|
||||||
|
|
||||||
|
fun read(): ByteArray? = try {
|
||||||
|
DataInputStream(BufferedInputStream(file.openRead())).use { input ->
|
||||||
|
val iv = input.readBytes(MAX_IV_BYTES)
|
||||||
|
val encrypted = input.readBytes(MAX_CIPHER_TEXT_BYTES)
|
||||||
|
cipher.decrypt(EncryptedPayload(iv, encrypted))
|
||||||
|
}
|
||||||
|
} catch (_: FileNotFoundException) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
fun write(bytes: ByteArray) {
|
||||||
|
val payload = cipher.encrypt(bytes)
|
||||||
|
val output = file.startWrite()
|
||||||
|
try {
|
||||||
|
DataOutputStream(BufferedOutputStream(output)).apply {
|
||||||
|
writeInt(payload.initializationVector.size)
|
||||||
|
write(payload.initializationVector)
|
||||||
|
writeInt(payload.cipherText.size)
|
||||||
|
write(payload.cipherText)
|
||||||
|
flush()
|
||||||
|
}
|
||||||
|
file.finishWrite(output)
|
||||||
|
} catch (error: Exception) {
|
||||||
|
file.failWrite(output)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun DataInputStream.readBytes(maximum: Int): ByteArray {
|
||||||
|
val length = readInt()
|
||||||
|
require(length in 1..maximum) { "Invalid encrypted configuration field length." }
|
||||||
|
return ByteArray(length).also(::readFully)
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val MAX_IV_BYTES = 32
|
||||||
|
const val MAX_CIPHER_TEXT_BYTES = 1024 * 1024
|
||||||
|
}
|
||||||
|
}
|
||||||
+9
-9
@@ -80,10 +80,11 @@ class EncryptedNotificationLogStore(context: Context) {
|
|||||||
val chunks = chunkFiles()
|
val chunks = chunkFiles()
|
||||||
val newest = chunks.lastOrNull()
|
val newest = chunks.lastOrNull()
|
||||||
val current = newest?.let(::readChunk).orEmpty()
|
val current = newest?.let(::readChunk).orEmpty()
|
||||||
if (newest == null || encodedSize(current + entry) > MAX_CHUNK_PLAINTEXT_BYTES) {
|
val combined = NotificationLogEntryJson.encode(current + entry)
|
||||||
writeChunk(nextChunkFile(chunks), listOf(entry))
|
if (newest == null || combined.size <= MAX_CHUNK_PLAINTEXT_BYTES) {
|
||||||
|
writeChunk(newest ?: nextChunkFile(chunks), combined)
|
||||||
} else {
|
} else {
|
||||||
writeChunk(newest, current + entry)
|
writeChunk(nextChunkFile(chunks), NotificationLogEntryJson.encode(listOf(entry)))
|
||||||
}
|
}
|
||||||
enforceLimits()
|
enforceLimits()
|
||||||
}
|
}
|
||||||
@@ -98,7 +99,7 @@ class EncryptedNotificationLogStore(context: Context) {
|
|||||||
val entries = readChunk(chunk)
|
val entries = readChunk(chunk)
|
||||||
val entry = entries.firstOrNull { it.id == id } ?: return@forEach
|
val entry = entries.firstOrNull { it.id == id } ?: return@forEach
|
||||||
val retained = entries.filterNot { it.id == id }
|
val retained = entries.filterNot { it.id == id }
|
||||||
if (retained.isEmpty()) chunk.delete() else writeChunk(chunk, retained)
|
if (retained.isEmpty()) chunk.delete() else writeChunk(chunk, NotificationLogEntryJson.encode(retained))
|
||||||
entry.imageId?.let(::deleteImage)
|
entry.imageId?.let(::deleteImage)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -137,7 +138,8 @@ class EncryptedNotificationLogStore(context: Context) {
|
|||||||
while (totalBytes > limit && retained.isNotEmpty()) {
|
while (totalBytes > limit && retained.isNotEmpty()) {
|
||||||
retained.first().imageId?.let(::deleteImage)
|
retained.first().imageId?.let(::deleteImage)
|
||||||
retained = retained.drop(1)
|
retained = retained.drop(1)
|
||||||
if (retained.isEmpty()) oldest.delete() else writeChunk(oldest, retained)
|
if (retained.isEmpty()) oldest.delete()
|
||||||
|
else writeChunk(oldest, NotificationLogEntryJson.encode(retained))
|
||||||
totalBytes = chunkFiles().sumOf(File::length)
|
totalBytes = chunkFiles().sumOf(File::length)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -203,8 +205,8 @@ class EncryptedNotificationLogStore(context: Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun writeChunk(file: File, entries: List<NotificationLogEntry>) {
|
private fun writeChunk(file: File, encoded: ByteArray) {
|
||||||
val payload = cipher.encrypt(NotificationLogEntryJson.encode(entries))
|
val payload = cipher.encrypt(encoded)
|
||||||
val atomicFile = AtomicFile(file)
|
val atomicFile = AtomicFile(file)
|
||||||
val output = atomicFile.startWrite()
|
val output = atomicFile.startWrite()
|
||||||
try {
|
try {
|
||||||
@@ -228,8 +230,6 @@ class EncryptedNotificationLogStore(context: Context) {
|
|||||||
return ByteArray(length).also(stream::readFully)
|
return ByteArray(length).also(stream::readFully)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun encodedSize(entries: List<NotificationLogEntry>) = NotificationLogEntryJson.encode(entries).size
|
|
||||||
|
|
||||||
private fun clearChunksOnly() {
|
private fun clearChunksOnly() {
|
||||||
chunkFiles().forEach(File::delete)
|
chunkFiles().forEach(File::delete)
|
||||||
}
|
}
|
||||||
|
|||||||
+35
-51
@@ -1,75 +1,59 @@
|
|||||||
package se.ajpanton.notificationsmaster.visibility
|
package se.ajpanton.notificationsmaster.visibility
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.util.AtomicFile
|
import se.ajpanton.notificationsmaster.data.EncryptedAtomicFile
|
||||||
import se.ajpanton.notificationsmaster.data.AesGcmCipher
|
|
||||||
import se.ajpanton.notificationsmaster.data.EncryptedPayload
|
|
||||||
import se.ajpanton.notificationsmaster.data.LogEncryptionKeyProvider
|
|
||||||
import se.ajpanton.notificationsmaster.module.VisibilityPolicySync
|
import se.ajpanton.notificationsmaster.module.VisibilityPolicySync
|
||||||
import se.ajpanton.notificationsmaster.settings.NotificationListenerComponentController
|
import se.ajpanton.notificationsmaster.settings.NotificationListenerComponentController
|
||||||
import java.io.BufferedInputStream
|
|
||||||
import java.io.BufferedOutputStream
|
|
||||||
import java.io.DataInputStream
|
|
||||||
import java.io.DataOutputStream
|
|
||||||
import java.io.File
|
|
||||||
import java.io.FileNotFoundException
|
|
||||||
|
|
||||||
class VisibilityPolicyStore(context: Context) {
|
class VisibilityPolicyStore(context: Context) {
|
||||||
private val context = context.applicationContext
|
private val context = context.applicationContext
|
||||||
private val file = AtomicFile(File(this.context.filesDir, FILE_NAME))
|
private val file = EncryptedAtomicFile(this.context, FILE_NAME)
|
||||||
private val cipher = AesGcmCipher(LogEncryptionKeyProvider().getOrCreate())
|
|
||||||
|
|
||||||
@Synchronized
|
fun load(): VisibilityPolicy = synchronized(lock) {
|
||||||
fun load(): VisibilityPolicy = try {
|
loadLocked()
|
||||||
DataInputStream(BufferedInputStream(file.openRead())).use { input ->
|
|
||||||
val iv = ByteArray(input.readInt().also { require(it in 1..32) })
|
|
||||||
input.readFully(iv)
|
|
||||||
val encrypted = ByteArray(input.readInt().also { require(it in 1..MAX_CIPHER_TEXT_BYTES) })
|
|
||||||
input.readFully(encrypted)
|
|
||||||
VisibilityPolicyJson.decode(cipher.decrypt(EncryptedPayload(iv, encrypted)))
|
|
||||||
}
|
|
||||||
} catch (_: FileNotFoundException) {
|
|
||||||
VisibilityPolicy()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Synchronized
|
fun save(policy: VisibilityPolicy) = update { policy }
|
||||||
fun save(policy: VisibilityPolicy): VisibilityPolicy {
|
|
||||||
require(policy.apps.flatMap { it.exceptions }.all { it.hasValidPattern() })
|
fun update(change: (VisibilityPolicy) -> VisibilityPolicy): VisibilityPolicy {
|
||||||
val stored = policy.copy(generation = load().generation + 1)
|
val stored = synchronized(lock) {
|
||||||
val encoded = VisibilityPolicyJson.encode(stored)
|
val current = loadLocked()
|
||||||
require(encoded.size <= VisibilityPolicySync.MAX_POLICY_BYTES) { "Visibility policy is too large." }
|
saveLocked(change(current), current.generation + 1)
|
||||||
val payload = cipher.encrypt(encoded)
|
|
||||||
val output = file.startWrite()
|
|
||||||
try {
|
|
||||||
DataOutputStream(BufferedOutputStream(output)).apply {
|
|
||||||
writeInt(payload.initializationVector.size)
|
|
||||||
write(payload.initializationVector)
|
|
||||||
writeInt(payload.cipherText.size)
|
|
||||||
write(payload.cipherText)
|
|
||||||
flush()
|
|
||||||
}
|
}
|
||||||
file.finishWrite(output)
|
return publish(stored)
|
||||||
} catch (error: Exception) {
|
|
||||||
file.failWrite(output)
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
NotificationListenerComponentController.synchronize(context)
|
|
||||||
VisibilityPolicySync.publish(context, stored)
|
|
||||||
return stored
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Synchronized
|
|
||||||
fun update(change: (VisibilityPolicy) -> VisibilityPolicy) = save(change(load()))
|
|
||||||
|
|
||||||
@Synchronized
|
|
||||||
fun removeUninstalledPackages(installedPackages: Set<String>) {
|
fun removeUninstalledPackages(installedPackages: Set<String>) {
|
||||||
val current = load()
|
val current = load()
|
||||||
val retained = current.apps.filter { it.packageName in installedPackages }
|
val retained = current.apps.filter { it.packageName in installedPackages }
|
||||||
if (retained.size != current.apps.size) save(current.copy(apps = retained))
|
if (retained.size != current.apps.size) save(current.copy(apps = retained))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun loadLocked(): VisibilityPolicy {
|
||||||
|
if (!file.exists) cached = null
|
||||||
|
return cached ?: file.read()?.let(VisibilityPolicyJson::decode)?.also { cached = it }
|
||||||
|
?: VisibilityPolicy()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun saveLocked(policy: VisibilityPolicy, generation: Long): VisibilityPolicy {
|
||||||
|
require(policy.apps.flatMap { it.exceptions }.all { it.hasValidPattern() })
|
||||||
|
val stored = policy.copy(generation = generation)
|
||||||
|
val encoded = VisibilityPolicyJson.encode(stored)
|
||||||
|
require(encoded.size <= VisibilityPolicySync.MAX_POLICY_BYTES) { "Visibility policy is too large." }
|
||||||
|
file.write(encoded)
|
||||||
|
cached = stored
|
||||||
|
return stored
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun publish(policy: VisibilityPolicy): VisibilityPolicy {
|
||||||
|
NotificationListenerComponentController.synchronize(context)
|
||||||
|
VisibilityPolicySync.publish(context, policy)
|
||||||
|
return policy
|
||||||
|
}
|
||||||
|
|
||||||
private companion object {
|
private companion object {
|
||||||
const val FILE_NAME = "visibility-policy.bin"
|
const val FILE_NAME = "visibility-policy.bin"
|
||||||
const val MAX_CIPHER_TEXT_BYTES = 1024 * 1024
|
val lock = Any()
|
||||||
|
var cached: VisibilityPolicy? = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user