diff --git a/app/src/androidTest/kotlin/at/bitfire/davdroid/settings/SettingsManagerTest.kt b/app/src/androidTest/kotlin/at/bitfire/davdroid/settings/SettingsManagerTest.kt index 9045d17f..4994723e 100644 --- a/app/src/androidTest/kotlin/at/bitfire/davdroid/settings/SettingsManagerTest.kt +++ b/app/src/androidTest/kotlin/at/bitfire/davdroid/settings/SettingsManagerTest.kt @@ -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() + 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) + } + } + } \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/settings/SettingsManager.kt b/app/src/main/kotlin/at/bitfire/davdroid/settings/SettingsManager.kt index 9c413ad0..a2bb9900 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/settings/SettingsManager.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/settings/SettingsManager.kt @@ -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() { + 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! diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/intro/BatteryOptimizationsFragment.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/intro/BatteryOptimizationsFragment.kt index 761e1f45..65f2c09a 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/ui/intro/BatteryOptimizationsFragment.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/intro/BatteryOptimizationsFragment.kt @@ -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()!!.isIgnoringBatteryOptimizations(BuildConfig.APPLICATION_ID) } - val shouldBeWhitelisted = MutableLiveData() - val isWhitelisted = MutableLiveData() - 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() + val isExempted = MutableLiveData() + 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() } -} \ No newline at end of file +} + +@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)) + } +} diff --git a/app/src/main/res/layout/intro_battery_optimizations.xml b/app/src/main/res/layout/intro_battery_optimizations.xml deleted file mode 100644 index 0440ece5..00000000 --- a/app/src/main/res/layout/intro_battery_optimizations.xml +++ /dev/null @@ -1,159 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -