mirror of
https://github.com/apognu/otter
synced 2025-02-17 11:20:34 +01:00
Allow for anonymous connection if server supports it. Should provide basic support for #14.
This commit is contained in:
parent
3101fa5302
commit
aad0ec439c
@ -3,6 +3,7 @@ package com.github.apognu.otter.activities
|
|||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
|
import android.view.View
|
||||||
import androidx.appcompat.app.AppCompatActivity
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
import com.github.apognu.otter.R
|
import com.github.apognu.otter.R
|
||||||
import com.github.apognu.otter.fragments.LoginDialog
|
import com.github.apognu.otter.fragments.LoginDialog
|
||||||
@ -30,6 +31,16 @@ class LoginActivity : AppCompatActivity() {
|
|||||||
override fun onResume() {
|
override fun onResume() {
|
||||||
super.onResume()
|
super.onResume()
|
||||||
|
|
||||||
|
anonymous?.setOnCheckedChangeListener { _, isChecked ->
|
||||||
|
val state = when (isChecked) {
|
||||||
|
true -> View.GONE
|
||||||
|
false -> View.VISIBLE
|
||||||
|
}
|
||||||
|
|
||||||
|
username_field.visibility = state
|
||||||
|
password_field.visibility = state
|
||||||
|
}
|
||||||
|
|
||||||
login?.setOnClickListener {
|
login?.setOnClickListener {
|
||||||
var hostname = hostname.text.toString().trim()
|
var hostname = hostname.text.toString().trim()
|
||||||
val username = username.text.toString()
|
val username = username.text.toString()
|
||||||
@ -55,58 +66,106 @@ class LoginActivity : AppCompatActivity() {
|
|||||||
|
|
||||||
hostname_field.error = ""
|
hostname_field.error = ""
|
||||||
|
|
||||||
val body = mapOf(
|
when (anonymous.isChecked) {
|
||||||
"username" to username,
|
false -> authedLogin(hostname, username, password)
|
||||||
"password" to password
|
true -> anonymousLogin(hostname)
|
||||||
).toList()
|
|
||||||
|
|
||||||
val dialog = LoginDialog().apply {
|
|
||||||
show(supportFragmentManager, "LoginDialog")
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
GlobalScope.launch(Main) {
|
private fun authedLogin(hostname: String, username: String, password: String) {
|
||||||
try {
|
val body = mapOf(
|
||||||
val (_, response, result) = Fuel.post("$hostname/api/v1/token/", body)
|
"username" to username,
|
||||||
.awaitObjectResponseResult(gsonDeserializerOf(FwCredentials::class.java))
|
"password" to password
|
||||||
|
).toList()
|
||||||
|
|
||||||
when (result) {
|
val dialog = LoginDialog().apply {
|
||||||
is Result.Success -> {
|
show(supportFragmentManager, "LoginDialog")
|
||||||
PowerPreference.getFileByName(AppContext.PREFS_CREDENTIALS).apply {
|
}
|
||||||
setString("hostname", hostname)
|
|
||||||
setString("username", username)
|
|
||||||
setString("password", password)
|
|
||||||
setString("access_token", result.get().token)
|
|
||||||
}
|
|
||||||
|
|
||||||
dialog.dismiss()
|
GlobalScope.launch(Main) {
|
||||||
startActivity(Intent(this@LoginActivity, MainActivity::class.java))
|
try {
|
||||||
finish()
|
val (_, response, result) = Fuel.post("$hostname/api/v1/token/", body)
|
||||||
|
.awaitObjectResponseResult(gsonDeserializerOf(FwCredentials::class.java))
|
||||||
|
|
||||||
|
when (result) {
|
||||||
|
is Result.Success -> {
|
||||||
|
PowerPreference.getFileByName(AppContext.PREFS_CREDENTIALS).apply {
|
||||||
|
setString("hostname", hostname)
|
||||||
|
setBoolean("anonymous", false)
|
||||||
|
setString("username", username)
|
||||||
|
setString("password", password)
|
||||||
|
setString("access_token", result.get().token)
|
||||||
}
|
}
|
||||||
|
|
||||||
is Result.Failure -> {
|
dialog.dismiss()
|
||||||
dialog.dismiss()
|
startActivity(Intent(this@LoginActivity, MainActivity::class.java))
|
||||||
|
finish()
|
||||||
|
}
|
||||||
|
|
||||||
val error = Gson().fromJson(String(response.data), FwCredentials::class.java)
|
is Result.Failure -> {
|
||||||
|
dialog.dismiss()
|
||||||
|
|
||||||
hostname_field.error = null
|
val error = Gson().fromJson(String(response.data), FwCredentials::class.java)
|
||||||
username_field.error = null
|
|
||||||
|
|
||||||
if (error != null && error.non_field_errors.isNotEmpty()) {
|
hostname_field.error = null
|
||||||
username_field.error = error.non_field_errors[0]
|
username_field.error = null
|
||||||
} else {
|
|
||||||
hostname_field.error = result.error.localizedMessage
|
if (error != null && error.non_field_errors.isNotEmpty()) {
|
||||||
}
|
username_field.error = error.non_field_errors[0]
|
||||||
|
} else {
|
||||||
|
hostname_field.error = result.error.localizedMessage
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
|
||||||
dialog.dismiss()
|
|
||||||
|
|
||||||
val message =
|
|
||||||
if (e.message?.isEmpty() == true) getString(R.string.login_error_hostname)
|
|
||||||
else e.message
|
|
||||||
|
|
||||||
hostname_field.error = message
|
|
||||||
}
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
dialog.dismiss()
|
||||||
|
|
||||||
|
val message =
|
||||||
|
if (e.message?.isEmpty() == true) getString(R.string.login_error_hostname)
|
||||||
|
else e.message
|
||||||
|
|
||||||
|
hostname_field.error = message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun anonymousLogin(hostname: String) {
|
||||||
|
val dialog = LoginDialog().apply {
|
||||||
|
show(supportFragmentManager, "LoginDialog")
|
||||||
|
}
|
||||||
|
|
||||||
|
GlobalScope.launch(Main) {
|
||||||
|
try {
|
||||||
|
val (_, _, result) = Fuel.get("$hostname/api/v1/tracks/")
|
||||||
|
.awaitObjectResponseResult(gsonDeserializerOf(FwCredentials::class.java))
|
||||||
|
|
||||||
|
when (result) {
|
||||||
|
is Result.Success -> {
|
||||||
|
PowerPreference.getFileByName(AppContext.PREFS_CREDENTIALS).apply {
|
||||||
|
setString("hostname", hostname)
|
||||||
|
setBoolean("anonymous", true)
|
||||||
|
}
|
||||||
|
|
||||||
|
dialog.dismiss()
|
||||||
|
startActivity(Intent(this@LoginActivity, MainActivity::class.java))
|
||||||
|
finish()
|
||||||
|
}
|
||||||
|
|
||||||
|
is Result.Failure -> {
|
||||||
|
dialog.dismiss()
|
||||||
|
|
||||||
|
hostname_field.error = result.error.localizedMessage
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
dialog.dismiss()
|
||||||
|
|
||||||
|
val message =
|
||||||
|
if (e.message?.isEmpty() == true) getString(R.string.login_error_hostname)
|
||||||
|
else e.message
|
||||||
|
|
||||||
|
hostname_field.error = message
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -5,13 +5,14 @@ import android.content.Intent
|
|||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import androidx.appcompat.app.AppCompatActivity
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
import com.github.apognu.otter.utils.AppContext
|
import com.github.apognu.otter.utils.AppContext
|
||||||
|
import com.github.apognu.otter.utils.Settings
|
||||||
|
|
||||||
class SplashActivity : AppCompatActivity() {
|
class SplashActivity : AppCompatActivity() {
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
|
||||||
getSharedPreferences(AppContext.PREFS_CREDENTIALS, Context.MODE_PRIVATE).apply {
|
getSharedPreferences(AppContext.PREFS_CREDENTIALS, Context.MODE_PRIVATE).apply {
|
||||||
when (contains("access_token")) {
|
when (Settings.hasAccessToken() || Settings.isAnonymous()) {
|
||||||
true -> Intent(this@SplashActivity, MainActivity::class.java).apply {
|
true -> Intent(this@SplashActivity, MainActivity::class.java).apply {
|
||||||
flags = Intent.FLAG_ACTIVITY_NO_ANIMATION
|
flags = Intent.FLAG_ACTIVITY_NO_ANIMATION
|
||||||
|
|
||||||
|
@ -10,7 +10,6 @@ import androidx.recyclerview.widget.RecyclerView
|
|||||||
import com.github.apognu.otter.repositories.HttpUpstream
|
import com.github.apognu.otter.repositories.HttpUpstream
|
||||||
import com.github.apognu.otter.repositories.Repository
|
import com.github.apognu.otter.repositories.Repository
|
||||||
import com.github.apognu.otter.utils.Cache
|
import com.github.apognu.otter.utils.Cache
|
||||||
import com.github.apognu.otter.utils.log
|
|
||||||
import com.github.apognu.otter.utils.untilNetwork
|
import com.github.apognu.otter.utils.untilNetwork
|
||||||
import com.google.gson.Gson
|
import com.google.gson.Gson
|
||||||
import kotlinx.android.synthetic.main.fragment_artists.*
|
import kotlinx.android.synthetic.main.fragment_artists.*
|
||||||
|
@ -57,11 +57,11 @@ class QueueManager(val context: Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun factory(): CacheDataSourceFactory {
|
private fun factory(): CacheDataSourceFactory {
|
||||||
val token = PowerPreference.getFileByName(AppContext.PREFS_CREDENTIALS).getString("access_token")
|
|
||||||
|
|
||||||
val http = DefaultHttpDataSourceFactory(Util.getUserAgent(context, context.getString(R.string.app_name))).apply {
|
val http = DefaultHttpDataSourceFactory(Util.getUserAgent(context, context.getString(R.string.app_name))).apply {
|
||||||
defaultRequestProperties.apply {
|
defaultRequestProperties.apply {
|
||||||
set("Authorization", "Bearer $token")
|
if (!Settings.isAnonymous()) {
|
||||||
|
set("Authorization", "Bearer ${Settings.getAccessToken()}")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -7,7 +7,6 @@ import com.github.kittinunf.fuel.coroutines.awaitByteArrayResponseResult
|
|||||||
import com.github.kittinunf.fuel.gson.gsonDeserializerOf
|
import com.github.kittinunf.fuel.gson.gsonDeserializerOf
|
||||||
import com.google.gson.Gson
|
import com.google.gson.Gson
|
||||||
import com.google.gson.reflect.TypeToken
|
import com.google.gson.reflect.TypeToken
|
||||||
import com.preference.PowerPreference
|
|
||||||
import kotlinx.coroutines.Dispatchers.IO
|
import kotlinx.coroutines.Dispatchers.IO
|
||||||
import kotlinx.coroutines.GlobalScope
|
import kotlinx.coroutines.GlobalScope
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@ -26,13 +25,16 @@ class FavoritesRepository(override val context: Context?) : Repository<Track, Tr
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun addFavorite(id: Int) {
|
fun addFavorite(id: Int) {
|
||||||
val token = PowerPreference.getFileByName(AppContext.PREFS_CREDENTIALS).getString("access_token")
|
|
||||||
val body = mapOf("track" to id)
|
val body = mapOf("track" to id)
|
||||||
|
|
||||||
|
val request = Fuel.post(mustNormalizeUrl("/api/v1/favorites/tracks/")).apply {
|
||||||
|
if (!Settings.isAnonymous()) {
|
||||||
|
header("Authorization", "Bearer ${Settings.getAccessToken()}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
GlobalScope.launch(IO) {
|
GlobalScope.launch(IO) {
|
||||||
Fuel
|
request
|
||||||
.post(mustNormalizeUrl("/api/v1/favorites/tracks/"))
|
|
||||||
.header("Authorization", "Bearer $token")
|
|
||||||
.header("Content-Type", "application/json")
|
.header("Content-Type", "application/json")
|
||||||
.body(Gson().toJson(body))
|
.body(Gson().toJson(body))
|
||||||
.awaitByteArrayResponseResult()
|
.awaitByteArrayResponseResult()
|
||||||
@ -40,13 +42,16 @@ class FavoritesRepository(override val context: Context?) : Repository<Track, Tr
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun deleteFavorite(id: Int) {
|
fun deleteFavorite(id: Int) {
|
||||||
val token = PowerPreference.getFileByName(AppContext.PREFS_CREDENTIALS).getString("access_token")
|
|
||||||
val body = mapOf("track" to id)
|
val body = mapOf("track" to id)
|
||||||
|
|
||||||
|
val request = Fuel.post(mustNormalizeUrl("/api/v1/favorites/tracks/remove/")).apply {
|
||||||
|
if (!Settings.isAnonymous()) {
|
||||||
|
request.header("Authorization", "Bearer ${Settings.getAccessToken()}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
GlobalScope.launch(IO) {
|
GlobalScope.launch(IO) {
|
||||||
Fuel
|
request
|
||||||
.post(mustNormalizeUrl("/api/v1/favorites/tracks/remove/"))
|
|
||||||
.header("Authorization", "Bearer $token")
|
|
||||||
.header("Content-Type", "application/json")
|
.header("Content-Type", "application/json")
|
||||||
.body(Gson().toJson(body))
|
.body(Gson().toJson(body))
|
||||||
.awaitByteArrayResponseResult()
|
.awaitByteArrayResponseResult()
|
||||||
|
@ -9,7 +9,6 @@ import com.github.kittinunf.fuel.coroutines.awaitObjectResponseResult
|
|||||||
import com.github.kittinunf.fuel.coroutines.awaitObjectResult
|
import com.github.kittinunf.fuel.coroutines.awaitObjectResult
|
||||||
import com.github.kittinunf.result.Result
|
import com.github.kittinunf.result.Result
|
||||||
import com.google.gson.Gson
|
import com.google.gson.Gson
|
||||||
import com.preference.PowerPreference
|
|
||||||
import kotlinx.coroutines.Dispatchers.IO
|
import kotlinx.coroutines.Dispatchers.IO
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.collect
|
import kotlinx.coroutines.flow.collect
|
||||||
@ -37,6 +36,8 @@ class HttpUpstream<D : Any, R : FunkwhaleResponse<D>>(val behavior: Behavior, pr
|
|||||||
.build()
|
.build()
|
||||||
.toString()
|
.toString()
|
||||||
|
|
||||||
|
log(offsetUrl)
|
||||||
|
|
||||||
get(offsetUrl).fold(
|
get(offsetUrl).fold(
|
||||||
{ response ->
|
{ response ->
|
||||||
val data = response.getData()
|
val data = response.getData()
|
||||||
@ -65,12 +66,13 @@ class HttpUpstream<D : Any, R : FunkwhaleResponse<D>>(val behavior: Behavior, pr
|
|||||||
}
|
}
|
||||||
|
|
||||||
suspend fun get(url: String): Result<R, FuelError> {
|
suspend fun get(url: String): Result<R, FuelError> {
|
||||||
val token = PowerPreference.getFileByName(AppContext.PREFS_CREDENTIALS).getString("access_token")
|
val request = Fuel.get(mustNormalizeUrl(url)).apply {
|
||||||
|
if (!Settings.isAnonymous()) {
|
||||||
|
header("Authorization", "Bearer ${Settings.isAnonymous()}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
val (_, response, result) = Fuel
|
val (_, response, result) = request.awaitObjectResponseResult(GenericDeserializer<R>(type))
|
||||||
.get(mustNormalizeUrl(url))
|
|
||||||
.header("Authorization", "Bearer $token")
|
|
||||||
.awaitObjectResponseResult(GenericDeserializer<R>(type))
|
|
||||||
|
|
||||||
if (response.statusCode == 401) {
|
if (response.statusCode == 401) {
|
||||||
return retryGet(url)
|
return retryGet(url)
|
||||||
@ -81,12 +83,13 @@ class HttpUpstream<D : Any, R : FunkwhaleResponse<D>>(val behavior: Behavior, pr
|
|||||||
|
|
||||||
private suspend fun retryGet(url: String): Result<R, FuelError> {
|
private suspend fun retryGet(url: String): Result<R, FuelError> {
|
||||||
return if (HTTP.refresh()) {
|
return if (HTTP.refresh()) {
|
||||||
val token = PowerPreference.getFileByName(AppContext.PREFS_CREDENTIALS).getString("access_token")
|
val request = Fuel.get(mustNormalizeUrl(url)).apply {
|
||||||
|
if (!Settings.isAnonymous()) {
|
||||||
|
header("Authorization", "Bearer ${Settings.isAnonymous()}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Fuel
|
request.awaitObjectResult(GenericDeserializer(type))
|
||||||
.get(mustNormalizeUrl(url))
|
|
||||||
.header("Authorization", "Bearer $token")
|
|
||||||
.awaitObjectResult(GenericDeserializer(type))
|
|
||||||
} else {
|
} else {
|
||||||
Result.Failure(FuelError.wrap(RefreshError))
|
Result.Failure(FuelError.wrap(RefreshError))
|
||||||
}
|
}
|
||||||
|
@ -36,12 +36,13 @@ object HTTP {
|
|||||||
}
|
}
|
||||||
|
|
||||||
suspend inline fun <reified T : Any> get(url: String): Result<T, FuelError> {
|
suspend inline fun <reified T : Any> get(url: String): Result<T, FuelError> {
|
||||||
val token = PowerPreference.getFileByName(AppContext.PREFS_CREDENTIALS).getString("access_token")
|
val request = Fuel.get(mustNormalizeUrl(url)).apply {
|
||||||
|
if (!Settings.isAnonymous()) {
|
||||||
|
header("Authorization", "Bearer ${Settings.getAccessToken()}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
val (_, response, result) = Fuel
|
val (_, response, result) = request.awaitObjectResponseResult(gsonDeserializerOf(T::class.java))
|
||||||
.get(mustNormalizeUrl(url))
|
|
||||||
.header("Authorization", "Bearer $token")
|
|
||||||
.awaitObjectResponseResult(gsonDeserializerOf(T::class.java))
|
|
||||||
|
|
||||||
if (response.statusCode == 401) {
|
if (response.statusCode == 401) {
|
||||||
return retryGet(url)
|
return retryGet(url)
|
||||||
@ -52,12 +53,13 @@ object HTTP {
|
|||||||
|
|
||||||
suspend inline fun <reified T : Any> retryGet(url: String): Result<T, FuelError> {
|
suspend inline fun <reified T : Any> retryGet(url: String): Result<T, FuelError> {
|
||||||
return if (refresh()) {
|
return if (refresh()) {
|
||||||
val token = PowerPreference.getFileByName(AppContext.PREFS_CREDENTIALS).getString("access_token")
|
val request = Fuel.get(mustNormalizeUrl(url)).apply {
|
||||||
|
if (!Settings.isAnonymous()) {
|
||||||
|
header("Authorization", "Bearer ${Settings.getAccessToken()}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Fuel
|
request.awaitObjectResult(gsonDeserializerOf(T::class.java))
|
||||||
.get(mustNormalizeUrl(url))
|
|
||||||
.header("Authorization", "Bearer $token")
|
|
||||||
.awaitObjectResult(gsonDeserializerOf(T::class.java))
|
|
||||||
} else {
|
} else {
|
||||||
Result.Failure(FuelError.wrap(RefreshError))
|
Result.Failure(FuelError.wrap(RefreshError))
|
||||||
}
|
}
|
||||||
|
@ -48,4 +48,10 @@ fun toDurationString(seconds: Long): String {
|
|||||||
if (minutes > 0) ret.append(" ${minutes}m")
|
if (minutes > 0) ret.append(" ${minutes}m")
|
||||||
|
|
||||||
return ret.toString()
|
return ret.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
object Settings {
|
||||||
|
fun hasAccessToken() = PowerPreference.getFileByName(AppContext.PREFS_CREDENTIALS).contains("access_token")
|
||||||
|
fun getAccessToken() = PowerPreference.getFileByName(AppContext.PREFS_CREDENTIALS).getString("access_token", "")
|
||||||
|
fun isAnonymous() = PowerPreference.getFileByName(AppContext.PREFS_CREDENTIALS).getBoolean("anonymous", false)
|
||||||
}
|
}
|
@ -49,6 +49,14 @@
|
|||||||
|
|
||||||
</com.google.android.material.textfield.TextInputLayout>
|
</com.google.android.material.textfield.TextInputLayout>
|
||||||
|
|
||||||
|
<com.google.android.material.checkbox.MaterialCheckBox
|
||||||
|
android:id="@+id/anonymous"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="@string/login_anonymous"
|
||||||
|
android:buttonTint="@android:color/white"
|
||||||
|
android:textColor="@android:color/white"/>
|
||||||
|
|
||||||
<com.google.android.material.textfield.TextInputLayout
|
<com.google.android.material.textfield.TextInputLayout
|
||||||
android:id="@+id/username_field"
|
android:id="@+id/username_field"
|
||||||
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox"
|
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox"
|
||||||
|
@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
<string name="login_welcome">Veuillez saisir les détails de votre instance Funkwhale pour accéder à son contenu</string>
|
<string name="login_welcome">Veuillez saisir les détails de votre instance Funkwhale pour accéder à son contenu</string>
|
||||||
<string name="login_hostname">Nom d\'hôte</string>
|
<string name="login_hostname">Nom d\'hôte</string>
|
||||||
|
<string name="login_anonymous">Authentification anonyme</string>
|
||||||
<string name="login_username">Nom d\'utilisateur</string>
|
<string name="login_username">Nom d\'utilisateur</string>
|
||||||
<string name="login_password">Mot de passe</string>
|
<string name="login_password">Mot de passe</string>
|
||||||
<string name="login_submit">Se connecter</string>
|
<string name="login_submit">Se connecter</string>
|
||||||
|
@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
<string name="login_welcome">Please enter the details of your Funkwhale instance to access its content</string>
|
<string name="login_welcome">Please enter the details of your Funkwhale instance to access its content</string>
|
||||||
<string name="login_hostname">Host name</string>
|
<string name="login_hostname">Host name</string>
|
||||||
|
<string name="login_anonymous">Anonymous authentication</string>
|
||||||
<string name="login_username">Username</string>
|
<string name="login_username">Username</string>
|
||||||
<string name="login_password">Password</string>
|
<string name="login_password">Password</string>
|
||||||
<string name="login_submit">Log in</string>
|
<string name="login_submit">Log in</string>
|
||||||
|
Loading…
x
Reference in New Issue
Block a user