Rewrite BatteryOptimizationsFragment to Compose (#580)

* Migrated to Jetpack Compose

Signed-off-by: Arnau Mora <arnyminerz@proton.me>

* Added `observeBoolean`

Signed-off-by: Arnau Mora <arnyminerz@proton.me>

* Simplified settings interaction

Signed-off-by: Arnau Mora Gras <arnyminerz@proton.me>

* Migrated to Jetpack Compose

Signed-off-by: Arnau Mora <arnyminerz@proton.me>

* Added `observeBoolean`

Signed-off-by: Arnau Mora <arnyminerz@proton.me>

* Simplified settings interaction

Signed-off-by: Arnau Mora Gras <arnyminerz@proton.me>

* Use SafeAndroidUriHandler instead of UiUtils.launchUri

* Removed animation for manufacturerWarning

Signed-off-by: Arnau Mora Gras <arnyminerz@proton.me>

* Removed animation for manufacturerWarning

Signed-off-by: Arnau Mora Gras <arnyminerz@proton.me>

* Added `getBooleanLive`

Signed-off-by: Arnau Mora <arnyminerz@proton.me>

* Using `getBooleanLive`

Signed-off-by: Arnau Mora <arnyminerz@proton.me>

* Moved UI definitions to file scope

Signed-off-by: Arnau Mora Gras <arnyminerz@proton.me>

* Don't use specific times for waiting in tests

* Renamed function

Signed-off-by: Arnau Mora Gras <arnyminerz@proton.me>

* More exact naming

---------

Signed-off-by: Arnau Mora <arnyminerz@proton.me>
Signed-off-by: Arnau Mora Gras <arnyminerz@proton.me>
Co-authored-by: Ricki Hirner <hirner@bitfire.at>
This commit is contained in:
Arnau Mora 2024-02-23 05:12:02 -08:00 committed by GitHub
parent 6b1367d6dc
commit be309e15b3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 328 additions and 217 deletions

View file

@ -4,10 +4,17 @@
package at.bitfire.davdroid.settings
import at.bitfire.davdroid.TestUtils.getOrAwaitValue
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runBlocking
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Rule
import org.junit.Test
@ -16,6 +23,12 @@ import javax.inject.Inject
@HiltAndroidTest
class SettingsManagerTest {
companion object {
/** Use this setting to test SettingsManager methods. Will be removed after every test run. */
const val SETTING_TEST = "test"
}
@get:Rule
val hiltRule = HiltAndroidRule(this)
@ -26,16 +39,58 @@ class SettingsManagerTest {
hiltRule.inject()
}
@After
fun removeTestSetting() {
settingsManager.remove(SETTING_TEST)
}
@Test
fun testContainsKey_NotExisting() {
fun test_containsKey_NotExisting() {
assertFalse(settingsManager.containsKey("notExisting"))
}
@Test
fun testContainsKey_Existing() {
fun test_containsKey_Existing() {
// provided by DefaultsProvider
assertEquals(Settings.PROXY_TYPE_SYSTEM, settingsManager.getInt(Settings.PROXY_TYPE))
}
@Test
fun test_getBooleanLive_getValue() = runBlocking(Dispatchers.Main) { // observeForever can't be run in background thread
val live = settingsManager.getBooleanLive(SETTING_TEST)
assertNull(live.value)
// set value
settingsManager.putBoolean(SETTING_TEST, true)
assertTrue(live.getOrAwaitValue()!!)
// set another value
live.value = false
assertFalse(live.getOrAwaitValue()!!)
}
@Test
fun test_ObserverCalledWhenValueChanges() {
val value = CompletableDeferred<Int>()
val observer = SettingsManager.OnChangeListener {
value.complete(settingsManager.getInt(SETTING_TEST))
}
try {
settingsManager.addOnChangeListener(observer)
settingsManager.putInt(SETTING_TEST, 123)
runBlocking {
// wait until observer is called
assertEquals(123, value.await())
}
} finally {
settingsManager.removeOnChangeListener(observer)
}
}
}

View file

@ -7,7 +7,9 @@ package at.bitfire.davdroid.settings
import android.content.Context
import android.util.NoSuchPropertyException
import androidx.annotation.AnyThread
import androidx.lifecycle.MutableLiveData
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.settings.SettingsManager.OnChangeListener
import dagger.Module
import dagger.Provides
import dagger.hilt.EntryPoint
@ -170,6 +172,38 @@ class SettingsManager internal constructor(
fun remove(key: String) = putString(key, null)
/*** LIVE DATA ***/
/**
* Returns a [MutableLiveData] which is backed by the settings with the given key.
* An observer must be added to the returned [MutableLiveData] to make it active.
*/
fun getBooleanLive(key: String) = object : MutableLiveData<Boolean?>() {
private val preferenceChangeListener = OnChangeListener { updateValue() }
private fun updateValue() {
value = getBooleanOrNull(key)
}
// setValue is also called from postValue, so no need to override
override fun setValue(value: Boolean?) {
super.setValue(value)
putBoolean(key, value)
}
override fun onActive() {
super.onActive()
updateValue()
addOnChangeListener(preferenceChangeListener)
}
override fun onInactive() {
super.onInactive()
removeOnChangeListener(preferenceChangeListener)
}
}
/*** HELPERS ***/
fun dump(writer: Writer) {
@ -180,7 +214,7 @@ class SettingsManager internal constructor(
}
interface OnChangeListener {
fun interface OnChangeListener {
/**
* Will be called when something has changed in a [SettingsProvider].
* May run in worker thread!

View file

@ -16,8 +16,38 @@ import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.activity.result.contract.ActivityResultContract
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.Card
import androidx.compose.material.Checkbox
import androidx.compose.material.MaterialTheme
import androidx.compose.material.OutlinedButton
import androidx.compose.material.Switch
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.ComposeView
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.platform.ViewCompositionStrategy
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.core.content.getSystemService
import androidx.databinding.ObservableBoolean
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import androidx.lifecycle.AndroidViewModel
@ -25,11 +55,11 @@ import androidx.lifecycle.MutableLiveData
import at.bitfire.davdroid.App
import at.bitfire.davdroid.BuildConfig
import at.bitfire.davdroid.R
import at.bitfire.davdroid.databinding.IntroBatteryOptimizationsBinding
import at.bitfire.davdroid.settings.SettingsManager
import at.bitfire.davdroid.ui.UiUtils
import at.bitfire.davdroid.ui.intro.BatteryOptimizationsFragment.Model.Companion.HINT_AUTOSTART_PERMISSION
import at.bitfire.davdroid.ui.intro.BatteryOptimizationsFragment.Model.Companion.HINT_BATTERY_OPTIMIZATIONS
import at.bitfire.davdroid.ui.widget.SafeAndroidUriHandler
import com.google.accompanist.themeadapter.material.MdcTheme
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
@ -48,38 +78,49 @@ class BatteryOptimizationsFragment: Fragment() {
private val ignoreBatteryOptimizationsResultLauncher =
registerForActivityResult(IgnoreBatteryOptimizationsContract) {
model.checkWhitelisted()
model.checkBatteryOptimizations()
}
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
val binding = IntroBatteryOptimizationsBinding.inflate(inflater, container, false)
binding.lifecycleOwner = viewLifecycleOwner
binding.model = model
return ComposeView(requireContext()).apply {
setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
setContent {
MdcTheme {
val hintBatteryOptimizations by model.hintBatteryOptimizations.observeAsState()
val shouldBeExempted by model.shouldBeExempted.observeAsState(false)
val isExempted by model.isExempted.observeAsState(false)
LaunchedEffect(shouldBeExempted, isExempted) {
if (shouldBeExempted && !isExempted)
ignoreBatteryOptimizationsResultLauncher.launch(BuildConfig.APPLICATION_ID)
}
model.shouldBeWhitelisted.observe(viewLifecycleOwner) { shouldBeWhitelisted ->
@SuppressLint("BatteryLife")
if (shouldBeWhitelisted && !model.isWhitelisted.value!!)
ignoreBatteryOptimizationsResultLauncher.launch(BuildConfig.APPLICATION_ID)
val hintAutostartPermission by model.hintAutostartPermission.observeAsState()
val uriHandler = SafeAndroidUriHandler(LocalContext.current)
CompositionLocalProvider(LocalUriHandler provides uriHandler) {
BatteryOptimizationsContent(
dontShowBattery = hintBatteryOptimizations == false,
onChangeDontShowBattery = {
model.hintBatteryOptimizations.value = !it
},
isExempted = isExempted,
shouldBeExempted = shouldBeExempted,
onChangeShouldBeExempted = model.shouldBeExempted::postValue,
dontShowAutostart = hintAutostartPermission == false,
onChangeDontShowAutostart = {
model.hintAutostartPermission.value = !it
},
manufacturerWarning = Model.manufacturerWarning
)
}
}
}
}
binding.batteryText.text = getString(R.string.intro_battery_text, getString(R.string.app_name))
binding.autostartHeading.text = getString(R.string.intro_autostart_title, WordUtils.capitalize(Build.MANUFACTURER))
binding.autostartText.setText(R.string.intro_autostart_text)
binding.autostartMoreInfo.setOnClickListener {
UiUtils.launchUri(requireActivity(), App.homepageUrl(requireActivity()).buildUpon()
.appendPath("faq").appendPath("synchronization-is-not-run-as-expected")
.appendQueryParameter("manufacturer", Build.MANUFACTURER.lowercase(Locale.ROOT)).build())
}
binding.infoLeaveUnchecked.text = getString(R.string.intro_leave_unchecked, getString(R.string.app_settings_reset_hints))
return binding.root
}
override fun onResume() {
super.onResume()
model.checkWhitelisted()
model.checkBatteryOptimizations()
}
@ -127,41 +168,23 @@ class BatteryOptimizationsFragment: Fragment() {
val manufacturerWarning =
(evilManufacturers.contains(Build.MANUFACTURER.lowercase(Locale.ROOT)) || BuildConfig.DEBUG)
fun isWhitelisted(context: Context) =
fun isExempted(context: Context) =
context.getSystemService<PowerManager>()!!.isIgnoringBatteryOptimizations(BuildConfig.APPLICATION_ID)
}
val shouldBeWhitelisted = MutableLiveData<Boolean>()
val isWhitelisted = MutableLiveData<Boolean>()
val dontShowBattery = object: ObservableBoolean() {
override fun get() = settings.getBooleanOrNull(HINT_BATTERY_OPTIMIZATIONS) == false
override fun set(dontShowAgain: Boolean) {
if (dontShowAgain)
settings.putBoolean(HINT_BATTERY_OPTIMIZATIONS, false)
else
settings.remove(HINT_BATTERY_OPTIMIZATIONS)
notifyChange()
}
}
val shouldBeExempted = MutableLiveData<Boolean>()
val isExempted = MutableLiveData<Boolean>()
val hintBatteryOptimizations = settings.getBooleanLive(HINT_BATTERY_OPTIMIZATIONS)
val dontShowAutostart = object: ObservableBoolean() {
override fun get() = settings.getBooleanOrNull(HINT_AUTOSTART_PERMISSION) == false
override fun set(dontShowAgain: Boolean) {
if (dontShowAgain)
settings.putBoolean(HINT_AUTOSTART_PERMISSION, false)
else
settings.remove(HINT_AUTOSTART_PERMISSION)
notifyChange()
}
}
val hintAutostartPermission = settings.getBooleanLive(HINT_AUTOSTART_PERMISSION)
fun checkWhitelisted() {
val whitelisted = isWhitelisted(getApplication())
isWhitelisted.value = whitelisted
shouldBeWhitelisted.value = whitelisted
fun checkBatteryOptimizations() {
val exempted = isExempted(getApplication())
isExempted.value = exempted
shouldBeExempted.value = exempted
// if DAVx5 is whitelisted, always show a reminder as soon as it's not whitelisted anymore
if (whitelisted)
if (exempted)
settings.remove(HINT_BATTERY_OPTIMIZATIONS)
}
@ -200,7 +223,7 @@ class BatteryOptimizationsFragment: Fragment() {
// 2a. evil manufacturer AND
// 2b. "don't show anymore" has not been clicked
if (
(!Model.isWhitelisted(context) && settingsManager.getBooleanOrNull(HINT_BATTERY_OPTIMIZATIONS) != false) ||
(!Model.isExempted(context) && settingsManager.getBooleanOrNull(HINT_BATTERY_OPTIMIZATIONS) != false) ||
(Model.manufacturerWarning && settingsManager.getBooleanOrNull(HINT_AUTOSTART_PERMISSION) != false)
)
100
@ -210,4 +233,162 @@ class BatteryOptimizationsFragment: Fragment() {
override fun create() = BatteryOptimizationsFragment()
}
}
}
@Preview(showBackground = true, showSystemUi = true)
@Composable
private fun BatteryOptimizationsContent_Preview() {
MdcTheme {
BatteryOptimizationsContent(
dontShowBattery = true,
onChangeDontShowBattery = {},
isExempted = false,
shouldBeExempted = true,
onChangeShouldBeExempted = {},
dontShowAutostart = false,
onChangeDontShowAutostart = {},
manufacturerWarning = true
)
}
}
@Composable
private fun BatteryOptimizationsContent(
dontShowBattery: Boolean,
onChangeDontShowBattery: (Boolean) -> Unit,
isExempted: Boolean,
shouldBeExempted: Boolean,
onChangeShouldBeExempted: (Boolean) -> Unit,
dontShowAutostart: Boolean,
onChangeDontShowAutostart: (Boolean) -> Unit,
manufacturerWarning: Boolean
) {
val context = LocalContext.current
val uriHandler = LocalUriHandler.current
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(8.dp)
) {
Card {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = stringResource(R.string.intro_battery_title),
style = MaterialTheme.typography.h6,
modifier = Modifier.weight(1f)
)
Switch(
checked = shouldBeExempted,
onCheckedChange = {
// Only accept click events if not whitelisted
if (!isExempted) {
onChangeShouldBeExempted(it)
}
},
enabled = !dontShowBattery
)
}
Text(
text = stringResource(
R.string.intro_battery_text,
stringResource(R.string.app_name)
),
style = MaterialTheme.typography.body1,
modifier = Modifier.padding(top = 12.dp)
)
AnimatedVisibility(visible = !isExempted) {
Row(
verticalAlignment = Alignment.CenterVertically
) {
Checkbox(
checked = dontShowBattery,
onCheckedChange = onChangeDontShowBattery,
enabled = !isExempted
)
Text(
text = stringResource(R.string.intro_battery_dont_show),
style = MaterialTheme.typography.caption,
modifier = Modifier
.clickable { onChangeDontShowBattery(!dontShowBattery) }
)
}
}
}
}
if (manufacturerWarning) {
Card(
modifier = Modifier.padding(top = 8.dp)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
) {
Text(
text = stringResource(
R.string.intro_autostart_title,
WordUtils.capitalize(Build.MANUFACTURER)
),
style = MaterialTheme.typography.h6,
modifier = Modifier.fillMaxWidth()
)
Text(
text = stringResource(R.string.intro_autostart_text),
style = MaterialTheme.typography.body1,
modifier = Modifier.padding(top = 12.dp)
)
OutlinedButton(
onClick = {
uriHandler.openUri(
App.homepageUrl(context)
.buildUpon()
.appendPath("faq")
.appendPath("synchronization-is-not-run-as-expected")
.appendQueryParameter(
"manufacturer",
Build.MANUFACTURER.lowercase(Locale.ROOT)
)
.build().toString()
)
}
) {
Text(stringResource(R.string.intro_more_info))
}
Row(
verticalAlignment = Alignment.CenterVertically
) {
Checkbox(
checked = dontShowAutostart,
onCheckedChange = onChangeDontShowAutostart
)
Text(
text = stringResource(R.string.intro_autostart_dont_show),
style = MaterialTheme.typography.caption,
modifier = Modifier
.clickable { onChangeDontShowAutostart(!dontShowAutostart) }
)
}
}
}
}
Text(
text = stringResource(
R.string.intro_leave_unchecked,
stringResource(R.string.app_settings_reset_hints)
),
style = MaterialTheme.typography.body2,
modifier = Modifier.padding(top = 8.dp)
)
Spacer(modifier = Modifier.height(90.dp))
}
}

View file

@ -1,159 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<data>
<import type="android.view.View" />
<variable name="model" type="at.bitfire.davdroid.ui.intro.BatteryOptimizationsFragment.Model" />
</data>
<ScrollView
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="?android:attr/colorBackground"
android:paddingBottom="@dimen/appintro2_bottombar_height">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="@dimen/activity_margin">
<com.google.android.material.card.MaterialCardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:contentPadding="@dimen/card_padding">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">
<TextView
android:id="@+id/batteryHeading"
style="@style/TextAppearance.MaterialComponents.Headline6"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:text="@string/intro_battery_title"
android:textAlignment="viewStart"
app:layout_constraintBottom_toTopOf="@id/batteryStatus"
app:layout_constraintEnd_toStartOf="@id/batterySwitch"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/batteryStatus"
style="@style/TextAppearance.MaterialComponents.Subtitle1"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:text="@{model.whitelisted ? @string/intro_battery_whitelisted : @string/intro_battery_not_whitelisted}"
android:textAlignment="viewStart"
android:textColor="?android:attr/textColorSecondary"
app:layout_constraintBottom_toTopOf="@id/batteryText"
app:layout_constraintEnd_toStartOf="@id/batterySwitch"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/batteryHeading" />
<com.google.android.material.switchmaterial.SwitchMaterial
android:id="@+id/batterySwitch"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:checked="@={model.shouldBeWhitelisted}"
android:clickable="@{!model.whitelisted}"
android:enabled="@{!model.dontShowBattery}"
app:layout_constraintBottom_toBottomOf="@id/batteryStatus"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/batteryHeading"
app:layout_constraintTop_toTopOf="@id/batteryHeading" />
<com.google.android.material.textview.MaterialTextView
android:id="@+id/batteryText"
style="@style/TextAppearance.MaterialComponents.Body1"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/card_margin_title_text"
android:text="@string/intro_battery_text"
android:textAlignment="viewStart"
app:layout_constraintTop_toBottomOf="@id/batteryStatus" />
<CheckBox
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:checked="@={model.dontShowBattery}"
android:enabled="@{!model.isWhitelisted()}"
android:text="@string/intro_battery_dont_show"
android:textAlignment="viewStart"
android:visibility="@{model.isWhitelisted() ? View.GONE : View.VISIBLE}"
app:layout_constraintTop_toBottomOf="@id/batteryText" />
</androidx.constraintlayout.widget.ConstraintLayout>
</com.google.android.material.card.MaterialCardView>
<com.google.android.material.card.MaterialCardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:visibility="@{model.Companion.manufacturerWarning ? View.VISIBLE : View.GONE}"
app:contentPadding="@dimen/card_padding">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">
<TextView
android:id="@+id/autostartHeading"
style="@style/TextAppearance.MaterialComponents.Headline6"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/intro_autostart_title"
android:textAlignment="viewStart"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/autostartText"
style="@style/TextAppearance.MaterialComponents.Body1"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/card_margin_title_text"
android:layout_marginBottom="8dp"
android:text="@string/intro_autostart_text"
android:textAlignment="viewStart"
app:layout_constraintBottom_toTopOf="@id/autostartMoreInfo"
app:layout_constraintTop_toBottomOf="@id/autostartHeading" />
<Button
android:id="@+id/autostartMoreInfo"
style="@style/Widget.MaterialComponents.Button.OutlinedButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/intro_more_info"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintBottom_toTopOf="@id/autostartDontShow"
app:layout_constraintTop_toBottomOf="@id/autostartText" />
<CheckBox
android:id="@+id/autostartDontShow"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:checked="@={model.dontShowAutostart}"
android:text="@string/intro_autostart_dont_show"
android:textAlignment="viewStart"
app:layout_constraintTop_toBottomOf="@id/autostartMoreInfo" />
</androidx.constraintlayout.widget.ConstraintLayout>
</com.google.android.material.card.MaterialCardView>
<TextView
android:id="@+id/infoLeaveUnchecked"
style="@style/TextAppearance.MaterialComponents.Body2"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/intro_leave_unchecked"
android:textAlignment="viewStart" />
</LinearLayout>
</ScrollView>
</layout>