Implement basic authntication flow

This commit is contained in:
Phillip Thelen 2022-06-09 11:41:02 +02:00
parent 02e74aa452
commit 430e49e6be
20 changed files with 417 additions and 55 deletions

View file

@ -37,21 +37,6 @@
<string name="preference_push_your_quest_has_begun">Your Quest has Begun</string>
<string name="preference_push_invited_to_quest">Invited to Quest</string>
<!-- Adding tasks -->
<string name="action_edit">Edit</string>
<string name="action_cancel">Cancel</string>
<string name="login_btn">Login</string>
<string name="register_btn">Register</string>
<string name="username">Username</string>
<string name="email_username">Email or Username</string>
<string name="password">Password</string>
<string name="emailAddress">Email address</string>
<string name="confirmpassword">Confirm password</string>
<string name="logout">Logout</string>
<string name="logout_description">Log out of your account</string>
<string name="LoginActivityName">Welcome</string>
<string name="about_title">About</string>
<string name="about_libraries">Libraries</string>
<string name="about_habitica_open_source">Habitica is available as open source software on Github</string>
<string name="about_rate_our_app">Rate our App</string>
@ -66,10 +51,6 @@
<string name="authentication_error_title">Authentication Error</string>
<string name="authentication_error_body">Your Username and/or Password was incorrect.</string>
<string name="login_validation_error_title">Validation Error</string>
<string name="login_validation_error_fieldsmissing">You have to fill out all fields.</string>
<string name="save_changes">Save</string>
<string name="copy">Copy</string>
<string name="notes">Notes</string>
@ -470,7 +451,6 @@
<string name="owned">Owned</string>
<string name="not_owned">Not owned</string>
<string name="login_btn_fb">Login with Facebook</string>
<string name="login_btn_google">Login with Google</string>
<string name="login_btn_apple">Sign in with Apple</string>
<string name="register_btn_fb">Sign up with Facebook</string>
<string name="register_btn_google">Sign up with Google</string>

View file

@ -18,13 +18,7 @@ import android.util.Log
import androidx.appcompat.app.AppCompatDelegate
import androidx.core.content.edit
import androidx.preference.PreferenceManager
import coil.Coil
import coil.ImageLoader
import coil.annotation.ExperimentalCoilApi
import coil.decode.GifDecoder
import coil.decode.ImageDecoderDecoder
import coil.transition.CrossfadeTransition
import coil.util.DebugLogger
import com.amplitude.api.Amplitude
import com.amplitude.api.Identify
import com.google.firebase.analytics.FirebaseAnalytics
@ -42,6 +36,7 @@ import com.habitrpg.android.habitica.modules.UserRepositoryModule
import com.habitrpg.android.habitica.proxy.AnalyticsManager
import com.habitrpg.android.habitica.ui.activities.BaseActivity
import com.habitrpg.android.habitica.ui.activities.LoginActivity
import com.habitrpg.common.habitica.extensions.setupCoil
import com.habitrpg.common.habitica.helpers.LanguageHelper
import com.habitrpg.common.habitica.helpers.MarkdownParser
import com.habitrpg.common.habitica.views.HabiticaIconsHelper
@ -90,20 +85,7 @@ abstract class HabiticaBaseApplication : Application(), Application.ActivityLife
} catch (ignored: Resources.NotFoundException) {
}
}
var builder = ImageLoader.Builder(this)
.transition(CrossfadeTransition())
.allowHardware(false)
.componentRegistry {
if (SDK_INT >= 28) {
add(ImageDecoderDecoder(this@HabiticaBaseApplication))
} else {
add(GifDecoder())
}
}
if (BuildConfig.DEBUG) {
builder = builder.logger(DebugLogger())
}
Coil.setImageLoader(builder.build())
setupCoil()
RxErrorHandler.init(analyticsManager)

View file

