move warning notification logic into a warning class (bitfireAT/davx5#170)

* move warning notification logic into a warning class

* Move Warnings to UI package

* Minor changes

* Move "global sync disabled" warning to AccountListFragment, too

Co-authored-by: Ricki Hirner <hirner@bitfire.at>
This commit is contained in:
Sunik Kupfer 2022-12-06 20:57:23 +01:00 committed by Ricki Hirner
parent 02885947da
commit 1b17124b80
No known key found for this signature in database
GPG key ID: 79A019FCAAEDD3AA
4 changed files with 191 additions and 147 deletions

View file

@ -28,7 +28,7 @@ class StorageLowReceiver private constructor(
@Module
@InstallIn(SingletonComponent::class)
object storageLowReceiverModule {
object StorageLowReceiverModule {
@Provides
@Singleton
fun storageLowReceiver(@ApplicationContext context: Context) = StorageLowReceiver(context)

View file

@ -10,18 +10,14 @@ import android.accounts.AccountManager
import android.accounts.OnAccountsUpdateListener
import android.app.Activity
import android.app.Application
import android.content.*
import android.content.ContentResolver
import android.content.Intent
import android.content.SyncStatusObserver
import android.content.pm.PackageManager
import android.net.ConnectivityManager
import android.net.Network
import android.net.NetworkCapabilities
import android.net.NetworkRequest
import android.os.Build
import android.os.Bundle
import android.provider.Settings
import android.view.*
import androidx.core.content.ContextCompat
import androidx.core.content.getSystemService
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import androidx.lifecycle.AndroidViewModel
@ -33,23 +29,25 @@ import androidx.recyclerview.widget.RecyclerView
import at.bitfire.davdroid.DavUtils
import at.bitfire.davdroid.DavUtils.SyncStatus
import at.bitfire.davdroid.R
import at.bitfire.davdroid.StorageLowReceiver
import at.bitfire.davdroid.databinding.AccountListBinding
import at.bitfire.davdroid.databinding.AccountListItemBinding
import at.bitfire.davdroid.ui.account.AccountActivity
import com.google.android.material.snackbar.Snackbar
import dagger.hilt.android.AndroidEntryPoint
import dagger.hilt.android.lifecycle.HiltViewModel
import java.text.Collator
import javax.inject.Inject
@AndroidEntryPoint
class AccountListFragment: Fragment() {
@Inject lateinit var storageLowReceiver: StorageLowReceiver
private var _binding: AccountListBinding? = null
private val binding get() = _binding!!
val model by viewModels<Model>()
private var syncStatusSnackbar: Snackbar? = null
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
setHasOptionsMenu(true)
@ -64,6 +62,23 @@ class AccountListFragment: Fragment() {
startActivity(Intent(requireActivity(), PermissionsActivity::class.java))
}
model.showSyncDisabled.observe(viewLifecycleOwner) { syncDisabled ->
if (syncDisabled) {
val snackbar = Snackbar
.make(view, R.string.accounts_global_sync_disabled, Snackbar.LENGTH_INDEFINITE)
.setAction(R.string.accounts_global_sync_enable) {
ContentResolver.setMasterSyncAutomatically(true)
}
snackbar.show()
syncStatusSnackbar = snackbar
} else {
syncStatusSnackbar?.let { snackbar ->
snackbar.dismiss()
syncStatusSnackbar = null
}
}
}
model.networkAvailable.observe(viewLifecycleOwner) { networkAvailable ->
binding.noNetworkInfo.visibility = if (networkAvailable) View.GONE else View.VISIBLE
}
@ -73,7 +88,7 @@ class AccountListFragment: Fragment() {
startActivity(intent)
}
storageLowReceiver.storageLow.observe(viewLifecycleOwner) { storageLow ->
model.storageLow.observe(viewLifecycleOwner) { storageLow ->
binding.lowStorageInfo.visibility = if (storageLow) View.VISIBLE else View.GONE
}
binding.manageStorage.setOnClickListener {
@ -87,7 +102,7 @@ class AccountListFragment: Fragment() {
layoutManager = LinearLayoutManager(requireActivity())
adapter = accountAdapter
}
model.accounts.observe(viewLifecycleOwner, { accounts ->
model.accounts.observe(viewLifecycleOwner) { accounts ->
if (accounts.isEmpty()) {
binding.list.visibility = View.GONE
binding.empty.visibility = View.VISIBLE
@ -97,11 +112,11 @@ class AccountListFragment: Fragment() {
}
accountAdapter.submitList(accounts)
requireActivity().invalidateOptionsMenu()
})
}
}
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) =
inflater.inflate(R.menu.activity_accounts, menu)
inflater.inflate(R.menu.activity_accounts, menu)
override fun onPrepareOptionsMenu(menu: Menu) {
// Show "Sync all" only when there is at least one account
@ -127,6 +142,7 @@ class AccountListFragment: Fragment() {
binding.noNotificationsInfo.visibility = View.VISIBLE
}
class AccountAdapter(
val activity: Activity
): ListAdapter<Model.AccountInfo, AccountAdapter.ViewHolder>(
@ -177,89 +193,37 @@ class AccountListFragment: Fragment() {
}
class Model(
application: Application
@HiltViewModel
class Model @Inject constructor(
application: Application,
val warnings: AppWarningsManager
): AndroidViewModel(application), OnAccountsUpdateListener, SyncStatusObserver {
data class AccountInfo(
val account: Account,
val status: SyncStatus
)
// Warnings
val showSyncDisabled = warnings.globalSyncDisabled
val networkAvailable = warnings.networkAvailable
val storageLow = warnings.storageLow
// Accounts
val accounts = MutableLiveData<List<AccountInfo>>()
val syncAuthorities by lazy { DavUtils.syncAuthorities(getApplication()) }
private val accountManager = AccountManager.get(application)!!
private val syncAuthorities by lazy { DavUtils.syncAuthorities(application) }
val networkAvailable = MutableLiveData<Boolean>()
private var networkCallback: ConnectivityManager.NetworkCallback? = null
private var networkReceiver: BroadcastReceiver? = null
private val accountManager = AccountManager.get(getApplication())!!
private val connectivityManager = application.getSystemService<ConnectivityManager>()!!
init {
// watch accounts
accountManager.addOnAccountsUpdatedListener(this, null, true)
// watch account status
ContentResolver.addStatusChangeListener(ContentResolver.SYNC_OBSERVER_TYPE_ACTIVE or ContentResolver.SYNC_OBSERVER_TYPE_PENDING, this)
// watch connectivity
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) { // API level <26
networkReceiver = object: BroadcastReceiver() {
init {
update()
}
override fun onReceive(context: Context?, intent: Intent?) = update()
private fun update() {
networkAvailable.postValue(connectivityManager.allNetworkInfo.any { it.isConnected })
}
}
application.registerReceiver(networkReceiver, IntentFilter(ConnectivityManager.CONNECTIVITY_ACTION))
} else { // API level >= 26
networkAvailable.postValue(false)
// check for working (e.g. WiFi after captive portal login) Internet connection
val networkRequest = NetworkRequest.Builder()
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
.addCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)
.build()
val callback = object: ConnectivityManager.NetworkCallback() {
val availableNetworks = hashSetOf<Network>()
override fun onAvailable(network: Network) {
availableNetworks += network
update()
}
override fun onLost(network: Network) {
availableNetworks -= network
update()
}
private fun update() {
networkAvailable.postValue(availableNetworks.isNotEmpty())
}
}
connectivityManager.registerNetworkCallback(networkRequest, callback)
networkCallback = callback
}
ContentResolver.addStatusChangeListener(
ContentResolver.SYNC_OBSERVER_TYPE_ACTIVE or ContentResolver.SYNC_OBSERVER_TYPE_PENDING,
this
)
}
override fun onCleared() {
accountManager.removeOnAccountsUpdatedListener(this)
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M)
networkReceiver?.let {
getApplication<Application>().unregisterReceiver(it)
}
else
networkCallback?.let {
connectivityManager.unregisterNetworkCallback(it)
}
}
data class AccountInfo(
val account: Account,
val status: SyncStatus
)
override fun onAccountsUpdated(newAccounts: Array<out Account>) {
reloadAccounts()
@ -274,16 +238,24 @@ class AccountListFragment: Fragment() {
val collator = Collator.getInstance()
val sortedAccounts = accountManager
.getAccountsByType(context.getString(R.string.account_type))
.sortedArrayWith { a, b ->
collator.compare(a.name, b.name)
}
.getAccountsByType(context.getString(R.string.account_type))
.sortedArrayWith { a, b ->
collator.compare(a.name, b.name)
}
val accountsWithInfo = sortedAccounts.map { account ->
AccountInfo(account, DavUtils.accountSyncStatus(context, syncAuthorities, account))
AccountInfo(
account,
DavUtils.accountSyncStatus(context, syncAuthorities, account)
)
}
accounts.postValue(accountsWithInfo)
}
override fun onCleared() {
accountManager.removeOnAccountsUpdatedListener(this)
warnings.close()
}
}
}

View file

@ -6,32 +6,22 @@ package at.bitfire.davdroid.ui
import android.accounts.AccountManager
import android.app.Activity
import android.content.ContentResolver
import android.content.Context
import android.content.Intent
import android.content.SyncStatusObserver
import android.content.pm.ShortcutManager
import android.os.Build
import android.os.Bundle
import android.view.MenuItem
import androidx.activity.viewModels
import androidx.annotation.AnyThread
import androidx.appcompat.app.ActionBarDrawerToggle
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.getSystemService
import androidx.core.view.GravityCompat
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import at.bitfire.davdroid.DavUtils
import at.bitfire.davdroid.R
import at.bitfire.davdroid.databinding.ActivityAccountsBinding
import at.bitfire.davdroid.ui.intro.IntroActivity
import at.bitfire.davdroid.ui.setup.LoginActivity
import com.google.android.material.navigation.NavigationView
import com.google.android.material.snackbar.Snackbar
import dagger.hilt.android.AndroidEntryPoint
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@ -47,9 +37,6 @@ class AccountsActivity: AppCompatActivity(), NavigationView.OnNavigationItemSele
@Inject lateinit var accountsDrawerHandler: AccountsDrawerHandler
private lateinit var binding: ActivityAccountsBinding
private val model by viewModels<Model>()
private var syncStatusSnackbar: Snackbar? = null
override fun onCreate(savedInstanceState: Bundle?) {
@ -73,23 +60,6 @@ class AccountsActivity: AppCompatActivity(), NavigationView.OnNavigationItemSele
}
binding.content.fab.show()
model.showSyncDisabled.observe(this) { syncDisabled ->
if (syncDisabled) {
val snackbar = Snackbar
.make(binding.content.coordinator, R.string.accounts_global_sync_disabled, Snackbar.LENGTH_INDEFINITE)
.setAction(R.string.accounts_global_sync_enable) {
ContentResolver.setMasterSyncAutomatically(true)
}
snackbar.show()
syncStatusSnackbar = snackbar
} else {
syncStatusSnackbar?.let { snackbar ->
snackbar.dismiss()
syncStatusSnackbar = null
}
}
}
setSupportActionBar(binding.content.toolbar)
val toggle = ActionBarDrawerToggle(
@ -144,29 +114,4 @@ class AccountsActivity: AppCompatActivity(), NavigationView.OnNavigationItemSele
DavUtils.requestSync(this, account)
}
@HiltViewModel
class Model @Inject constructor(
@ApplicationContext val context: Context
): ViewModel(), SyncStatusObserver {
private var syncStatusObserver: Any? = null
val showSyncDisabled = MutableLiveData(false)
init {
syncStatusObserver = ContentResolver.addStatusChangeListener(ContentResolver.SYNC_OBSERVER_TYPE_SETTINGS, this)
onStatusChanged(ContentResolver.SYNC_OBSERVER_TYPE_SETTINGS)
}
override fun onCleared() {
ContentResolver.removeStatusChangeListener(syncStatusObserver)
}
@AnyThread
override fun onStatusChanged(which: Int) {
showSyncDisabled.postValue(!ContentResolver.getMasterSyncAutomatically())
}
}
}
}

View file

@ -0,0 +1,127 @@
/***************************************************************************************************
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
**************************************************************************************************/
package at.bitfire.davdroid.ui
import android.content.*
import android.net.ConnectivityManager
import android.net.Network
import android.net.NetworkCapabilities
import android.net.NetworkRequest
import android.os.Build
import androidx.core.content.getSystemService
import androidx.lifecycle.MutableLiveData
import at.bitfire.davdroid.StorageLowReceiver
import at.bitfire.davdroid.log.Logger
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
/**
* Watches some conditions that result in *Warnings* that should
* be shown to the user in the launcher activity. The variables are
* available as LiveData so they can be directly observed in the UI.
*
* Currently watches:
*
* - whether storage is low [storageLow]
* - whether global sync is disabled [globalSyncDisabled]
* - whether a network connection is available [networkAvailable]
*/
class AppWarningsManager @Inject constructor(
@ApplicationContext private val context: Context,
storageLowReceiver: StorageLowReceiver
) : AutoCloseable, SyncStatusObserver {
/** whether storage is low (prevents sync framework from running synchronization) */
val storageLow = storageLowReceiver.storageLow
/** whether global sync is disabled (sync framework won't run automatic synchronization in this case) */
val globalSyncDisabled = MutableLiveData(false)
private var syncStatusObserver: Any? = null
/** whether a usable network connection is available (sync framework won't run synchronization otherwise) */
val networkAvailable = MutableLiveData<Boolean>()
private var networkCallback: ConnectivityManager.NetworkCallback? = null
private var networkReceiver: BroadcastReceiver? = null
private val connectivityManager = context.getSystemService<ConnectivityManager>()!!
init {
Logger.log.fine("Watching for warning conditions")
// Automatic sync
syncStatusObserver = ContentResolver.addStatusChangeListener(ContentResolver.SYNC_OBSERVER_TYPE_SETTINGS, this)
onStatusChanged(ContentResolver.SYNC_OBSERVER_TYPE_SETTINGS)
// Network
watchConnectivity()
}
private fun watchConnectivity() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) { // API level <26
networkReceiver = object: BroadcastReceiver() {
init {
update()
}
override fun onReceive(context: Context?, intent: Intent?) = update()
private fun update() {
networkAvailable.postValue(connectivityManager.allNetworkInfo.any { it.isConnected })
}
}
@Suppress("DEPRECATION")
context.registerReceiver(networkReceiver,
IntentFilter(ConnectivityManager.CONNECTIVITY_ACTION)
)
} else { // API level >= 26
networkAvailable.postValue(false)
// check for working (e.g. WiFi after captive portal login) Internet connection
val networkRequest = NetworkRequest.Builder()
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
.addCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)
.build()
val callback = object: ConnectivityManager.NetworkCallback() {
val availableNetworks = hashSetOf<Network>()
override fun onAvailable(network: Network) {
availableNetworks += network
update()
}
override fun onLost(network: Network) {
availableNetworks -= network
update()
}
private fun update() {
networkAvailable.postValue(availableNetworks.isNotEmpty())
}
}
connectivityManager.registerNetworkCallback(networkRequest, callback)
networkCallback = callback
}
}
override fun onStatusChanged(which: Int) {
globalSyncDisabled.postValue(!ContentResolver.getMasterSyncAutomatically())
}
override fun close() {
Logger.log.fine("Stopping watching for warning conditions")
// Automatic sync
ContentResolver.removeStatusChangeListener(syncStatusObserver)
// Network
networkReceiver?.let {
context.unregisterReceiver(it)
}
networkCallback?.let {
connectivityManager.unregisterNetworkCallback(it)
}
}
}