Compare commits

...

16 Commits

Author SHA1 Message Date
Ricki Hirner
ccbe1d7645 Remove last XML styles that were required for AppIntro 2024-06-25 15:06:52 +02:00
Ricki Hirner
85b29329eb Minor changes 2024-06-25 15:01:39 +02:00
Arnau Mora
c7b18b1f41 Do not create new task
Signed-off-by: Arnau Mora Gras <arnyminerz@proton.me>
2024-06-25 14:54:02 +02:00
Arnau Mora
c2cca01b3b Moved intro composables together
Signed-off-by: Arnau Mora Gras <arnyminerz@proton.me>
2024-06-25 14:54:02 +02:00
Arnau Mora
3027feb18d Given fixed padding
Signed-off-by: Arnau Mora Gras <arnyminerz@proton.me>
2024-06-25 14:54:02 +02:00
Arnau Mora
d44cfdf437 Moved IntroActivity.Model to IntroModel
Signed-off-by: Arnau Mora Gras <arnyminerz@proton.me>
2024-06-25 14:54:02 +02:00
Arnau Mora
0935bca1a2 Added content description
Signed-off-by: Arnau Mora Gras <arnyminerz@proton.me>
2024-06-25 14:54:02 +02:00
Arnau Mora
3aa205c3c3 When launching intro, going back closes the app
Signed-off-by: Arnau Mora <arnyminerz@proton.me>
2024-06-25 14:54:02 +02:00
Arnau Mora
1550c41a18 Removed padding
Signed-off-by: Arnau Mora <arnyminerz@proton.me>
2024-06-25 14:54:02 +02:00
Arnau Mora
8d54e618fc Imports cleanup
Signed-off-by: Arnau Mora Gras <arnyminerz@proton.me>
2024-06-25 14:54:02 +02:00
Arnau Mora
05e708eed5 Imports cleanup
Signed-off-by: Arnau Mora Gras <arnyminerz@proton.me>
2024-06-25 14:54:02 +02:00
Arnau Mora
de1ceb625e Got rid of AppIntro
Signed-off-by: Arnau Mora Gras <arnyminerz@proton.me>
2024-06-25 14:54:02 +02:00
Arnau Mora
e2ee0f459a Migrated into to compose
Signed-off-by: Arnau Mora Gras <arnyminerz@proton.me>
2024-06-25 14:54:02 +02:00
Ricki Hirner
ea035fa931 Version bump to 4.4.1-alpha.2 2024-06-25 14:52:51 +02:00
Ricki Hirner
8167e8e3cb Update dependencies 2024-06-25 14:49:57 +02:00
Ricki Hirner
bcc16e1ab6
Implement basic Push functionality (#856)
* Move PushRegistrationWorker to push package

* Add UP dependency

* [WIP] UnifiedPush basic implementation

* Handle endpoint unregistration

* [WIP] Parse push notification message

* Parse push message in PushMessageParser

* Sync only affected account on push message

* Only initiate sync when push message is about a syncable collection

* Push registration worker: log when there's no configured endpoint

* Handle invalid/non-XML push messages

* app settings: show UP endpoint
2024-06-25 14:07:29 +02:00
29 changed files with 583 additions and 192 deletions

View File

@ -18,8 +18,8 @@ android {
defaultConfig {
applicationId = "at.bitfire.davdroid"
versionCode = 404010000
versionName = "4.4.1-alpha.1"
versionCode = 404010001
versionName = "4.4.1-alpha.2"
buildConfigField("long", "buildTime", "${System.currentTimeMillis()}L")
@ -184,7 +184,6 @@ dependencies {
implementation(libs.bitfire.vcard4android)
// third-party libs
implementation(libs.appintro)
implementation(libs.commons.collections)
@Suppress("RedundantSuppression")
implementation(libs.commons.io)
@ -198,6 +197,7 @@ dependencies {
implementation(libs.okhttp.brotli)
implementation(libs.okhttp.logging)
implementation(libs.openid.appauth)
implementation(libs.unifiedpush)
// for tests
androidTestImplementation(libs.androidx.arch.core.testing)

View File

@ -1,7 +1,7 @@
package at.bitfire.davdroid
import at.bitfire.davdroid.repository.DavCollectionRepository
import at.bitfire.davdroid.syncadapter.PushRegistrationWorker
import at.bitfire.davdroid.push.PushRegistrationWorker
import dagger.Module
import dagger.hilt.components.SingletonComponent
import dagger.multibindings.Multibinds

View File

@ -49,7 +49,7 @@
android:networkSecurityConfig="@xml/network_security_config"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:theme="@style/AppTheme"
android:theme="@style/Theme.AppCompat.DayNight.NoActionBar"
android:resizeableActivity="true"
tools:ignore="UnusedAttribute"
android:supportsRtl="true">
@ -288,6 +288,16 @@
android:resource="@xml/debug_paths" />
</provider>
<!-- UnifiedPush receiver -->
<receiver android:exported="true" android:enabled="true" android:name=".push.UnifiedPushReceiver" tools:ignore="ExportedReceiver">
<intent-filter>
<action android:name="org.unifiedpush.android.connector.MESSAGE"/>
<action android:name="org.unifiedpush.android.connector.UNREGISTERED"/>
<action android:name="org.unifiedpush.android.connector.NEW_ENDPOINT"/>
<action android:name="org.unifiedpush.android.connector.REGISTRATION_FAILED"/>
</intent-filter>
</receiver>
<!-- Widgets -->
<receiver android:name=".ui.widget.SyncButtonWidgetReceiver"
android:exported="true">

View File

@ -32,6 +32,9 @@ interface CollectionDao {
@Query("SELECT * FROM collection WHERE serviceId=:serviceId AND type=:type ORDER BY displayName COLLATE NOCASE, url COLLATE NOCASE")
fun getByServiceAndType(serviceId: Long, type: String): List<Collection>
@Query("SELECT * FROM collection WHERE pushTopic=:topic AND sync")
fun getSyncableByPushTopic(topic: String): Collection?
@Query("SELECT COUNT(*) FROM collection WHERE serviceId=:serviceId AND type=:type")
suspend fun anyOfType(serviceId: Long, type: String): Boolean

View File

@ -8,7 +8,6 @@ import androidx.room.Dao
import androidx.room.Delete
import androidx.room.Insert
import androidx.room.Query
import androidx.room.Transaction
import androidx.room.Update
import kotlinx.coroutines.flow.Flow

View File

@ -0,0 +1,40 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.push
import at.bitfire.dav4jvm.XmlUtils
import at.bitfire.dav4jvm.property.push.PushMessage
import at.bitfire.davdroid.log.Logger
import org.xmlpull.v1.XmlPullParserException
import java.io.StringReader
import java.util.logging.Level
import javax.inject.Inject
class PushMessageParser @Inject constructor() {
/**
* Parses a WebDAV-Push message and returns the `topic` that the message is about.
*
* @return topic of the modified collection, or `null` if the topic couldn't be determined
*/
operator fun invoke(message: String): String? {
var topic: String? = null
val parser = XmlUtils.newPullParser()
try {
parser.setInput(StringReader(message))
XmlUtils.processTag(parser, PushMessage.NAME) {
val pushMessage = PushMessage.Factory.create(parser)
topic = pushMessage.topic
}
} catch (e: XmlPullParserException) {
Logger.log.log(Level.WARNING, "Couldn't parse push message", e)
}
return topic
}
}

View File

@ -1,4 +1,8 @@
package at.bitfire.davdroid.syncadapter
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.push
import android.accounts.Account
import android.content.Context
@ -22,6 +26,7 @@ import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.network.HttpClient
import at.bitfire.davdroid.repository.DavCollectionRepository
import at.bitfire.davdroid.repository.DavServiceRepository
import at.bitfire.davdroid.repository.PreferenceRepository
import at.bitfire.davdroid.settings.AccountSettings
import dagger.Binds
import dagger.Module
@ -51,6 +56,7 @@ class PushRegistrationWorker @AssistedInject constructor(
@Assisted context: Context,
@Assisted workerParameters: WorkerParameters,
private val collectionRepository: DavCollectionRepository,
private val preferenceRepository: PreferenceRepository,
private val serviceRepository: DavServiceRepository
) : CoroutineWorker(context, workerParameters) {
@ -118,16 +124,18 @@ class PushRegistrationWorker @AssistedInject constructor(
override suspend fun doWork(): Result {
Logger.log.info("Running push registration worker")
// We will get this endpoint from UnifiedPush:
val sampleEndpoint = "https://endpoint.example.com"
val endpoint = preferenceRepository.unifiedPushEndpoint()
for (collection in collectionRepository.getSyncEnabledAndPushCapable()) {
Logger.log.info("Registering push for ${collection.url}")
val service = serviceRepository.get(collection.serviceId) ?: continue
val account = Account(service.accountName, applicationContext.getString(R.string.account_type))
if (endpoint != null)
for (collection in collectionRepository.getSyncableAndPushCapable()) {
Logger.log.info("Registering push for ${collection.url}")
val service = serviceRepository.get(collection.serviceId) ?: continue
val account = Account(service.accountName, applicationContext.getString(R.string.account_type))
requestPushRegistration(collection, account, sampleEndpoint)
}
requestPushRegistration(collection, account, endpoint)
}
else
Logger.log.info("No UnifiedPush endpoint configured")
return Result.success()
}

View File

@ -0,0 +1,79 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.push
import android.content.Context
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.repository.AccountRepository
import at.bitfire.davdroid.repository.DavCollectionRepository
import at.bitfire.davdroid.repository.DavServiceRepository
import at.bitfire.davdroid.repository.PreferenceRepository
import at.bitfire.davdroid.syncadapter.OneTimeSyncWorker
import dagger.hilt.android.AndroidEntryPoint
import org.unifiedpush.android.connector.MessagingReceiver
import java.util.logging.Level
import javax.inject.Inject
@AndroidEntryPoint
class UnifiedPushReceiver: MessagingReceiver() {
@Inject
lateinit var accountRepository: AccountRepository
@Inject
lateinit var collectionRepository: DavCollectionRepository
@Inject
lateinit var serviceRepository: DavServiceRepository
@Inject
lateinit var preferenceRepository: PreferenceRepository
@Inject
lateinit var parsePushMessage: PushMessageParser
override fun onNewEndpoint(context: Context, endpoint: String, instance: String) {
// remember new endpoint
preferenceRepository.unifiedPushEndpoint(endpoint)
// register new endpoint at CalDAV/CardDAV servers
PushRegistrationWorker.enqueue(context)
}
override fun onUnregistered(context: Context, instance: String) {
// reset known endpoint
preferenceRepository.unifiedPushEndpoint(null)
}
override fun onMessage(context: Context, message: ByteArray, instance: String) {
val messageXml = message.toString(Charsets.UTF_8)
Logger.log.log(Level.INFO, "Received push message", messageXml)
// parse push notification
val topic = parsePushMessage(messageXml)
// sync affected collection
if (topic != null) {
Logger.log.info("Got push notification for topic $topic")
// Sync all authorities of account that the collection belongs to
// Later: only sync affected collection and authorities
collectionRepository.getSyncableByTopic(topic)?.let { collection ->
serviceRepository.get(collection.serviceId)?.let { service ->
val account = accountRepository.fromName(service.accountName)
OneTimeSyncWorker.enqueueAllAuthorities(context, account)
}
}
} else {
Logger.log.warning("Got push message without topic, syncing all accounts")
for (account in accountRepository.getAll())
OneTimeSyncWorker.enqueueAllAuthorities(context, account)
}
}
}

View File

@ -69,7 +69,7 @@ class AccountRepository @Inject constructor(
* @return account if account creation was successful; null otherwise (for instance because an account with this name already exists)
*/
fun create(accountName: String, credentials: Credentials?, config: DavResourceFinder.Configuration, groupMethod: GroupMethod): Account? {
val account = account(accountName)
val account = fromName(accountName)
// create Android account
val userData = AccountSettings.initialUserData(credentials)
@ -135,7 +135,7 @@ class AccountRepository @Inject constructor(
suspend fun delete(accountName: String): Boolean {
// remove account
val future = accountManager.removeAccount(account(accountName), null, null, null)
val future = accountManager.removeAccount(fromName(accountName), null, null, null)
return try {
// wait for operation to complete
withContext(Dispatchers.Default) {
@ -162,7 +162,10 @@ class AccountRepository @Inject constructor(
else
accountManager
.getAccountsByType(accountType)
.contains(Account(accountName, accountType))
.any { it.name == accountName }
fun fromName(accountName: String) =
Account(accountName, accountType)
fun getAll(): Array<Account> = accountManager.getAccountsByType(accountType)
@ -191,8 +194,8 @@ class AccountRepository @Inject constructor(
* @throws Exception (or sub-classes) on other errors
*/
suspend fun rename(oldName: String, newName: String) {
val oldAccount = account(oldName)
val newAccount = account(newName)
val oldAccount = fromName(oldName)
val newAccount = fromName(newName)
// check whether new account name already exists
if (accountManager.getAccountsByType(context.getString(R.string.account_type)).contains(newAccount))
@ -286,8 +289,6 @@ class AccountRepository @Inject constructor(
// helpers
private fun account(accountName: String) = Account(accountName, accountType)
private fun insertService(accountName: String, type: String, info: DavResourceFinder.Configuration.ServiceInfo): Long {
// insert service
val service = Service(0, accountName, type, info.principal)

View File

@ -179,10 +179,12 @@ class DavCollectionRepository @Inject constructor(
}
}
fun getSyncableByTopic(topic: String) = dao.getSyncableByPushTopic(topic)
fun getFlow(id: Long) = dao.getFlow(id)
/** Returns all collections that are both selected for synchronization and push-capable. */
suspend fun getSyncEnabledAndPushCapable(): List<Collection> =
suspend fun getSyncableAndPushCapable(): List<Collection> =
dao.getPushCapableSyncCollections()
/**

View File

@ -22,6 +22,10 @@ class PreferenceRepository @Inject constructor(
context: Application
) {
companion object {
const val UNIFIED_PUSH_ENDPOINT = "unified_push_endpoint"
}
private val preferences = PreferenceManager.getDefaultSharedPreferences(context)
/**
@ -41,6 +45,20 @@ class PreferenceRepository @Inject constructor(
preferences.getBoolean(Logger.LOG_TO_FILE, false)
}
fun unifiedPushEndpoint() =
preferences.getString(UNIFIED_PUSH_ENDPOINT, null)
fun unifiedPushEndpointFlow() = observeAsFlow(UNIFIED_PUSH_ENDPOINT) {
unifiedPushEndpoint()
}
fun unifiedPushEndpoint(endpoint: String?) {
preferences
.edit()
.putString(UNIFIED_PUSH_ENDPOINT, endpoint)
.apply()
}
private fun<T> observeAsFlow(keyToObserve: String, getValue: () -> T): Flow<T> =
callbackFlow {

View File

@ -101,4 +101,8 @@ class AppSettingsModel @Inject constructor(
val icon = appInfoFlow.map { it?.loadIcon(pm) }
// push
val pushEndpoint = preference.unifiedPushEndpointFlow()
}

View File

@ -32,6 +32,7 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.stringArrayResource
import androidx.compose.ui.res.stringResource
@ -50,6 +51,7 @@ import at.bitfire.davdroid.ui.composable.Setting
import at.bitfire.davdroid.ui.composable.SettingsHeader
import at.bitfire.davdroid.ui.composable.SwitchSetting
import kotlinx.coroutines.launch
import org.unifiedpush.android.connector.UnifiedPush
@Composable
fun AppSettingsScreen(
@ -92,9 +94,10 @@ fun AppSettingsScreen(
onThemeSelected = model::updateTheme,
onResetHints = model::resetHints,
// Integration (Tasks)
// Integration (Tasks and Push)
tasksAppName = model.appName.collectAsStateWithLifecycle(null).value ?: stringResource(R.string.app_settings_tasks_provider_none),
tasksAppIcon = model.icon.collectAsStateWithLifecycle(null).value,
pushEndpoint = model.pushEndpoint.collectAsStateWithLifecycle(null).value,
onNavTasksScreen = onNavTasksScreen
)
}
@ -133,6 +136,7 @@ fun AppSettingsScreen(
// AppSettings Integration
tasksAppName: String,
tasksAppIcon: Drawable?,
pushEndpoint: String?,
onNavTasksScreen: () -> Unit,
onShowNotificationSettings: () -> Unit,
@ -219,6 +223,7 @@ fun AppSettingsScreen(
AppSettings_Integration(
appName = tasksAppName,
icon = tasksAppIcon,
pushEndpoint = pushEndpoint,
onNavTasksScreen = onNavTasksScreen
)
}
@ -254,6 +259,7 @@ fun AppSettingsScreen_Preview() {
onResetHints = {},
tasksAppName = "No tasks app",
tasksAppIcon = null,
pushEndpoint = null,
onNavTasksScreen = {}
)
}
@ -464,6 +470,7 @@ fun AppSettings_UserInterface(
@Composable
fun AppSettings_Integration(
appName: String,
pushEndpoint: String?,
icon: Drawable? = null,
onNavTasksScreen: () -> Unit = {}
) {
@ -482,4 +489,14 @@ fun AppSettings_Integration(
summary = appName,
onClick = onNavTasksScreen
)
val context = LocalContext.current
Setting(
name = "UnifiedPush",
summary = pushEndpoint ?: stringResource(R.string.app_settings_unifiedpush_no_endpoint),
onClick = {
UnifiedPush.registerAppWithDialog(context)
}
)
}

View File

@ -12,10 +12,10 @@ import at.bitfire.davdroid.util.TaskUtils
import at.bitfire.davdroid.util.packageChangedFlow
import at.bitfire.ical4android.TaskProvider
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
class TasksModel @Inject constructor(

View File

@ -5,7 +5,6 @@
package at.bitfire.davdroid.ui.composable
import androidx.annotation.StringRes
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.ExperimentalMaterial3Api

View File

@ -16,7 +16,6 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier

View File

@ -15,7 +15,6 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.SwitchDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment

View File

@ -12,9 +12,9 @@ import android.net.Uri
import androidx.activity.result.contract.ActivityResultContract
import androidx.compose.runtime.Composable
import at.bitfire.davdroid.settings.SettingsManager
import javax.inject.Inject
import at.bitfire.davdroid.ui.intro.BatteryOptimizationsPageModel.Companion.HINT_AUTOSTART_PERMISSION
import at.bitfire.davdroid.ui.intro.BatteryOptimizationsPageModel.Companion.HINT_BATTERY_OPTIMIZATIONS
import javax.inject.Inject
class BatteryOptimizationsPage @Inject constructor(
private val application: Application,

View File

@ -35,8 +35,8 @@ import at.bitfire.davdroid.Constants
import at.bitfire.davdroid.Constants.withStatParams
import at.bitfire.davdroid.R
import at.bitfire.davdroid.ui.AppTheme
import java.util.Locale
import org.apache.commons.text.WordUtils
import java.util.Locale
@Composable
fun BatteryOptimizationsPageContent(

View File

@ -16,9 +16,9 @@ import at.bitfire.davdroid.settings.SettingsManager
import at.bitfire.davdroid.util.PermissionUtils
import at.bitfire.davdroid.util.broadcastReceiverFlow
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.launch
import java.util.Locale
import javax.inject.Inject
import kotlinx.coroutines.launch
@HiltViewModel
class BatteryOptimizationsPageModel @Inject constructor(

View File

@ -8,98 +8,54 @@ import android.app.Activity
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.activity.addCallback
import androidx.activity.compose.BackHandler
import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContract
import androidx.activity.viewModels
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Surface
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.ComposeView
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.ViewModel
import at.bitfire.davdroid.log.Logger
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.runtime.rememberCoroutineScope
import at.bitfire.davdroid.ui.AppTheme
import at.bitfire.davdroid.ui.M3ColorScheme
import com.github.appintro.AppIntro2
import dagger.hilt.android.AndroidEntryPoint
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.launch
@AndroidEntryPoint
class IntroActivity : AppIntro2() {
@OptIn(ExperimentalFoundationApi::class)
class IntroActivity : AppCompatActivity() {
val model by viewModels<Model>()
private var currentSlide = 0
val model by viewModels<IntroModel>()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
model.pages.forEachIndexed { idx, _ ->
addSlide(PageFragment().apply {
arguments = Bundle(1).apply {
putInt(PageFragment.ARG_PAGE_IDX, idx)
}
})
}
val pages = model.pages
setBarColor(M3ColorScheme.primaryLight.toArgb())
isSkipButtonEnabled = false
setContent {
AppTheme {
val scope = rememberCoroutineScope()
val pagerState = rememberPagerState { pages.size }
onBackPressedDispatcher.addCallback(this) {
if (currentSlide == 0) {
setResult(Activity.RESULT_CANCELED)
finish()
} else {
goToPreviousSlide()
}
}
}
override fun onPageSelected(position: Int) {
super.onPageSelected(position)
currentSlide = position
}
override fun onDonePressed(currentFragment: Fragment?) {
super.onDonePressed(currentFragment)
setResult(Activity.RESULT_OK)
finish()
}
@AndroidEntryPoint
class PageFragment: Fragment() {
companion object {
const val ARG_PAGE_IDX = "page"
}
val model by activityViewModels<Model>()
val page by lazy { model.pages[requireArguments().getInt(ARG_PAGE_IDX)] }
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?) =
ComposeView(requireActivity()).apply {
setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
setContent {
AppTheme {
Surface(Modifier.fillMaxSize()) {
Box(Modifier.padding(bottom = dimensionResource(com.github.appintro.R.dimen.appintro2_bottombar_height))) {
page.ComposePage()
}
}
BackHandler {
if (pagerState.settledPage == 0) {
setResult(Activity.RESULT_CANCELED)
finish()
} else scope.launch {
pagerState.animateScrollToPage(pagerState.settledPage - 1)
}
}
}
IntroScreen(
pages = pages,
pagerState = pagerState,
onDonePressed = {
setResult(Activity.RESULT_OK)
finish()
}
)
}
}
}
@ -115,49 +71,4 @@ class IntroActivity : AppIntro2() {
}
}
@HiltViewModel
class Model @Inject constructor(
introPageFactory: IntroPageFactory
): ViewModel() {
private val introPages = introPageFactory.introPages
private var _pages: List<IntroPage>? = null
val pages: List<IntroPage>
@Synchronized
get() {
_pages?.let { return it }
val newPages = calculatePages()
_pages = newPages
return newPages
}
private fun calculatePages(): List<IntroPage> {
for (page in introPages)
Logger.log.fine("Found intro page ${page::class.java} with order ${page.getShowPolicy()}")
val activePages: Map<IntroPage, IntroPage.ShowPolicy> = introPages
.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 }
return if (anyShowAlways) {
val pages = mutableListOf<IntroPage>()
activePages.filterValues { it != IntroPage.ShowPolicy.DONT_SHOW }.forEach { page, _ ->
pages += page
}
pages
} else
emptyList()
}
}
}

View File

@ -0,0 +1,45 @@
package at.bitfire.davdroid.ui.intro
import androidx.lifecycle.ViewModel
import at.bitfire.davdroid.log.Logger
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
@HiltViewModel
class IntroModel @Inject constructor(
introPageFactory: IntroPageFactory
): ViewModel() {
private val introPages = introPageFactory.introPages
val pages: List<IntroPage> by lazy {
calculatePages()
}
private fun calculatePages(): List<IntroPage> {
for (page in introPages)
Logger.log.fine("Found intro page ${page::class.java} with order ${page.getShowPolicy()}")
// Calculate which intro pages shall be shown
val activePages: Map<IntroPage, IntroPage.ShowPolicy> = introPages
.associateWith { page ->
page.getShowPolicy().also { policy ->
Logger.log.fine("IntroActivity: found intro page ${page::class.java} with $policy")
}
}
.filterValues { it != IntroPage.ShowPolicy.DONT_SHOW }
// Show intro screen when there's at least one page that shall [always] be shown
val anyShowAlways = activePages.values.any { it == IntroPage.ShowPolicy.SHOW_ALWAYS }
return if (anyShowAlways) {
val pages = mutableListOf<IntroPage>()
activePages.filterValues { it != IntroPage.ShowPolicy.DONT_SHOW }.forEach { page, _ ->
pages += page
}
pages
} else
emptyList()
}
}

View File

@ -0,0 +1,255 @@
package at.bitfire.davdroid.ui.intro
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxHeight
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.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.PagerState
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowForward
import androidx.compose.material.icons.filled.Check
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.contentColorFor
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.translate
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import at.bitfire.davdroid.R
import at.bitfire.davdroid.ui.AppTheme
import kotlinx.coroutines.launch
@Composable
@OptIn(ExperimentalFoundationApi::class)
fun IntroScreen(
pages: List<IntroPage>,
pagerState: PagerState = rememberPagerState { pages.size },
onDonePressed: () -> Unit
) {
val scope = rememberCoroutineScope()
Column(modifier = Modifier.fillMaxSize()) {
HorizontalPager(
state = pagerState,
modifier = Modifier
.fillMaxWidth()
.weight(1f)
) { pages[it].ComposePage() }
Box(
modifier = Modifier
.fillMaxWidth()
.height(90.dp)
.background(MaterialTheme.colorScheme.primary)
) {
PositionIndicator(
index = pagerState.currentPage,
max = pages.size,
modifier = Modifier
.fillMaxHeight()
.padding(horizontal = 128.dp)
.align(Alignment.Center)
.fillMaxWidth(),
selectedIndicatorColor = MaterialTheme.colorScheme.onPrimary,
unselectedIndicatorColor = MaterialTheme.colorScheme.tertiary,
indicatorSize = 15f
)
ButtonWithIcon(
icon = if (pagerState.currentPage + 1 == pagerState.pageCount) {
Icons.Default.Check
} else {
Icons.AutoMirrored.Default.ArrowForward
},
contentDescription = stringResource(R.string.intro_next),
modifier = Modifier
.padding(end = 16.dp)
.align(Alignment.CenterEnd)
) {
if (pagerState.currentPage + 1 == pagerState.pageCount) {
onDonePressed()
} else scope.launch {
pagerState.animateScrollToPage(pagerState.currentPage + 1)
}
}
}
}
}
@Preview(
showSystemUi = true
)
@Composable
@OptIn(ExperimentalFoundationApi::class)
fun IntroScreen_Preview() {
AppTheme {
IntroScreen(
listOf(
object : IntroPage {
override fun getShowPolicy(): IntroPage.ShowPolicy =
IntroPage.ShowPolicy.SHOW_ALWAYS
@Composable
override fun ComposePage() {
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.surface)
)
}
},
object : IntroPage {
override fun getShowPolicy(): IntroPage.ShowPolicy =
IntroPage.ShowPolicy.SHOW_ALWAYS
@Composable
override fun ComposePage() {
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.primary)
)
}
}
),
onDonePressed = {}
)
}
}
@Composable
fun PositionIndicator(
index: Int,
max: Int,
modifier: Modifier = Modifier,
selectedIndicatorColor: Color = MaterialTheme.colorScheme.tertiary,
unselectedIndicatorColor: Color = contentColorFor(selectedIndicatorColor),
indicatorSize: Float = 20f,
indicatorPadding: Float = 20f
) {
val selectedPosition by animateFloatAsState(
targetValue = index.toFloat(),
label = "position"
)
Canvas(modifier = modifier) {
// idx * indicatorSize * 2 + idx * indicatorPadding + indicatorSize
// idx * (indicatorSize * 2 + indicatorPadding) + indicatorSize
val padding = indicatorSize * 2 + indicatorPadding
val totalWidth = indicatorSize * 2 * max + indicatorPadding * (max - 1)
translate(
left = size.width / 2 - totalWidth / 2
) {
for (idx in 0 until max) {
drawCircle(
color = unselectedIndicatorColor,
radius = indicatorSize,
center = Offset(
x = idx * padding + indicatorSize,
y = size.height / 2
)
)
}
drawCircle(
color = selectedIndicatorColor,
radius = indicatorSize,
center = Offset(
x = selectedPosition * padding + indicatorSize,
y = size.height / 2
)
)
}
}
}
@Preview(
showBackground = true,
backgroundColor = 0xff000000
)
@Composable
fun PositionIndicator_Preview() {
var index by remember { mutableIntStateOf(0) }
PositionIndicator(
index = index,
max = 5,
modifier = Modifier
.width(200.dp)
.height(50.dp)
.clickable { if (index == 4) index = 0 else index++ }
)
}
@Composable
fun ButtonWithIcon(
icon: ImageVector,
contentDescription: String?,
modifier: Modifier = Modifier,
size: Dp = 56.dp,
color: Color = MaterialTheme.colorScheme.tertiary,
onClick: () -> Unit
) {
Surface(
color = color,
contentColor = contentColorFor(backgroundColor = color),
modifier = modifier
.size(size)
.aspectRatio(1f),
onClick = onClick,
shape = CircleShape
) {
AnimatedContent(
targetState = icon,
label = "Button Icon"
) {
Icon(
imageVector = it,
contentDescription = contentDescription,
modifier = Modifier.padding(12.dp)
)
}
}
}
@Preview
@Composable
fun ButtonWithIcon_Preview() {
AppTheme {
ButtonWithIcon(
icon = Icons.AutoMirrored.Filled.ArrowForward,
contentDescription = null
) { }
}
}

View File

@ -6,7 +6,6 @@ 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.PermissionsModel
import at.bitfire.davdroid.ui.PermissionsScreen
import at.bitfire.davdroid.util.PermissionUtils

View File

@ -22,7 +22,6 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
@ -84,11 +83,6 @@ class WelcomePage: IntroPage {
.fillMaxWidth()
.wrapContentHeight()
.padding(horizontal = 32.dp)
.padding(
bottom = dimensionResource(
com.github.appintro.R.dimen.appintro2_bottombar_height
)
)
)
Spacer(modifier = Modifier.weight(0.1f))
@ -128,12 +122,7 @@ class WelcomePage: IntroPage {
Row(
modifier = Modifier
.fillMaxSize()
.background(color = MaterialTheme.colorScheme.primary)
.padding(
bottom = dimensionResource(
com.github.appintro.R.dimen.appintro2_bottombar_height
)
),
.background(color = MaterialTheme.colorScheme.primary),
verticalAlignment = Alignment.CenterVertically
) {
Image(

View File

@ -60,6 +60,7 @@
<string name="intro_open_source_text">We\'re happy that you use %s, which is open-source software. Development, maintenance and support are hard work. Please consider contributing (there are many ways) or a donation. It would be highly appreciated!</string>
<string name="intro_open_source_details">How to contribute/donate</string>
<string name="intro_open_source_dont_show">Don\'t show in the near future</string>
<string name="intro_next">Next</string>
<!-- PermissionsActivity -->
<string name="permissions_title">Permissions</string>
@ -209,6 +210,8 @@
<string name="app_settings_integration">Integration</string>
<string name="app_settings_tasks_provider">Tasks app</string>
<string name="app_settings_tasks_provider_none">No compatible tasks app found</string>
<string name="app_settings_unifiedpush" translatable="false">UnifiedPush</string>
<string name="app_settings_unifiedpush_no_endpoint">No endpoint configured</string>
<!-- AccountScreen -->
<string name="account_carddav">CardDAV</string>

View File

@ -1,20 +0,0 @@
<!--
~ Copyright (c) 2013 2016 Ricki Hirner (bitfire web engineering).
~ All rights reserved. This program and the accompanying materials
~ are made available under the terms of the GNU Public License v3.0
~ which accompanies this distribution, and is available at
~ http://www.gnu.org/licenses/gpl.html
-->
<resources>
<!-- app theme -->
<style name="AppTheme" parent="Theme.AppCompat.DayNight.NoActionBar">
<!-- These non-Compose theme variables are required for AppIntro as long it doesn't support Compose: -->
<item name="colorPrimary">@color/primaryColor</item>
<item name="colorPrimaryDark">@color/primaryDarkColor</item>
<item name="android:colorBackground">@android:color/white</item>
</style>
</resources>

View File

@ -0,0 +1,31 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.push
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class PushMessageParserTest {
private val parse = PushMessageParser()
@Test
fun testInvalidXml() {
assertNull(parse("Non-XML content"))
}
@Test
fun testWithXmlDeclAndTopic() {
val topic = parse(
"<?xml version=\"1.0\" ?>" +
"<push-message xmlns='DAV:Push'>" +
"<topic>sample-topic</topic>" +
"</push-message>"
)
assertEquals("sample-topic", topic)
}
}

View File

@ -13,15 +13,14 @@ androidx-lifecycle = "2.8.2"
androidx-paging = "3.3.0"
androidx-preference = "1.2.1"
androidx-security = "1.1.0-alpha06"
androidx-test-core = "1.5.0"
androidx-test-runner = "1.5.2"
androidx-test-rules = "1.5.0"
androidx-test-junit = "1.1.5"
androidx-test-core = "1.6.0"
androidx-test-runner = "1.6.0"
androidx-test-rules = "1.6.0"
androidx-test-junit = "1.2.0"
androidx-work = "2.9.0"
appIntro = "7.0.0-beta02"
bitfire-cert4android = "f1cc9b9ca3"
bitfire-dav4jvm = "fa173ab215"
bitfire-ical4android = "ba5a013d69"
bitfire-dav4jvm = "b8be778202"
bitfire-ical4android = "83cda23ceb"
bitfire-vcard4android = "03a37a8284"
commons-collections = "4.4"
commons-lang = "3.14.0"
@ -48,6 +47,7 @@ mockk = "1.13.11"
okhttp = "4.12.0"
openid-appauth = "0.11.1"
room = "2.6.1"
unifiedpush = "2.4.0"
[libraries]
android-desugaring = { module = "com.android.tools:desugar_jdk_libs", version.ref = "android-desugaring" }
@ -72,7 +72,6 @@ androidx-test-rules = { module = "androidx.test:rules", version.ref = "androidx-
androidx-test-junit = { module = "androidx.test.ext:junit", version.ref = "androidx-test-junit" }
androidx-work-base = { module = "androidx.work:work-runtime-ktx", version.ref = "androidx-work" }
androidx-work-testing = { module = "androidx.work:work-testing", version.ref = "androidx-work" }
appintro = { module = "com.github.AppIntro:AppIntro", version.ref = "appIntro" }
bitfire-cert4android = { module = "com.github.bitfireAT:cert4android", version.ref = "bitfire-cert4android" }
bitfire-dav4jvm = { module = "com.github.bitfireAT:dav4jvm", version.ref = "bitfire-dav4jvm" }
bitfire-ical4android = { module = "com.github.bitfireAT:ical4android", version.ref = "bitfire-ical4android" }
@ -110,6 +109,7 @@ room-compiler = { module = "androidx.room:room-compiler", version.ref = "room" }
room-paging = { module = "androidx.room:room-paging", version.ref = "room" }
room-runtime = { module = "androidx.room:room-runtime", version.ref = "room" }
room-testing = { module = "androidx.room:room-testing", version.ref = "room" }
unifiedpush = { module = "com.github.UnifiedPush:android-connector", version.ref = "unifiedpush" }
[plugins]
android-application = { id = "com.android.application", version.ref = "android-agp" }