Merge pull request #1417 from Andries-Smit/feat/protection-timeout

Feature: protection password and PIN retention
This commit is contained in:
Milos Kozak 2022-03-13 10:29:54 +01:00 committed by GitHub
commit b047e45daa
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
17 changed files with 238 additions and 18 deletions

View file

@ -6,6 +6,7 @@ import android.content.IntentFilter
import android.net.ConnectivityManager
import android.net.wifi.WifiManager
import android.os.Build
import androidx.lifecycle.ProcessLifecycleOwner
import com.uber.rxdogtag.RxDogTag
import dagger.android.AndroidInjector
import dagger.android.DaggerApplication
@ -35,6 +36,7 @@ import info.nightscout.androidaps.receivers.TimeDateOrTZChangeReceiver
import info.nightscout.androidaps.services.AlarmSoundServiceHelper
import info.nightscout.androidaps.utils.ActivityMonitor
import info.nightscout.androidaps.utils.DateUtil
import info.nightscout.androidaps.utils.ProcessLifecycleListener
import info.nightscout.androidaps.utils.buildHelper.BuildHelper
import info.nightscout.androidaps.utils.locale.LocaleHelper
import info.nightscout.androidaps.utils.protection.PasswordCheck
@ -70,6 +72,7 @@ class MainApp : DaggerApplication() {
@Inject lateinit var passwordCheck: PasswordCheck
@Inject lateinit var alarmSoundServiceHelper: AlarmSoundServiceHelper
@Inject lateinit var notificationStore: NotificationStore
@Inject lateinit var processLifecycleListener: ProcessLifecycleListener
override fun onCreate() {
super.onCreate()
@ -77,6 +80,7 @@ class MainApp : DaggerApplication() {
RxDogTag.install()
setRxErrorHandler()
LocaleHelper.update(this)
ProcessLifecycleOwner.get().lifecycle.addObserver(processLifecycleListener)
var gitRemote: String? = BuildConfig.REMOTE
var commitHash: String? = BuildConfig.HEAD

View file

@ -27,6 +27,7 @@ import info.nightscout.androidaps.plugins.iob.iobCobCalculator.GlucoseStatusProv
import info.nightscout.androidaps.queue.Callback
import info.nightscout.androidaps.utils.*
import info.nightscout.androidaps.utils.alertDialogs.OKDialog
import info.nightscout.androidaps.utils.protection.ProtectionCheck
import info.nightscout.androidaps.utils.resources.ResourceHelper
import io.reactivex.rxjava3.disposables.CompositeDisposable
import io.reactivex.rxjava3.kotlin.plusAssign
@ -50,6 +51,7 @@ class CarbsDialog : DialogFragmentWithDate() {
@Inject lateinit var bolusTimer: BolusTimer
@Inject lateinit var commandQueue: CommandQueue
@Inject lateinit var repository: AppRepository
@Inject lateinit var protectionCheck: ProtectionCheck
companion object {
@ -377,4 +379,17 @@ class CarbsDialog : DialogFragmentWithDate() {
}
return true
}
}
override fun onResume() {
super.onResume()
activity?.let { activity ->
val cancelFail = {
aapsLogger.debug(LTag.APS, "Dialog canceled on resume protection: ${this.javaClass.name}")
ToastUtils.showToastInUiThread(ctx, R.string.dialog_cancled)
dismiss()
}
protectionCheck.queryProtection(activity, ProtectionCheck.Protection.BOLUS, {}, cancelFail, fail = cancelFail)
}
}
}

View file

@ -22,7 +22,10 @@ import info.nightscout.androidaps.utils.HtmlHelper
import info.nightscout.shared.SafeParse
import info.nightscout.androidaps.utils.alertDialogs.OKDialog
import info.nightscout.androidaps.extensions.formatColor
import info.nightscout.androidaps.utils.ToastUtils
import info.nightscout.androidaps.utils.protection.ProtectionCheck
import info.nightscout.androidaps.utils.resources.ResourceHelper
import info.nightscout.shared.logging.LTag
import java.text.DecimalFormat
import java.util.*
import javax.inject.Inject
@ -36,6 +39,7 @@ class ExtendedBolusDialog : DialogFragmentWithDate() {
@Inject lateinit var commandQueue: CommandQueue
@Inject lateinit var activePlugin: ActivePlugin
@Inject lateinit var uel: UserEntryLogger
@Inject lateinit var protectionCheck: ProtectionCheck
private var _binding: DialogExtendedbolusBinding? = null
@ -106,4 +110,17 @@ class ExtendedBolusDialog : DialogFragmentWithDate() {
}
return true
}
}
override fun onResume() {
super.onResume()
activity?.let { activity ->
val cancelFail = {
aapsLogger.debug(LTag.APS, "Dialog canceled on resume protection: ${this.javaClass.name}")
ToastUtils.showToastInUiThread(ctx, R.string.dialog_cancled)
dismiss()
}
protectionCheck.queryProtection(activity, ProtectionCheck.Protection.BOLUS, {}, cancelFail, fail = cancelFail)
}
}
}