@ -232,7 +232,7 @@ class ApiClientImpl(
if (res.message != null && res.message == "RECEIPT_ALREADY_USED") {
return
}
}
if (error.response()?.raw()?.request?.url?.toString()?.endsWith("/user/push-devices") == true) {
// workaround for an error that sometimes displays that the user already has this push device
return

View file

@ -0,0 +1,26 @@
package com.habitrpg.common.habitica.extensions
import android.app.Application
import android.os.Build
import coil.Coil
import coil.ImageLoader
import coil.decode.GifDecoder
import coil.decode.ImageDecoderDecoder
import coil.util.DebugLogger
import com.habitrpg.common.habitica.BuildConfig
fun Application.setupCoil() {
var builder = ImageLoader.Builder(this)
.allowHardware(false)
.componentRegistry {
if (Build.VERSION.SDK_INT >= 28) {
add(ImageDecoderDecoder(this@setupCoil))
} else {
add(GifDecoder())
}
}
if (BuildConfig.DEBUG) {
builder = builder.logger(DebugLogger())
}
Coil.setImageLoader(builder.build())
}

View file

@ -1,8 +1,6 @@
package com.habitrpg.common.habitica.models.auth
class UserAuth {
var username: String? = null
var password: String? = null
var confirmPassword: String? = null
var email: String? = null
class UserAuth(var username: String? = null, var password: String? = null,
var confirmPassword: String? = null, var email: String? = null
) {
}

View file

@ -166,6 +166,7 @@ class AvatarView : FrameLayout {
}
override fun onSuccess(result: Drawable) {
result.isFilterBitmap = false
super.onSuccess(result)
val bounds = getLayerBounds(layerKey, layerName, result)
imageView.imageMatrix = avatarMatrix
@ -384,7 +385,7 @@ class AvatarView : FrameLayout {
} else {
PointF(24.0f, 0f)
}
hasPet -> PointF(24.0f, 24.5f)
hasPet -> PointF(24.0f, 24f)
else -> PointF(24.0f, 28.0f)
}
} else if (showBackground) {

View file

@ -23,4 +23,24 @@
<string name="settings">Settings</string>
<string name="new_task">New Task</string>
<string name="avatar">Avatar</string>
<string name="action_edit">Edit</string>
<string name="action_cancel">Cancel</string>
<string name="login_btn">Login</string>
<string name="register_btn">Register</string>
<string name="username">Username</string>
<string name="email_username">Email or Username</string>
<string name="password">Password</string>
<string name="emailAddress">Email address</string>
<string name="confirmpassword">Confirm password</string>
<string name="login_btn_google">Login with Google</string>
<string name="logout">Logout</string>
<string name="logout_description">Log out of your account</string>
<string name="LoginActivityName">Welcome</string>
<string name="about_title">About</string>
<string name="login_validation_error_title">Validation Error</string>
<string name="login_validation_error_fieldsmissing">You have to fill out all fields.</string>
</resources>

View file

@ -53,7 +53,7 @@ dependencies {
//Analytics
implementation 'com.amplitude:android-sdk:3.35.1'
implementation 'androidx.core:core-ktx:1.7.0'
implementation 'androidx.core:core-ktx:1.8.0'
implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:2.4.1"
implementation "androidx.lifecycle:lifecycle-livedata-ktx:2.4.1"
implementation "androidx.lifecycle:lifecycle-runtime-ktx:2.4.1"
@ -63,13 +63,15 @@ dependencies {
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.1'
implementation "androidx.preference:preference-ktx:1.2.0"
implementation 'com.google.android.gms:play-services-auth:20.2.0'
implementation project(':common')
implementation project(':shared')
implementation 'androidx.appcompat:appcompat:1.4.1'
implementation 'androidx.appcompat:appcompat:1.4.2'
implementation "com.google.dagger:hilt-android:2.41"
kapt "com.google.dagger:hilt-compiler:2.41"
implementation "androidx.core:core-ktx:1.7.0"
implementation "androidx.core:core-ktx:1.8.0"
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
}
repositories {

View file

@ -35,6 +35,8 @@
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<activity android:name=".ui.activities.LoginActivity" />
<activity android:name=".ui.activities.TaskListActivity" />
<activity android:name=".ui.activities.TaskFormActivity" />

View file

@ -1,6 +1,7 @@
package com.habitrpg.wearos.habitica
import android.app.Application
import com.habitrpg.common.habitica.extensions.setupCoil
import com.habitrpg.common.habitica.helpers.MarkdownParser
import com.habitrpg.common.habitica.views.HabiticaIconsHelper
import dagger.hilt.android.HiltAndroidApp
@ -11,5 +12,6 @@ class MainApplication : Application() {
super.onCreate()
HabiticaIconsHelper.init(this)
MarkdownParser.setup(this)
setupCoil()
}
}

View file

@ -1,6 +1,7 @@
package com.habitrpg.wearos.habitica.data
import android.content.Context
import com.amplitude.api.Amplitude
import com.habitrpg.common.habitica.BuildConfig
import com.habitrpg.common.habitica.api.HostConfig
import com.habitrpg.common.habitica.api.Server
@ -85,6 +86,12 @@ class ApiClient @Inject constructor(
this.apiService = retrofitAdapter.create(ApiService::class.java)
}
fun updateAuthenticationCredentials(userID: String?, apiToken: String?) {
this.hostConfig.userID = userID ?: ""
this.hostConfig.apiKey = apiToken ?: ""
Amplitude.getInstance().userId = this.hostConfig.userID
}
private fun <T> process(response: WearableHabitResponse<T>): T? {
return response.data
}
@ -104,4 +111,5 @@ class ApiClient @Inject constructor(
suspend fun getTasks() = process(apiService.getTasks())
suspend fun scoreTask(id: String, direction: String) = process(apiService.scoreTask(id, direction))
}

View file

@ -6,7 +6,6 @@ import androidx.preference.PreferenceManager
import com.habitrpg.common.habitica.api.HostConfig
import com.habitrpg.common.habitica.helpers.KeyHelper
import com.habitrpg.shared.habitica.HLogger
import com.habitrpg.wearos.habitica.BuildConfig
import com.habitrpg.wearos.habitica.data.ApiClient
import com.habitrpg.wearos.habitica.data.AttributeAdapter
import com.habitrpg.wearos.habitica.data.FrequencyAdapter
@ -39,8 +38,7 @@ class AppModule {
keyHelper: KeyHelper?,
@ApplicationContext context: Context
): HostConfig {
return HostConfig(BuildConfig.DEBUG_USER_ID,
BuildConfig.DEBUG_API_KEY)
return HostConfig(sharedPreferences, keyHelper, context)
}
@Provides

View file

@ -28,16 +28,23 @@ class AvatarActivity: BaseActivity<ActivityAvatarBinding, AvatarViewModel>() {
override fun onStart() {
super.onStart()
scaleAvatar()
}
private fun scaleAvatar() {
val params = binding.root.layoutParams as FrameLayout.LayoutParams
val maxSize = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
max(windowManager.currentWindowMetrics.bounds.bottom, windowManager.currentWindowMetrics.bounds.right)
max(
windowManager.currentWindowMetrics.bounds.bottom,
windowManager.currentWindowMetrics.bounds.right
)
} else {
max(windowManager.defaultDisplay.width, windowManager.defaultDisplay.height)
}
var factor = (maxSize / 46f) / 3f
var viewSize = 138 * factor.roundToInt()
if (maxSize - viewSize > 20.dpToPx(this)) {
viewSize += 45
viewSize += 46
factor += 1
}
params.width = viewSize

View file

@ -0,0 +1,101 @@
package com.habitrpg.wearos.habitica.ui.activities
import android.accounts.AccountManager
import android.app.Activity
import android.app.AlertDialog
import android.content.Intent
import android.os.Bundle
import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.viewModels
import androidx.lifecycle.lifecycleScope
import com.habitrpg.common.habitica.models.auth.UserAuthResponse
import com.habitrpg.wearos.habitica.R
import com.habitrpg.wearos.habitica.databinding.ActivityLoginBinding
import com.habitrpg.wearos.habitica.ui.viewmodels.LoginViewModel
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.launch
@AndroidEntryPoint
class LoginActivity: BaseActivity<ActivityLoginBinding, LoginViewModel>() {
override val viewModel: LoginViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
binding = ActivityLoginBinding.inflate(layoutInflater)
super.onCreate(savedInstanceState)
binding.loginButton.setOnClickListener { loginLocal() }
binding.googleLoginButton.setOnClickListener { loginGoogle() }
binding.registerButton.setOnClickListener { openRegisterOnPhone() }
}
private fun openRegisterOnPhone() {
}
private fun loginLocal() {
val username: String = binding.usernameEditText.text.toString().trim { it <= ' ' }
val password: String = binding.passwordEditText.text.toString()
if (username.isEmpty() || password.isEmpty()) {
showValidationError(getString(R.string.login_validation_error_fieldsmissing))
return
}
lifecycleScope.launch {
val result = viewModel.login(username, password)
if (result != null) {
handleAuthResponse(result)
}
}
}
private fun loginGoogle() {
viewModel.handleGoogleLogin(this, pickAccountResult)
}
private fun handleAuthResponse(response: UserAuthResponse) {
viewModel.handleAuthResponse(response)
lifecycleScope.launch {
viewModel.retrieveUser()
startMainActivity()
}
}
private fun showValidationError(message: String) {
val alert = AlertDialog.Builder(this).create()
alert.setTitle(R.string.login_validation_error_title)
alert.setMessage(message)
alert.setButton(AlertDialog.BUTTON_NEUTRAL, getString(R.string.ok)) { alert, _ ->
alert.dismiss()
}
alert.show()
}
private val pickAccountResult = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
if (it.resultCode == Activity.RESULT_OK) {
viewModel.googleEmail = it?.data?.getStringExtra(AccountManager.KEY_ACCOUNT_NAME)
viewModel.handleGoogleLoginResult(this, recoverFromPlayServicesErrorResult) { response ->
if (response != null) {
handleAuthResponse(response)
}
}
}
}
private val recoverFromPlayServicesErrorResult = registerForActivityResult(
ActivityResultContracts.StartActivityForResult()
) {
if (it.resultCode != Activity.RESULT_CANCELED) {
viewModel.handleGoogleLoginResult(this, null) { response ->
if (response != null) {
handleAuthResponse(response)
}
}
}
}
private fun startMainActivity() {
val intent = Intent(this@LoginActivity, MainActivity::class.java)
intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_NEW_TASK)
startActivity(intent)
finish()
}
}

View file

@ -29,6 +29,9 @@ class MainActivity : BaseActivity<ActivityMainBinding, MainViewModel>() {
WearableLinearLayoutManager(this@MainActivity, HabiticaScrollingLayoutCallback())
adapter = this@MainActivity.adapter
}
if (!viewModel.isAuthenticated) {
openLoginActivity()
}
}
override fun onStart() {
@ -123,6 +126,12 @@ class MainActivity : BaseActivity<ActivityMainBinding, MainViewModel>() {
startActivity(Intent(this, SettingsActivity::class.java))
}
private fun openLoginActivity() {
val intent = Intent(this, LoginActivity::class.java)
intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK)
startActivity(intent)
}
private fun openTasklist(type: TaskType) {
val intent = Intent(this, TaskListActivity::class.java).apply {
putExtra("task_type", type.value)

View file

@ -4,6 +4,7 @@ import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import com.habitrpg.wearos.habitica.data.repositories.UserRepository
import com.habitrpg.wearos.habitica.models.DisplayedError
import com.habitrpg.wearos.habitica.models.User
import com.habitrpg.wearos.habitica.util.ErrorPresenter
import com.habitrpg.wearos.habitica.util.ExceptionHandlerBuilder
@ -11,5 +12,9 @@ open class BaseViewModel(
val userRepository: UserRepository,
val exceptionBuilder: ExceptionHandlerBuilder
): ViewModel(), ErrorPresenter {
suspend fun retrieveUser(): User? {
return userRepository.retrieveUser()
}
override val errorValues = MutableLiveData<DisplayedError>()
}

View file

@ -0,0 +1,166 @@
package com.habitrpg.wearos.habitica.ui.viewmodels
import android.accounts.AccountManager
import android.app.Activity
import android.content.ActivityNotFoundException
import android.content.Intent
import android.content.SharedPreferences
import androidx.activity.result.ActivityResultLauncher
import androidx.core.content.edit
import androidx.lifecycle.viewModelScope
import com.google.android.gms.auth.GoogleAuthException
import com.google.android.gms.auth.GoogleAuthUtil
import com.google.android.gms.auth.GooglePlayServicesAvailabilityException
import com.google.android.gms.auth.UserRecoverableAuthException
import com.google.android.gms.common.ConnectionResult
import com.google.android.gms.common.GoogleApiAvailability
import com.google.android.gms.common.GooglePlayServicesUtil
import com.google.android.gms.common.Scopes
import com.google.android.gms.common.UserRecoverableException
import com.habitrpg.common.habitica.helpers.KeyHelper
import com.habitrpg.common.habitica.models.auth.UserAuth
import com.habitrpg.common.habitica.models.auth.UserAuthResponse
import com.habitrpg.common.habitica.models.auth.UserAuthSocial
import com.habitrpg.wearos.habitica.data.ApiClient
import com.habitrpg.wearos.habitica.data.repositories.UserRepository
import com.habitrpg.wearos.habitica.util.ExceptionHandlerBuilder
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.launch
import java.io.IOException
import javax.inject.Inject
@HiltViewModel
class LoginViewModel @Inject constructor(userRepository: UserRepository,
exceptionBuilder: ExceptionHandlerBuilder,
val keyHelper: KeyHelper?,
val sharedPreferences: SharedPreferences,
val apiClient: ApiClient
) : BaseViewModel(userRepository, exceptionBuilder) {
var googleEmail: String? = null
fun handleGoogleLogin(
activity: Activity,
pickAccountResult: ActivityResultLauncher<Intent>
) {
if (!checkPlayServices(activity)) {
return
}
val accountTypes = arrayOf("com.google")
val intent = AccountManager.newChooseAccountIntent(
null, null,
accountTypes, true, null, null, null, null
)
try {
pickAccountResult.launch(intent)
} catch (e: ActivityNotFoundException) {
/*val alert = AlertDialog.Builder(activity).create()
alert.setTitle(R.string.authentication_error_title)
alert.setMessage(R.string.google_services_missing)
alert.addCloseButton()
alert.show()*/
}
}
fun handleGoogleLoginResult(
activity: Activity,
recoverFromPlayServicesErrorResult: ActivityResultLauncher<Intent>?,
onSuccess: (UserAuthResponse?) -> Unit
) {
val scopesString = Scopes.PROFILE + " " + Scopes.EMAIL
val scopes = "oauth2:$scopesString"
viewModelScope.launch {
val token = try {
GoogleAuthUtil.getToken(activity, googleEmail ?: "", scopes)
} catch (e: IOException) {
return@launch
} catch (e: GoogleAuthException) {
if (recoverFromPlayServicesErrorResult != null) {
handleGoogleAuthException(e, activity, recoverFromPlayServicesErrorResult)
}
return@launch
} catch (e: UserRecoverableException) {
return@launch
}
val response = apiClient.loginSocial(UserAuthSocial())
onSuccess(response)
}
}
private fun handleGoogleAuthException(
e: Exception,
activity: Activity,
recoverFromPlayServicesErrorResult: ActivityResultLauncher<Intent>
) {
if (e is GooglePlayServicesAvailabilityException) {
GoogleApiAvailability.getInstance()
GooglePlayServicesUtil.showErrorDialogFragment(
e.connectionStatusCode,
activity,
null,
REQUEST_CODE_RECOVER_FROM_PLAY_SERVICES_ERROR
) {
}
return
} else if (e is UserRecoverableAuthException) {
// Unable to authenticate, such as when the user has not yet granted
// the app access to the account, but the user can fix this.
// Forward the user to an activity in Google Play services.
val intent = e.intent
recoverFromPlayServicesErrorResult.launch(intent)
return
}
}
private fun checkPlayServices(activity: Activity): Boolean {
val googleAPI = GoogleApiAvailability.getInstance()
val result = googleAPI.isGooglePlayServicesAvailable(activity)
if (result != ConnectionResult.SUCCESS) {
if (googleAPI.isUserResolvableError(result)) {
googleAPI.getErrorDialog(
activity, result,
PLAY_SERVICES_RESOLUTION_REQUEST
)?.show()
}
return false
}
return true
}
fun handleAuthResponse(userAuthResponse: UserAuthResponse) {
try {
saveTokens(userAuthResponse.apiToken, userAuthResponse.id)
} catch (e: Exception) {
}
}
@Throws(Exception::class)
private fun saveTokens(api: String, user: String) {
this.apiClient.updateAuthenticationCredentials(user, api)
sharedPreferences.edit {
putString("UserID", user)
val encryptedKey =
try {
keyHelper?.encrypt(api)
} catch (e: Exception) {
null
}
if ((encryptedKey?.length ?: 0) > 5) {
putString(user, encryptedKey)
} else {
// Something might have gone wrong with encryption, so fall back to this.
putString("APIToken", api)
}
}
}
suspend fun login(username: String, password: String): UserAuthResponse? {
return apiClient.loginLocal(UserAuth(username, password))
}
companion object {
private const val REQUEST_CODE_RECOVER_FROM_PLAY_SERVICES_ERROR = 1001
private const val PLAY_SERVICES_RESOLUTION_REQUEST = 9000
}
}

View file

@ -3,6 +3,7 @@ package com.habitrpg.wearos.habitica.ui.viewmodels
import androidx.lifecycle.LiveData
import androidx.lifecycle.asLiveData
import androidx.lifecycle.viewModelScope
import com.habitrpg.common.habitica.api.HostConfig
import com.habitrpg.wearos.habitica.data.repositories.TaskRepository
import com.habitrpg.wearos.habitica.data.repositories.UserRepository
import com.habitrpg.wearos.habitica.models.User
@ -13,10 +14,15 @@ import javax.inject.Inject
@HiltViewModel
class MainViewModel @Inject constructor(
val hostConfig: HostConfig,
userRepository: UserRepository,
val taskRepository: TaskRepository,
exceptionBuilder: ExceptionHandlerBuilder
) : BaseViewModel(userRepository, exceptionBuilder) {
val isAuthenticated: Boolean
get() {
return hostConfig.hasAuthentication()
}
var user: LiveData<User>
init {

View file

@ -0,0 +1,48 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.core.widget.NestedScrollView
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:orientation="vertical"
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.wear.widget.BoxInsetLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
app:layout_boxedEdges="all">
<EditText
android:id="@+id/username_edit_text"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="textEmailAddress"
android:autofillHints="username" />
<androidx.appcompat.widget.AppCompatEditText
android:id="@+id/password_edit_text"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="textPassword"
android:autofillHints="password"/>
<Button
android:id="@+id/login_button"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/login_btn"/>
<Button
android:id="@+id/google_login_button"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/login_btn_google"
android:layout_marginTop="@dimen/spacing_small"/>
<Button
android:id="@+id/register_button"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/register_btn"
android:layout_marginTop="@dimen/spacing_large"/>
</LinearLayout>
</androidx.wear.widget.BoxInsetLayout>
</androidx.core.widget.NestedScrollView>

View file

@ -1,3 +1,4 @@
<resources>
<string name="app_name">Habitica</string>
<string name="ok">OK</string>
</resources>