mirror of
https://github.com/iSoron/uhabits.git
synced 2025-12-06 09:08:52 -06:00
Make registration functional
This commit is contained in:
@@ -10,6 +10,7 @@ KOTLIN_VERSION = 1.3.61
|
||||
SUPPORT_LIBRARY_VERSION = 28.0.0
|
||||
AUTO_FACTORY_VERSION = 1.0-beta6
|
||||
BUILD_TOOLS_VERSION = 4.0.0
|
||||
KTOR_VERSION=1.4.2
|
||||
|
||||
org.gradle.parallel=false
|
||||
org.gradle.daemon=true
|
||||
|
||||
@@ -94,6 +94,10 @@ dependencies {
|
||||
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$KOTLIN_VERSION"
|
||||
implementation "androidx.constraintlayout:constraintlayout:2.0.0-beta4"
|
||||
implementation 'com.google.zxing:core:3.4.1'
|
||||
implementation "io.ktor:ktor-client-core:$KTOR_VERSION"
|
||||
implementation "io.ktor:ktor-client-android:$KTOR_VERSION"
|
||||
implementation "io.ktor:ktor-client-json:$KTOR_VERSION"
|
||||
implementation "io.ktor:ktor-client-jackson:$KTOR_VERSION"
|
||||
|
||||
implementation 'androidx.constraintlayout:constraintlayout:1.1.3'
|
||||
compileOnly "javax.annotation:jsr250-api:1.0"
|
||||
@@ -113,6 +117,8 @@ dependencies {
|
||||
androidTestImplementation 'androidx.test:rules:1.1.1'
|
||||
androidTestImplementation 'androidx.test.ext:junit:1.1.1'
|
||||
androidTestImplementation "com.google.guava:guava:24.1-android"
|
||||
androidTestImplementation "io.ktor:ktor-client-mock:$KTOR_VERSION"
|
||||
androidTestImplementation "io.ktor:ktor-jackson:$KTOR_VERSION"
|
||||
androidTestImplementation project(":uhabits-core")
|
||||
kaptAndroidTest "com.google.dagger:dagger-compiler:$DAGGER_VERSION"
|
||||
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
/*
|
||||
* Copyright (C) 2016-2020 Álinson Santos Xavier <isoron@gmail.com>
|
||||
*
|
||||
* This file is part of Loop Habit Tracker.
|
||||
*
|
||||
* Loop Habit Tracker is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by the
|
||||
* Free Software Foundation, either version 3 of the License, or (at your
|
||||
* option) any later version.
|
||||
*
|
||||
* Loop Habit Tracker is distributed in the hope that it will be useful, but
|
||||
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
|
||||
* more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License along
|
||||
* with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.isoron.uhabits.sync
|
||||
|
||||
import com.fasterxml.jackson.databind.*
|
||||
import io.ktor.client.*
|
||||
import io.ktor.client.engine.mock.*
|
||||
import io.ktor.client.features.json.*
|
||||
import io.ktor.client.request.*
|
||||
import io.ktor.http.*
|
||||
import junit.framework.Assert.*
|
||||
import kotlinx.coroutines.*
|
||||
import org.junit.*
|
||||
|
||||
class RemoteSyncServerTest {
|
||||
|
||||
private val mapper = ObjectMapper()
|
||||
val data = SyncData(1, "Hello world")
|
||||
|
||||
@Test
|
||||
fun when_register_succeeds_should_return_key() = runBlocking {
|
||||
val server = server("/register") {
|
||||
respondWithJson(RegisterReponse("ABCDEF"))
|
||||
}
|
||||
assertEquals("ABCDEF", server.register())
|
||||
}
|
||||
|
||||
@Test(expected = ServiceUnavailable::class)
|
||||
fun when_register_fails_should_raise_correct_exception() = runBlocking {
|
||||
val server = server("/register") {
|
||||
respondError(HttpStatusCode.ServiceUnavailable)
|
||||
}
|
||||
server.register()
|
||||
return@runBlocking
|
||||
}
|
||||
|
||||
@Test
|
||||
fun when_get_data_version_succeeds_should_return_version() = runBlocking {
|
||||
server("/ABC/version") {
|
||||
respondWithJson(GetDataVersionResponse(5))
|
||||
}.apply {
|
||||
assertEquals(5, getDataVersion("ABC"))
|
||||
}
|
||||
return@runBlocking
|
||||
}
|
||||
|
||||
@Test(expected = ServiceUnavailable::class)
|
||||
fun when_get_data_version_with_server_error_should_raise_exception() = runBlocking {
|
||||
server("/ABC/version") {
|
||||
respondError(HttpStatusCode.InternalServerError)
|
||||
}.apply {
|
||||
getDataVersion("ABC")
|
||||
}
|
||||
return@runBlocking
|
||||
}
|
||||
|
||||
@Test(expected = KeyNotFoundException::class)
|
||||
fun when_get_data_version_with_invalid_key_should_raise_exception() = runBlocking {
|
||||
server("/ABC/version") {
|
||||
respondError(HttpStatusCode.NotFound)
|
||||
}.apply {
|
||||
getDataVersion("ABC")
|
||||
}
|
||||
return@runBlocking
|
||||
}
|
||||
|
||||
@Test
|
||||
fun when_get_data_succeeds_should_return_data() = runBlocking {
|
||||
server("/ABC") {
|
||||
respondWithJson(data)
|
||||
}.apply {
|
||||
assertEquals(data, getData("ABC"))
|
||||
}
|
||||
return@runBlocking
|
||||
}
|
||||
|
||||
@Test(expected = KeyNotFoundException::class)
|
||||
fun when_get_data_with_invalid_key_should_raise_exception() = runBlocking {
|
||||
server("/ABC") {
|
||||
respondError(HttpStatusCode.NotFound)
|
||||
}.apply {
|
||||
getData("ABC")
|
||||
}
|
||||
return@runBlocking
|
||||
}
|
||||
|
||||
@Test
|
||||
fun when_put_succeeds_should_not_raise_exceptions() = runBlocking {
|
||||
server("/ABC") {
|
||||
respondOk()
|
||||
}.apply {
|
||||
put("ABC", data)
|
||||
}
|
||||
return@runBlocking
|
||||
}
|
||||
|
||||
private fun server(expectedPath: String,
|
||||
action: MockRequestHandleScope.(HttpRequestData) -> HttpResponseData
|
||||
): AbstractSyncServer {
|
||||
return RemoteSyncServer(httpClient = HttpClient(MockEngine) {
|
||||
install(JsonFeature)
|
||||
engine {
|
||||
addHandler { request ->
|
||||
when (request.url.fullPath) {
|
||||
expectedPath -> action(request)
|
||||
else -> error("unexpected call: ${request.url.fullPath}")
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private fun MockRequestHandleScope.respondWithJson(content: Any) =
|
||||
respond(mapper.writeValueAsBytes(content),
|
||||
headers = headersOf("Content-Type" to listOf("application/json")))
|
||||
}
|
||||
@@ -23,6 +23,8 @@
|
||||
<uses-permission android:name="android.permission.VIBRATE" />
|
||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
|
||||
|
||||
<application
|
||||
android:name=".HabitsApplication"
|
||||
|
||||
@@ -24,17 +24,23 @@ import android.content.ClipboardManager
|
||||
import android.graphics.*
|
||||
import android.os.*
|
||||
import android.text.*
|
||||
import android.view.*
|
||||
import com.google.zxing.*
|
||||
import com.google.zxing.qrcode.*
|
||||
import kotlinx.coroutines.*
|
||||
import org.isoron.androidbase.activities.*
|
||||
import org.isoron.androidbase.utils.*
|
||||
import org.isoron.androidbase.utils.InterfaceUtils.getFontAwesome
|
||||
import org.isoron.uhabits.*
|
||||
import org.isoron.uhabits.activities.*
|
||||
import org.isoron.uhabits.core.tasks.*
|
||||
import org.isoron.uhabits.databinding.*
|
||||
import org.isoron.uhabits.sync.*
|
||||
|
||||
|
||||
class SyncActivity : BaseActivity() {
|
||||
|
||||
private lateinit var taskRunner: TaskRunner
|
||||
private lateinit var baseScreen: BaseScreen
|
||||
private lateinit var themeSwitcher: AndroidThemeSwitcher
|
||||
private lateinit var binding: ActivitySyncBinding
|
||||
@@ -47,10 +53,13 @@ class SyncActivity : BaseActivity() {
|
||||
val component = (application as HabitsApplication).component
|
||||
themeSwitcher = AndroidThemeSwitcher(this, component.preferences)
|
||||
themeSwitcher.apply()
|
||||
taskRunner = component.taskRunner
|
||||
|
||||
binding = ActivitySyncBinding.inflate(layoutInflater)
|
||||
setContentView(binding.root)
|
||||
|
||||
binding.errorIcon.typeface = getFontAwesome(this)
|
||||
|
||||
setSupportActionBar(binding.toolbar)
|
||||
supportActionBar?.setDisplayHomeAsUpEnabled(true)
|
||||
supportActionBar?.setDisplayShowHomeEnabled(true)
|
||||
@@ -58,14 +67,50 @@ class SyncActivity : BaseActivity() {
|
||||
|
||||
binding.instructions.setText(Html.fromHtml(resources.getString(R.string.sync_instructions)))
|
||||
|
||||
displayLink("https://loophabits.org/sync/KA9GvblSWrcLk9iwJrplHvWiWdE6opAokdf2qqRl6n6ECX8IUhvcksqlfkQACoMM")
|
||||
displayPassword("6B2W9F5X")
|
||||
|
||||
binding.syncLink.setOnClickListener {
|
||||
copyToClipboard()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
displayLoading()
|
||||
taskRunner.execute(object : Task {
|
||||
private var key = ""
|
||||
private var error = false
|
||||
override fun doInBackground() {
|
||||
runBlocking {
|
||||
val server = RemoteSyncServer()
|
||||
try {
|
||||
key = server.register()
|
||||
} catch(e: ServiceUnavailable) {
|
||||
error = true
|
||||
}
|
||||
}
|
||||
}
|
||||
override fun onPostExecute() {
|
||||
if(error) {
|
||||
displayError()
|
||||
} else {
|
||||
displayLink("https://loophabits.org/sync/$key")
|
||||
displayPassword("6B2W9F5X")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private fun displayLoading() {
|
||||
binding.qrCode.visibility = View.GONE
|
||||
binding.progress.visibility = View.VISIBLE
|
||||
binding.errorPanel.visibility = View.GONE
|
||||
}
|
||||
|
||||
private fun displayError() {
|
||||
binding.qrCode.visibility = View.GONE
|
||||
binding.progress.visibility = View.GONE
|
||||
binding.errorPanel.visibility = View.VISIBLE
|
||||
}
|
||||
|
||||
private fun copyToClipboard() {
|
||||
val clipboard = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
||||
clipboard.setPrimaryClip(ClipData.newPlainText("Loop Sync Link", binding.syncLink.text))
|
||||
@@ -77,6 +122,9 @@ class SyncActivity : BaseActivity() {
|
||||
}
|
||||
|
||||
private fun displayLink(link: String) {
|
||||
binding.qrCode.visibility = View.VISIBLE
|
||||
binding.progress.visibility = View.GONE
|
||||
binding.errorPanel.visibility = View.GONE
|
||||
binding.syncLink.text = link
|
||||
displayQR(link)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
/*
|
||||
* Copyright (C) 2016-2020 Alinson Santos Xavier <git@axavier.org>
|
||||
*
|
||||
* This file is part of Loop Habit Tracker.
|
||||
*
|
||||
* Loop Habit Tracker is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by the
|
||||
* Free Software Foundation, either version 3 of the License, or (at your
|
||||
* option) any later version.
|
||||
*
|
||||
* Loop Habit Tracker is distributed in the hope that it will be useful, but
|
||||
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
|
||||
* more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License along
|
||||
* with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.isoron.uhabits.sync
|
||||
|
||||
interface AbstractSyncServer {
|
||||
/**
|
||||
* Generates and returns a new sync key, which can be used to store and retrive
|
||||
* data.
|
||||
*
|
||||
* @throws ServiceUnavailable If key cannot be generated at this time, for example,
|
||||
* due to insufficient server resources, temporary server maintenance or network problems.
|
||||
*/
|
||||
suspend fun register(): String
|
||||
|
||||
/**
|
||||
* Replaces data for a given sync key.
|
||||
*
|
||||
* @throws KeyNotFoundException If key is not found
|
||||
* @throws EditConflictException If the version of the data provided is not
|
||||
* exactly the current data version plus one.
|
||||
* @throws ServiceUnavailable If data cannot be put at this time, for example, due
|
||||
* to insufficient server resources or network problems.
|
||||
*/
|
||||
suspend fun put(key: String, newData: SyncData)
|
||||
|
||||
/**
|
||||
* Returns data for a given sync key.
|
||||
*
|
||||
* @throws KeyNotFoundException If key is not found
|
||||
* @throws ServiceUnavailable If data cannot be retrieved at this time, for example, due
|
||||
* to insufficient server resources or network problems.
|
||||
*/
|
||||
suspend fun getData(key: String): SyncData
|
||||
|
||||
/**
|
||||
* Returns the current data version for the given key
|
||||
*
|
||||
* @throws KeyNotFoundException If key is not found
|
||||
* @throws ServiceUnavailable If data cannot be retrieved at this time, for example, due
|
||||
* to insufficient server resources or network problems.
|
||||
*/
|
||||
suspend fun getDataVersion(key: String): Long
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
/*
|
||||
* Copyright (C) 2016-2020 Álinson Santos Xavier <isoron@gmail.com>
|
||||
*
|
||||
* This file is part of Loop Habit Tracker.
|
||||
*
|
||||
* Loop Habit Tracker is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by the
|
||||
* Free Software Foundation, either version 3 of the License, or (at your
|
||||
* option) any later version.
|
||||
*
|
||||
* Loop Habit Tracker is distributed in the hope that it will be useful, but
|
||||
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
|
||||
* more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License along
|
||||
* with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.isoron.uhabits.sync
|
||||
|
||||
import io.ktor.client.*
|
||||
import io.ktor.client.engine.android.*
|
||||
import io.ktor.client.features.*
|
||||
import io.ktor.client.features.json.*
|
||||
import io.ktor.client.request.*
|
||||
|
||||
data class RegisterReponse(val key: String)
|
||||
data class GetDataVersionResponse(val version: Long)
|
||||
|
||||
class RemoteSyncServer(
|
||||
private val baseURL: String = "https://sync.loophabits.org",
|
||||
private val httpClient: HttpClient = HttpClient(Android) {
|
||||
install(JsonFeature)
|
||||
}
|
||||
) : AbstractSyncServer {
|
||||
|
||||
override suspend fun register(): String {
|
||||
try {
|
||||
val response: RegisterReponse = httpClient.post("$baseURL/register")
|
||||
return response.key
|
||||
} catch(e: ServerResponseException) {
|
||||
throw ServiceUnavailable()
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun put(key: String, newData: SyncData) {
|
||||
try {
|
||||
val response: String = httpClient.put("$baseURL/$key") {
|
||||
header("Content-Type", "application/json")
|
||||
body = newData
|
||||
}
|
||||
} catch (e: ServerResponseException) {
|
||||
throw ServiceUnavailable()
|
||||
} catch (e: ClientRequestException) {
|
||||
throw KeyNotFoundException()
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getData(key: String): SyncData {
|
||||
try {
|
||||
return httpClient.get("$baseURL/$key")
|
||||
} catch (e: ServerResponseException) {
|
||||
throw ServiceUnavailable()
|
||||
} catch (e: ClientRequestException) {
|
||||
throw KeyNotFoundException()
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getDataVersion(key: String): Long {
|
||||
try {
|
||||
val response: GetDataVersionResponse = httpClient.get("$baseURL/$key/version")
|
||||
return response.version
|
||||
} catch(e: ServerResponseException) {
|
||||
throw ServiceUnavailable()
|
||||
} catch (e: ClientRequestException) {
|
||||
throw KeyNotFoundException()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
/*
|
||||
* Copyright (C) 2016-2020 Alinson Santos Xavier <git@axavier.org>
|
||||
*
|
||||
* This file is part of Loop Habit Tracker.
|
||||
*
|
||||
* Loop Habit Tracker is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by the
|
||||
* Free Software Foundation, either version 3 of the License, or (at your
|
||||
* option) any later version.
|
||||
*
|
||||
* Loop Habit Tracker is distributed in the hope that it will be useful, but
|
||||
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
|
||||
* more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License along
|
||||
* with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.isoron.uhabits.sync
|
||||
|
||||
data class SyncData(
|
||||
val version: Long,
|
||||
val content: String
|
||||
)
|
||||
@@ -0,0 +1,28 @@
|
||||
/*
|
||||
* Copyright (C) 2016-2020 Alinson Santos Xavier <git@axavier.org>
|
||||
*
|
||||
* This file is part of Loop Habit Tracker.
|
||||
*
|
||||
* Loop Habit Tracker is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by the
|
||||
* Free Software Foundation, either version 3 of the License, or (at your
|
||||
* option) any later version.
|
||||
*
|
||||
* Loop Habit Tracker is distributed in the hope that it will be useful, but
|
||||
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
|
||||
* more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License along
|
||||
* with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.isoron.uhabits.sync
|
||||
|
||||
open class SyncException: RuntimeException()
|
||||
|
||||
class KeyNotFoundException: SyncException()
|
||||
|
||||
class ServiceUnavailable: SyncException()
|
||||
|
||||
class EditConflictException: SyncException()
|
||||
@@ -52,6 +52,55 @@
|
||||
android:id="@+id/instructions"
|
||||
/>
|
||||
|
||||
<!-- Sync Link (QR) -->
|
||||
<FrameLayout style="@style/FormOuterBox">
|
||||
|
||||
<LinearLayout
|
||||
style="@style/FormInnerBox">
|
||||
<TextView
|
||||
style="@style/FormLabel"
|
||||
android:translationZ="0.01dp"
|
||||
android:text="@string/sync_link_qr" />
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/errorPanel"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="200dp"
|
||||
android:orientation="vertical"
|
||||
android:gravity="center">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/errorIcon"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:textSize="40dp"
|
||||
android:layout_margin="40dp"
|
||||
android:text="@string/fa_exclamation_circle" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Error generating code. Please try again later."
|
||||
/>
|
||||
</LinearLayout>
|
||||
|
||||
<ProgressBar
|
||||
android:id="@+id/progress"
|
||||
android:layout_width="50dp"
|
||||
android:layout_height="200dp"
|
||||
android:layout_gravity="center"
|
||||
android:indeterminate="true"
|
||||
/>
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/qrCode"
|
||||
android:layout_width="200dp"
|
||||
android:layout_height="200dp"
|
||||
android:layout_gravity="center"
|
||||
/>
|
||||
</LinearLayout>
|
||||
</FrameLayout>
|
||||
|
||||
<!-- Sync Link -->
|
||||
<FrameLayout style="@style/FormOuterBox">
|
||||
|
||||
@@ -73,22 +122,7 @@
|
||||
</LinearLayout>
|
||||
</FrameLayout>
|
||||
|
||||
<!-- Sync Link -->
|
||||
<FrameLayout style="@style/FormOuterBox">
|
||||
|
||||
<LinearLayout style="@style/FormInnerBox">
|
||||
<TextView
|
||||
style="@style/FormLabel"
|
||||
android:text="@string/sync_link_qr" />
|
||||
<ImageView
|
||||
android:layout_width="200dp"
|
||||
android:layout_height="200dp"
|
||||
android:layout_marginTop="8dp"
|
||||
android:layout_gravity="center"
|
||||
android:id="@+id/qrCode" />
|
||||
</LinearLayout>
|
||||
</FrameLayout>
|
||||
|
||||
<!-- Password -->
|
||||
<FrameLayout style="@style/FormOuterBox">
|
||||
|
||||
<LinearLayout style="@style/FormInnerBox">
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
<string translatable="false" name="fa_skipped"></string>
|
||||
<string translatable="false" name="fa_bell_o"></string>
|
||||
<string translatable="false" name="fa_calendar"></string>
|
||||
<string translatable="false" name="fa_exclamation_circle"></string>
|
||||
|
||||
<!--<string translatable="false" name="fa_glass"></string>-->
|
||||
<!--<string translatable="false" name="fa_music"></string>-->
|
||||
@@ -123,7 +124,6 @@
|
||||
<!--<string translatable="false" name="fa_plus"></string>-->
|
||||
<!--<string translatable="false" name="fa_minus"></string>-->
|
||||
<!--<string translatable="false" name="fa_asterisk"></string>-->
|
||||
<!--<string translatable="false" name="fa_exclamation_circle"></string>-->
|
||||
<!--<string translatable="false" name="fa_gift"></string>-->
|
||||
<!--<string translatable="false" name="fa_leaf"></string>-->
|
||||
<!--<string translatable="false" name="fa_fire"></string>-->
|
||||
|
||||
Reference in New Issue
Block a user