View file

@ -28,6 +28,8 @@ import info.nightscout.androidaps.utils.HtmlHelper
import info.nightscout.shared.SafeParse
import info.nightscout.androidaps.utils.alertDialogs.OKDialog
import info.nightscout.androidaps.extensions.formatColor
import info.nightscout.androidaps.utils.ToastUtils
import info.nightscout.androidaps.utils.protection.ProtectionCheck
import info.nightscout.androidaps.utils.resources.ResourceHelper
import io.reactivex.rxjava3.disposables.CompositeDisposable
import io.reactivex.rxjava3.kotlin.plusAssign
@ -44,6 +46,7 @@ class FillDialog : DialogFragmentWithDate() {
@Inject lateinit var activePlugin: ActivePlugin
@Inject lateinit var uel: UserEntryLogger
@Inject lateinit var repository: AppRepository
@Inject lateinit var protectionCheck: ProtectionCheck
private val disposable = CompositeDisposable()
@ -196,4 +199,17 @@ class FillDialog : DialogFragmentWithDate() {
}
})
}
override fun onResume() {
super.onResume()
activity?.let { activity ->
val cancelFail = {
aapsLogger.debug(LTag.APS, "Dialog canceled on resume protection: ${this.javaClass.name}")
ToastUtils.showToastInUiThread(ctx, R.string.dialog_cancled)
dismiss()
}
protectionCheck.queryProtection(activity, ProtectionCheck.Protection.BOLUS, {}, cancelFail, fail = cancelFail)
}
}
}

View file

@ -28,6 +28,7 @@ import info.nightscout.androidaps.queue.Callback
import info.nightscout.androidaps.utils.*
import info.nightscout.androidaps.utils.alertDialogs.OKDialog
import info.nightscout.androidaps.utils.extensions.toSignedString
import info.nightscout.androidaps.utils.protection.ProtectionCheck
import info.nightscout.androidaps.utils.resources.ResourceHelper
import info.nightscout.shared.SafeParse
import io.reactivex.rxjava3.disposables.CompositeDisposable
@ -52,6 +53,7 @@ class InsulinDialog : DialogFragmentWithDate() {
@Inject lateinit var config: Config
@Inject lateinit var bolusTimer: BolusTimer
@Inject lateinit var uel: UserEntryLogger
@Inject lateinit var protectionCheck: ProtectionCheck
companion object {
@ -255,4 +257,17 @@ class InsulinDialog : DialogFragmentWithDate() {
}
return true
}
}
override fun onResume() {
super.onResume()
activity?.let { activity ->
val cancelFail = {
aapsLogger.debug(LTag.APS, "Dialog canceled on resume protection: ${this.javaClass.name}")
ToastUtils.showToastInUiThread(ctx, R.string.dialog_cancled)
dismiss()
}
protectionCheck.queryProtection(activity, ProtectionCheck.Protection.BOLUS, {}, cancelFail, fail = cancelFail)
}
}
}

View file

