mirror of
https://github.com/oxen-io/session-android.git
synced 2024-12-23 08:27:30 +00:00
commit
bbb625ddf8
@ -31,7 +31,7 @@ configurations.all {
|
|||||||
exclude module: "commons-logging"
|
exclude module: "commons-logging"
|
||||||
}
|
}
|
||||||
|
|
||||||
def canonicalVersionCode = 374
|
def canonicalVersionCode = 375
|
||||||
def canonicalVersionName = "1.18.5"
|
def canonicalVersionName = "1.18.5"
|
||||||
|
|
||||||
def postFixSize = 10
|
def postFixSize = 10
|
||||||
|
@ -60,7 +60,6 @@
|
|||||||
<uses-permission android:name="android.permission.RAISED_THREAD_PRIORITY" />
|
<uses-permission android:name="android.permission.RAISED_THREAD_PRIORITY" />
|
||||||
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
|
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
|
||||||
<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE" tools:node="remove"/>
|
<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE" tools:node="remove"/>
|
||||||
<uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT"/>
|
|
||||||
|
|
||||||
<queries>
|
<queries>
|
||||||
<intent>
|
<intent>
|
||||||
|
@ -214,6 +214,17 @@ public class ApplicationContext extends Application implements DefaultLifecycleO
|
|||||||
DatabaseModule.init(this);
|
DatabaseModule.init(this);
|
||||||
MessagingModuleConfiguration.configure(this);
|
MessagingModuleConfiguration.configure(this);
|
||||||
super.onCreate();
|
super.onCreate();
|
||||||
|
|
||||||
|
// we need to clear the snode and onionrequest databases once on first launch
|
||||||
|
// in order to apply a patch that adds a version number to the Snode objects.
|
||||||
|
if(!TextSecurePreferences.hasAppliedPatchSnodeVersion(this)) {
|
||||||
|
ThreadUtils.queue(() -> {
|
||||||
|
lokiAPIDatabase.clearSnodePool();
|
||||||
|
lokiAPIDatabase.clearOnionRequestPaths();
|
||||||
|
TextSecurePreferences.setHasAppliedPatchSnodeVersion(this, true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
messagingModuleConfiguration = new MessagingModuleConfiguration(
|
messagingModuleConfiguration = new MessagingModuleConfiguration(
|
||||||
this,
|
this,
|
||||||
storage,
|
storage,
|
||||||
|
@ -166,6 +166,8 @@ class LokiAPIDatabase(context: Context, helper: SQLCipherOpenHelper) : Database(
|
|||||||
|
|
||||||
const val RESET_SEQ_NO = "UPDATE $lastMessageServerIDTable SET $lastMessageServerID = 0;"
|
const val RESET_SEQ_NO = "UPDATE $lastMessageServerIDTable SET $lastMessageServerID = 0;"
|
||||||
|
|
||||||
|
const val EMPTY_VERSION = "0.0.0"
|
||||||
|
|
||||||
// endregion
|
// endregion
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -179,7 +181,8 @@ class LokiAPIDatabase(context: Context, helper: SQLCipherOpenHelper) : Database(
|
|||||||
val port = components.getOrNull(1)?.toIntOrNull() ?: return@mapNotNull null
|
val port = components.getOrNull(1)?.toIntOrNull() ?: return@mapNotNull null
|
||||||
val ed25519Key = components.getOrNull(2) ?: return@mapNotNull null
|
val ed25519Key = components.getOrNull(2) ?: return@mapNotNull null
|
||||||
val x25519Key = components.getOrNull(3) ?: return@mapNotNull null
|
val x25519Key = components.getOrNull(3) ?: return@mapNotNull null
|
||||||
Snode(address, port, Snode.KeySet(ed25519Key, x25519Key))
|
val version = components.getOrNull(4) ?: EMPTY_VERSION
|
||||||
|
Snode(address, port, Snode.KeySet(ed25519Key, x25519Key), version)
|
||||||
}
|
}
|
||||||
}?.toSet() ?: setOf()
|
}?.toSet() ?: setOf()
|
||||||
}
|
}
|
||||||
@ -192,6 +195,7 @@ class LokiAPIDatabase(context: Context, helper: SQLCipherOpenHelper) : Database(
|
|||||||
if (keySet != null) {
|
if (keySet != null) {
|
||||||
string += "-${keySet.ed25519Key}-${keySet.x25519Key}"
|
string += "-${keySet.ed25519Key}-${keySet.x25519Key}"
|
||||||
}
|
}
|
||||||
|
string += "-${snode.version}"
|
||||||
string
|
string
|
||||||
}
|
}
|
||||||
val row = wrap(mapOf( Companion.dummyKey to "dummy_key", snodePool to snodePoolAsString ))
|
val row = wrap(mapOf( Companion.dummyKey to "dummy_key", snodePool to snodePoolAsString ))
|
||||||
@ -207,6 +211,7 @@ class LokiAPIDatabase(context: Context, helper: SQLCipherOpenHelper) : Database(
|
|||||||
if (keySet != null) {
|
if (keySet != null) {
|
||||||
snodeAsString += "-${keySet.ed25519Key}-${keySet.x25519Key}"
|
snodeAsString += "-${keySet.ed25519Key}-${keySet.x25519Key}"
|
||||||
}
|
}
|
||||||
|
snodeAsString += "-${snode.version}"
|
||||||
val row = wrap(mapOf( Companion.indexPath to indexPath, Companion.snode to snodeAsString ))
|
val row = wrap(mapOf( Companion.indexPath to indexPath, Companion.snode to snodeAsString ))
|
||||||
database.insertOrUpdate(onionRequestPathTable, row, "${Companion.indexPath} = ?", wrap(indexPath))
|
database.insertOrUpdate(onionRequestPathTable, row, "${Companion.indexPath} = ?", wrap(indexPath))
|
||||||
}
|
}
|
||||||
@ -232,8 +237,9 @@ class LokiAPIDatabase(context: Context, helper: SQLCipherOpenHelper) : Database(
|
|||||||
val port = components.getOrNull(1)?.toIntOrNull()
|
val port = components.getOrNull(1)?.toIntOrNull()
|
||||||
val ed25519Key = components.getOrNull(2)
|
val ed25519Key = components.getOrNull(2)
|
||||||
val x25519Key = components.getOrNull(3)
|
val x25519Key = components.getOrNull(3)
|
||||||
|
val version = components.getOrNull(4) ?: EMPTY_VERSION
|
||||||
if (port != null && ed25519Key != null && x25519Key != null) {
|
if (port != null && ed25519Key != null && x25519Key != null) {
|
||||||
Snode(address, port, Snode.KeySet(ed25519Key, x25519Key))
|
Snode(address, port, Snode.KeySet(ed25519Key, x25519Key), version)
|
||||||
} else {
|
} else {
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
@ -251,6 +257,11 @@ class LokiAPIDatabase(context: Context, helper: SQLCipherOpenHelper) : Database(
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun clearSnodePool() {
|
||||||
|
val database = databaseHelper.writableDatabase
|
||||||
|
database.delete(snodePoolTable, null, null)
|
||||||
|
}
|
||||||
|
|
||||||
override fun clearOnionRequestPaths() {
|
override fun clearOnionRequestPaths() {
|
||||||
val database = databaseHelper.writableDatabase
|
val database = databaseHelper.writableDatabase
|
||||||
fun delete(indexPath: String) {
|
fun delete(indexPath: String) {
|
||||||
@ -271,7 +282,8 @@ class LokiAPIDatabase(context: Context, helper: SQLCipherOpenHelper) : Database(
|
|||||||
val port = components.getOrNull(1)?.toIntOrNull() ?: return@mapNotNull null
|
val port = components.getOrNull(1)?.toIntOrNull() ?: return@mapNotNull null
|
||||||
val ed25519Key = components.getOrNull(2) ?: return@mapNotNull null
|
val ed25519Key = components.getOrNull(2) ?: return@mapNotNull null
|
||||||
val x25519Key = components.getOrNull(3) ?: return@mapNotNull null
|
val x25519Key = components.getOrNull(3) ?: return@mapNotNull null
|
||||||
Snode(address, port, Snode.KeySet(ed25519Key, x25519Key))
|
val version = components.getOrNull(4) ?: EMPTY_VERSION
|
||||||
|
Snode(address, port, Snode.KeySet(ed25519Key, x25519Key), version)
|
||||||
}
|
}
|
||||||
}?.toSet()
|
}?.toSet()
|
||||||
}
|
}
|
||||||
|
@ -10,6 +10,7 @@ import okhttp3.Request
|
|||||||
import org.session.libsession.messaging.file_server.FileServerApi
|
import org.session.libsession.messaging.file_server.FileServerApi
|
||||||
import org.session.libsession.utilities.AESGCM
|
import org.session.libsession.utilities.AESGCM
|
||||||
import org.session.libsession.utilities.AESGCM.EncryptionResult
|
import org.session.libsession.utilities.AESGCM.EncryptionResult
|
||||||
|
import org.session.libsession.utilities.Util
|
||||||
import org.session.libsession.utilities.getBodyForOnionRequest
|
import org.session.libsession.utilities.getBodyForOnionRequest
|
||||||
import org.session.libsession.utilities.getHeadersForOnionRequest
|
import org.session.libsession.utilities.getHeadersForOnionRequest
|
||||||
import org.session.libsignal.crypto.getRandomElement
|
import org.session.libsignal.crypto.getRandomElement
|
||||||
@ -190,8 +191,21 @@ object OnionRequestAPI {
|
|||||||
if (unusedSnodes.count() < pathSnodeCount) { throw InsufficientSnodesException() }
|
if (unusedSnodes.count() < pathSnodeCount) { throw InsufficientSnodesException() }
|
||||||
// Don't test path snodes as this would reveal the user's IP to them
|
// Don't test path snodes as this would reveal the user's IP to them
|
||||||
guardSnodes.minus(reusableGuardSnodes).map { guardSnode ->
|
guardSnodes.minus(reusableGuardSnodes).map { guardSnode ->
|
||||||
val result = listOf( guardSnode ) + (0 until (pathSize - 1)).map {
|
val result = listOf( guardSnode ) + (0 until (pathSize - 1)).mapIndexed() { index, _ ->
|
||||||
val pathSnode = unusedSnodes.getRandomElement()
|
var pathSnode = unusedSnodes.getRandomElement()
|
||||||
|
|
||||||
|
// For the last node: We need to make sure the version is >= 2.8.0
|
||||||
|
// to help with an issue that will disappear once the nodes are all updated
|
||||||
|
if(index == pathSize - 2) {
|
||||||
|
val suitableSnodes = unusedSnodes.filter { Util.compareVersions(it.version, "2.8.0") >= 0 }
|
||||||
|
pathSnode = if (suitableSnodes.isNotEmpty()) {
|
||||||
|
suitableSnodes.random()
|
||||||
|
} else {
|
||||||
|
throw InsufficientSnodesException()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// remove the snode from the unused list and return it
|
||||||
unusedSnodes = unusedSnodes.minus(pathSnode)
|
unusedSnodes = unusedSnodes.minus(pathSnode)
|
||||||
pathSnode
|
pathSnode
|
||||||
}
|
}
|
||||||
|
@ -88,6 +88,14 @@ object SnodeAPI {
|
|||||||
|
|
||||||
const val useTestnet = false
|
const val useTestnet = false
|
||||||
|
|
||||||
|
const val KEY_IP = "public_ip"
|
||||||
|
const val KEY_PORT = "storage_port"
|
||||||
|
const val KEY_X25519 = "pubkey_x25519"
|
||||||
|
const val KEY_ED25519 = "pubkey_ed25519"
|
||||||
|
const val KEY_VERSION = "storage_server_version"
|
||||||
|
|
||||||
|
const val EMPTY_VERSION = "0.0.0"
|
||||||
|
|
||||||
// Error
|
// Error
|
||||||
internal sealed class Error(val description: String) : Exception(description) {
|
internal sealed class Error(val description: String) : Exception(description) {
|
||||||
object Generic : Error("An error occurred.")
|
object Generic : Error("An error occurred.")
|
||||||
@ -146,6 +154,7 @@ object SnodeAPI {
|
|||||||
|
|
||||||
internal fun getRandomSnode(): Promise<Snode, Exception> {
|
internal fun getRandomSnode(): Promise<Snode, Exception> {
|
||||||
val snodePool = this.snodePool
|
val snodePool = this.snodePool
|
||||||
|
|
||||||
if (snodePool.count() < minimumSnodePoolCount) {
|
if (snodePool.count() < minimumSnodePoolCount) {
|
||||||
val target = seedNodePool.random()
|
val target = seedNodePool.random()
|
||||||
val url = "$target/json_rpc"
|
val url = "$target/json_rpc"
|
||||||
@ -154,8 +163,11 @@ object SnodeAPI {
|
|||||||
"method" to "get_n_service_nodes",
|
"method" to "get_n_service_nodes",
|
||||||
"params" to mapOf(
|
"params" to mapOf(
|
||||||
"active_only" to true,
|
"active_only" to true,
|
||||||
"limit" to 256,
|
"fields" to mapOf(
|
||||||
"fields" to mapOf("public_ip" to true, "storage_port" to true, "pubkey_x25519" to true, "pubkey_ed25519" to true)
|
KEY_IP to true, KEY_PORT to true,
|
||||||
|
KEY_X25519 to true, KEY_ED25519 to true,
|
||||||
|
KEY_VERSION to true
|
||||||
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
val deferred = deferred<Snode, Exception>()
|
val deferred = deferred<Snode, Exception>()
|
||||||
@ -173,12 +185,22 @@ object SnodeAPI {
|
|||||||
if (rawSnodes != null) {
|
if (rawSnodes != null) {
|
||||||
val snodePool = rawSnodes.mapNotNull { rawSnode ->
|
val snodePool = rawSnodes.mapNotNull { rawSnode ->
|
||||||
val rawSnodeAsJSON = rawSnode as? Map<*, *>
|
val rawSnodeAsJSON = rawSnode as? Map<*, *>
|
||||||
val address = rawSnodeAsJSON?.get("public_ip") as? String
|
val address = rawSnodeAsJSON?.get(KEY_IP) as? String
|
||||||
val port = rawSnodeAsJSON?.get("storage_port") as? Int
|
val port = rawSnodeAsJSON?.get(KEY_PORT) as? Int
|
||||||
val ed25519Key = rawSnodeAsJSON?.get("pubkey_ed25519") as? String
|
val ed25519Key = rawSnodeAsJSON?.get(KEY_ED25519) as? String
|
||||||
val x25519Key = rawSnodeAsJSON?.get("pubkey_x25519") as? String
|
val x25519Key = rawSnodeAsJSON?.get(KEY_X25519) as? String
|
||||||
if (address != null && port != null && ed25519Key != null && x25519Key != null && address != "0.0.0.0") {
|
val version = (rawSnodeAsJSON?.get(KEY_VERSION) as? ArrayList<*>)
|
||||||
Snode("https://$address", port, Snode.KeySet(ed25519Key, x25519Key))
|
?.filterIsInstance<Int>() // get the array as Integers
|
||||||
|
?.joinToString(separator = ".") // turn it int a version string
|
||||||
|
|
||||||
|
if (address != null && port != null && ed25519Key != null && x25519Key != null
|
||||||
|
&& address != "0.0.0.0" && version != null) {
|
||||||
|
Snode(
|
||||||
|
address = "https://$address",
|
||||||
|
port = port,
|
||||||
|
publicKeySet = Snode.KeySet(ed25519Key, x25519Key),
|
||||||
|
version = version
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
Log.d("Loki", "Failed to parse: ${rawSnode?.prettifiedDescription()}.")
|
Log.d("Loki", "Failed to parse: ${rawSnode?.prettifiedDescription()}.")
|
||||||
null
|
null
|
||||||
@ -206,6 +228,10 @@ object SnodeAPI {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun extractVersionString(jsonVersion: String): String{
|
||||||
|
return jsonVersion.removeSurrounding("[", "]").split(", ").joinToString(separator = ".")
|
||||||
|
}
|
||||||
|
|
||||||
internal fun dropSnodeFromSwarmIfNeeded(snode: Snode, publicKey: String) {
|
internal fun dropSnodeFromSwarmIfNeeded(snode: Snode, publicKey: String) {
|
||||||
val swarm = database.getSwarm(publicKey)?.toMutableSet()
|
val swarm = database.getSwarm(publicKey)?.toMutableSet()
|
||||||
if (swarm != null && swarm.contains(snode)) {
|
if (swarm != null && swarm.contains(snode)) {
|
||||||
@ -716,10 +742,11 @@ object SnodeAPI {
|
|||||||
val address = rawSnodeAsJSON?.get("ip") as? String
|
val address = rawSnodeAsJSON?.get("ip") as? String
|
||||||
val portAsString = rawSnodeAsJSON?.get("port") as? String
|
val portAsString = rawSnodeAsJSON?.get("port") as? String
|
||||||
val port = portAsString?.toInt()
|
val port = portAsString?.toInt()
|
||||||
val ed25519Key = rawSnodeAsJSON?.get("pubkey_ed25519") as? String
|
val ed25519Key = rawSnodeAsJSON?.get(KEY_ED25519) as? String
|
||||||
val x25519Key = rawSnodeAsJSON?.get("pubkey_x25519") as? String
|
val x25519Key = rawSnodeAsJSON?.get(KEY_X25519) as? String
|
||||||
|
|
||||||
if (address != null && port != null && ed25519Key != null && x25519Key != null && address != "0.0.0.0") {
|
if (address != null && port != null && ed25519Key != null && x25519Key != null && address != "0.0.0.0") {
|
||||||
Snode("https://$address", port, Snode.KeySet(ed25519Key, x25519Key))
|
Snode("https://$address", port, Snode.KeySet(ed25519Key, x25519Key), EMPTY_VERSION)
|
||||||
} else {
|
} else {
|
||||||
Log.d("Loki", "Failed to parse snode from: ${rawSnode?.prettifiedDescription()}.")
|
Log.d("Loki", "Failed to parse snode from: ${rawSnode?.prettifiedDescription()}.")
|
||||||
null
|
null
|
||||||
|
@ -292,6 +292,8 @@ interface TextSecurePreferences {
|
|||||||
|
|
||||||
const val ALLOW_MESSAGE_REQUESTS = "libsession.ALLOW_MESSAGE_REQUESTS"
|
const val ALLOW_MESSAGE_REQUESTS = "libsession.ALLOW_MESSAGE_REQUESTS"
|
||||||
|
|
||||||
|
const val PATCH_SNODE_VERSION_2024_07_23 = "libsession.patch_snode_version_2024_07_23"
|
||||||
|
|
||||||
@JvmStatic
|
@JvmStatic
|
||||||
fun getLastConfigurationSyncTime(context: Context): Long {
|
fun getLastConfigurationSyncTime(context: Context): Long {
|
||||||
return getLongPreference(context, LAST_CONFIGURATION_SYNC_TIME, 0)
|
return getLongPreference(context, LAST_CONFIGURATION_SYNC_TIME, 0)
|
||||||
@ -1013,6 +1015,16 @@ interface TextSecurePreferences {
|
|||||||
fun clearAll(context: Context) {
|
fun clearAll(context: Context) {
|
||||||
getDefaultSharedPreferences(context).edit().clear().commit()
|
getDefaultSharedPreferences(context).edit().clear().commit()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@JvmStatic
|
||||||
|
fun hasAppliedPatchSnodeVersion(context: Context): Boolean {
|
||||||
|
return getBooleanPreference(context, PATCH_SNODE_VERSION_2024_07_23, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
@JvmStatic
|
||||||
|
fun setHasAppliedPatchSnodeVersion(context: Context, applied: Boolean) {
|
||||||
|
setBooleanPreference(context, PATCH_SNODE_VERSION_2024_07_23, applied)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -365,6 +365,34 @@ object Util {
|
|||||||
val digitGroups = (Math.log10(sizeBytes.toDouble()) / Math.log10(1024.0)).toInt()
|
val digitGroups = (Math.log10(sizeBytes.toDouble()) / Math.log10(1024.0)).toInt()
|
||||||
return DecimalFormat("#,##0.#").format(sizeBytes / Math.pow(1024.0, digitGroups.toDouble())) + " " + units[digitGroups]
|
return DecimalFormat("#,##0.#").format(sizeBytes / Math.pow(1024.0, digitGroups.toDouble())) + " " + units[digitGroups]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compares two version strings (for example "1.8.0")
|
||||||
|
*
|
||||||
|
* @param version1 the first version string to compare.
|
||||||
|
* @param version2 the second version string to compare.
|
||||||
|
* @return an integer indicating the result of the comparison:
|
||||||
|
* - 0 if the versions are equal
|
||||||
|
* - a positive number if version1 is greater than version2
|
||||||
|
* - a negative number if version1 is less than version2
|
||||||
|
*/
|
||||||
|
@JvmStatic
|
||||||
|
fun compareVersions(version1: String, version2: String): Int {
|
||||||
|
val parts1 = version1.split(".").map { it.toIntOrNull() ?: 0 }
|
||||||
|
val parts2 = version2.split(".").map { it.toIntOrNull() ?: 0 }
|
||||||
|
|
||||||
|
val maxLength = maxOf(parts1.size, parts2.size)
|
||||||
|
val paddedParts1 = parts1 + List(maxLength - parts1.size) { 0 }
|
||||||
|
val paddedParts2 = parts2 + List(maxLength - parts2.size) { 0 }
|
||||||
|
|
||||||
|
for (i in 0 until maxLength) {
|
||||||
|
val compare = paddedParts1[i].compareTo(paddedParts2[i])
|
||||||
|
if (compare != 0) {
|
||||||
|
return compare
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun <T, R> T.runIf(condition: Boolean, block: T.() -> R): R where T: R = if (condition) block() else this
|
fun <T, R> T.runIf(condition: Boolean, block: T.() -> R): R where T: R = if (condition) block() else this
|
||||||
|
@ -10,6 +10,7 @@ interface LokiAPIDatabaseProtocol {
|
|||||||
fun getSnodePool(): Set<Snode>
|
fun getSnodePool(): Set<Snode>
|
||||||
fun setSnodePool(newValue: Set<Snode>)
|
fun setSnodePool(newValue: Set<Snode>)
|
||||||
fun getOnionRequestPaths(): List<List<Snode>>
|
fun getOnionRequestPaths(): List<List<Snode>>
|
||||||
|
fun clearSnodePool()
|
||||||
fun clearOnionRequestPaths()
|
fun clearOnionRequestPaths()
|
||||||
fun setOnionRequestPaths(newValue: List<List<Snode>>)
|
fun setOnionRequestPaths(newValue: List<List<Snode>>)
|
||||||
fun getSwarm(publicKey: String): Set<Snode>?
|
fun getSwarm(publicKey: String): Set<Snode>?
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
package org.session.libsignal.utilities
|
package org.session.libsignal.utilities
|
||||||
|
|
||||||
class Snode(val address: String, val port: Int, val publicKeySet: KeySet?) {
|
class Snode(val address: String, val port: Int, val publicKeySet: KeySet?, val version: String) {
|
||||||
val ip: String get() = address.removePrefix("https://")
|
val ip: String get() = address.removePrefix("https://")
|
||||||
|
|
||||||
public enum class Method(val rawValue: String) {
|
public enum class Method(val rawValue: String) {
|
||||||
|
Loading…
x
Reference in New Issue
Block a user