Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<Registration?> = dataStore.data.map { prefs ->
Expand All @@ -39,30 +41,44 @@ 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
}
}

suspend fun clear(scope: AccountScope) {
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}"
}
Original file line number Diff line number Diff line change
Expand Up @@ -36,11 +36,16 @@ class RemotePushRepository @Inject constructor(
?: PushCapability()
}

override suspend fun registerDevice(deviceId: String, endpoint: String): Result<Unit> = guarded {
override suspend fun registerDevice(
deviceId: String,
endpoint: String,
p256dh: String,
auth: String,
): Result<Unit> = 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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<Unit>
suspend fun registerDevice(deviceId: String, endpoint: String, p256dh: String, auth: String): Result<Unit>

suspend fun unregisterDevice(deviceId: String): Result<Unit>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) },
)
Expand Down Expand Up @@ -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) },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
}
}
19 changes: 19 additions & 0 deletions release-notes/v1.9.0-beta.3.md
Original file line number Diff line number Diff line change
@@ -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