@ -38,6 +38,7 @@ import info.nightscout.androidaps.utils.FabricPrivacy
import info.nightscout.androidaps.utils.T
import info.nightscout.androidaps.utils.ToastUtils
import info.nightscout.androidaps.utils.alertDialogs.OKDialog
import info.nightscout.androidaps.utils.protection.ProtectionCheck
import info.nightscout.androidaps.utils.resources.ResourceHelper
import info.nightscout.shared.sharedPreferences.SP
import io.reactivex.rxjava3.disposables.CompositeDisposable
@ -62,6 +63,7 @@ class LoopDialog : DaggerDialogFragment() {
@Inject lateinit var dateUtil: DateUtil
@Inject lateinit var repository: AppRepository
@Inject lateinit var objectivePlugin: ObjectivesPlugin
@Inject lateinit var protectionCheck: ProtectionCheck
private var showOkCancel: Boolean = true
private var _binding: DialogLoopBinding? = null
@ -437,4 +439,17 @@ class LoopDialog : DaggerDialogFragment() {
aapsLogger.debug(e.localizedMessage ?: e.toString())
}
}
override fun onResume() {
super.onResume()
activity?.let { activity ->
val cancelFail = {
aapsLogger.debug(LTag.APS, "Dialog canceled on resume protection: ${this.javaClass.name}")
ToastUtils.showToastInUiThread(ctx, R.string.dialog_cancled)
dismiss()
}
protectionCheck.queryProtection(activity, ProtectionCheck.Protection.BOLUS, {}, cancelFail, fail = cancelFail)
}
}
}

View file

@ -1,5 +1,6 @@
package info.nightscout.androidaps.dialogs
import android.content.Context
import android.os.Bundle
import android.text.Editable
import android.text.TextWatcher
@ -30,7 +31,9 @@ import info.nightscout.androidaps.utils.DefaultValueHelper
import info.nightscout.androidaps.utils.HardLimits
import info.nightscout.androidaps.utils.HtmlHelper
import info.nightscout.androidaps.utils.T
import info.nightscout.androidaps.utils.ToastUtils
import info.nightscout.androidaps.utils.alertDialogs.OKDialog
import info.nightscout.androidaps.utils.protection.ProtectionCheck
import info.nightscout.androidaps.utils.resources.ResourceHelper
import io.reactivex.rxjava3.disposables.CompositeDisposable
import io.reactivex.rxjava3.kotlin.plusAssign
@ -51,6 +54,8 @@ class ProfileSwitchDialog : DialogFragmentWithDate() {
@Inject lateinit var hardLimits: HardLimits
@Inject lateinit var rxBus: RxBus
@Inject lateinit var defaultValueHelper: DefaultValueHelper
@Inject lateinit var ctx: Context
@Inject lateinit var protectionCheck: ProtectionCheck
private var profileIndex: Int? = null
@ -245,4 +250,17 @@ class ProfileSwitchDialog : DialogFragmentWithDate() {
}
return true
}
override fun onResume() {
super.onResume()
activity?.let { activity ->
val cancelFail = {
aapsLogger.debug(LTag.APS, "Dialog canceled on resume protection: ${this.javaClass.name}")
ToastUtils.showToastInUiThread(ctx, R.string.dialog_cancled)
dismiss()
}
protectionCheck.queryProtection(activity, ProtectionCheck.Protection.BOLUS, {}, cancelFail, fail = cancelFail)
}
}
}

View file

@ -20,7 +20,10 @@ import info.nightscout.androidaps.utils.HtmlHelper
import info.nightscout.shared.SafeParse
import info.nightscout.androidaps.utils.alertDialogs.OKDialog
import info.nightscout.androidaps.extensions.formatColor
import info.nightscout.androidaps.utils.ToastUtils
import info.nightscout.androidaps.utils.protection.ProtectionCheck
import info.nightscout.androidaps.utils.resources.ResourceHelper
import info.nightscout.shared.logging.LTag
import java.text.DecimalFormat
import java.util.*
import javax.inject.Inject
@ -35,6 +38,7 @@ class TempBasalDialog : DialogFragmentWithDate() {
@Inject lateinit var commandQueue: CommandQueue
@Inject lateinit var ctx: Context
@Inject lateinit var uel: UserEntryLogger
@Inject lateinit var protectionCheck: ProtectionCheck
private var isPercentPump = true
@ -141,4 +145,17 @@ class TempBasalDialog : DialogFragmentWithDate() {
}
return true
}
}
override fun onResume() {
super.onResume()
activity?.let { activity ->
val cancelFail = {
aapsLogger.debug(LTag.APS, "Dialog canceled on resume protection: ${this.javaClass.name}")
ToastUtils.showToastInUiThread(ctx, R.string.dialog_cancled)
dismiss()
}
protectionCheck.queryProtection(activity, ProtectionCheck.Protection.BOLUS, {}, cancelFail, fail = cancelFail)
}
}
}

