Intro pages: use Hilt for dependency injection (#779)

* Accounts: move syncAll to model; better Hilt usage

* Move calculation of whether IntroActivity is shown into AccountsModel

* Intro pages: inject dependencies with Hilt

* Fix preview
This commit is contained in:
Ricki Hirner 2024-05-04 20:10:36 +02:00 committed by GitHub
parent 4cffbe7b40
commit 1ad8e892b6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 166 additions and 209 deletions

View file

@ -7,15 +7,11 @@ package at.bitfire.davdroid.ui
import android.content.Intent
import android.os.Bundle
import androidx.activity.compose.setContent
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
import at.bitfire.davdroid.ui.account.AccountActivity
import at.bitfire.davdroid.ui.intro.IntroActivity
import at.bitfire.davdroid.ui.setup.LoginActivity
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import javax.inject.Inject
@ -30,27 +26,19 @@ class AccountsActivity: AppCompatActivity() {
finish()
}
val model by viewModels<AccountsModel>()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// use a separate thread to check whether IntroActivity should be shown
if (savedInstanceState == null) {
// move to Model
CoroutineScope(Dispatchers.Default).launch {
if (IntroActivity.shouldShowIntroActivity(this@AccountsActivity))
introActivityLauncher.launch(null)
}
}
// handle "Sync all" intent from launcher shortcut
if (savedInstanceState == null && intent.action == Intent.ACTION_SYNC)
model.syncAllAccounts()
val syncAccounts = intent.action == Intent.ACTION_SYNC
setContent {
AccountsScreen(
initialSyncAccounts = syncAccounts,
onShowAppIntro = {
introActivityLauncher.launch(null)
},
accountsDrawerHandler = accountsDrawerHandler,
onAddAccount = {
startActivity(Intent(this, LoginActivity::class.java))

View file

@ -4,43 +4,58 @@ import android.accounts.Account
import android.app.Application
import android.content.Intent
import android.content.IntentFilter
import android.content.pm.ShortcutManager
import android.net.ConnectivityManager
import android.net.Network
import android.net.NetworkCapabilities
import android.net.NetworkRequest
import android.os.Build
import android.os.PowerManager
import androidx.core.content.getSystemService
import androidx.core.content.pm.ShortcutManagerCompat
import androidx.lifecycle.ViewModel
import androidx.work.WorkInfo
import androidx.work.WorkManager
import androidx.work.WorkQuery
import at.bitfire.davdroid.db.AppDatabase
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.repository.AccountRepository
import at.bitfire.davdroid.servicedetection.RefreshCollectionsWorker
import at.bitfire.davdroid.syncadapter.BaseSyncWorker
import at.bitfire.davdroid.syncadapter.OneTimeSyncWorker
import at.bitfire.davdroid.syncadapter.SyncUtils
import at.bitfire.davdroid.ui.account.AccountProgress
import at.bitfire.davdroid.ui.intro.IntroPage
import at.bitfire.davdroid.ui.intro.IntroPageFactory
import at.bitfire.davdroid.util.broadcastReceiverFlow
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import java.text.Collator
import javax.inject.Inject
@HiltViewModel
class AccountsModel @Inject constructor(
val context: Application,
val accountRepository: AccountRepository,
private val db: AppDatabase
@HiltViewModel(assistedFactory = AccountsModel.Factory::class)
class AccountsModel @AssistedInject constructor(
@Assisted val syncAccountsOnInit: Boolean,
private val context: Application,
private val accountRepository: AccountRepository,
private val db: AppDatabase,
introPageFactory: IntroPageFactory
): ViewModel() {
// UI state
@AssistedFactory
interface Factory {
fun create(syncAccountsOnInit: Boolean): AccountsModel
}
// Accounts UI state
enum class FABStyle {
WithText,
@ -98,6 +113,20 @@ class AccountsModel @Inject constructor(
}
// other UI state
val showAppIntro: Flow<Boolean> = flow<Boolean> {
val anyShowAlwaysPage = introPageFactory.introPages.any { introPage ->
val policy = introPage.getShowPolicy()
Logger.log.fine("Intro page ${introPage::class.java.name} policy = $policy")
policy == IntroPage.ShowPolicy.SHOW_ALWAYS
}
emit(anyShowAlwaysPage)
}.flowOn(Dispatchers.Default)
// warnings
private val connectivityManager = context.getSystemService<ConnectivityManager>()!!
@ -167,12 +196,18 @@ class AccountsModel @Inject constructor(
}
}
init {
if (syncAccountsOnInit)
syncAllAccounts()
}
// actions
fun syncAllAccounts() {
if (Build.VERSION.SDK_INT >= 25)
context.getSystemService<ShortcutManager>()?.reportShortcutUsed(UiUtils.SHORTCUT_SYNC_ALL)
// report shortcut action to system
ShortcutManagerCompat.reportShortcutUsed(context, UiUtils.SHORTCUT_SYNC_ALL)
// Enqueue sync worker for all accounts and authorities. Will sync once internet is available
for (account in accountRepository.getAll())

View file

@ -49,6 +49,7 @@ import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState
import androidx.compose.material3.rememberDrawerState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
@ -63,8 +64,8 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import at.bitfire.davdroid.BuildConfig
import at.bitfire.davdroid.R
import at.bitfire.davdroid.ui.account.AccountProgress
@ -76,16 +77,28 @@ import kotlinx.coroutines.launch
@Composable
fun AccountsScreen(
initialSyncAccounts: Boolean,
onShowAppIntro: () -> Unit,
accountsDrawerHandler: AccountsDrawerHandler,
onAddAccount: () -> Unit,
onShowAccount: (Account) -> Unit,
onManagePermissions: () -> Unit,
model: AccountsModel = viewModel()
model: AccountsModel = hiltViewModel(
creationCallback = { factory: AccountsModel.Factory ->
factory.create(initialSyncAccounts)
}
)
) {
val accounts by model.accountInfos.collectAsStateWithLifecycle(emptyList())
val showSyncAll by model.showSyncAll.collectAsStateWithLifecycle(true)
val showAddAccount by model.showAddAccount.collectAsStateWithLifecycle(AccountsModel.FABStyle.Standard)
val showAppIntro by model.showAppIntro.collectAsState(false)
LaunchedEffect(showAppIntro) {
if (showAppIntro)
onShowAppIntro()
}
AccountsScreen(
accountsDrawerHandler = accountsDrawerHandler,
accounts = accounts,

View file

@ -57,27 +57,18 @@ import at.bitfire.davdroid.ui.intro.BatteryOptimizationsPage.Model.Companion.HIN
import at.bitfire.davdroid.ui.intro.BatteryOptimizationsPage.Model.Companion.HINT_BATTERY_OPTIMIZATIONS
import at.bitfire.davdroid.util.PermissionUtils
import at.bitfire.davdroid.util.broadcastReceiverFlow
import dagger.hilt.EntryPoint
import dagger.hilt.InstallIn
import dagger.hilt.android.EntryPointAccessors
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.components.SingletonComponent
import kotlinx.coroutines.launch
import org.apache.commons.text.WordUtils
import java.util.Locale
import javax.inject.Inject
class BatteryOptimizationsPage: IntroPage {
@EntryPoint
@InstallIn(SingletonComponent::class)
interface BatteryOptimizationsPageEntryPoint {
fun settingsManager(): SettingsManager
}
override fun getShowPolicy(application: Application): IntroPage.ShowPolicy {
val settingsManager = EntryPointAccessors.fromApplication(application, BatteryOptimizationsPageEntryPoint::class.java).settingsManager()
class BatteryOptimizationsPage @Inject constructor(
private val application: Application,
private val settingsManager: SettingsManager
): IntroPage {
override fun getShowPolicy(): IntroPage.ShowPolicy {
// show fragment when:
// 1. DAVx5 is not whitelisted yet and "don't show anymore" has not been clicked, and/or
// 2a. evil manufacturer AND

View file

@ -5,7 +5,6 @@
package at.bitfire.davdroid.ui.intro
import android.app.Activity
import android.app.Application
import android.content.Context
import android.content.Intent
import android.os.Bundle
@ -14,7 +13,6 @@ import android.view.ViewGroup
import androidx.activity.addCallback
import androidx.activity.result.contract.ActivityResultContract
import androidx.activity.viewModels
import androidx.annotation.WorkerThread
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding
import androidx.compose.ui.Modifier
@ -24,40 +22,18 @@ import androidx.compose.ui.platform.ViewCompositionStrategy
import androidx.compose.ui.res.dimensionResource
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.ViewModel
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.ui.M2Colors
import at.bitfire.davdroid.ui.M2Theme
import com.github.appintro.AppIntro2
import dagger.hilt.EntryPoint
import dagger.hilt.InstallIn
import dagger.hilt.android.AndroidEntryPoint
import dagger.hilt.android.EntryPointAccessors
import dagger.hilt.android.components.ActivityComponent
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
@AndroidEntryPoint
class IntroActivity : AppIntro2() {
companion object {
@EntryPoint
@InstallIn(ActivityComponent::class)
interface IntroActivityEntryPoint {
fun introPageFactory(): IntroPageFactory
}
@WorkerThread
fun shouldShowIntroActivity(activity: Activity): Boolean {
val introPageFactory = EntryPointAccessors.fromActivity(activity, IntroActivityEntryPoint::class.java).introPageFactory()
return introPageFactory.introPages.any {
it.getShowPolicy(activity.application) == IntroPage.ShowPolicy.SHOW_ALWAYS
}
}
}
val model by viewModels<Model>()
private var currentSlide = 0
@ -120,12 +96,6 @@ class IntroActivity : AppIntro2() {
}
}
// For on resume actions of intro pages
override fun onResume() {
super.onResume()
activity?.application?.let { page.onResume(it) }
}
}
@ -144,9 +114,8 @@ class IntroActivity : AppIntro2() {
@HiltViewModel
class Model @Inject constructor(
application: Application,
introPageFactory: IntroPageFactory
): AndroidViewModel(application) {
): ViewModel() {
private val introPages = introPageFactory.introPages
@ -164,10 +133,14 @@ class IntroActivity : AppIntro2() {
private fun calculatePages(): List<IntroPage> {
for (page in introPages)
Logger.log.fine("Found intro page ${page::class.java} with order ${page.getShowPolicy(getApplication())}")
Logger.log.fine("Found intro page ${page::class.java} with order ${page.getShowPolicy()}")
val activePages: Map<IntroPage, IntroPage.ShowPolicy> = introPages
.associateWith { it.getShowPolicy(getApplication()) }
.associateWith { page ->
page.getShowPolicy().also { policy ->
Logger.log.fine("IntroActivity: found intro page ${page::class.java} with $policy")
}
}
.filterValues { it != IntroPage.ShowPolicy.DONT_SHOW }
val anyShowAlways = activePages.values.any { it == IntroPage.ShowPolicy.SHOW_ALWAYS }

View file

@ -4,7 +4,6 @@
package at.bitfire.davdroid.ui.intro
import android.app.Application
import androidx.compose.runtime.Composable
interface IntroPage {
@ -19,15 +18,13 @@ interface IntroPage {
* Used to determine whether an intro page of this type (for instance,
* the [BatteryOptimizationsPage]) should be shown.
*
* @param application used to determine whether the page shall be shown
*
* @return Order with which an instance of this page type shall be created and shown. Possible values:
*
* * < 0: only show the page when there is at least one other page with positive order (lower numbers are shown first)
* * [DONT_SHOW] (0): don't show the page
* * 0: show the page (lower numbers are shown first)
*/
fun getShowPolicy(application: Application): ShowPolicy
fun getShowPolicy(): ShowPolicy
/**
* Composes this page. Will only be called when [getShowPolicy] is not [DONT_SHOW].
@ -35,9 +32,4 @@ interface IntroPage {
@Composable
fun ComposePage()
/**
* Called when the user leaves and re-enters the app intro
*/
fun onResume(application: Application) {}
}

View file

@ -4,7 +4,6 @@
package at.bitfire.davdroid.ui.intro
import android.app.Application
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@ -37,24 +36,14 @@ import at.bitfire.davdroid.Constants.withStatParams
import at.bitfire.davdroid.R
import at.bitfire.davdroid.settings.SettingsManager
import at.bitfire.davdroid.ui.composable.CardWithImage
import dagger.hilt.EntryPoint
import dagger.hilt.InstallIn
import dagger.hilt.android.EntryPointAccessors
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.components.SingletonComponent
import javax.inject.Inject
class OpenSourcePage : IntroPage {
@EntryPoint
@InstallIn(SingletonComponent::class)
interface OpenSourcePageEntryPoint {
fun settingsManager(): SettingsManager
}
override fun getShowPolicy(application: Application): IntroPage.ShowPolicy {
val settingsManager = EntryPointAccessors.fromApplication(application, OpenSourcePageEntryPoint::class.java).settingsManager()
class OpenSourcePage @Inject constructor(
private val settingsManager: SettingsManager
): IntroPage {
override fun getShowPolicy(): IntroPage.ShowPolicy {
return if (System.currentTimeMillis() > (settingsManager.getLongOrNull(Model.SETTING_NEXT_DONATION_POPUP) ?: 0))
IntroPage.ShowPolicy.SHOW_ALWAYS
else
@ -69,7 +58,7 @@ class OpenSourcePage : IntroPage {
@Composable
private fun Page(model: Model = viewModel()) {
val dontShow by model.dontShow.collectAsStateWithLifecycle(false)
PageContent(
OpenSourcePage(
dontShow = dontShow,
onChangeDontShow = {
model.setDontShow(it)
@ -77,67 +66,6 @@ class OpenSourcePage : IntroPage {
)
}
@Preview(
showBackground = true,
showSystemUi = true
)
@Composable
fun PageContent(
dontShow: Boolean = false,
onChangeDontShow: (Boolean) -> Unit = {}
) {
val uriHandler = LocalUriHandler.current
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(8.dp)
) {
CardWithImage(
title = stringResource(R.string.intro_open_source_title),
image = painterResource(R.drawable.intro_open_source),
imageContentScale = ContentScale.Inside,
message = stringResource(
R.string.intro_open_source_text,
stringResource(R.string.app_name)
)
) {
OutlinedButton(
onClick = {
uriHandler.openUri(
Constants.HOMEPAGE_URL.buildUpon()
.appendPath(Constants.HOMEPAGE_PATH_OPEN_SOURCE)
.withStatParams("OpenSourcePage")
.build()
.toString()
)
}
) {
Text(stringResource(R.string.intro_open_source_details))
}
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
Checkbox(
checked = dontShow,
onCheckedChange = onChangeDontShow
)
Text(
text = stringResource(R.string.intro_open_source_dont_show),
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier
.clickable { onChangeDontShow(!dontShow) }
.weight(1f)
)
}
}
Spacer(Modifier.height(90.dp))
}
}
@HiltViewModel
class Model @Inject constructor(
val settings: SettingsManager
@ -159,4 +87,61 @@ class OpenSourcePage : IntroPage {
}
}
@Preview
@Composable
fun OpenSourcePage(
dontShow: Boolean = false,
onChangeDontShow: (Boolean) -> Unit = {}
) {
val uriHandler = LocalUriHandler.current
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(8.dp)
) {
CardWithImage(
title = stringResource(R.string.intro_open_source_title),
image = painterResource(R.drawable.intro_open_source),
imageContentScale = ContentScale.Inside,
message = stringResource(
R.string.intro_open_source_text,
stringResource(R.string.app_name)
)
) {
OutlinedButton(
onClick = {
uriHandler.openUri(
Constants.HOMEPAGE_URL.buildUpon()
.appendPath(Constants.HOMEPAGE_PATH_OPEN_SOURCE)
.withStatParams("OpenSourcePage")
.build()
.toString()
)
}
) {
Text(stringResource(R.string.intro_open_source_details))
}
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
Checkbox(
checked = dontShow,
onCheckedChange = onChangeDontShow
)
Text(
text = stringResource(R.string.intro_open_source_dont_show),
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier
.clickable { onChangeDontShow(!dontShow) }
.weight(1f)
)
}
}
Spacer(Modifier.height(90.dp))
}
}

View file

@ -7,18 +7,21 @@ package at.bitfire.davdroid.ui.intro
import android.app.Application
import androidx.compose.runtime.Composable
import androidx.lifecycle.viewmodel.compose.viewModel
import at.bitfire.davdroid.ui.PermissionsScreen
import at.bitfire.davdroid.ui.PermissionsModel
import at.bitfire.davdroid.ui.PermissionsScreen
import at.bitfire.davdroid.util.PermissionUtils
import at.bitfire.davdroid.util.PermissionUtils.CALENDAR_PERMISSIONS
import at.bitfire.davdroid.util.PermissionUtils.CONTACT_PERMISSIONS
import at.bitfire.ical4android.TaskProvider
import javax.inject.Inject
class PermissionsIntroPage: IntroPage {
class PermissionsIntroPage @Inject constructor(
private val application: Application
): IntroPage {
var model: PermissionsModel? = null
override fun getShowPolicy(application: Application): IntroPage.ShowPolicy {
override fun getShowPolicy(): IntroPage.ShowPolicy {
// show PermissionsFragment as intro fragment when no permissions are granted
val permissions = CONTACT_PERMISSIONS + CALENDAR_PERMISSIONS +
TaskProvider.PERMISSIONS_JTX +
@ -32,15 +35,7 @@ class PermissionsIntroPage: IntroPage {
@Composable
override fun ComposePage() {
val newModel: PermissionsModel = viewModel()
model = newModel
PermissionsScreen(model = newModel)
}
// Check whether permissions have changed after user comes back from settings app
override fun onResume(application: Application) {
model?.checkPermissions()
PermissionsScreen()
}
}

View file

@ -10,22 +10,14 @@ import at.bitfire.davdroid.settings.SettingsManager
import at.bitfire.davdroid.ui.TasksActivity
import at.bitfire.davdroid.ui.TasksCard
import at.bitfire.davdroid.util.TaskUtils
import dagger.hilt.EntryPoint
import dagger.hilt.InstallIn
import dagger.hilt.android.EntryPointAccessors
import dagger.hilt.components.SingletonComponent
import javax.inject.Inject
class TasksIntroPage : IntroPage {
@EntryPoint
@InstallIn(SingletonComponent::class)
interface TasksIntroPageEntryPoint {
fun settingsManager(): SettingsManager
}
override fun getShowPolicy(application: Application): IntroPage.ShowPolicy {
val settingsManager = EntryPointAccessors.fromApplication(application, TasksIntroPageEntryPoint::class.java).settingsManager()
class TasksIntroPage @Inject constructor(
private val application: Application,
private val settingsManager: SettingsManager
): IntroPage {
override fun getShowPolicy(): IntroPage.ShowPolicy {
return if (TaskUtils.isAvailable(application) || settingsManager.getBooleanOrNull(TasksActivity.Model.HINT_OPENTASKS_NOT_INSTALLED) == false)
IntroPage.ShowPolicy.DONT_SHOW
else

View file

@ -35,7 +35,7 @@ import at.bitfire.davdroid.ui.M2Colors.primaryDark
class WelcomePage: IntroPage {
override fun getShowPolicy(application: Application) = IntroPage.ShowPolicy.SHOW_ONLY_WITH_OTHERS
override fun getShowPolicy() = IntroPage.ShowPolicy.SHOW_ONLY_WITH_OTHERS
@Composable
override fun ComposePage() {

View file

@ -15,7 +15,6 @@ import at.bitfire.davdroid.ui.setup.LoginTypesProvider
import at.bitfire.davdroid.ui.setup.StandardLoginTypesProvider
import dagger.Binds
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.components.ActivityComponent
import dagger.hilt.android.components.ViewModelComponent
@ -48,15 +47,4 @@ interface OseFlavorModules {
fun introPageFactory(impl: OseIntroPageFactory): IntroPageFactory
}
//// intro pages ////
@Module
@InstallIn(SingletonComponent::class)
interface IntroPagesModule {
@Provides
@IntoSet
fun introPage(): IntroPage = BatteryOptimizationsPage()
}
}

View file

@ -12,14 +12,19 @@ import at.bitfire.davdroid.ui.intro.TasksIntroPage
import at.bitfire.davdroid.ui.intro.WelcomePage
import javax.inject.Inject
class OseIntroPageFactory @Inject constructor(): IntroPageFactory {
class OseIntroPageFactory @Inject constructor(
batteryOptimizationsPage: BatteryOptimizationsPage,
openSourcePage: OpenSourcePage,
permissionsIntroPage: PermissionsIntroPage,
tasksIntroPage: TasksIntroPage
): IntroPageFactory {
override val introPages = arrayOf(
WelcomePage(),
TasksIntroPage(),
PermissionsIntroPage(),
BatteryOptimizationsPage(),
OpenSourcePage()
tasksIntroPage,
permissionsIntroPage,
batteryOptimizationsPage,
openSourcePage
)
}