Skip to content

Commit f62509e

Browse files
ArnyminerZrfc2822
andauthored
[Push] Support UnifiedPush Connector 3.x, VAPID, Encryption, Google FCM (#1325)
* Upgrade UnifiedPush Connector to 3.0.4 Signed-off-by: Arnau Mora <arnyminerz@proton.me> * Updated overrides Signed-off-by: Arnau Mora <arnyminerz@proton.me> * Added storing keys and auths Signed-off-by: Arnau Mora <arnyminerz@proton.me> * Excluded tink Signed-off-by: Arnau Mora <arnyminerz@proton.me> * Fixed deprecations and calls Signed-off-by: Arnau Mora <arnyminerz@proton.me> * Integrate UnifiedPush 3.x connector and FCM distributor * Integrate UnifiedPush connector 3.x, use VAPID and message encryption * [WIP] Refactor push registration logic and remove deprecated methods * [WIP] Remove PushRegistrationWorkerManager and refactor PushRegistrationManager * Remove unused service repository dependency and update worker to suspend * Add suspend modifier to DAO methods and repository methods * Add runBlocking to getByService call in CollectionListRefresherTest * Add documentation for UnifiedPushService and PushRegistrationManager * Add fallback for push messages without topic * [WIP] Add UnifiedPushService test with workaround for PushService binder * Update UnifiedPush library version and clean up test code * Refactor push message handling, synchronization and coroutines * Add coroutine dispatchers for push registration and unregistration * Add async support for push subscription updates * Refactor unsubscribe logic into reusable method --------- Signed-off-by: Arnau Mora <arnyminerz@proton.me> Co-authored-by: Ricki Hirner <hirner@bitfire.at>
1 parent e79c362 commit f62509e

25 files changed

+762
-528
lines changed

app/build.gradle.kts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -197,7 +197,13 @@ dependencies {
197197
implementation(libs.okhttp.brotli)
198198
implementation(libs.okhttp.logging)
199199
implementation(libs.openid.appauth)
200-
implementation(libs.unifiedpush)
200+
implementation(libs.unifiedpush) {
201+
// UnifiedPush connector seems to be using a workaround by importing this library.
202+
// Will be removed after https://github.com/tink-crypto/tink-java-apps/pull/5 is merged.
203+
// See: https://codeberg.org/UnifiedPush/android-connector/src/commit/28cb0d622ed0a972996041ab9cc85b701abc48c6/connector/build.gradle#L56-L59
204+
exclude(group = "com.google.crypto.tink", module = "tink")
205+
}
206+
implementation(libs.unifiedpush.fcm)
201207

202208
// force some versions for compatibility with our minSdk level (see version catalog for details)
203209
implementation(libs.commons.codec)

app/src/androidTest/kotlin/at/bitfire/davdroid/TestModules.kt

Lines changed: 0 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -4,27 +4,13 @@
44

55
package at.bitfire.davdroid
66

7-
import at.bitfire.davdroid.push.PushRegistrationWorkerManager
8-
import at.bitfire.davdroid.repository.DavCollectionRepository
97
import at.bitfire.davdroid.startup.StartupPlugin
108
import at.bitfire.davdroid.startup.TasksAppWatcher
119
import dagger.Module
1210
import dagger.hilt.components.SingletonComponent
1311
import dagger.hilt.testing.TestInstallIn
1412
import dagger.multibindings.Multibinds
1513

16-
// remove PushRegistrationWorkerModule from Android tests
17-
@Module
18-
@TestInstallIn(
19-
components = [SingletonComponent::class],
20-
replaces = [PushRegistrationWorkerManager.PushRegistrationWorkerModule::class]
21-
)
22-
abstract class TestPushRegistrationWorkerModule {
23-
// provides empty set of listeners
24-
@Multibinds
25-
abstract fun empty(): Set<DavCollectionRepository.OnChangeListener>
26-
}
27-
2814
// remove TasksAppWatcherModule from Android tests
2915
@Module
3016
@TestInstallIn(
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
/*
2+
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
3+
*/
4+
5+
package at.bitfire.davdroid.push
6+
7+
import dagger.hilt.android.testing.HiltAndroidRule
8+
import dagger.hilt.android.testing.HiltAndroidTest
9+
import org.junit.Assert
10+
import org.junit.Before
11+
import org.junit.Rule
12+
import org.junit.Test
13+
import javax.inject.Inject
14+
15+
@HiltAndroidTest
16+
class PushMessageHandlerTest {
17+
18+
@get:Rule
19+
val hiltRule = HiltAndroidRule(this)
20+
21+
@Inject
22+
lateinit var handler: PushMessageHandler
23+
24+
@Before
25+
fun setUp() {
26+
hiltRule.inject()
27+
}
28+
29+
30+
@Test
31+
fun testParse_InvalidXml() {
32+
Assert.assertNull(handler.parse("Non-XML content"))
33+
}
34+
35+
@Test
36+
fun testParse_WithXmlDeclAndTopic() {
37+
val topic = handler.parse(
38+
"<?xml version=\"1.0\" encoding=\"utf-8\" ?>" +
39+
"<P:push-message xmlns:D=\"DAV:\" xmlns:P=\"https://bitfire.at/webdav-push\">" +
40+
" <P:topic>O7M1nQ7cKkKTKsoS_j6Z3w</P:topic>" +
41+
"</P:push-message>"
42+
)
43+
Assert.assertEquals("O7M1nQ7cKkKTKsoS_j6Z3w", topic)
44+
}
45+
46+
}
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
/*
2+
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
3+
*/
4+
5+
package at.bitfire.davdroid.push
6+
7+
import android.content.Context
8+
import android.content.Intent
9+
import android.os.IBinder
10+
import androidx.test.rule.ServiceTestRule
11+
import dagger.hilt.android.qualifiers.ApplicationContext
12+
import dagger.hilt.android.testing.BindValue
13+
import dagger.hilt.android.testing.HiltAndroidRule
14+
import dagger.hilt.android.testing.HiltAndroidTest
15+
import io.mockk.coVerify
16+
import io.mockk.confirmVerified
17+
import io.mockk.every
18+
import io.mockk.impl.annotations.RelaxedMockK
19+
import io.mockk.junit4.MockKRule
20+
import io.mockk.mockk
21+
import org.junit.Before
22+
import org.junit.Rule
23+
import org.junit.Test
24+
import org.unifiedpush.android.connector.FailedReason
25+
import org.unifiedpush.android.connector.PushService
26+
import org.unifiedpush.android.connector.data.PushEndpoint
27+
import javax.inject.Inject
28+
29+
@HiltAndroidTest
30+
class UnifiedPushServiceTest {
31+
32+
@get:Rule
33+
val hiltRule = HiltAndroidRule(this)
34+
35+
@get:Rule
36+
val mockKRule = MockKRule(this)
37+
38+
@get:Rule
39+
val serviceTestRule = ServiceTestRule()
40+
41+
@Inject
42+
@ApplicationContext
43+
lateinit var context: Context
44+
45+
@RelaxedMockK
46+
@BindValue
47+
lateinit var pushRegistrationManager: PushRegistrationManager
48+
49+
lateinit var binder: IBinder
50+
lateinit var unifiedPushService: UnifiedPushService
51+
52+
53+
@Before
54+
fun setUp() {
55+
hiltRule.inject()
56+
57+
binder = serviceTestRule.bindService(Intent(context, UnifiedPushService::class.java))!!
58+
unifiedPushService = (binder as PushService.PushBinder).getService() as UnifiedPushService
59+
}
60+
61+
62+
@Test
63+
fun testOnNewEndpoint() {
64+
val endpoint = mockk<PushEndpoint> {
65+
every { url } returns "https://example.com/12"
66+
}
67+
unifiedPushService.onNewEndpoint(endpoint, "12")
68+
69+
coVerify {
70+
pushRegistrationManager.processSubscription(12, endpoint)
71+
}
72+
confirmVerified(pushRegistrationManager)
73+
}
74+
75+
@Test
76+
fun testOnRegistrationFailed() {
77+
unifiedPushService.onRegistrationFailed(FailedReason.INTERNAL_ERROR, "34")
78+
79+
coVerify {
80+
pushRegistrationManager.removeSubscription(34)
81+
}
82+
confirmVerified(pushRegistrationManager)
83+
}
84+
85+
@Test
86+
fun testOnUnregistered() {
87+
unifiedPushService.onRegistrationFailed(FailedReason.INTERNAL_ERROR, "45")
88+
89+
coVerify {
90+
pushRegistrationManager.removeSubscription(45)
91+
}
92+
confirmVerified(pushRegistrationManager)
93+
}
94+
95+
}

app/src/androidTest/kotlin/at/bitfire/davdroid/servicedetection/CollectionListRefresherTest.kt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import dagger.hilt.android.testing.HiltAndroidTest
1919
import io.mockk.every
2020
import io.mockk.impl.annotations.MockK
2121
import io.mockk.junit4.MockKRule
22+
import kotlinx.coroutines.test.runTest
2223
import okhttp3.mockwebserver.Dispatcher
2324
import okhttp3.mockwebserver.MockResponse
2425
import okhttp3.mockwebserver.MockWebServer
@@ -121,7 +122,7 @@ class CollectionListRefresherTest {
121122
// refreshHomesetsAndTheirCollections
122123

123124
@Test
124-
fun refreshHomesetsAndTheirCollections_addsNewCollection() {
125+
fun refreshHomesetsAndTheirCollections_addsNewCollection() = runTest {
125126
// save homeset in DB
126127
val homesetId = db.homeSetDao().insert(
127128
HomeSet(id=0, service.id, true, mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK_HOMESET_PERSONAL"))

app/src/main/AndroidManifest.xml

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -268,15 +268,12 @@
268268
android:resource="@xml/debug_paths" />
269269
</provider>
270270

271-
<!-- UnifiedPush receiver -->
272-
<receiver android:exported="true" android:enabled="true" android:name=".push.UnifiedPushReceiver" tools:ignore="ExportedReceiver">
271+
<!-- UnifiedPush -->
272+
<service android:exported="false" android:name=".push.UnifiedPushService">
273273
<intent-filter>
274-
<action android:name="org.unifiedpush.android.connector.MESSAGE"/>
275-
<action android:name="org.unifiedpush.android.connector.UNREGISTERED"/>
276-
<action android:name="org.unifiedpush.android.connector.NEW_ENDPOINT"/>
277-
<action android:name="org.unifiedpush.android.connector.REGISTRATION_FAILED"/>
274+
<action android:name="org.unifiedpush.android.connector.PUSH_EVENT"/>
278275
</intent-filter>
279-
</receiver>
276+
</service>
280277

281278
<!-- Widgets -->
282279
<receiver android:name=".ui.widget.SyncButtonWidgetReceiver"

app/src/main/kotlin/at/bitfire/davdroid/db/CollectionDao.kt

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ interface CollectionDao {
2424
fun getFlow(id: Long): Flow<Collection?>
2525

2626
@Query("SELECT * FROM collection WHERE serviceId=:serviceId")
27-
fun getByService(serviceId: Long): List<Collection>
27+
suspend fun getByService(serviceId: Long): List<Collection>
2828

2929
@Query("SELECT * FROM collection WHERE serviceId=:serviceId AND homeSetId IS :homeSetId")
3030
fun getByServiceAndHomeset(serviceId: Long, homeSetId: Long?): List<Collection>
@@ -33,7 +33,10 @@ interface CollectionDao {
3333
fun getByServiceAndType(serviceId: Long, @CollectionType type: String): List<Collection>
3434

3535
@Query("SELECT * FROM collection WHERE pushTopic=:topic AND sync")
36-
fun getSyncableByPushTopic(topic: String): Collection?
36+
suspend fun getSyncableByPushTopic(topic: String): Collection?
37+
38+
@Query("SELECT pushVapidKey FROM collection WHERE serviceId=:serviceId AND pushVapidKey IS NOT NULL LIMIT 1")
39+
suspend fun getFirstVapidKey(serviceId: Long): String?
3740

3841
@Query("SELECT COUNT(*) FROM collection WHERE serviceId=:serviceId AND type=:type")
3942
suspend fun anyOfType(serviceId: Long, @CollectionType type: String): Boolean
@@ -72,11 +75,14 @@ interface CollectionDao {
7275
* Get a list of collections that are both sync enabled and push capable (supportsWebPush and
7376
* pushTopic is available).
7477
*/
75-
@Query("SELECT * FROM collection WHERE sync AND supportsWebPush AND pushTopic IS NOT NULL")
76-
suspend fun getPushCapableSyncCollections(): List<Collection>
78+
@Query("SELECT * FROM collection WHERE serviceId=:serviceId AND sync AND supportsWebPush AND pushTopic IS NOT NULL")
79+
suspend fun getPushCapableSyncCollections(serviceId: Long): List<Collection>
80+
81+
@Query("SELECT * FROM collection WHERE serviceId=:serviceId AND pushSubscription IS NOT NULL")
82+
suspend fun getPushRegistered(serviceId: Long): List<Collection>
7783

78-
@Query("SELECT * FROM collection WHERE pushSubscription IS NOT NULL AND NOT sync")
79-
suspend fun getPushRegisteredAndNotSyncable(): List<Collection>
84+
@Query("SELECT * FROM collection WHERE serviceId=:serviceId AND pushSubscription IS NOT NULL AND NOT sync")
85+
suspend fun getPushRegisteredAndNotSyncable(serviceId: Long): List<Collection>
8086

8187
@Insert(onConflict = OnConflictStrategy.IGNORE)
8288
fun insert(collection: Collection): Long
@@ -91,7 +97,7 @@ interface CollectionDao {
9197
suspend fun updateForceReadOnly(id: Long, forceReadOnly: Boolean)
9298

9399
@Query("UPDATE collection SET pushSubscription=:pushSubscription, pushSubscriptionExpires=:pushSubscriptionExpires, pushSubscriptionCreated=:updatedAt WHERE id=:id")
94-
fun updatePushSubscription(id: Long, pushSubscription: String?, pushSubscriptionExpires: Long?, updatedAt: Long = System.currentTimeMillis()/1000)
100+
suspend fun updatePushSubscription(id: Long, pushSubscription: String?, pushSubscriptionExpires: Long?, updatedAt: Long = System.currentTimeMillis()/1000)
95101

96102
@Query("UPDATE collection SET sync=:sync WHERE id=:id")
97103
suspend fun updateSync(id: Long, sync: Boolean)
@@ -116,4 +122,4 @@ interface CollectionDao {
116122
@Delete
117123
fun delete(collection: Collection)
118124

119-
}
125+
}

app/src/main/kotlin/at/bitfire/davdroid/db/ServiceDao.kt

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,12 @@ interface ServiceDao {
2525
@Query("SELECT * FROM service WHERE id=:id")
2626
fun get(id: Long): Service?
2727

28+
@Query("SELECT * FROM service WHERE id=:id")
29+
suspend fun getAsync(id: Long): Service?
30+
31+
@Query("SELECT * FROM service")
32+
suspend fun getAll(): List<Service>
33+
2834
@Insert(onConflict = OnConflictStrategy.REPLACE)
2935
fun insertOrReplace(service: Service): Long
3036

app/src/main/kotlin/at/bitfire/davdroid/network/HttpClient.kt

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ package at.bitfire.davdroid.network
66

77
import android.accounts.Account
88
import android.content.Context
9+
import androidx.annotation.WorkerThread
910
import at.bitfire.cert4android.CustomCertManager
1011
import at.bitfire.dav4jvm.BasicDigestAuthHandler
1112
import at.bitfire.dav4jvm.UrlUtils
@@ -14,7 +15,10 @@ import at.bitfire.davdroid.settings.AccountSettings
1415
import at.bitfire.davdroid.settings.Settings
1516
import at.bitfire.davdroid.settings.SettingsManager
1617
import at.bitfire.davdroid.ui.ForegroundTracker
18+
import at.bitfire.davdroid.util.IoDispatcher
1719
import dagger.hilt.android.qualifiers.ApplicationContext
20+
import kotlinx.coroutines.CoroutineDispatcher
21+
import kotlinx.coroutines.withContext
1822
import net.openid.appauth.AuthState
1923
import net.openid.appauth.AuthorizationService
2024
import okhttp3.Authenticator
@@ -64,6 +68,7 @@ class HttpClient(
6468
private val authorizationServiceProvider: Provider<AuthorizationService>,
6569
@ApplicationContext private val context: Context,
6670
defaultLogger: Logger,
71+
@IoDispatcher private val ioDispatcher: CoroutineDispatcher,
6772
private val keyManagerFactory: ClientCertKeyManager.Factory,
6873
private val settingsManager: SettingsManager
6974
) {
@@ -141,9 +146,12 @@ class HttpClient(
141146
/**
142147
* Takes authentication (basic/digest or OAuth and client certificate) from a given account.
143148
*
149+
* **Must not be run on main thread, because it creates [AccountSettings]!** Use [fromAccountAsync] if possible.
150+
*
144151
* @param account the account to take authentication from
145152
* @param onlyHost if set: only authenticate for this host name
146153
*/
154+
@WorkerThread
147155
fun fromAccount(account: Account, onlyHost: String? = null): Builder {
148156
val accountSettings = accountSettingsFactory.create(account)
149157
authenticate(
@@ -156,6 +164,13 @@ class HttpClient(
156164
return this
157165
}
158166

167+
/**
168+
* Same as [fromAccount], but can be called on any thread.
169+
*/
170+
suspend fun fromAccountAsync(account: Account, onlyHost: String? = null): Builder = withContext(ioDispatcher) {
171+
fromAccount(account, onlyHost)
172+
}
173+
159174

160175
// actual builder
161176

0 commit comments

Comments
 (0)