View file

@ -1,5 +1,6 @@
package info.nightscout.androidaps.dialogs
import android.content.Context
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
@ -26,7 +27,9 @@ import info.nightscout.androidaps.logging.UserEntryLogger
import info.nightscout.androidaps.plugins.configBuilder.ConstraintChecker
import info.nightscout.androidaps.utils.DefaultValueHelper
import info.nightscout.androidaps.utils.HtmlHelper
import info.nightscout.androidaps.utils.ToastUtils
import info.nightscout.androidaps.utils.alertDialogs.OKDialog
import info.nightscout.androidaps.utils.protection.ProtectionCheck
import info.nightscout.androidaps.utils.resources.ResourceHelper
import io.reactivex.rxjava3.disposables.CompositeDisposable
import io.reactivex.rxjava3.kotlin.plusAssign
@ -43,6 +46,8 @@ class TempTargetDialog : DialogFragmentWithDate() {
@Inject lateinit var defaultValueHelper: DefaultValueHelper
@Inject lateinit var uel: UserEntryLogger
@Inject lateinit var repository: AppRepository
@Inject lateinit var ctx: Context
@Inject lateinit var protectionCheck: ProtectionCheck
private lateinit var reasonList: List<String>
@ -218,4 +223,17 @@ class TempTargetDialog : DialogFragmentWithDate() {
}
return true
}
override fun onResume() {
super.onResume()
activity?.let { activity ->
val cancelFail = {
aapsLogger.debug(LTag.APS, "Dialog canceled on resume protection: ${this.javaClass.name}")
ToastUtils.showToastInUiThread(ctx, R.string.dialog_cancled)
dismiss()
}
protectionCheck.queryProtection(activity, ProtectionCheck.Protection.BOLUS, {}, cancelFail, fail = cancelFail)
}
}
}

View file

@ -30,6 +30,7 @@ import info.nightscout.shared.SafeParse
import info.nightscout.androidaps.utils.ToastUtils
import info.nightscout.androidaps.utils.alertDialogs.OKDialog
import info.nightscout.androidaps.extensions.formatColor
import info.nightscout.androidaps.utils.protection.ProtectionCheck
import info.nightscout.androidaps.utils.resources.ResourceHelper
import io.reactivex.rxjava3.disposables.CompositeDisposable
import io.reactivex.rxjava3.kotlin.plusAssign
@ -48,6 +49,7 @@ class TreatmentDialog : DialogFragmentWithDate() {
@Inject lateinit var config: Config
@Inject lateinit var uel: UserEntryLogger
@Inject lateinit var repository: AppRepository
@Inject lateinit var protectionCheck: ProtectionCheck
private val disposable = CompositeDisposable()
@ -201,4 +203,17 @@ class TreatmentDialog : DialogFragmentWithDate() {
}
return true
}
}
override fun onResume() {
super.onResume()
activity?.let { activity ->
val cancelFail = {
aapsLogger.debug(LTag.APS, "Dialog canceled on resume protection: ${this.javaClass.name}")
ToastUtils.showToastInUiThread(ctx, R.string.dialog_cancled)
dismiss()
}
protectionCheck.queryProtection(activity, ProtectionCheck.Protection.BOLUS, {}, cancelFail, fail = cancelFail)
}
}
}

View file

