diff --git a/app/build.gradle.kts b/app/build.gradle.kts index eab581b99c..ac66e0fddb 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -197,7 +197,13 @@ dependencies { implementation(libs.okhttp.brotli) implementation(libs.okhttp.logging) implementation(libs.openid.appauth) - implementation(libs.unifiedpush) + implementation(libs.unifiedpush) { + // UnifiedPush connector seems to be using a workaround by importing this library. + // Will be removed after https://github.com/tink-crypto/tink-java-apps/pull/5 is merged. + // See: https://codeberg.org/UnifiedPush/android-connector/src/commit/28cb0d622ed0a972996041ab9cc85b701abc48c6/connector/build.gradle#L56-L59 + exclude(group = "com.google.crypto.tink", module = "tink") + } + implementation(libs.unifiedpush.fcm) // force some versions for compatibility with our minSdk level (see version catalog for details) implementation(libs.commons.codec) diff --git a/app/src/androidTest/kotlin/at/bitfire/davdroid/TestModules.kt b/app/src/androidTest/kotlin/at/bitfire/davdroid/TestModules.kt index 5f96cc89f2..c1e426fe6a 100644 --- a/app/src/androidTest/kotlin/at/bitfire/davdroid/TestModules.kt +++ b/app/src/androidTest/kotlin/at/bitfire/davdroid/TestModules.kt @@ -4,8 +4,6 @@ package at.bitfire.davdroid -import at.bitfire.davdroid.push.PushRegistrationWorkerManager -import at.bitfire.davdroid.repository.DavCollectionRepository import at.bitfire.davdroid.startup.StartupPlugin import at.bitfire.davdroid.startup.TasksAppWatcher import dagger.Module @@ -13,18 +11,6 @@ import dagger.hilt.components.SingletonComponent import dagger.hilt.testing.TestInstallIn import dagger.multibindings.Multibinds -// remove PushRegistrationWorkerModule from Android tests -@Module -@TestInstallIn( - components = [SingletonComponent::class], - replaces = [PushRegistrationWorkerManager.PushRegistrationWorkerModule::class] -) -abstract class TestPushRegistrationWorkerModule { - // provides empty set of listeners - @Multibinds - abstract fun empty(): Set -} - // remove TasksAppWatcherModule from Android tests @Module @TestInstallIn( diff --git a/app/src/androidTest/kotlin/at/bitfire/davdroid/push/PushMessageHandlerTest.kt b/app/src/androidTest/kotlin/at/bitfire/davdroid/push/PushMessageHandlerTest.kt new file mode 100644 index 0000000000..9d1549ea6e --- /dev/null +++ b/app/src/androidTest/kotlin/at/bitfire/davdroid/push/PushMessageHandlerTest.kt @@ -0,0 +1,46 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.push + +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import org.junit.Assert +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import javax.inject.Inject + +@HiltAndroidTest +class PushMessageHandlerTest { + + @get:Rule + val hiltRule = HiltAndroidRule(this) + + @Inject + lateinit var handler: PushMessageHandler + + @Before + fun setUp() { + hiltRule.inject() + } + + + @Test + fun testParse_InvalidXml() { + Assert.assertNull(handler.parse("Non-XML content")) + } + + @Test + fun testParse_WithXmlDeclAndTopic() { + val topic = handler.parse( + "" + + "" + + " O7M1nQ7cKkKTKsoS_j6Z3w" + + "" + ) + Assert.assertEquals("O7M1nQ7cKkKTKsoS_j6Z3w", topic) + } + +} \ No newline at end of file diff --git a/app/src/androidTest/kotlin/at/bitfire/davdroid/push/UnifiedPushServiceTest.kt b/app/src/androidTest/kotlin/at/bitfire/davdroid/push/UnifiedPushServiceTest.kt new file mode 100644 index 0000000000..0dd5c16104 --- /dev/null +++ b/app/src/androidTest/kotlin/at/bitfire/davdroid/push/UnifiedPushServiceTest.kt @@ -0,0 +1,95 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.push + +import android.content.Context +import android.content.Intent +import android.os.IBinder +import androidx.test.rule.ServiceTestRule +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.android.testing.BindValue +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import io.mockk.coVerify +import io.mockk.confirmVerified +import io.mockk.every +import io.mockk.impl.annotations.RelaxedMockK +import io.mockk.junit4.MockKRule +import io.mockk.mockk +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.unifiedpush.android.connector.FailedReason +import org.unifiedpush.android.connector.PushService +import org.unifiedpush.android.connector.data.PushEndpoint +import javax.inject.Inject + +@HiltAndroidTest +class UnifiedPushServiceTest { + + @get:Rule + val hiltRule = HiltAndroidRule(this) + + @get:Rule + val mockKRule = MockKRule(this) + + @get:Rule + val serviceTestRule = ServiceTestRule() + + @Inject + @ApplicationContext + lateinit var context: Context + + @RelaxedMockK + @BindValue + lateinit var pushRegistrationManager: PushRegistrationManager + + lateinit var binder: IBinder + lateinit var unifiedPushService: UnifiedPushService + + + @Before + fun setUp() { + hiltRule.inject() + + binder = serviceTestRule.bindService(Intent(context, UnifiedPushService::class.java))!! + unifiedPushService = (binder as PushService.PushBinder).getService() as UnifiedPushService + } + + + @Test + fun testOnNewEndpoint() { + val endpoint = mockk { + every { url } returns "https://example.com/12" + } + unifiedPushService.onNewEndpoint(endpoint, "12") + + coVerify { + pushRegistrationManager.processSubscription(12, endpoint) + } + confirmVerified(pushRegistrationManager) + } + + @Test + fun testOnRegistrationFailed() { + unifiedPushService.onRegistrationFailed(FailedReason.INTERNAL_ERROR, "34") + + coVerify { + pushRegistrationManager.removeSubscription(34) + } + confirmVerified(pushRegistrationManager) + } + + @Test + fun testOnUnregistered() { + unifiedPushService.onRegistrationFailed(FailedReason.INTERNAL_ERROR, "45") + + coVerify { + pushRegistrationManager.removeSubscription(45) + } + confirmVerified(pushRegistrationManager) + } + +} \ No newline at end of file diff --git a/app/src/androidTest/kotlin/at/bitfire/davdroid/servicedetection/CollectionListRefresherTest.kt b/app/src/androidTest/kotlin/at/bitfire/davdroid/servicedetection/CollectionListRefresherTest.kt index 53dbeb3003..86549602cd 100644 --- a/app/src/androidTest/kotlin/at/bitfire/davdroid/servicedetection/CollectionListRefresherTest.kt +++ b/app/src/androidTest/kotlin/at/bitfire/davdroid/servicedetection/CollectionListRefresherTest.kt @@ -19,6 +19,7 @@ import dagger.hilt.android.testing.HiltAndroidTest import io.mockk.every import io.mockk.impl.annotations.MockK import io.mockk.junit4.MockKRule +import kotlinx.coroutines.test.runTest import okhttp3.mockwebserver.Dispatcher import okhttp3.mockwebserver.MockResponse import okhttp3.mockwebserver.MockWebServer @@ -121,7 +122,7 @@ class CollectionListRefresherTest { // refreshHomesetsAndTheirCollections @Test - fun refreshHomesetsAndTheirCollections_addsNewCollection() { + fun refreshHomesetsAndTheirCollections_addsNewCollection() = runTest { // save homeset in DB val homesetId = db.homeSetDao().insert( HomeSet(id=0, service.id, true, mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK_HOMESET_PERSONAL")) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 173b80fc00..c73e4078a9 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -268,15 +268,12 @@ android:resource="@xml/debug_paths" /> - - + + - - - - + - + @Query("SELECT * FROM collection WHERE serviceId=:serviceId") - fun getByService(serviceId: Long): List + suspend fun getByService(serviceId: Long): List @Query("SELECT * FROM collection WHERE serviceId=:serviceId AND homeSetId IS :homeSetId") fun getByServiceAndHomeset(serviceId: Long, homeSetId: Long?): List @@ -33,7 +33,10 @@ interface CollectionDao { fun getByServiceAndType(serviceId: Long, @CollectionType type: String): List @Query("SELECT * FROM collection WHERE pushTopic=:topic AND sync") - fun getSyncableByPushTopic(topic: String): Collection? + suspend fun getSyncableByPushTopic(topic: String): Collection? + + @Query("SELECT pushVapidKey FROM collection WHERE serviceId=:serviceId AND pushVapidKey IS NOT NULL LIMIT 1") + suspend fun getFirstVapidKey(serviceId: Long): String? @Query("SELECT COUNT(*) FROM collection WHERE serviceId=:serviceId AND type=:type") suspend fun anyOfType(serviceId: Long, @CollectionType type: String): Boolean @@ -72,11 +75,14 @@ interface CollectionDao { * Get a list of collections that are both sync enabled and push capable (supportsWebPush and * pushTopic is available). */ - @Query("SELECT * FROM collection WHERE sync AND supportsWebPush AND pushTopic IS NOT NULL") - suspend fun getPushCapableSyncCollections(): List + @Query("SELECT * FROM collection WHERE serviceId=:serviceId AND sync AND supportsWebPush AND pushTopic IS NOT NULL") + suspend fun getPushCapableSyncCollections(serviceId: Long): List + + @Query("SELECT * FROM collection WHERE serviceId=:serviceId AND pushSubscription IS NOT NULL") + suspend fun getPushRegistered(serviceId: Long): List - @Query("SELECT * FROM collection WHERE pushSubscription IS NOT NULL AND NOT sync") - suspend fun getPushRegisteredAndNotSyncable(): List + @Query("SELECT * FROM collection WHERE serviceId=:serviceId AND pushSubscription IS NOT NULL AND NOT sync") + suspend fun getPushRegisteredAndNotSyncable(serviceId: Long): List @Insert(onConflict = OnConflictStrategy.IGNORE) fun insert(collection: Collection): Long @@ -91,7 +97,7 @@ interface CollectionDao { suspend fun updateForceReadOnly(id: Long, forceReadOnly: Boolean) @Query("UPDATE collection SET pushSubscription=:pushSubscription, pushSubscriptionExpires=:pushSubscriptionExpires, pushSubscriptionCreated=:updatedAt WHERE id=:id") - fun updatePushSubscription(id: Long, pushSubscription: String?, pushSubscriptionExpires: Long?, updatedAt: Long = System.currentTimeMillis()/1000) + suspend fun updatePushSubscription(id: Long, pushSubscription: String?, pushSubscriptionExpires: Long?, updatedAt: Long = System.currentTimeMillis()/1000) @Query("UPDATE collection SET sync=:sync WHERE id=:id") suspend fun updateSync(id: Long, sync: Boolean) @@ -116,4 +122,4 @@ interface CollectionDao { @Delete fun delete(collection: Collection) -} +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/db/ServiceDao.kt b/app/src/main/kotlin/at/bitfire/davdroid/db/ServiceDao.kt index 03769448db..a9b205296e 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/db/ServiceDao.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/db/ServiceDao.kt @@ -25,6 +25,12 @@ interface ServiceDao { @Query("SELECT * FROM service WHERE id=:id") fun get(id: Long): Service? + @Query("SELECT * FROM service WHERE id=:id") + suspend fun getAsync(id: Long): Service? + + @Query("SELECT * FROM service") + suspend fun getAll(): List + @Insert(onConflict = OnConflictStrategy.REPLACE) fun insertOrReplace(service: Service): Long diff --git a/app/src/main/kotlin/at/bitfire/davdroid/network/HttpClient.kt b/app/src/main/kotlin/at/bitfire/davdroid/network/HttpClient.kt index e675a0d3e8..364712c879 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/network/HttpClient.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/network/HttpClient.kt @@ -6,6 +6,7 @@ package at.bitfire.davdroid.network import android.accounts.Account import android.content.Context +import androidx.annotation.WorkerThread import at.bitfire.cert4android.CustomCertManager import at.bitfire.dav4jvm.BasicDigestAuthHandler import at.bitfire.dav4jvm.UrlUtils @@ -14,7 +15,10 @@ import at.bitfire.davdroid.settings.AccountSettings import at.bitfire.davdroid.settings.Settings import at.bitfire.davdroid.settings.SettingsManager import at.bitfire.davdroid.ui.ForegroundTracker +import at.bitfire.davdroid.util.IoDispatcher import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.withContext import net.openid.appauth.AuthState import net.openid.appauth.AuthorizationService import okhttp3.Authenticator @@ -64,6 +68,7 @@ class HttpClient( private val authorizationServiceProvider: Provider, @ApplicationContext private val context: Context, defaultLogger: Logger, + @IoDispatcher private val ioDispatcher: CoroutineDispatcher, private val keyManagerFactory: ClientCertKeyManager.Factory, private val settingsManager: SettingsManager ) { @@ -141,9 +146,12 @@ class HttpClient( /** * Takes authentication (basic/digest or OAuth and client certificate) from a given account. * + * **Must not be run on main thread, because it creates [AccountSettings]!** Use [fromAccountAsync] if possible. + * * @param account the account to take authentication from * @param onlyHost if set: only authenticate for this host name */ + @WorkerThread fun fromAccount(account: Account, onlyHost: String? = null): Builder { val accountSettings = accountSettingsFactory.create(account) authenticate( @@ -156,6 +164,13 @@ class HttpClient( return this } + /** + * Same as [fromAccount], but can be called on any thread. + */ + suspend fun fromAccountAsync(account: Account, onlyHost: String? = null): Builder = withContext(ioDispatcher) { + fromAccount(account, onlyHost) + } + // actual builder diff --git a/app/src/main/kotlin/at/bitfire/davdroid/push/PushMessageHandler.kt b/app/src/main/kotlin/at/bitfire/davdroid/push/PushMessageHandler.kt new file mode 100644 index 0000000000..042cae926c --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/push/PushMessageHandler.kt @@ -0,0 +1,119 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.push + +import androidx.annotation.VisibleForTesting +import at.bitfire.dav4jvm.XmlReader +import at.bitfire.dav4jvm.XmlUtils +import at.bitfire.davdroid.db.Collection.Companion.TYPE_ADDRESSBOOK +import at.bitfire.davdroid.repository.AccountRepository +import at.bitfire.davdroid.repository.DavCollectionRepository +import at.bitfire.davdroid.repository.DavServiceRepository +import at.bitfire.davdroid.sync.SyncDataType +import at.bitfire.davdroid.sync.TasksAppManager +import at.bitfire.davdroid.sync.worker.SyncWorkerManager +import dagger.Lazy +import org.unifiedpush.android.connector.data.PushMessage +import org.xmlpull.v1.XmlPullParserException +import java.io.StringReader +import java.util.logging.Level +import java.util.logging.Logger +import javax.inject.Inject +import at.bitfire.dav4jvm.property.push.PushMessage as DavPushMessage + +/** + * Handles incoming WebDAV-Push messages. + */ +class PushMessageHandler @Inject constructor( + private val accountRepository: AccountRepository, + private val collectionRepository: DavCollectionRepository, + private val logger: Logger, + private val serviceRepository: DavServiceRepository, + private val syncWorkerManager: SyncWorkerManager, + private val tasksAppManager: Lazy +) { + + suspend fun processMessage(message: PushMessage, instance: String) { + if (!message.decrypted) { + logger.severe("Received a push message that could not be decrypted.") + return + } + val messageXml = message.content.toString(Charsets.UTF_8) + logger.log(Level.INFO, "Received push message", messageXml) + + // parse push notification + val topic = parse(messageXml) + + // sync affected collection + if (topic != null) { + logger.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.getAsync(collection.serviceId)?.let { service -> + val syncDataTypes = mutableSetOf() + // If the type is an address book, add the contacts type + if (collection.type == TYPE_ADDRESSBOOK) + syncDataTypes += SyncDataType.CONTACTS + + // If the collection supports events, add the events type + if (collection.supportsVEVENT != false) + syncDataTypes += SyncDataType.EVENTS + + // If the collection supports tasks, make sure there's a provider installed, + // and add the tasks type + if (collection.supportsVJOURNAL != false || collection.supportsVTODO != false) + if (tasksAppManager.get().currentProvider() != null) + syncDataTypes += SyncDataType.TASKS + + // Schedule sync for all the types identified + val account = accountRepository.fromName(service.accountName) + for (syncDataType in syncDataTypes) + syncWorkerManager.enqueueOneTime(account, syncDataType, fromPush = true) + } + } + + } else { + // fallback when no known topic is present (shouldn't happen) + val service = instance.toLongOrNull()?.let { serviceRepository.get(it) } + if (service != null) { + logger.warning("Got push message without topic and service, syncing all accounts") + val account = accountRepository.fromName(service.accountName) + syncWorkerManager.enqueueOneTimeAllAuthorities(account, fromPush = true) + + } else { + logger.warning("Got push message without topic, syncing all accounts") + for (account in accountRepository.getAll()) + syncWorkerManager.enqueueOneTimeAllAuthorities(account, fromPush = true) + } + } + } + + /** + * 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 + */ + @VisibleForTesting + internal fun parse(message: String): String? { + var topic: String? = null + + val parser = XmlUtils.newPullParser() + try { + parser.setInput(StringReader(message)) + + XmlReader(parser).processTag(DavPushMessage.NAME) { + val pushMessage = DavPushMessage.Factory.create(parser) + topic = pushMessage.topic?.topic + } + } catch (e: XmlPullParserException) { + logger.log(Level.WARNING, "Couldn't parse push message", e) + } + + return topic + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/push/PushMessageParser.kt b/app/src/main/kotlin/at/bitfire/davdroid/push/PushMessageParser.kt deleted file mode 100644 index a25cb0c97b..0000000000 --- a/app/src/main/kotlin/at/bitfire/davdroid/push/PushMessageParser.kt +++ /dev/null @@ -1,43 +0,0 @@ -/* - * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. - */ - -package at.bitfire.davdroid.push - -import at.bitfire.dav4jvm.XmlReader -import at.bitfire.dav4jvm.XmlUtils -import at.bitfire.dav4jvm.property.push.PushMessage -import org.xmlpull.v1.XmlPullParserException -import java.io.StringReader -import java.util.logging.Level -import java.util.logging.Logger -import javax.inject.Inject - -class PushMessageParser @Inject constructor( - private val logger: Logger -) { - - /** - * 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)) - - XmlReader(parser).processTag(PushMessage.NAME) { - val pushMessage = PushMessage.Factory.create(parser) - topic = pushMessage.topic?.topic - } - } catch (e: XmlPullParserException) { - logger.log(Level.WARNING, "Couldn't parse push message", e) - } - - return topic - } - -} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/push/PushRegistrationManager.kt b/app/src/main/kotlin/at/bitfire/davdroid/push/PushRegistrationManager.kt new file mode 100644 index 0000000000..934ddbdf61 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/push/PushRegistrationManager.kt @@ -0,0 +1,315 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.push + +import android.content.Context +import androidx.work.BackoffPolicy +import androidx.work.Constraints +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.NetworkType +import androidx.work.PeriodicWorkRequest +import androidx.work.WorkManager +import at.bitfire.dav4jvm.DavCollection +import at.bitfire.dav4jvm.DavResource +import at.bitfire.dav4jvm.HttpUtils +import at.bitfire.dav4jvm.XmlUtils +import at.bitfire.dav4jvm.XmlUtils.insertTag +import at.bitfire.dav4jvm.exception.DavException +import at.bitfire.dav4jvm.property.push.AuthSecret +import at.bitfire.dav4jvm.property.push.PushRegister +import at.bitfire.dav4jvm.property.push.PushResource +import at.bitfire.dav4jvm.property.push.Subscription +import at.bitfire.dav4jvm.property.push.SubscriptionPublicKey +import at.bitfire.dav4jvm.property.push.WebPushSubscription +import at.bitfire.davdroid.db.Collection +import at.bitfire.davdroid.db.Service +import at.bitfire.davdroid.network.HttpClient +import at.bitfire.davdroid.push.PushRegistrationManager.Companion.mutex +import at.bitfire.davdroid.repository.AccountRepository +import at.bitfire.davdroid.repository.DavCollectionRepository +import at.bitfire.davdroid.repository.DavServiceRepository +import at.bitfire.davdroid.util.IoDispatcher +import dagger.Lazy +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.runInterruptible +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import okhttp3.HttpUrl +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull +import okhttp3.RequestBody.Companion.toRequestBody +import org.unifiedpush.android.connector.UnifiedPush +import org.unifiedpush.android.connector.data.PushEndpoint +import java.io.StringWriter +import java.time.Duration +import java.time.Instant +import java.util.concurrent.TimeUnit +import java.util.logging.Level +import java.util.logging.Logger +import javax.inject.Inject +import javax.inject.Provider + +/** + * Manages push registrations and subscriptions. + * + * To update push registrations and subscriptions (for instance after collections have been changed), call [update]. + * + * Public API calls are protected by [mutex] so that there won't be multiple subscribe/unsubscribe operations at the same time. + */ +class PushRegistrationManager @Inject constructor( + private val accountRepository: Lazy, + private val collectionRepository: DavCollectionRepository, + @ApplicationContext private val context: Context, + private val httpClientBuilder: Provider, + @IoDispatcher private val ioDispatcher: CoroutineDispatcher, + private val logger: Logger, + private val serviceRepository: DavServiceRepository +) { + + /** + * Updates all push registrations and subscriptions so that if Push is available, it's up-to-date and + * working for all database services. + * + * Also makes sure that the [PushRegistrationWorker] is enabled if there's a Push-enabled collection. + */ + suspend fun update() = mutex.withLock { + for (service in serviceRepository.getAll()) + updateService(service.id) + + updatePeriodicWorker() + } + + /** + * Same as [update], but for a specific database service. + */ + suspend fun update(serviceId: Long) = mutex.withLock { + updateService(serviceId) + updatePeriodicWorker() + } + + private suspend fun updateService(serviceId: Long) { + val service = serviceRepository.getAsync(serviceId) ?: return + val vapid = collectionRepository.getVapidKey(serviceId) + + if (vapid != null) + try { + UnifiedPush.register(context, serviceId.toString(), service.accountName, vapid) + } catch (e: UnifiedPush.VapidNotValidException) { + logger.log(Level.WARNING, "Couldn't register invalid VAPID key for service $serviceId", e) + } + else + UnifiedPush.unregister(context, serviceId.toString()) + + // UnifiedPush has now been called. It will do its work and then asynchronously call back to UnifiedPushService, which + // will then call processSubscription or removeSubscription. + } + + + /** + * Called by [UnifiedPushService] when a subscription (endpoint) is available for the given service. + * + * Uses the subscription to subscribe to syncable collections, and then unsubscribes from non-syncable collections. + */ + internal suspend fun processSubscription(serviceId: Long, endpoint: PushEndpoint) = mutex.withLock { + val service = serviceRepository.getAsync(serviceId) ?: return + + // subscribe to collections which are selected for synchronization + subscribeSyncable(service, endpoint) + + // unsubscribe from collections which are not selected for synchronization + unsubscribeCollections(service, collectionRepository.getPushRegisteredAndNotSyncable(service.id)) + } + + private suspend fun subscribeSyncable(service: Service, endpoint: PushEndpoint) { + val subscribeTo = collectionRepository.getPushCapableAndSyncable(service.id) + if (subscribeTo.isEmpty()) + return + + val account = accountRepository.get().fromName(service.accountName) + httpClientBuilder.get() + .fromAccountAsync(account) + .build() + .use { httpClient -> + for (collection in subscribeTo) + try { + val expires = collection.pushSubscriptionExpires + // calculate next run time, but use the duplicate interval for safety (times are not exact) + val nextRun = Instant.now() + Duration.ofDays(2 * WORKER_INTERVAL_DAYS) + if (expires != null && expires >= nextRun.epochSecond) + logger.fine("Push subscription for ${collection.url} is still valid until ${collection.pushSubscriptionExpires}") + else { + // no existing subscription or expiring soon + logger.fine("Registering push subscription for ${collection.url}") + subscribe(httpClient, collection, endpoint) + } + } catch (e: Exception) { + logger.log(Level.WARNING, "Couldn't register subscription at CalDAV/CardDAV server", e) + } + } + } + + /** + * Called when no subscription is available (anymore) for the given service. + * + * Unsubscribes from all subscribed collections. + */ + internal suspend fun removeSubscription(serviceId: Long) = mutex.withLock { + val service = serviceRepository.getAsync(serviceId) ?: return + val unsubscribeFrom = collectionRepository.getPushRegistered(service.id) + unsubscribeCollections(service, unsubscribeFrom) + } + + + /** + * Registers the subscription to a given collection ("subscribe to a collection"). + * + * @param httpClient HTTP client to use + * @param collection collection to subscribe to + * @param endpoint subscription to register + */ + private suspend fun subscribe(httpClient: HttpClient, collection: Collection, endpoint: PushEndpoint) { + // requested expiration time: 3 days + val requestedExpiration = Instant.now() + Duration.ofDays(3) + + val serializer = XmlUtils.newSerializer() + val writer = StringWriter() + serializer.setOutput(writer) + serializer.startDocument("UTF-8", true) + serializer.insertTag(PushRegister.NAME) { + serializer.insertTag(Subscription.NAME) { + // subscription URL + serializer.insertTag(WebPushSubscription.NAME) { + serializer.insertTag(PushResource.NAME) { + text(endpoint.url) + } + endpoint.pubKeySet?.let { pubKeySet -> + serializer.insertTag(SubscriptionPublicKey.NAME) { + attribute(null, "type", "p256dh") + text(pubKeySet.pubKey) + } + serializer.insertTag(AuthSecret.NAME) { + text(pubKeySet.auth) + } + } + } + } + // requested expiration + serializer.insertTag(PushRegister.EXPIRES) { + text(HttpUtils.formatDate(requestedExpiration)) + } + } + serializer.endDocument() + + runInterruptible(ioDispatcher) { + val xml = writer.toString().toRequestBody(DavResource.MIME_XML) + DavCollection(httpClient.okHttpClient, collection.url).post(xml) { response -> + if (response.isSuccessful) { + // update subscription URL and expiration in DB + val subscriptionUrl = response.header("Location") + val expires = response.header("Expires")?.let { expiresDate -> + HttpUtils.parseDate(expiresDate) + } ?: requestedExpiration + + runBlocking { + collectionRepository.updatePushSubscription( + id = collection.id, + subscriptionUrl = subscriptionUrl, + expires = expires?.epochSecond + ) + } + } else + logger.warning("Couldn't register push for ${collection.url}: $response") + } + } + } + + /** + * Unsubscribe from the given collections. + */ + private suspend fun unsubscribeCollections(service: Service, from: List) { + if (from.isEmpty()) + return + + val account = accountRepository.get().fromName(service.accountName) + httpClientBuilder.get() + .fromAccountAsync(account) + .build() + .use { httpClient -> + for (collection in from) + collection.pushSubscription?.toHttpUrlOrNull()?.let { url -> + logger.info("Unsubscribing Push from ${collection.url}") + unsubscribe(httpClient, collection, url) + } + } + } + + private suspend fun unsubscribe(httpClient: HttpClient, collection: Collection, url: HttpUrl) { + try { + runInterruptible(ioDispatcher) { + DavResource(httpClient.okHttpClient, url).delete { + // deleted + } + } + } catch (e: DavException) { + logger.log(Level.WARNING, "Couldn't unregister push for ${collection.url}", e) + } + + // remove registration URL from DB in any case + collectionRepository.updatePushSubscription( + id = collection.id, + subscriptionUrl = null, + expires = null + ) + } + + + /** + * Determines whether there are any push-capable collections and updates the periodic worker accordingly. + * + * If there are push-capable collections, a unique periodic worker with an initial delay of 5 seconds is enqueued. + * A potentially existing worker is replaced, so that the first run should be soon. + * + * Otherwise, a potentially existing worker is cancelled. + */ + private suspend fun updatePeriodicWorker() { + val workerNeeded = collectionRepository.anyPushCapable() + + val workManager = WorkManager.getInstance(context) + if (workerNeeded) { + logger.info("Enqueuing periodic PushRegistrationWorker") + workManager.enqueueUniquePeriodicWork( + WORKER_UNIQUE_NAME, + ExistingPeriodicWorkPolicy.UPDATE, + PeriodicWorkRequest.Builder(PushRegistrationWorker::class, WORKER_INTERVAL_DAYS, TimeUnit.DAYS) + .setInitialDelay(5, TimeUnit.SECONDS) + .setConstraints( + Constraints.Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) + .build() + ) + .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 1, TimeUnit.MINUTES) + .build() + ) + } else { + logger.info("Cancelling periodic PushRegistrationWorker") + workManager.cancelUniqueWork(WORKER_UNIQUE_NAME) + } + } + + + companion object { + + private const val WORKER_UNIQUE_NAME = "push-registration" + const val WORKER_INTERVAL_DAYS = 1L + + /** + * Mutex to synchronize (un)subscription. + */ + val mutex = Mutex() + + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/push/PushRegistrationWorker.kt b/app/src/main/kotlin/at/bitfire/davdroid/push/PushRegistrationWorker.kt index 50c2fc4c93..061452bbf4 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/push/PushRegistrationWorker.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/push/PushRegistrationWorker.kt @@ -4,185 +4,35 @@ package at.bitfire.davdroid.push -import android.accounts.Account import android.content.Context import androidx.hilt.work.HiltWorker import androidx.work.CoroutineWorker import androidx.work.WorkerParameters -import at.bitfire.dav4jvm.DavCollection -import at.bitfire.dav4jvm.DavResource -import at.bitfire.dav4jvm.HttpUtils -import at.bitfire.dav4jvm.Property -import at.bitfire.dav4jvm.XmlUtils -import at.bitfire.dav4jvm.XmlUtils.insertTag -import at.bitfire.dav4jvm.exception.DavException -import at.bitfire.dav4jvm.property.push.NS_WEBDAV_PUSH -import at.bitfire.davdroid.R -import at.bitfire.davdroid.db.Collection -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 dagger.assisted.Assisted import dagger.assisted.AssistedInject -import kotlinx.coroutines.runInterruptible -import okhttp3.HttpUrl -import okhttp3.HttpUrl.Companion.toHttpUrlOrNull -import okhttp3.RequestBody.Companion.toRequestBody -import java.io.IOException -import java.io.StringWriter -import java.time.Duration -import java.time.Instant -import java.util.logging.Level import java.util.logging.Logger -import javax.inject.Provider /** - * Worker that registers push for all collections that support it. - * To be run as soon as a collection that supports push is changed (selected for sync status - * changes, or collection is created, deleted, etc). + * Worker that runs regularly and initiates push registration updates for all collections. + * + * Managed by [PushRegistrationManager]. */ @Suppress("unused") @HiltWorker class PushRegistrationWorker @AssistedInject constructor( @Assisted context: Context, @Assisted workerParameters: WorkerParameters, - private val collectionRepository: DavCollectionRepository, - private val httpClientBuilder: Provider, private val logger: Logger, - private val preferenceRepository: PreferenceRepository, - private val serviceRepository: DavServiceRepository + private val pushRegistrationManager: PushRegistrationManager ) : CoroutineWorker(context, workerParameters) { override suspend fun doWork(): Result { logger.info("Running push registration worker") - try { - registerSyncable() - unregisterNotSyncable() - } catch (_: IOException) { - return Result.retry() // retry on I/O errors - } + // update registrations for all services + pushRegistrationManager.update() return Result.success() } - private suspend fun registerPushSubscription(collection: Collection, account: Account, endpoint: String) { - httpClientBuilder.get() - .fromAccount(account) - .build() - .use { client -> - runInterruptible { - val httpClient = client.okHttpClient - - // requested expiration time: 3 days - val requestedExpiration = Instant.now() + Duration.ofDays(3) - - val serializer = XmlUtils.newSerializer() - val writer = StringWriter() - serializer.setOutput(writer) - serializer.startDocument("UTF-8", true) - serializer.insertTag(Property.Name(NS_WEBDAV_PUSH, "push-register")) { - serializer.insertTag(Property.Name(NS_WEBDAV_PUSH, "subscription")) { - // subscription URL - serializer.insertTag(Property.Name(NS_WEBDAV_PUSH, "web-push-subscription")) { - serializer.insertTag(Property.Name(NS_WEBDAV_PUSH, "push-resource")) { - text(endpoint) - } - } - } - // requested expiration - serializer.insertTag(Property.Name(NS_WEBDAV_PUSH, "expires")) { - text(HttpUtils.formatDate(requestedExpiration)) - } - } - serializer.endDocument() - - val xml = writer.toString().toRequestBody(DavResource.MIME_XML) - DavCollection(httpClient, collection.url).post(xml) { response -> - if (response.isSuccessful) { - val subscriptionUrl = response.header("Location") - val expires = response.header("Expires")?.let { expiresDate -> - HttpUtils.parseDate(expiresDate) - } ?: requestedExpiration - collectionRepository.updatePushSubscription( - id = collection.id, - subscriptionUrl = subscriptionUrl, - expires = expires?.epochSecond - ) - } else - logger.warning("Couldn't register push for ${collection.url}: $response") - } - } - } - } - - private suspend fun registerSyncable() { - val endpoint = preferenceRepository.unifiedPushEndpoint() - - // register push subscription for syncable collections - if (endpoint != null) - for (collection in collectionRepository.getPushCapableAndSyncable()) { - val expires = collection.pushSubscriptionExpires - // calculate next run time, but use the duplicate interval for safety (times are not exact) - val nextRun = Instant.now() + Duration.ofDays(2*PushRegistrationWorkerManager.INTERVAL_DAYS) - if (expires != null && expires >= nextRun.epochSecond) { - logger.fine("Push subscription for ${collection.url} is still valid until ${collection.pushSubscriptionExpires}") - continue - } - - // no existing subscription or expiring soon - logger.info("Registering push for ${collection.url}") - serviceRepository.get(collection.serviceId)?.let { service -> - val account = Account(service.accountName, applicationContext.getString(R.string.account_type)) - try { - registerPushSubscription(collection, account, endpoint) - } catch (e: DavException) { - // catch possible per-collection exception so that all collections can be processed - logger.log(Level.WARNING, "Couldn't register push for ${collection.url}", e) - } - } - } - else - logger.info("No UnifiedPush endpoint configured") - } - - private suspend fun unregisterPushSubscription(collection: Collection, account: Account, url: HttpUrl) { - httpClientBuilder.get() - .fromAccount(account) - .build() - .use { httpClient -> - runInterruptible { - val httpClient = httpClient.okHttpClient - - try { - DavResource(httpClient, url).delete { - // deleted - } - } catch (e: DavException) { - logger.log(Level.WARNING, "Couldn't unregister push for ${collection.url}", e) - } - - // remove registration URL from DB in any case - collectionRepository.updatePushSubscription( - id = collection.id, - subscriptionUrl = null, - expires = null - ) - } - } - } - - private suspend fun unregisterNotSyncable() { - for (collection in collectionRepository.getPushRegisteredAndNotSyncable()) { - logger.info("Unregistering push for ${collection.url}") - collection.pushSubscription?.toHttpUrlOrNull()?.let { url -> - serviceRepository.get(collection.serviceId)?.let { service -> - val account = Account(service.accountName, applicationContext.getString(R.string.account_type)) - unregisterPushSubscription(collection, account, url) - } - } - } - } - } \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/push/PushRegistrationWorkerManager.kt b/app/src/main/kotlin/at/bitfire/davdroid/push/PushRegistrationWorkerManager.kt deleted file mode 100644 index 453f32a52c..0000000000 --- a/app/src/main/kotlin/at/bitfire/davdroid/push/PushRegistrationWorkerManager.kt +++ /dev/null @@ -1,98 +0,0 @@ -/* - * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. - */ - -package at.bitfire.davdroid.push - -import android.content.Context -import androidx.work.BackoffPolicy -import androidx.work.Constraints -import androidx.work.ExistingPeriodicWorkPolicy -import androidx.work.NetworkType -import androidx.work.PeriodicWorkRequest -import androidx.work.WorkManager -import at.bitfire.davdroid.repository.DavCollectionRepository -import dagger.Binds -import dagger.Module -import dagger.hilt.InstallIn -import dagger.hilt.android.qualifiers.ApplicationContext -import dagger.hilt.components.SingletonComponent -import dagger.multibindings.IntoSet -import kotlinx.coroutines.runBlocking -import java.util.concurrent.TimeUnit -import java.util.logging.Logger -import javax.inject.Inject - -class PushRegistrationWorkerManager @Inject constructor( - @ApplicationContext val context: Context, - val collectionRepository: DavCollectionRepository, - val logger: Logger -) { - - /** - * Determines whether there are any push-capable collections and updates the periodic worker accordingly. - * - * If there are push-capable collections, a unique periodic worker with an initial delay of 5 seconds is enqueued. - * A potentially existing worker is replaced, so that the first run should be soon. - * - * Otherwise, a potentially existing worker is cancelled. - */ - fun updatePeriodicWorker() { - val workerNeeded = runBlocking { - collectionRepository.anyPushCapable() - } - - val workManager = WorkManager.getInstance(context) - if (workerNeeded) { - logger.info("Enqueuing periodic PushRegistrationWorker") - workManager.enqueueUniquePeriodicWork( - UNIQUE_WORK_NAME, - ExistingPeriodicWorkPolicy.CANCEL_AND_REENQUEUE, - PeriodicWorkRequest.Builder(PushRegistrationWorker::class, INTERVAL_DAYS, TimeUnit.DAYS) - .setInitialDelay(5, TimeUnit.SECONDS) - .setConstraints( - Constraints.Builder() - .setRequiredNetworkType(NetworkType.CONNECTED) - .build() - ) - .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 1, TimeUnit.MINUTES) - .build() - ) - } else { - logger.info("Cancelling periodic PushRegistrationWorker") - workManager.cancelUniqueWork(UNIQUE_WORK_NAME) - } - } - - - companion object { - private const val UNIQUE_WORK_NAME = "push-registration" - const val INTERVAL_DAYS = 1L - } - - - /** - * Listener that enqueues a push registration worker when the collection list changes. - */ - class CollectionsListener @Inject constructor( - @ApplicationContext val context: Context, - val workerManager: PushRegistrationWorkerManager - ): DavCollectionRepository.OnChangeListener { - - override fun onCollectionsChanged() { - workerManager.updatePeriodicWorker() - } - - } - - /** - * Hilt module that registers [CollectionsListener] in [DavCollectionRepository]. - */ - @Module - @InstallIn(SingletonComponent::class) - interface PushRegistrationWorkerModule { - @Binds - @IntoSet - fun listener(impl: CollectionsListener): DavCollectionRepository.OnChangeListener - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/push/UnifiedPushReceiver.kt b/app/src/main/kotlin/at/bitfire/davdroid/push/UnifiedPushReceiver.kt deleted file mode 100644 index be94a03a5b..0000000000 --- a/app/src/main/kotlin/at/bitfire/davdroid/push/UnifiedPushReceiver.kt +++ /dev/null @@ -1,117 +0,0 @@ -/* - * 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.db.Collection.Companion.TYPE_ADDRESSBOOK -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.sync.SyncDataType -import at.bitfire.davdroid.sync.TasksAppManager -import at.bitfire.davdroid.sync.worker.SyncWorkerManager -import dagger.Lazy -import dagger.hilt.android.AndroidEntryPoint -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import org.unifiedpush.android.connector.MessagingReceiver -import java.util.logging.Level -import java.util.logging.Logger -import javax.inject.Inject - -@AndroidEntryPoint -class UnifiedPushReceiver: MessagingReceiver() { - - @Inject - lateinit var accountRepository: AccountRepository - - @Inject - lateinit var collectionRepository: DavCollectionRepository - - @Inject - lateinit var logger: Logger - - @Inject - lateinit var serviceRepository: DavServiceRepository - - @Inject - lateinit var preferenceRepository: PreferenceRepository - - @Inject - lateinit var parsePushMessage: PushMessageParser - - @Inject - lateinit var pushRegistrationWorkerManager: PushRegistrationWorkerManager - - @Inject - lateinit var tasksAppManager: Lazy - - @Inject - lateinit var syncWorkerManager: SyncWorkerManager - - - override fun onNewEndpoint(context: Context, endpoint: String, instance: String) { - // remember new endpoint - preferenceRepository.unifiedPushEndpoint(endpoint) - - // register new endpoint at CalDAV/CardDAV servers - pushRegistrationWorkerManager.updatePeriodicWorker() - } - - override fun onUnregistered(context: Context, instance: String) { - // reset known endpoint - preferenceRepository.unifiedPushEndpoint(null) - } - - override fun onMessage(context: Context, message: ByteArray, instance: String) { - CoroutineScope(Dispatchers.Default).launch { - val messageXml = message.toString(Charsets.UTF_8) - logger.log(Level.INFO, "Received push message", messageXml) - - // parse push notification - val topic = parsePushMessage(messageXml) - - // sync affected collection - if (topic != null) { - logger.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 syncDataTypes = mutableSetOf() - // If the type is an address book, add the contacts type - if (collection.type == TYPE_ADDRESSBOOK) - syncDataTypes += SyncDataType.CONTACTS - - // If the collection supports events, add the events type - if (collection.supportsVEVENT != false) - syncDataTypes += SyncDataType.EVENTS - - // If the collection supports tasks, make sure there's a provider installed, - // and add the tasks type - if (collection.supportsVJOURNAL != false || collection.supportsVTODO != false) - if (tasksAppManager.get().currentProvider() != null) - syncDataTypes += SyncDataType.TASKS - - // Schedule sync for all the types identified - val account = accountRepository.fromName(service.accountName) - for (syncDataType in syncDataTypes) - syncWorkerManager.enqueueOneTime(account, syncDataType, fromPush = true) - } - } - - } else { - logger.warning("Got push message without topic, syncing all accounts") - for (account in accountRepository.getAll()) - syncWorkerManager.enqueueOneTimeAllAuthorities(account, fromPush = true) - - } - } - } - -} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/push/UnifiedPushService.kt b/app/src/main/kotlin/at/bitfire/davdroid/push/UnifiedPushService.kt new file mode 100644 index 0000000000..fb182651ca --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/push/UnifiedPushService.kt @@ -0,0 +1,71 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.push + +import dagger.Lazy +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.runBlocking +import org.unifiedpush.android.connector.FailedReason +import org.unifiedpush.android.connector.PushService +import org.unifiedpush.android.connector.data.PushEndpoint +import org.unifiedpush.android.connector.data.PushMessage +import java.util.logging.Logger +import javax.inject.Inject + +/** + * Entry point for UnifiedPush. + * + * Calls [PushRegistrationManager] for most tasks, except incoming push messages, + * which are handled directly. + */ +@AndroidEntryPoint +class UnifiedPushService : PushService() { + + @Inject + lateinit var logger: Logger + + @Inject + lateinit var pushMessageHandler: Lazy + + @Inject + lateinit var pushRegistrationManager: Lazy + + + override fun onNewEndpoint(endpoint: PushEndpoint, instance: String) { + val serviceId = instance.toLongOrNull() ?: return + logger.warning("Got UnifiedPush endpoint for service $serviceId: ${endpoint.url}") + + // register new endpoint at CalDAV/CardDAV servers + runBlocking { + pushRegistrationManager.get().processSubscription(serviceId, endpoint) + } + } + + override fun onRegistrationFailed(reason: FailedReason, instance: String) { + val serviceId = instance.toLongOrNull() ?: return + logger.warning("UnifiedPush registration failed for service $serviceId: $reason") + + // unregister subscriptions + runBlocking { + pushRegistrationManager.get().removeSubscription(serviceId) + } + } + + override fun onUnregistered(instance: String) { + val serviceId = instance.toLongOrNull() ?: return + logger.warning("UnifiedPush unregistered for service $serviceId") + + runBlocking { + pushRegistrationManager.get().removeSubscription(serviceId) + } + } + + override fun onMessage(message: PushMessage, instance: String) { + runBlocking { + pushMessageHandler.get().processMessage(message, instance) + } + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/repository/AccountRepository.kt b/app/src/main/kotlin/at/bitfire/davdroid/repository/AccountRepository.kt index 27b3aaac60..47c80de396 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/repository/AccountRepository.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/repository/AccountRepository.kt @@ -8,7 +8,6 @@ import android.accounts.Account import android.accounts.AccountManager import android.accounts.OnAccountsUpdateListener import android.content.Context -import at.bitfire.davdroid.sync.account.InvalidAccountException import at.bitfire.davdroid.R import at.bitfire.davdroid.db.Credentials import at.bitfire.davdroid.db.HomeSet @@ -23,6 +22,7 @@ import at.bitfire.davdroid.sync.AutomaticSyncManager import at.bitfire.davdroid.sync.SyncDataType import at.bitfire.davdroid.sync.TasksAppManager import at.bitfire.davdroid.sync.account.AccountsCleanupWorker +import at.bitfire.davdroid.sync.account.InvalidAccountException import at.bitfire.davdroid.sync.account.SystemAccountUtils import at.bitfire.davdroid.sync.worker.SyncWorkerManager import at.bitfire.vcard4android.GroupMethod diff --git a/app/src/main/kotlin/at/bitfire/davdroid/repository/DavCollectionRepository.kt b/app/src/main/kotlin/at/bitfire/davdroid/repository/DavCollectionRepository.kt index f483e3fb84..603037cae5 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/repository/DavCollectionRepository.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/repository/DavCollectionRepository.kt @@ -193,13 +193,13 @@ class DavCollectionRepository @Inject constructor( } } - fun getSyncableByTopic(topic: String) = dao.getSyncableByPushTopic(topic) + suspend fun getSyncableByTopic(topic: String) = dao.getSyncableByPushTopic(topic) fun get(id: Long) = dao.get(id) fun getFlow(id: Long) = dao.getFlow(id) - fun getByService(serviceId: Long) = dao.getByService(serviceId) + suspend fun getByService(serviceId: Long) = dao.getByService(serviceId) fun getByServiceAndUrl(serviceId: Long, url: String) = dao.getByServiceAndUrl(serviceId, url) @@ -212,11 +212,12 @@ class DavCollectionRepository @Inject constructor( fun getSyncTaskLists(serviceId: Long) = dao.getSyncTaskLists(serviceId) /** Returns all collections that are both selected for synchronization and push-capable. */ - suspend fun getPushCapableAndSyncable(): List = - dao.getPushCapableSyncCollections() + suspend fun getPushCapableAndSyncable(serviceId: Long) = dao.getPushCapableSyncCollections(serviceId) - suspend fun getPushRegisteredAndNotSyncable(): List = - dao.getPushRegisteredAndNotSyncable() + suspend fun getPushRegistered(serviceId: Long) = dao.getPushRegistered(serviceId) + suspend fun getPushRegisteredAndNotSyncable(serviceId: Long) = dao.getPushRegisteredAndNotSyncable(serviceId) + + suspend fun getVapidKey(serviceId: Long) = dao.getFirstVapidKey(serviceId) /** * Inserts or updates the collection. @@ -273,7 +274,7 @@ class DavCollectionRepository @Inject constructor( notifyOnChangeListeners() } - fun updatePushSubscription(id: Long, subscriptionUrl: String?, expires: Long?) { + suspend fun updatePushSubscription(id: Long, subscriptionUrl: String?, expires: Long?) { dao.updatePushSubscription( id = id, pushSubscription = subscriptionUrl, diff --git a/app/src/main/kotlin/at/bitfire/davdroid/repository/DavServiceRepository.kt b/app/src/main/kotlin/at/bitfire/davdroid/repository/DavServiceRepository.kt index bdfb8c41bf..8556d10b12 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/repository/DavServiceRepository.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/repository/DavServiceRepository.kt @@ -19,6 +19,9 @@ class DavServiceRepository @Inject constructor( // Read fun get(id: Long): Service? = dao.get(id) + suspend fun getAsync(id: Long): Service? = dao.getAsync(id) + + suspend fun getAll(): List = dao.getAll() fun getByAccountAndType(name: String, @ServiceType serviceType: String): Service? = dao.getByAccountAndType(name, serviceType) diff --git a/app/src/main/kotlin/at/bitfire/davdroid/repository/PreferenceRepository.kt b/app/src/main/kotlin/at/bitfire/davdroid/repository/PreferenceRepository.kt index 5d614fbbdd..e9efffdafa 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/repository/PreferenceRepository.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/repository/PreferenceRepository.kt @@ -25,7 +25,6 @@ class PreferenceRepository @Inject constructor( companion object { const val LOG_TO_FILE = "log_to_file" - const val UNIFIED_PUSH_ENDPOINT = "unified_push_endpoint" } private val preferences = PreferenceManager.getDefaultSharedPreferences(context) @@ -54,20 +53,6 @@ class PreferenceRepository @Inject constructor( } - 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) - } - } - - // helpers private fun observeAsFlow(keyToObserve: String, getValue: () -> T): Flow = diff --git a/app/src/main/kotlin/at/bitfire/davdroid/servicedetection/RefreshCollectionsWorker.kt b/app/src/main/kotlin/at/bitfire/davdroid/servicedetection/RefreshCollectionsWorker.kt index 4f66306a38..d3686f4bc6 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/servicedetection/RefreshCollectionsWorker.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/servicedetection/RefreshCollectionsWorker.kt @@ -23,10 +23,12 @@ import androidx.work.WorkInfo import androidx.work.WorkManager import androidx.work.WorkerParameters import at.bitfire.dav4jvm.exception.UnauthorizedException -import at.bitfire.davdroid.sync.account.InvalidAccountException import at.bitfire.davdroid.R import at.bitfire.davdroid.network.HttpClient +import at.bitfire.davdroid.push.PushRegistrationManager import at.bitfire.davdroid.repository.DavServiceRepository +import at.bitfire.davdroid.servicedetection.RefreshCollectionsWorker.Companion.ARG_SERVICE_ID +import at.bitfire.davdroid.sync.account.InvalidAccountException import at.bitfire.davdroid.ui.DebugInfoActivity import at.bitfire.davdroid.ui.NotificationRegistry import at.bitfire.davdroid.ui.account.AccountSettingsActivity @@ -64,6 +66,7 @@ class RefreshCollectionsWorker @AssistedInject constructor( private val httpClientBuilder: HttpClient.Builder, private val logger: Logger, private val notificationRegistry: NotificationRegistry, + private val pushRegistrationManager: PushRegistrationManager, serviceRepository: DavServiceRepository ): CoroutineWorker(appContext, workerParams) { @@ -199,6 +202,9 @@ class RefreshCollectionsWorker @AssistedInject constructor( return Result.failure() } + // update push registrations + pushRegistrationManager.update(serviceId) + // Success return Result.success() } diff --git a/app/src/main/kotlin/at/bitfire/davdroid/settings/migration/AccountSettingsMigration18.kt b/app/src/main/kotlin/at/bitfire/davdroid/settings/migration/AccountSettingsMigration18.kt index 32a0e200e3..a855460cbd 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/settings/migration/AccountSettingsMigration18.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/settings/migration/AccountSettingsMigration18.kt @@ -19,6 +19,7 @@ import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent import dagger.multibindings.IntKey import dagger.multibindings.IntoMap +import kotlinx.coroutines.runBlocking import javax.inject.Inject /** @@ -39,17 +40,24 @@ class AccountSettingsMigration18 @Inject constructor( override fun migrate(account: Account) { val accountManager = AccountManager.get(context) - db.serviceDao().getByAccountAndType(account.name, Service.TYPE_CARDDAV)?.let { service -> - db.collectionDao().getByService(service.id).forEach { collection -> - // Find associated address book account by collection ID (if it exists) - val addressBookAccount = accountManager - .getAccountsByType(context.getString(R.string.account_type_address_book)) - .firstOrNull { accountManager.getUserData(it, LocalAddressBook.USER_DATA_COLLECTION_ID) == collection.id.toString() } + runBlocking { + db.serviceDao().getByAccountAndType(account.name, Service.TYPE_CARDDAV)?.let { service -> + db.collectionDao().getByService(service.id).forEach { collection -> + // Find associated address book account by collection ID (if it exists) + val addressBookAccount = accountManager + .getAccountsByType(context.getString(R.string.account_type_address_book)) + .firstOrNull { + accountManager.getUserData( + it, + LocalAddressBook.USER_DATA_COLLECTION_ID + ) == collection.id.toString() + } - if (addressBookAccount != null) { - // (Re-)assign address book to account - accountManager.setAndVerifyUserData(addressBookAccount, LocalAddressBook.USER_DATA_ACCOUNT_NAME, account.name) - accountManager.setAndVerifyUserData(addressBookAccount, LocalAddressBook.USER_DATA_ACCOUNT_TYPE, account.type) + if (addressBookAccount != null) { + // (Re-)assign address book to account + accountManager.setAndVerifyUserData(addressBookAccount, LocalAddressBook.USER_DATA_ACCOUNT_NAME, account.name) + accountManager.setAndVerifyUserData(addressBookAccount, LocalAddressBook.USER_DATA_ACCOUNT_TYPE, account.type) + } } } } diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/AppSettingsModel.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/AppSettingsModel.kt index 73551c2e65..d04ea834c6 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/ui/AppSettingsModel.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/AppSettingsModel.kt @@ -14,17 +14,19 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import at.bitfire.cert4android.CustomCertStore import at.bitfire.davdroid.BuildConfig +import at.bitfire.davdroid.push.PushRegistrationManager import at.bitfire.davdroid.repository.PreferenceRepository import at.bitfire.davdroid.settings.Settings import at.bitfire.davdroid.settings.SettingsManager import at.bitfire.davdroid.sync.TasksAppManager import at.bitfire.davdroid.ui.intro.BatteryOptimizationsPageModel import at.bitfire.davdroid.ui.intro.OpenSourcePage +import at.bitfire.davdroid.util.IoDispatcher import at.bitfire.davdroid.util.PermissionUtils import at.bitfire.davdroid.util.broadcastReceiverFlow import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext -import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.asStateFlow @@ -36,8 +38,10 @@ import javax.inject.Inject @HiltViewModel class AppSettingsModel @Inject constructor( - @ApplicationContext val context: Context, - private val preference: PreferenceRepository, + @ApplicationContext private val context: Context, + @IoDispatcher private val ioDispatcher: CoroutineDispatcher, + private val preferences: PreferenceRepository, + private val pushRegistrationManager: PushRegistrationManager, private val settings: SettingsManager, tasksAppManager: TasksAppManager ) : ViewModel() { @@ -46,13 +50,14 @@ class AppSettingsModel @Inject constructor( // debugging private val powerManager = context.getSystemService()!! - val batterySavingExempted = broadcastReceiverFlow(context, IntentFilter(PermissionUtils.ACTION_POWER_SAVE_WHITELIST_CHANGED), immediate = true) - .map { powerManager.isIgnoringBatteryOptimizations(BuildConfig.APPLICATION_ID) } - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), false) + val batterySavingExempted = + broadcastReceiverFlow(context, IntentFilter(PermissionUtils.ACTION_POWER_SAVE_WHITELIST_CHANGED), immediate = true) + .map { powerManager.isIgnoringBatteryOptimizations(BuildConfig.APPLICATION_ID) } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), false) - fun verboseLogging() = preference.logToFileFlow() + fun verboseLogging() = preferences.logToFileFlow() fun updateVerboseLogging(verbose: Boolean) { - preference.logToFile(verbose) + preferences.logToFile(verbose) } @@ -130,7 +135,7 @@ class AppSettingsModel @Inject constructor( * - If there's only one push distributor available, and none is selected, it's selected automatically. * - Makes sure the app is registered with UnifiedPush if there's already a distributor selected. */ - private suspend fun loadPushDistributors() { + private fun loadPushDistributors() { val savedPushDistributor = UnifiedPush.getSavedDistributor(context) _pushDistributor.value = savedPushDistributor @@ -157,15 +162,16 @@ class AppSettingsModel @Inject constructor( * @param pushDistributor The package name of the push distributor, _null_ to disable push. */ fun updatePushDistributor(pushDistributor: String?) { - viewModelScope.launch(Dispatchers.IO) { + viewModelScope.launch(ioDispatcher) { if (pushDistributor == null) { // Disable UnifiedPush if the distributor given is null - UnifiedPush.safeRemoveDistributor(context) - UnifiedPush.unregisterApp(context) + UnifiedPush.removeDistributor(context) } else { - // If a distributor was passed, store it and register the app + // If a distributor was passed, store it UnifiedPush.saveDistributor(context, pushDistributor) - UnifiedPush.registerApp(context) + + // … and register it so that UnifiedPushReceiver.onNewEndpoint is called + pushRegistrationManager.update() } _pushDistributor.value = pushDistributor } @@ -173,7 +179,7 @@ class AppSettingsModel @Inject constructor( init { - viewModelScope.launch(Dispatchers.IO) { + viewModelScope.launch(ioDispatcher) { loadPushDistributors() } } diff --git a/app/src/test/kotlin/at/bitfire/davdroid/push/PushMessageParserTest.kt b/app/src/test/kotlin/at/bitfire/davdroid/push/PushMessageParserTest.kt deleted file mode 100644 index 22c72b11d8..0000000000 --- a/app/src/test/kotlin/at/bitfire/davdroid/push/PushMessageParserTest.kt +++ /dev/null @@ -1,32 +0,0 @@ -/* - * 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 -import java.util.logging.Logger - -class PushMessageParserTest { - - private val parse = PushMessageParser(logger = Logger.getGlobal()) - - @Test - fun testInvalidXml() { - assertNull(parse("Non-XML content")) - } - - @Test - fun testWithXmlDeclAndTopic() { - val topic = parse( - "" + - "" + - " O7M1nQ7cKkKTKsoS_j6Z3w" + - "" - ) - assertEquals("O7M1nQ7cKkKTKsoS_j6Z3w", topic) - } - -} \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index f2e0e1a243..a81638e1f8 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -39,7 +39,8 @@ mockk = "1.14.0" okhttp = "4.12.0" openid-appauth = "0.11.1" room = "2.7.0" -unifiedpush = "2.4.0" +unifiedpush = "3.0.8" +unifiedpush-fcm = "3.0.0" # Other libraries, especially ical4android/ical4j, require Apache Commons. Some recent versions of Apache # Commons require a newer Java version than our minSdk provides. So we require these strict versions here: @@ -107,7 +108,8 @@ 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" } +unifiedpush = { module = "org.unifiedpush.android:connector", version.ref = "unifiedpush" } +unifiedpush-fcm = { module = "org.unifiedpush.android:embedded-fcm-distributor", version.ref = "unifiedpush-fcm" } [plugins] android-application = { id = "com.android.application", version.ref = "android-agp" }