From f598643514599441c91b992470719a800c49b1ed Mon Sep 17 00:00:00 2001 From: Priveetee Date: Mon, 28 Sep 2026 13:00:29 +0000 Subject: [PATCH 1/2] fix: send UnifiedPush encryption keys --- .../android/data/network/dto/PushDto.kt | 2 ++ .../data/push/PushRegistrationStore.kt | 28 +++++++++++++++---- .../android/data/push/RemotePushRepository.kt | 9 ++++-- .../android/domain/push/PushRepository.kt | 2 +- .../services/push/PushRegistrationManager.kt | 21 +++++++------- .../services/push/TypeTypePushService.kt | 7 ++++- .../PushDeviceRegistrationRequestDtoTest.kt | 26 +++++++++++++++++ 7 files changed, 74 insertions(+), 21 deletions(-) create mode 100644 app/src/test/java/dev/typetype/android/data/network/dto/PushDeviceRegistrationRequestDtoTest.kt diff --git a/app/src/main/java/dev/typetype/android/data/network/dto/PushDto.kt b/app/src/main/java/dev/typetype/android/data/network/dto/PushDto.kt index 2824192f..77e70599 100644 --- a/app/src/main/java/dev/typetype/android/data/network/dto/PushDto.kt +++ b/app/src/main/java/dev/typetype/android/data/network/dto/PushDto.kt @@ -9,6 +9,8 @@ data class PushDeviceRegistrationRequestDto( val deviceId: String, val platform: String = "android", val endpoint: String, + val p256dh: String, + val auth: String, val expiresAt: Long? = null, ) diff --git a/app/src/main/java/dev/typetype/android/data/push/PushRegistrationStore.kt b/app/src/main/java/dev/typetype/android/data/push/PushRegistrationStore.kt index 46159fc0..fae37d70 100644 --- a/app/src/main/java/dev/typetype/android/data/push/PushRegistrationStore.kt +++ b/app/src/main/java/dev/typetype/android/data/push/PushRegistrationStore.kt @@ -19,6 +19,8 @@ class PushRegistrationStore @Inject constructor( data class Registration( val deviceId: String, val endpoint: String?, + val p256dh: String?, + val auth: String?, ) fun registration(scope: AccountScope): Flow = dataStore.data.map { prefs -> @@ -39,10 +41,11 @@ class PushRegistrationStore @Inject constructor( return dataStore.data.first()[deviceIdKey(scope)] ?: error("The stored push device id disappeared") } - suspend fun setEndpoint(scope: AccountScope, endpoint: String?) { + suspend fun setSubscription(scope: AccountScope, endpoint: String, p256dh: String, auth: String) { dataStore.edit { prefs -> - val key = endpointKey(scope) - if (endpoint == null) prefs.remove(key) else prefs[key] = endpoint + prefs[endpointKey(scope)] = endpoint + prefs[p256dhKey(scope)] = p256dh + prefs[authKey(scope)] = auth } } @@ -50,19 +53,32 @@ class PushRegistrationStore @Inject constructor( dataStore.edit { prefs -> prefs.remove(deviceIdKey(scope)) prefs.remove(endpointKey(scope)) + prefs.remove(p256dhKey(scope)) + prefs.remove(authKey(scope)) } } private fun Preferences.registration(scope: AccountScope): Registration? { val deviceId = this[deviceIdKey(scope)]?.takeIf(String::isNotBlank) ?: return null - return Registration(deviceId, this[endpointKey(scope)]) + return Registration( + deviceId = deviceId, + endpoint = this[endpointKey(scope)], + p256dh = this[p256dhKey(scope)], + auth = this[authKey(scope)], + ) } private fun deviceIdKey(scope: AccountScope) = - stringPreferencesKey("push_device_id_${scopeKey(scope)}") + stringPreferencesKey("push_device_id_" + scopeKey(scope)) private fun endpointKey(scope: AccountScope) = - stringPreferencesKey("push_endpoint_${scopeKey(scope)}") + stringPreferencesKey("push_endpoint_" + scopeKey(scope)) + + private fun p256dhKey(scope: AccountScope) = + stringPreferencesKey("push_p256dh_" + scopeKey(scope)) + + private fun authKey(scope: AccountScope) = + stringPreferencesKey("push_auth_" + scopeKey(scope)) private fun scopeKey(scope: AccountScope) = "${scope.serverId}_${scope.accountId}" } diff --git a/app/src/main/java/dev/typetype/android/data/push/RemotePushRepository.kt b/app/src/main/java/dev/typetype/android/data/push/RemotePushRepository.kt index 7c5fe569..19fea4c0 100644 --- a/app/src/main/java/dev/typetype/android/data/push/RemotePushRepository.kt +++ b/app/src/main/java/dev/typetype/android/data/push/RemotePushRepository.kt @@ -36,11 +36,16 @@ class RemotePushRepository @Inject constructor( ?: PushCapability() } - override suspend fun registerDevice(deviceId: String, endpoint: String): Result = guarded { + override suspend fun registerDevice( + deviceId: String, + endpoint: String, + p256dh: String, + auth: String, + ): Result = guarded { val api = apiHolder.require(requireEligibleScope()) val response = withContext(Dispatchers.IO) { api.registerPushDevice( - PushDeviceRegistrationRequestDto(deviceId = deviceId, endpoint = endpoint), + PushDeviceRegistrationRequestDto(deviceId = deviceId, endpoint = endpoint, p256dh = p256dh, auth = auth), ) } response.requireSuccessfulResponse() diff --git a/app/src/main/java/dev/typetype/android/domain/push/PushRepository.kt b/app/src/main/java/dev/typetype/android/domain/push/PushRepository.kt index 7579a30a..d8fcb3f5 100644 --- a/app/src/main/java/dev/typetype/android/domain/push/PushRepository.kt +++ b/app/src/main/java/dev/typetype/android/domain/push/PushRepository.kt @@ -5,7 +5,7 @@ import dev.typetype.android.domain.server.PushCapability interface PushRepository { suspend fun currentCapability(): PushCapability - suspend fun registerDevice(deviceId: String, endpoint: String): Result + suspend fun registerDevice(deviceId: String, endpoint: String, p256dh: String, auth: String): Result suspend fun unregisterDevice(deviceId: String): Result diff --git a/app/src/main/java/dev/typetype/android/services/push/PushRegistrationManager.kt b/app/src/main/java/dev/typetype/android/services/push/PushRegistrationManager.kt index dfaf7a77..9eacb490 100644 --- a/app/src/main/java/dev/typetype/android/services/push/PushRegistrationManager.kt +++ b/app/src/main/java/dev/typetype/android/services/push/PushRegistrationManager.kt @@ -49,13 +49,10 @@ class PushRegistrationManager @Inject constructor( return update(PushRegistrationStatus.Unavailable) } val endpoint = registration.endpoint ?: return startRegistration(scope) - val devices = repository.devices().getOrElse { - return update(PushRegistrationStatus.Failed) - } - if (devices.any { device -> device.deviceId == registration.deviceId }) { - return update(PushRegistrationStatus.Registered) - } - return repository.registerDevice(registration.deviceId, endpoint).fold( + val p256dh = registration.p256dh + val auth = registration.auth + if (p256dh.isNullOrBlank() || auth.isNullOrBlank()) return startRegistration(scope) + return repository.registerDevice(registration.deviceId, endpoint, p256dh, auth).fold( onSuccess = { update(PushRegistrationStatus.Registered) }, onFailure = { update(PushRegistrationStatus.Failed) }, ) @@ -92,18 +89,20 @@ class PushRegistrationManager @Inject constructor( val registration = registrationStore.registrationOnce(scope) return when { registration == null -> update(PushRegistrationStatus.Disabled) - registration.endpoint != null -> update(PushRegistrationStatus.Registered) + !registration.endpoint.isNullOrBlank() && + !registration.p256dh.isNullOrBlank() && + !registration.auth.isNullOrBlank() -> update(PushRegistrationStatus.Registered) else -> update(PushRegistrationStatus.Registering) } } - fun onEndpointAvailable(instance: String, endpoint: String) { + fun onEndpointAvailable(instance: String, endpoint: String, p256dh: String, auth: String) { managerScope.launch { val accountScope = scopeFromInstanceName(instance) ?: return@launch val deviceId = registrationStore.ensureDeviceId(accountScope) - repository.registerDevice(deviceId, endpoint).fold( + repository.registerDevice(deviceId, endpoint, p256dh, auth).fold( onSuccess = { - registrationStore.setEndpoint(accountScope, endpoint) + registrationStore.setSubscription(accountScope, endpoint, p256dh, auth) update(PushRegistrationStatus.Registered) }, onFailure = { update(PushRegistrationStatus.Failed) }, diff --git a/app/src/main/java/dev/typetype/android/services/push/TypeTypePushService.kt b/app/src/main/java/dev/typetype/android/services/push/TypeTypePushService.kt index af093df2..d7191236 100644 --- a/app/src/main/java/dev/typetype/android/services/push/TypeTypePushService.kt +++ b/app/src/main/java/dev/typetype/android/services/push/TypeTypePushService.kt @@ -15,7 +15,12 @@ class TypeTypePushService : PushService() { @Inject lateinit var notifier: PushNotifier override fun onNewEndpoint(endpoint: PushEndpoint, instance: String) { - registrationManager.onEndpointAvailable(instance, endpoint.url) + val keySet = endpoint.pubKeySet + if (keySet == null) { + registrationManager.onRegistrationFailed(instance) + return + } + registrationManager.onEndpointAvailable(instance, endpoint.url, keySet.pubKey, keySet.auth) } override fun onMessage(message: PushMessage, instance: String) { diff --git a/app/src/test/java/dev/typetype/android/data/network/dto/PushDeviceRegistrationRequestDtoTest.kt b/app/src/test/java/dev/typetype/android/data/network/dto/PushDeviceRegistrationRequestDtoTest.kt new file mode 100644 index 00000000..92f516f0 --- /dev/null +++ b/app/src/test/java/dev/typetype/android/data/network/dto/PushDeviceRegistrationRequestDtoTest.kt @@ -0,0 +1,26 @@ +package dev.typetype.android.data.network.dto + +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import org.junit.Assert.assertEquals +import org.junit.Test + +class PushDeviceRegistrationRequestDtoTest { + @Test + fun registrationJsonIncludesWebPushSubscriptionKeys() { + val request = PushDeviceRegistrationRequestDto( + deviceId = "device-1", + endpoint = "https://push.example/subscription", + p256dh = "BCVxsr7N_eNgVRqvHtD0zTZsEc6-VV-JvLexhqUzORcxaOzi6-AYWXvTBHm4bjyPjs7Vd8pZGH6SRpkNtoIAiw4", + auth = "BTBZMqHH6r4Tts7J_aSIgg", + ) + + val json = Json.parseToJsonElement(Json.encodeToString(request)).jsonObject + + assertEquals(request.endpoint, json["endpoint"]?.jsonPrimitive?.content) + assertEquals(request.p256dh, json["p256dh"]?.jsonPrimitive?.content) + assertEquals(request.auth, json["auth"]?.jsonPrimitive?.content) + } +} From 36d2c6b40b2f58c0a275d33e1ba8db7c5f2f6d98 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Mon, 28 Sep 2026 15:27:59 +0200 Subject: [PATCH 2/2] build: prepare mobile beta 1.9.0-beta.3 --- app/build.gradle.kts | 4 ++-- release-notes/v1.9.0-beta.3.md | 19 +++++++++++++++++++ 2 files changed, 21 insertions(+), 2 deletions(-) create mode 100644 release-notes/v1.9.0-beta.3.md diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 3ba296c1..70fe93e8 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -26,8 +26,8 @@ android { applicationId = "dev.typetype.android" minSdk = 23 targetSdk = 37 - versionCode = 10825 - versionName = "1.9.0-beta.2" + versionCode = 10826 + versionName = "1.9.0-beta.3" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" resValue("string", "app_name", "TypeType") } diff --git a/release-notes/v1.9.0-beta.3.md b/release-notes/v1.9.0-beta.3.md new file mode 100644 index 00000000..9273d633 --- /dev/null +++ b/release-notes/v1.9.0-beta.3.md @@ -0,0 +1,19 @@ +# TypeType for Android 1.9.0-beta.3 + +TypeType for Android 1.9.0-beta.3 sends the subscription keys required by the server to encrypt UnifiedPush notifications. + +## Notifications + +- Register the endpoint with its Web Push `p256dh` public key and `auth` secret, scoped to the current instance and account. +- Keep registration inactive if the distributor does not provide the encryption keys. +- Please verify delivery with Sunup by enabling a channel bell and waiting for a new video notification. + +## Thx + +Thx to everyone testing beta notifications and reporting that they did not arrive. + +## Installing + +Download the APK attached below, or use the beta channel of the TypeType F-Droid repository. The APK is built from the tagged source, signed by the release workflow, checked for 16 KiB page alignment, and verified against the Gradle application version. A SHA-256 checksum is provided alongside it. + +**Full changelog:** https://github.com/TypeType-Video/TypeType-Android/compare/v1.9.0-beta.2...v1.9.0-beta.3