@ -29,6 +29,7 @@ import info.nightscout.androidaps.extensions.toVisibility
import info.nightscout.androidaps.extensions.valueToUnits
import info.nightscout.androidaps.interfaces.*
import info.nightscout.androidaps.utils.*
import info.nightscout.androidaps.utils.protection.ProtectionCheck
import info.nightscout.androidaps.utils.resources.ResourceHelper
import info.nightscout.androidaps.utils.rx.AapsSchedulers
import info.nightscout.shared.sharedPreferences.SP
@ -55,6 +56,7 @@ class WizardDialog : DaggerDialogFragment() {
@Inject lateinit var iobCobCalculator: IobCobCalculator
@Inject lateinit var repository: AppRepository
@Inject lateinit var dateUtil: DateUtil
@Inject lateinit var protectionCheck: ProtectionCheck
private var wizard: BolusWizard? = null
private var calculatedPercentage = 100.0
@ -497,4 +499,17 @@ class WizardDialog : DaggerDialogFragment() {
aapsLogger.debug(e.localizedMessage ?: "")
}
}
override fun onResume() {
super.onResume()
activity?.let { activity ->
val cancelFail = {
aapsLogger.debug(LTag.APS, "Dialog canceled on resume protection: ${this.javaClass.name}")
ToastUtils.showToastInUiThread(ctx, R.string.dialog_cancled)
dismiss()
}
protectionCheck.queryProtection(activity, ProtectionCheck.Protection.BOLUS, {}, cancelFail, fail = cancelFail)
}
}
}

View file

@ -0,0 +1,13 @@
package info.nightscout.androidaps.utils
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import info.nightscout.androidaps.utils.protection.ProtectionCheck
import javax.inject.Inject
class ProcessLifecycleListener @Inject constructor(private val protectionCheck: ProtectionCheck) : DefaultLifecycleObserver {
override fun onPause(owner: LifecycleOwner) {
protectionCheck.resetAuthorization()
}
}

View file

@ -1224,4 +1224,5 @@
<string name="show_loop">Show loop</string>
<string name="count_selected">%1$d selected</string>
<string name="sort_label">Sort</string>
<string name="dialog_cancled">Dialog canceled</string>
</resources>

View file

@ -1,6 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.preference.PreferenceScreen xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:validate="http://schemas.android.com/apk/res-auto">
<PreferenceCategory
android:key="@string/key_configbuilder_general_settings"
@ -24,9 +25,8 @@
<EditTextPreference
android:inputType="textPersonName"
android:key="@string/key_patient_name"
android:title="@string/patient_name"
android:summary="@string/patient_name_summary"
/>
android:title="@string/patient_name" />
<PreferenceCategory
android:key="@string/key_protection_settings"
@ -35,8 +35,7 @@
<Preference
android:inputType="textPassword"
android:key="@string/key_master_password"
android:title="@string/master_password"
/>
android:title="@string/master_password" />
<ListPreference
android:defaultValue="0"
@ -83,6 +82,16 @@
android:key="@string/key_bolus_pin"
android:title="@string/bolus_pin" />
<EditTextPreference
android:inputType="number"
android:key="@string/key_protection_timeout"
android:title="@string/protection_timeout_title"
android:summary="@string/protection_timeout_summary"
app:defaultValue="0"
validate:maxNumber="180"
validate:minNumber="0"
validate:testType="numeric" />
</PreferenceCategory>
<info.nightscout.androidaps.skins.SkinListPreference
@ -91,4 +100,4 @@
</PreferenceCategory>
</androidx.preference.PreferenceScreen>
</androidx.preference.PreferenceScreen>

View file

