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
This commit is contained in:
Ricki Hirner 2024-06-25 14:07:29 +02:00 committed by GitHub
parent 28948485f6
commit bcc16e1ab6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
24 changed files with 242 additions and 29 deletions

View File

@ -198,6 +198,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

@ -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

@ -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

@ -209,6 +209,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

@ -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

@ -20,7 +20,7 @@ androidx-test-junit = "1.1.5"
androidx-work = "2.9.0"
appIntro = "7.0.0-beta02"
bitfire-cert4android = "f1cc9b9ca3"
bitfire-dav4jvm = "fa173ab215"
bitfire-dav4jvm = "b8be778202"
bitfire-ical4android = "ba5a013d69"
bitfire-vcard4android = "03a37a8284"
commons-collections = "4.4"
@ -48,6 +48,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" }
@ -110,6 +111,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" }