@ -14,6 +14,7 @@ dependencies {
api "androidx.browser:browser:1.3.0"
api "androidx.activity:activity-ktx:${activity_version}"
api "androidx.lifecycle:lifecycle-runtime-ktx:$lifecycle_version"
api "androidx.lifecycle:lifecycle-process:$lifecycle_version"
api 'androidx.cardview:cardview:1.0.0'
api 'androidx.recyclerview:recyclerview:1.2.1'
api 'androidx.gridlayout:gridlayout:1.0.0'

View file

@ -2,16 +2,22 @@ package info.nightscout.androidaps.utils.protection
import androidx.fragment.app.FragmentActivity
import info.nightscout.androidaps.core.R
import info.nightscout.androidaps.utils.DateUtil
import info.nightscout.shared.sharedPreferences.SP
import java.util.concurrent.TimeUnit
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class ProtectionCheck @Inject constructor(
val sp: SP,
val passwordCheck: PasswordCheck
val passwordCheck: PasswordCheck,
val dateUtil: DateUtil
) {
private var lastAuthorization = mutableListOf(0L, 0L, 0L)
private val timeout = TimeUnit.SECONDS.toMillis(sp.getInt(R.string.key_protection_timeout, 0).toLong())
enum class Protection {
PREFERENCES,
APPLICATION,
@ -52,6 +58,9 @@ class ProtectionCheck @Inject constructor(
R.string.bolus_pin)
fun isLocked(protection: Protection): Boolean {
if (activeSession(protection)) {
return false
}
return when (ProtectionType.values()[sp.getInt(protectionTypeResourceIDs[protection.ordinal], ProtectionType.NONE.ordinal)]) {
ProtectionType.NONE -> false
ProtectionType.BIOMETRIC -> true
@ -61,19 +70,38 @@ class ProtectionCheck @Inject constructor(
}
}
fun queryProtection(activity: FragmentActivity, protection: Protection,
ok: Runnable?, cancel: Runnable? = null, fail: Runnable? = null) {
fun resetAuthorization() {
lastAuthorization = mutableListOf(0L, 0L, 0L)
}
private fun activeSession(protection: Protection): Boolean {
val last = lastAuthorization[protection.ordinal]
val diff = dateUtil.now() - last
return diff < timeout
}
private fun onOk(protection: Protection) {
lastAuthorization[protection.ordinal] = dateUtil.now()
}
fun queryProtection(activity: FragmentActivity, protection: Protection, ok: Runnable?, cancel: Runnable? = null, fail: Runnable? = null) {
if (activeSession(protection)) {
onOk(protection)
ok?.run()
return
}
when (ProtectionType.values()[sp.getInt(protectionTypeResourceIDs[protection.ordinal], ProtectionType.NONE.ordinal)]) {
ProtectionType.NONE ->
ok?.run()
ProtectionType.BIOMETRIC ->
BiometricCheck.biometricPrompt(activity, titlePassResourceIDs[protection.ordinal], ok, cancel, fail, passwordCheck)
BiometricCheck.biometricPrompt(activity, titlePassResourceIDs[protection.ordinal], { onOk(protection); ok?.run() }, cancel, fail, passwordCheck)
ProtectionType.MASTER_PASSWORD ->
passwordCheck.queryPassword(activity, R.string.master_password, R.string.key_master_password, { ok?.run() }, { cancel?.run() }, { fail?.run() })
passwordCheck.queryPassword(activity, R.string.master_password, R.string.key_master_password, { onOk(protection); ok?.run() }, { cancel?.run() }, { fail?.run() })
ProtectionType.CUSTOM_PASSWORD ->
passwordCheck.queryPassword(activity, titlePassResourceIDs[protection.ordinal], passwordsResourceIDs[protection.ordinal], { ok?.run() }, { cancel?.run() }, { fail?.run() })
passwordCheck.queryPassword(activity, titlePassResourceIDs[protection.ordinal], passwordsResourceIDs[protection.ordinal], { onOk(protection); ok?.run() }, { cancel?.run() }, { fail?.run() })
ProtectionType.CUSTOM_PIN ->
passwordCheck.queryPassword(activity, titlePinResourceIDs[protection.ordinal], pinsResourceIDs[protection.ordinal], { ok?.run() }, { cancel?.run() }, { fail?.run() }, true)
passwordCheck.queryPassword(activity, titlePinResourceIDs[protection.ordinal], pinsResourceIDs[protection.ordinal], { onOk(protection); ok?.run() }, { cancel?.run() }, { fail?.run() }, true)
}
}
}

View file

@ -12,6 +12,8 @@
<string name="application_pin">Application PIN</string>
<string name="bolus_password">Bolus password</string>
<string name="bolus_pin">Bolus PIN</string>
<string name="protection_timeout_title">Password and PIN retention [s]</string>
<string name="protection_timeout_summary">Time before the password or PIN should be entered</string>
<string name="unlock_settings">Unlock settings</string>
<string name="biometric">Biometric</string>
<string name="custom_password">Custom password</string>
@ -43,4 +45,5 @@
<string name="key_settings_protection" translatable="false">settings_protection</string>
<string name="key_application_protection" translatable="false">application_protection</string>
<string name="key_bolus_protection" translatable="false">bolus_protection</string>
<string name="key_protection_timeout" translatable="false">protection_timeout</string>
</resources>