From 651395cf7af0432f46a03bcb70943c7eaaf60408 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Wed, 21 Oct 2026 15:09:33 +0200 Subject: [PATCH 1/7] fix: retry failed push registration automatically --- .../android/domain/push/PushModels.kt | 2 + .../notifications/PushNotificationsScreen.kt | 1 + .../services/push/PushRegistrationManager.kt | 83 +++++++++++++++---- app/src/main/res/values/strings.xml | 11 ++- .../push/PushRegistrationFailureCodeTest.kt | 15 ++++ 5 files changed, 93 insertions(+), 19 deletions(-) create mode 100644 app/src/test/java/dev/typetype/android/services/push/PushRegistrationFailureCodeTest.kt diff --git a/app/src/main/java/dev/typetype/android/domain/push/PushModels.kt b/app/src/main/java/dev/typetype/android/domain/push/PushModels.kt index a2791d45..38c2e172 100644 --- a/app/src/main/java/dev/typetype/android/domain/push/PushModels.kt +++ b/app/src/main/java/dev/typetype/android/domain/push/PushModels.kt @@ -26,5 +26,7 @@ sealed interface PushRegistrationStatus { data object Registered : PushRegistrationStatus + data object ActionRequired : PushRegistrationStatus + data object Failed : PushRegistrationStatus } diff --git a/app/src/main/java/dev/typetype/android/feature/settings/notifications/PushNotificationsScreen.kt b/app/src/main/java/dev/typetype/android/feature/settings/notifications/PushNotificationsScreen.kt index 883af2bc..a351bb7c 100644 --- a/app/src/main/java/dev/typetype/android/feature/settings/notifications/PushNotificationsScreen.kt +++ b/app/src/main/java/dev/typetype/android/feature/settings/notifications/PushNotificationsScreen.kt @@ -214,6 +214,7 @@ private fun statusLabel(status: PushRegistrationStatus): Int = when (status) { PushRegistrationStatus.Registering -> R.string.push_status_connecting PushRegistrationStatus.MissingDistributor -> R.string.push_status_missing_distributor PushRegistrationStatus.Unavailable -> R.string.push_status_unavailable + PushRegistrationStatus.ActionRequired -> R.string.push_status_action_required PushRegistrationStatus.Failed -> R.string.push_status_failed PushRegistrationStatus.Disabled -> R.string.push_status_disabled } 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 9eacb490..f0b776c1 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 @@ -5,6 +5,8 @@ import dagger.hilt.android.qualifiers.ApplicationContext import dev.typetype.android.R import dev.typetype.android.data.account.ActiveAccountScope import dev.typetype.android.data.account.AccountScope +import dev.typetype.android.data.diagnostics.PushDiagnosticEvent +import dev.typetype.android.data.diagnostics.PushDiagnosticRecorder import dev.typetype.android.data.push.PushRegistrationStore import dev.typetype.android.data.push.pushInstanceName import dev.typetype.android.data.push.scopeFromInstanceName @@ -12,12 +14,13 @@ import dev.typetype.android.domain.push.PushRegistrationStatus import dev.typetype.android.domain.push.PushRepository import javax.inject.Inject import javax.inject.Singleton -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import org.unifiedpush.android.connector.UnifiedPush @@ -27,6 +30,7 @@ class PushRegistrationManager @Inject constructor( private val repository: PushRepository, private val registrationStore: PushRegistrationStore, private val activeAccountScope: ActiveAccountScope, + private val pushDiagnostics: PushDiagnosticRecorder, ) { private val _status = MutableStateFlow(PushRegistrationStatus.Disabled) val status: StateFlow = _status.asStateFlow() @@ -36,6 +40,7 @@ class PushRegistrationManager @Inject constructor( suspend fun enable(): PushRegistrationStatus { val scope = activeAccountScope.require() if (!repository.currentCapability().enabled) { + pushDiagnostics.record(scope, PushDiagnosticEvent.RegistrationFailed, "server_push_unavailable") return update(PushRegistrationStatus.Unavailable) } return startRegistration(scope) @@ -46,6 +51,7 @@ class PushRegistrationManager @Inject constructor( val registration = registrationStore.registrationOnce(scope) ?: return update(PushRegistrationStatus.Disabled) if (!repository.currentCapability().enabled) { + pushDiagnostics.record(scope, PushDiagnosticEvent.RegistrationFailed, "server_push_unavailable") return update(PushRegistrationStatus.Unavailable) } val endpoint = registration.endpoint ?: return startRegistration(scope) @@ -53,22 +59,37 @@ class PushRegistrationManager @Inject constructor( 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) }, + onSuccess = { + pushDiagnostics.record(scope, PushDiagnosticEvent.RegistrationActive) + update(PushRegistrationStatus.Registered) + }, + onFailure = { + pushDiagnostics.record(scope, PushDiagnosticEvent.RegistrationFailed, "server_registration_failed") + update(PushRegistrationStatus.Failed) + }, ) } private suspend fun startRegistration(scope: AccountScope): PushRegistrationStatus { val instance = pushInstanceName(scope) if (UnifiedPush.getDistributors(context).isEmpty()) { + pushDiagnostics.record(scope, PushDiagnosticEvent.RegistrationFailed, "missing_distributor") return update(PushRegistrationStatus.MissingDistributor) } - if (UnifiedPush.getAckDistributor(context) == null) { - UnifiedPush.saveDistributor(context, UnifiedPush.getDistributors(context).first()) + return try { + if (UnifiedPush.getAckDistributor(context) == null) { + UnifiedPush.saveDistributor(context, UnifiedPush.getDistributors(context).first()) + } + registrationStore.ensureDeviceId(scope) + pushDiagnostics.record(scope, PushDiagnosticEvent.RegistrationRequested) + UnifiedPush.register(context, instance, context.getString(R.string.app_name)) + update(PushRegistrationStatus.Registering) + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Exception) { + pushDiagnostics.record(scope, PushDiagnosticEvent.RegistrationFailed, "unifiedpush_request_failed") + update(PushRegistrationStatus.Failed) } - registrationStore.ensureDeviceId(scope) - UnifiedPush.register(context, instance, context.getString(R.string.app_name)) - return update(PushRegistrationStatus.Registering) } suspend fun disable(): PushRegistrationStatus { @@ -78,6 +99,7 @@ class PushRegistrationManager @Inject constructor( } UnifiedPush.unregister(context, pushInstanceName(scope)) registrationStore.clear(scope) + pushDiagnostics.record(scope, PushDiagnosticEvent.RegistrationDisabled) return update(PushRegistrationStatus.Disabled) } @@ -99,13 +121,30 @@ class PushRegistrationManager @Inject constructor( fun onEndpointAvailable(instance: String, endpoint: String, p256dh: String, auth: String) { managerScope.launch { val accountScope = scopeFromInstanceName(instance) ?: return@launch - val deviceId = registrationStore.ensureDeviceId(accountScope) + val deviceId = try { + registrationStore.ensureDeviceId(accountScope).also { + registrationStore.setSubscription(accountScope, endpoint, p256dh, auth) + } + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Exception) { + pushDiagnostics.record( + accountScope, + PushDiagnosticEvent.RegistrationFailed, + "registration_storage_failed", + ) + update(PushRegistrationStatus.Failed) + return@launch + } repository.registerDevice(deviceId, endpoint, p256dh, auth).fold( onSuccess = { - registrationStore.setSubscription(accountScope, endpoint, p256dh, auth) + pushDiagnostics.record(accountScope, PushDiagnosticEvent.RegistrationActive) update(PushRegistrationStatus.Registered) }, - onFailure = { update(PushRegistrationStatus.Failed) }, + onFailure = { + pushDiagnostics.record(accountScope, PushDiagnosticEvent.RegistrationFailed, "server_registration_failed") + update(PushRegistrationStatus.Failed) + }, ) } } @@ -117,13 +156,25 @@ class PushRegistrationManager @Inject constructor( repository.unregisterDevice(registration.deviceId) } registrationStore.clear(accountScope) + pushDiagnostics.record(accountScope, PushDiagnosticEvent.RegistrationDisabled) update(PushRegistrationStatus.Disabled) } } - fun onRegistrationFailed(instance: String) { - if (scopeFromInstanceName(instance) != null) { - update(PushRegistrationStatus.Failed) + fun onRegistrationFailed( + instance: String, + failureCode: String = "unifiedpush_registration_failed", + ) { + val scope = scopeFromInstanceName(instance) + if (scope != null) { + pushDiagnostics.record(scope, PushDiagnosticEvent.RegistrationFailed, failureCode) + update( + if (failureCode == "unifiedpush_action_required") { + PushRegistrationStatus.ActionRequired + } else { + PushRegistrationStatus.Failed + }, + ) } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 782c59bb..5a6f316b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -358,7 +358,7 @@ Control history tracking and stored activity Diagnostics Review redacted app and network activity - Stored only on this device, separately for the active instance and account. The journal contains redacted network metadata, app exit reasons and predefined SABR failure categories. A crash report keeps exception class names, method names and line numbers, but never exception messages. Session IDs are replaced with local fingerprints. URLs, search terms, video IDs, headers, cookies, tokens, request bodies and raw server messages are never recorded. + Stored only on this device, separately for the active instance and account. The journal contains redacted network metadata, app exit reasons, predefined SABR failure categories and opaque push trace IDs. A crash report keeps exception class names, method names and line numbers, but never exception messages. Session IDs are replaced with local fingerprints. URLs, search terms, video IDs, headers, cookies, tokens, request bodies and raw server messages are never recorded. Refresh Clear Share @@ -369,9 +369,13 @@ SABR %1$s Request %1$s Code %1$s + Trace %1$s + Local event Copy request ID - TypeType request ID + TypeType diagnostic ID Request ID copied + Trace ID copied + Copy trace ID Request %1$s Code %1$s App stopped unexpectedly @@ -1209,11 +1213,12 @@ New videos Push notifications New video alerts via UnifiedPush - Active + Registered Connecting… Off No push distributor installed Not offered by this instance + Open your push distributor and complete its pending action Registration failed %1$d of %2$d devices registered Install a UnifiedPush distributor such as ntfy to receive notifications. diff --git a/app/src/test/java/dev/typetype/android/services/push/PushRegistrationFailureCodeTest.kt b/app/src/test/java/dev/typetype/android/services/push/PushRegistrationFailureCodeTest.kt new file mode 100644 index 00000000..3f82038c --- /dev/null +++ b/app/src/test/java/dev/typetype/android/services/push/PushRegistrationFailureCodeTest.kt @@ -0,0 +1,15 @@ +package dev.typetype.android.services.push + +import org.junit.Assert.assertEquals +import org.junit.Test +import org.unifiedpush.android.connector.FailedReason + +class PushRegistrationFailureCodeTest { + @Test + fun `maps distributor registration failures to stable diagnostic codes`() { + assertEquals("unifiedpush_network", FailedReason.NETWORK.diagnosticFailureCode()) + assertEquals("unifiedpush_internal_error", FailedReason.INTERNAL_ERROR.diagnosticFailureCode()) + assertEquals("unifiedpush_action_required", FailedReason.ACTION_REQUIRED.diagnosticFailureCode()) + assertEquals("unifiedpush_vapid_required", FailedReason.VAPID_REQUIRED.diagnosticFailureCode()) + } +} From 581121a15d1d8c334ff6e83594db27f9c04df476 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Wed, 21 Oct 2026 15:09:38 +0200 Subject: [PATCH 2/7] fix: retry undelivered local notifications --- .../LocalNotificationDecision.kt | 5 +- .../notifications/LocalNotificationPoller.kt | 56 ++++++++++++++++++- .../notifications/LocalNotificationWorker.kt | 28 +++++++++- .../LocalNotificationDecisionTest.kt | 16 ++++++ 4 files changed, 100 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/dev/typetype/android/data/notifications/LocalNotificationDecision.kt b/app/src/main/java/dev/typetype/android/data/notifications/LocalNotificationDecision.kt index 9cbb81a1..00b50d9a 100644 --- a/app/src/main/java/dev/typetype/android/data/notifications/LocalNotificationDecision.kt +++ b/app/src/main/java/dev/typetype/android/data/notifications/LocalNotificationDecision.kt @@ -33,5 +33,8 @@ internal fun decideLocalNotifications( ) } -private fun NotificationItem.localNotificationKey(): String = +internal fun NotificationItem.localNotificationKey(): String = video.id.takeIf { it.isNotBlank() } ?: video.url + +internal fun LocalNotificationDecision.keysToPersist(unpostedKeys: Set): List = + keysToRecord.filterNot(unpostedKeys::contains) diff --git a/app/src/main/java/dev/typetype/android/data/notifications/LocalNotificationPoller.kt b/app/src/main/java/dev/typetype/android/data/notifications/LocalNotificationPoller.kt index 5c8c548b..870c5318 100644 --- a/app/src/main/java/dev/typetype/android/data/notifications/LocalNotificationPoller.kt +++ b/app/src/main/java/dev/typetype/android/data/notifications/LocalNotificationPoller.kt @@ -3,8 +3,11 @@ package dev.typetype.android.data.notifications import dev.typetype.android.data.account.AccountDao import dev.typetype.android.data.account.AccountScope import dev.typetype.android.data.account.ActiveAccountScope +import dev.typetype.android.data.diagnostics.PushDiagnosticEvent +import dev.typetype.android.data.diagnostics.PushDiagnosticRecorder import dev.typetype.android.domain.notifications.NotificationsRepository import dev.typetype.android.domain.push.PushRepository +import dev.typetype.android.services.push.NotificationPostResult import dev.typetype.android.services.push.PushNotifier import javax.inject.Inject import javax.inject.Singleton @@ -18,6 +21,7 @@ class LocalNotificationPoller @Inject constructor( private val pushRepository: PushRepository, private val seenStore: LocalNotificationSeenStore, private val notifier: PushNotifier, + private val pushDiagnostics: PushDiagnosticRecorder, ) { suspend fun poll(): Result = try { Result.success(pollOnce()) @@ -31,6 +35,7 @@ class LocalNotificationPoller @Inject constructor( val scope = eligibleScope() val enabledChannels = pushRepository.channelPreferences().getOrThrow() val page = notifications.page(0).getOrThrow() + pushDiagnostics.record(scope, PushDiagnosticEvent.LocalPollChecked) val state = seenStore.state(scope) val decision = decideLocalNotifications( items = page.items, @@ -39,9 +44,54 @@ class LocalNotificationPoller @Inject constructor( enabledChannels = enabledChannels, ) if (decision.keysToRecord.isEmpty() && decision.notified.isEmpty()) return 0 - seenStore.record(scope, decision.keysToRecord, seeded = true) - decision.notified.forEach(notifier::notifySubscription) - return decision.notified.size + val unpostedKeys = mutableSetOf() + var postedCount = 0 + decision.notified.forEach { item -> + when (notifier.notifySubscription(item)) { + NotificationPostResult.Posted -> { + postedCount += 1 + pushDiagnostics.record(scope, PushDiagnosticEvent.LocalNotificationPosted) + } + NotificationPostResult.NotificationsDisabled -> { + unpostedKeys += item.localNotificationKey() + pushDiagnostics.record( + scope, + PushDiagnosticEvent.LocalNotificationSuppressed, + failureCode = "notifications_disabled", + ) + } + NotificationPostResult.ChannelBlocked -> { + unpostedKeys += item.localNotificationKey() + pushDiagnostics.record( + scope, + PushDiagnosticEvent.LocalNotificationSuppressed, + failureCode = "channel_blocked", + ) + } + NotificationPostResult.PermissionDenied -> { + unpostedKeys += item.localNotificationKey() + pushDiagnostics.record( + scope, + PushDiagnosticEvent.LocalNotificationSuppressed, + failureCode = "permission_denied", + ) + } + NotificationPostResult.Failed -> { + unpostedKeys += item.localNotificationKey() + pushDiagnostics.record( + scope, + PushDiagnosticEvent.LocalNotificationFailed, + failureCode = "notification_manager_unavailable", + ) + } + } + } + seenStore.record( + scope, + decision.keysToPersist(unpostedKeys), + seeded = true, + ) + return postedCount } private suspend fun eligibleScope(): AccountScope { diff --git a/app/src/main/java/dev/typetype/android/data/notifications/LocalNotificationWorker.kt b/app/src/main/java/dev/typetype/android/data/notifications/LocalNotificationWorker.kt index 895f7afb..8ffa4479 100644 --- a/app/src/main/java/dev/typetype/android/data/notifications/LocalNotificationWorker.kt +++ b/app/src/main/java/dev/typetype/android/data/notifications/LocalNotificationWorker.kt @@ -6,7 +6,12 @@ import androidx.work.CoroutineWorker import androidx.work.WorkerParameters import dagger.assisted.Assisted import dagger.assisted.AssistedInject +import dev.typetype.android.data.account.ActiveAccountScope +import dev.typetype.android.data.diagnostics.PushDiagnosticEvent +import dev.typetype.android.data.diagnostics.PushDiagnosticRecorder +import dev.typetype.android.services.push.NotificationPostResult import dev.typetype.android.services.push.PushNotifier +import kotlinx.coroutines.flow.first @HiltWorker class LocalNotificationWorker @AssistedInject constructor( @@ -14,9 +19,22 @@ class LocalNotificationWorker @AssistedInject constructor( @Assisted parameters: WorkerParameters, private val poller: LocalNotificationPoller, private val notifier: PushNotifier, + private val activeAccountScope: ActiveAccountScope, + private val pushDiagnostics: PushDiagnosticRecorder, ) : CoroutineWorker(context, parameters) { override suspend fun doWork(): Result { - if (!notifier.canNotify()) return Result.success() + val blockedReason = notifier.blockedReason() + if (blockedReason != null) { + val scope = activeAccountScope.observe().first() + scope?.let { + pushDiagnostics.record( + it, + PushDiagnosticEvent.LocalNotificationSuppressed, + failureCode = blockedReason.failureCode(), + ) + } + return Result.success() + } return poller.poll().fold( onSuccess = { Result.success() }, onFailure = { failure -> @@ -24,4 +42,12 @@ class LocalNotificationWorker @AssistedInject constructor( }, ) } + + private fun NotificationPostResult.failureCode(): String = when (this) { + NotificationPostResult.NotificationsDisabled -> "notifications_disabled" + NotificationPostResult.ChannelBlocked -> "channel_blocked" + NotificationPostResult.PermissionDenied -> "permission_denied" + NotificationPostResult.Failed -> "notification_manager_unavailable" + NotificationPostResult.Posted -> "notification_available" + } } diff --git a/app/src/test/java/dev/typetype/android/data/notifications/LocalNotificationDecisionTest.kt b/app/src/test/java/dev/typetype/android/data/notifications/LocalNotificationDecisionTest.kt index fea55273..f378eb31 100644 --- a/app/src/test/java/dev/typetype/android/data/notifications/LocalNotificationDecisionTest.kt +++ b/app/src/test/java/dev/typetype/android/data/notifications/LocalNotificationDecisionTest.kt @@ -97,6 +97,22 @@ class LocalNotificationDecisionTest { assertEquals(listOf("older", "newest"), decision.notified.map { it.video.id }) } + @Test + fun `failed notifications stay eligible for the next poll`() { + val decision = decideLocalNotifications( + items = listOf( + item(videoId = "posted", channelUrl = CHANNEL), + item(videoId = "failed", channelUrl = CHANNEL), + item(videoId = "silent", channelUrl = OTHER_CHANNEL), + ), + knownKeys = emptySet(), + seeded = true, + enabledChannels = listOf(ChannelNotificationsPreference(CHANNEL, enabled = true)), + ) + + assertEquals(listOf("posted", "silent"), decision.keysToPersist(setOf("failed"))) + } + private fun item(videoId: String, channelUrl: String) = NotificationItem( type = "subscription_new_video", title = "Video $videoId", From 5269cf5c7cadd42f8a6a85f10313b3c94df28f5e Mon Sep 17 00:00:00 2001 From: Priveetee Date: Wed, 21 Oct 2026 15:09:54 +0200 Subject: [PATCH 3/7] feat: classify incoming push messages --- .../push/PushMessageClassification.kt | 32 +++++++ .../push/PushMessageClassificationTest.kt | 94 +++++++++++++++++++ 2 files changed, 126 insertions(+) create mode 100644 app/src/main/java/dev/typetype/android/services/push/PushMessageClassification.kt create mode 100644 app/src/test/java/dev/typetype/android/services/push/PushMessageClassificationTest.kt diff --git a/app/src/main/java/dev/typetype/android/services/push/PushMessageClassification.kt b/app/src/main/java/dev/typetype/android/services/push/PushMessageClassification.kt new file mode 100644 index 00000000..13396b8d --- /dev/null +++ b/app/src/main/java/dev/typetype/android/services/push/PushMessageClassification.kt @@ -0,0 +1,32 @@ +package dev.typetype.android.services.push + +import dev.typetype.android.data.account.AccountScope +import dev.typetype.android.data.push.scopeFromInstanceName +import dev.typetype.android.domain.push.PushPayload +import dev.typetype.android.domain.push.parsePushPayload + +internal sealed interface PushMessageClassification { + data class Accepted(val scope: AccountScope, val payload: PushPayload) : PushMessageClassification + + data class Rejected(val scope: AccountScope?, val failureCode: String) : PushMessageClassification +} + +internal fun classifyPushMessage(instance: String, content: ByteArray): PushMessageClassification { + val scope = scopeFromInstanceName(instance) + ?: return PushMessageClassification.Rejected(null, "unknown_instance") + val payload = parsePushPayload(content) + ?: return PushMessageClassification.Rejected(scope, "invalid_payload") + if (payload.version != SUPPORTED_PAYLOAD_VERSION) { + return PushMessageClassification.Rejected(scope, "unsupported_payload_version") + } + if (payload.eventType != NEW_VIDEO_EVENT) { + return PushMessageClassification.Rejected(scope, "unsupported_event_type") + } + if (payload.accountId != scope.accountId) { + return PushMessageClassification.Rejected(scope, "account_mismatch") + } + return PushMessageClassification.Accepted(scope, payload) +} + +private const val SUPPORTED_PAYLOAD_VERSION = 1 +private const val NEW_VIDEO_EVENT = "subscription_new_video" diff --git a/app/src/test/java/dev/typetype/android/services/push/PushMessageClassificationTest.kt b/app/src/test/java/dev/typetype/android/services/push/PushMessageClassificationTest.kt new file mode 100644 index 00000000..5c3ba812 --- /dev/null +++ b/app/src/test/java/dev/typetype/android/services/push/PushMessageClassificationTest.kt @@ -0,0 +1,94 @@ +package dev.typetype.android.services.push + +import dev.typetype.android.data.account.AccountScope +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class PushMessageClassificationTest { + @Test + fun `accepts only supported event for registered instance scope`() { + val result = classifyPushMessage("server-id:account-id", payload(EVENT_TYPE).toByteArray()) + + assertTrue(result is PushMessageClassification.Accepted) + val accepted = result as PushMessageClassification.Accepted + assertEquals(AccountScope("server-id", "account-id"), accepted.scope) + assertEquals(EVENT_TYPE, accepted.payload.eventType) + assertEquals(EVENT_ID, accepted.payload.eventId) + } + + @Test + fun `rejects malformed payload without losing instance scope`() { + val result = classifyPushMessage("server-id:account-id", "not-json".toByteArray()) + + assertTrue(result is PushMessageClassification.Rejected) + val rejected = result as PushMessageClassification.Rejected + assertEquals(AccountScope("server-id", "account-id"), rejected.scope) + assertEquals("invalid_payload", rejected.failureCode) + } + + @Test + fun `rejects unsupported event types`() { + val result = classifyPushMessage("server-id:account-id", payload("unknown_event").toByteArray()) + + assertTrue(result is PushMessageClassification.Rejected) + assertEquals("unsupported_event_type", (result as PushMessageClassification.Rejected).failureCode) + } + + @Test + fun `rejects payload from a different account`() { + val result = classifyPushMessage( + "server-id:account-id", + payload(EVENT_TYPE, accountId = "other-account").toByteArray(), + ) + + assertTrue(result is PushMessageClassification.Rejected) + assertEquals("account_mismatch", (result as PushMessageClassification.Rejected).failureCode) + } + + @Test + fun `rejects unsupported payload versions`() { + val result = classifyPushMessage( + "server-id:account-id", + payload(EVENT_TYPE, version = 2).toByteArray(), + ) + + assertTrue(result is PushMessageClassification.Rejected) + assertEquals("unsupported_payload_version", (result as PushMessageClassification.Rejected).failureCode) + } + + @Test + fun `rejects unknown instance without assigning another account`() { + val result = classifyPushMessage("malformed-instance", payload(EVENT_TYPE).toByteArray()) + + assertTrue(result is PushMessageClassification.Rejected) + val rejected = result as PushMessageClassification.Rejected + assertNull(rejected.scope) + assertEquals("unknown_instance", rejected.failureCode) + } + + private fun payload(eventType: String, accountId: String = "account-id", version: Int = 1) = """ + { + "version": $version, + "eventType": "$eventType", + "serviceId": 0, + "serviceName": "YouTube", + "eventId": "$EVENT_ID", + "videoId": "video-id", + "videoUrl": "https://www.youtube.com/watch?v=video-id", + "channelId": "channel-id", + "channelName": "Channel", + "channelAvatarUrl": "", + "instanceId": "server-instance", + "accountId": "$accountId", + "publishedAt": 1760000000000, + "title": "New video" + } + """.trimIndent() + + private companion object { + const val EVENT_TYPE = "subscription_new_video" + const val EVENT_ID = "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789" + } +} From 404990e6ad4f934e4f93d8fec087c6cbb8c245cb Mon Sep 17 00:00:00 2001 From: Priveetee Date: Wed, 21 Oct 2026 15:09:59 +0200 Subject: [PATCH 4/7] feat: trace push delivery outcomes --- .../services/push/PushNotifierAndroidTest.kt | 3 +- .../diagnostics/LocalDiagnosticsRepository.kt | 51 ++++++++- .../diagnostics/PushDiagnosticRecorder.kt | 69 +++++++++++ .../domain/diagnostics/DiagnosticEntry.kt | 1 + .../android/services/push/PushNotifier.kt | 83 +++++++++----- .../services/push/TypeTypePushService.kt | 107 +++++++++++++++++- 6 files changed, 276 insertions(+), 38 deletions(-) create mode 100644 app/src/main/java/dev/typetype/android/data/diagnostics/PushDiagnosticRecorder.kt diff --git a/app/src/androidTest/java/dev/typetype/android/services/push/PushNotifierAndroidTest.kt b/app/src/androidTest/java/dev/typetype/android/services/push/PushNotifierAndroidTest.kt index 6e9e9a12..883e48c1 100644 --- a/app/src/androidTest/java/dev/typetype/android/services/push/PushNotifierAndroidTest.kt +++ b/app/src/androidTest/java/dev/typetype/android/services/push/PushNotifierAndroidTest.kt @@ -39,11 +39,12 @@ class PushNotifierAndroidTest { val notificationId = subscriptionNotificationId(payload.videoId, payload.videoUrl, payload.eventId) try { - PushNotifier(context).notify(payload) + val result = PushNotifier(context).notify(payload) val notification = manager.activeNotifications .firstOrNull { record -> record.id == notificationId } ?.notification + assertEquals(NotificationPostResult.Posted, result) assertNotNull(notification) assertEquals( payload.channelName, diff --git a/app/src/main/java/dev/typetype/android/data/diagnostics/LocalDiagnosticsRepository.kt b/app/src/main/java/dev/typetype/android/data/diagnostics/LocalDiagnosticsRepository.kt index 850037e9..3c026b0b 100644 --- a/app/src/main/java/dev/typetype/android/data/diagnostics/LocalDiagnosticsRepository.kt +++ b/app/src/main/java/dev/typetype/android/data/diagnostics/LocalDiagnosticsRepository.kt @@ -101,9 +101,32 @@ class LocalDiagnosticsRepository @Inject constructor( statusCode: Int? = null, requestId: String? = null, failureCode: String? = null, + traceId: String? = null, ) { - if (route !in LOCAL_EVENT_ROUTES) return val scope = currentScope()?.copy(route = route) ?: return + recordLocalEvent( + serverId = scope.serverId, + accountId = scope.accountId, + route = route, + timestampEpochMillis = timestampEpochMillis, + statusCode = statusCode, + requestId = requestId, + failureCode = failureCode, + traceId = traceId, + ) + } + + internal fun recordLocalEvent( + serverId: String, + accountId: String, + route: String, + timestampEpochMillis: Long = System.currentTimeMillis(), + statusCode: Int? = null, + requestId: String? = null, + failureCode: String? = null, + traceId: String? = null, + ) { + if (serverId.isBlank() || accountId.isBlank() || route !in LOCAL_EVENT_ROUTES) return val entry = DiagnosticEntry( timestampEpochMillis = timestampEpochMillis, method = LOCAL_METHOD, @@ -112,10 +135,11 @@ class LocalDiagnosticsRepository @Inject constructor( durationMillis = 0, requestId = requestId?.takeIf(REQUEST_ID_PATTERN::matches), failureCode = failureCode?.takeIf(FAILURE_CODE_PATTERN::matches), + traceId = traceId?.takeIf(TRACE_ID_PATTERN::matches), ) synchronized(lock) { directory.mkdirs() - val file = fileFor(scope) + val file = fileFor(DiagnosticScope(serverId, accountId, route)) file.appendText(encode(entry) + "\n", Charsets.UTF_8) if (file.length() > MAX_FILE_BYTES) trim(file) } @@ -168,11 +192,12 @@ class LocalDiagnosticsRepository @Inject constructor( entry.requestId.orEmpty(), entry.sabr?.let(SabrDiagnosticDetailCodec::encode).orEmpty(), entry.failureCode.orEmpty(), + entry.traceId.orEmpty(), ).joinToString("\t") private fun decode(line: String): DiagnosticEntry? { - val fields = line.split('\t', limit = 8) - if (fields.size !in 6..8) return null + val fields = line.split('\t', limit = 9) + if (fields.size !in 6..9) return null val route = fields[2].takeIf { it.startsWith('/') && it.length <= 40 } ?: return null return DiagnosticEntry( timestampEpochMillis = fields[0].toLongOrNull() ?: return null, @@ -189,6 +214,7 @@ class LocalDiagnosticsRepository @Inject constructor( ?.takeIf(String::isNotEmpty) ?.let(SabrDiagnosticDetailCodec::decode), failureCode = fields.getOrNull(7)?.takeIf(FAILURE_CODE_PATTERN::matches), + traceId = fields.getOrNull(8)?.takeIf(TRACE_ID_PATTERN::matches), ) } @@ -216,6 +242,7 @@ class LocalDiagnosticsRepository @Inject constructor( val ALLOWED_METHODS = setOf("DELETE", "GET", "PATCH", "POST", "PUT") val REQUEST_ID_PATTERN = Regex("[A-Za-z0-9_-]{8,64}") val FAILURE_CODE_PATTERN = Regex("[a-z0-9_]{3,64}") + val TRACE_ID_PATTERN = Regex("[A-Fa-f0-9]{8,64}") const val LOCAL_METHOD = "LOCAL" const val LEGACY_APPLICATION_METHOD = "APP" val LOCAL_EVENT_ROUTES = setOf( @@ -234,6 +261,22 @@ class LocalDiagnosticsRepository @Inject constructor( "/subscriptions/feed/persistence", "/subscriptions/feed/ready", "/subscriptions/feed/server", + "/push/instance/invalid", + "/push/registration/requested", + "/push/registration/endpoint", + "/push/registration/registered", + "/push/registration/failed", + "/push/registration/disabled", + "/push/message/received", + "/push/message/invalid", + "/push/notification/posted", + "/push/notification/suppressed", + "/push/notification/failed", + "/push/distributor/unavailable", + "/notifications/local/checked", + "/notifications/local/posted", + "/notifications/local/suppressed", + "/notifications/local/failed", ) } } diff --git a/app/src/main/java/dev/typetype/android/data/diagnostics/PushDiagnosticRecorder.kt b/app/src/main/java/dev/typetype/android/data/diagnostics/PushDiagnosticRecorder.kt new file mode 100644 index 00000000..2f9288c0 --- /dev/null +++ b/app/src/main/java/dev/typetype/android/data/diagnostics/PushDiagnosticRecorder.kt @@ -0,0 +1,69 @@ +package dev.typetype.android.data.diagnostics + +import dev.typetype.android.data.account.AccountScope +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class PushDiagnosticRecorder @Inject constructor( + private val diagnostics: LocalDiagnosticsRepository, +) { + fun record( + scope: AccountScope, + event: PushDiagnosticEvent, + failureCode: String? = null, + traceId: String? = null, + ) { + try { + diagnostics.recordLocalEvent( + serverId = scope.serverId, + accountId = scope.accountId, + route = event.route, + failureCode = failureCode, + traceId = traceId, + ) + } catch (_: Exception) { + return + } + } + + fun recordCurrent( + event: PushDiagnosticEvent, + failureCode: String? = null, + ) { + try { + diagnostics.recordLocalEvent( + route = event.route, + failureCode = failureCode, + ) + } catch (_: Exception) { + return + } + } + + fun traceId(eventId: String): String? = + eventId.takeIf(EVENT_ID_PATTERN::matches)?.take(16) + + private companion object { + val EVENT_ID_PATTERN = Regex("[a-fA-F0-9]{64}") + } +} + +enum class PushDiagnosticEvent(val route: String) { + InstanceInvalid("/push/instance/invalid"), + RegistrationRequested("/push/registration/requested"), + EndpointReceived("/push/registration/endpoint"), + RegistrationActive("/push/registration/registered"), + RegistrationFailed("/push/registration/failed"), + RegistrationDisabled("/push/registration/disabled"), + MessageReceived("/push/message/received"), + MessageInvalid("/push/message/invalid"), + NotificationPosted("/push/notification/posted"), + NotificationSuppressed("/push/notification/suppressed"), + NotificationFailed("/push/notification/failed"), + DistributorUnavailable("/push/distributor/unavailable"), + LocalPollChecked("/notifications/local/checked"), + LocalNotificationPosted("/notifications/local/posted"), + LocalNotificationSuppressed("/notifications/local/suppressed"), + LocalNotificationFailed("/notifications/local/failed"), +} diff --git a/app/src/main/java/dev/typetype/android/domain/diagnostics/DiagnosticEntry.kt b/app/src/main/java/dev/typetype/android/domain/diagnostics/DiagnosticEntry.kt index ff85cf22..1abe47de 100644 --- a/app/src/main/java/dev/typetype/android/domain/diagnostics/DiagnosticEntry.kt +++ b/app/src/main/java/dev/typetype/android/domain/diagnostics/DiagnosticEntry.kt @@ -9,4 +9,5 @@ data class DiagnosticEntry( val requestId: String?, val sabr: SabrDiagnosticDetail? = null, val failureCode: String? = null, + val traceId: String? = null, ) diff --git a/app/src/main/java/dev/typetype/android/services/push/PushNotifier.kt b/app/src/main/java/dev/typetype/android/services/push/PushNotifier.kt index ef8bf960..2a9f18eb 100644 --- a/app/src/main/java/dev/typetype/android/services/push/PushNotifier.kt +++ b/app/src/main/java/dev/typetype/android/services/push/PushNotifier.kt @@ -16,7 +16,6 @@ import dev.typetype.android.R import dev.typetype.android.domain.navigation.toPublicWatchParameter import dev.typetype.android.domain.notifications.NotificationItem import dev.typetype.android.domain.push.PushPayload -import dev.typetype.android.domain.push.parsePushPayload import javax.inject.Inject import javax.inject.Singleton @@ -24,41 +23,61 @@ import javax.inject.Singleton class PushNotifier @Inject constructor( @ApplicationContext private val context: Context, ) { - fun canNotify(): Boolean = NotificationManagerCompat.from(context).areNotificationsEnabled() + fun canNotify(): Boolean = blockedReason() == null - fun notify(payload: PushPayload) { - show( - title = payload.channelName.ifBlank { payload.serviceName }, - text = payload.title, - videoUrl = payload.videoUrl, - notificationId = subscriptionNotificationId( - videoId = payload.videoId, - videoUrl = payload.videoUrl, - fallback = payload.eventId, - ), - ) + fun blockedReason(): NotificationPostResult? = when { + !NotificationManagerCompat.from(context).areNotificationsEnabled() -> + NotificationPostResult.NotificationsDisabled + isChannelBlocked() -> NotificationPostResult.ChannelBlocked + else -> null } - fun notifySubscription(item: NotificationItem) { - show( - title = item.channelName.ifBlank { item.video.uploaderName }, - text = item.title, + fun notify(payload: PushPayload): NotificationPostResult = show( + title = payload.channelName.ifBlank { payload.serviceName }, + text = payload.title, + videoUrl = payload.videoUrl, + notificationId = subscriptionNotificationId( + videoId = payload.videoId, + videoUrl = payload.videoUrl, + fallback = payload.eventId, + ), + ) + + fun notifySubscription(item: NotificationItem): NotificationPostResult = show( + title = item.channelName.ifBlank { item.video.uploaderName }, + text = item.title, + videoUrl = item.video.url, + notificationId = subscriptionNotificationId( + videoId = item.video.id, videoUrl = item.video.url, - notificationId = subscriptionNotificationId( - videoId = item.video.id, - videoUrl = item.video.url, - fallback = item.video.url, - ), - ) - } + fallback = item.video.url, + ), + ) - private fun show(title: String, text: String, videoUrl: String, notificationId: Int) { - val manager = context.getSystemService(NotificationManager::class.java) ?: return - if (!NotificationManagerCompat.from(context).areNotificationsEnabled()) return + private fun show( + title: String, + text: String, + videoUrl: String, + notificationId: Int, + ): NotificationPostResult { + val manager = context.getSystemService(NotificationManager::class.java) + ?: return NotificationPostResult.Failed if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { manager.createNotificationChannel(notificationChannel()) } - manager.notify(notificationId, buildNotification(title, text, videoUrl, notificationId)) + blockedReason()?.let { return it } + return try { + manager.notify(notificationId, buildNotification(title, text, videoUrl, notificationId)) + NotificationPostResult.Posted + } catch (_: SecurityException) { + NotificationPostResult.PermissionDenied + } + } + + private fun isChannelBlocked(): Boolean { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return false + val manager = context.getSystemService(NotificationManager::class.java) ?: return false + return manager.getNotificationChannel(CHANNEL_ID)?.importance == NotificationManager.IMPORTANCE_NONE } private fun buildNotification( @@ -100,5 +119,13 @@ class PushNotifier @Inject constructor( } } +enum class NotificationPostResult { + Posted, + NotificationsDisabled, + ChannelBlocked, + PermissionDenied, + Failed, +} + internal fun subscriptionNotificationId(videoId: String, videoUrl: String, fallback: String): Int = videoId.trim().ifBlank { videoUrl.trim() }.ifBlank { fallback.trim() }.hashCode() 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 d7191236..a14db10b 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 @@ -1,7 +1,11 @@ package dev.typetype.android.services.push import dagger.hilt.android.AndroidEntryPoint -import dev.typetype.android.domain.push.parsePushPayload +import dev.typetype.android.data.account.AccountScope +import dev.typetype.android.data.diagnostics.PushDiagnosticEvent +import dev.typetype.android.data.diagnostics.PushDiagnosticRecorder +import dev.typetype.android.data.push.scopeFromInstanceName +import dev.typetype.android.domain.push.PushPayload import javax.inject.Inject import org.unifiedpush.android.connector.FailedReason import org.unifiedpush.android.connector.PushService @@ -14,25 +18,118 @@ class TypeTypePushService : PushService() { @Inject lateinit var notifier: PushNotifier + @Inject lateinit var pushDiagnostics: PushDiagnosticRecorder + override fun onNewEndpoint(endpoint: PushEndpoint, instance: String) { + val scope = scopeFromInstanceName(instance) + if (scope == null) { + pushDiagnostics.recordCurrent(PushDiagnosticEvent.InstanceInvalid, "unknown_instance") + registrationManager.onRegistrationFailed(instance) + return + } val keySet = endpoint.pubKeySet - if (keySet == null) { + if (keySet == null || endpoint.url.isBlank() || keySet.pubKey.isBlank() || keySet.auth.isBlank()) { + pushDiagnostics.record( + scope, + PushDiagnosticEvent.RegistrationFailed, + failureCode = "endpoint_missing_keys", + ) registrationManager.onRegistrationFailed(instance) return } + pushDiagnostics.record(scope, PushDiagnosticEvent.EndpointReceived) registrationManager.onEndpointAvailable(instance, endpoint.url, keySet.pubKey, keySet.auth) } override fun onMessage(message: PushMessage, instance: String) { - val payload = parsePushPayload(message.content) ?: return - notifier.notify(payload) + when (val classification = classifyPushMessage(instance, message.content)) { + is PushMessageClassification.Rejected -> { + val scope = classification.scope + if (scope == null) { + pushDiagnostics.recordCurrent(PushDiagnosticEvent.InstanceInvalid, classification.failureCode) + } else { + pushDiagnostics.record(scope, PushDiagnosticEvent.MessageReceived) + pushDiagnostics.record( + scope, + PushDiagnosticEvent.MessageInvalid, + failureCode = classification.failureCode, + ) + } + } + is PushMessageClassification.Accepted -> showNotification(classification.scope, classification.payload) + } + } + + private fun showNotification( + scope: AccountScope, + payload: PushPayload, + ) { + pushDiagnostics.record(scope, PushDiagnosticEvent.MessageReceived) + val traceId = pushDiagnostics.traceId(payload.eventId) + val result = try { + notifier.notify(payload) + } catch (_: Exception) { + NotificationPostResult.Failed + } + when (result) { + NotificationPostResult.Posted -> pushDiagnostics.record( + scope, + PushDiagnosticEvent.NotificationPosted, + traceId = traceId, + ) + NotificationPostResult.NotificationsDisabled -> pushDiagnostics.record( + scope, + PushDiagnosticEvent.NotificationSuppressed, + failureCode = "notifications_disabled", + traceId = traceId, + ) + NotificationPostResult.ChannelBlocked -> pushDiagnostics.record( + scope, + PushDiagnosticEvent.NotificationSuppressed, + failureCode = "channel_blocked", + traceId = traceId, + ) + NotificationPostResult.PermissionDenied -> pushDiagnostics.record( + scope, + PushDiagnosticEvent.NotificationSuppressed, + failureCode = "permission_denied", + traceId = traceId, + ) + NotificationPostResult.Failed -> pushDiagnostics.record( + scope, + PushDiagnosticEvent.NotificationFailed, + failureCode = "notification_manager_unavailable", + traceId = traceId, + ) + } } override fun onRegistrationFailed(reason: FailedReason, instance: String) { - registrationManager.onRegistrationFailed(instance) + registrationManager.onRegistrationFailed(instance, reason.diagnosticFailureCode()) + } + + override fun onTempUnavailable(instance: String) { + val scope = scopeFromInstanceName(instance) + if (scope == null) { + pushDiagnostics.recordCurrent(PushDiagnosticEvent.InstanceInvalid, "unknown_instance") + return + } + pushDiagnostics.record( + scope, + PushDiagnosticEvent.DistributorUnavailable, + failureCode = "distributor_temporarily_unavailable", + ) } override fun onUnregistered(instance: String) { registrationManager.onUnregistered(instance) } } + +internal fun FailedReason.diagnosticFailureCode(): String = when (name) { + "NETWORK" -> "unifiedpush_network" + "INTERNAL_ERROR" -> "unifiedpush_internal_error" + "ACTION_REQUIRED" -> "unifiedpush_action_required" + "VAPID_REQUIRED" -> "unifiedpush_vapid_required" + else -> "unifiedpush_registration_failed" +} From c8abf726cad0bf0aaaf6141365884f8dd4001b92 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Wed, 21 Oct 2026 15:10:03 +0200 Subject: [PATCH 5/7] feat: show push traces in diagnostics --- .../settings/diagnostics/DiagnosticReport.kt | 1 + .../settings/diagnostics/DiagnosticsScreen.kt | 51 ++++++++++++++++--- .../diagnostics/DiagnosticReportTest.kt | 19 +++++++ 3 files changed, 64 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/dev/typetype/android/feature/settings/diagnostics/DiagnosticReport.kt b/app/src/main/java/dev/typetype/android/feature/settings/diagnostics/DiagnosticReport.kt index 7cb00328..9e0c5f5f 100644 --- a/app/src/main/java/dev/typetype/android/feature/settings/diagnostics/DiagnosticReport.kt +++ b/app/src/main/java/dev/typetype/android/feature/settings/diagnostics/DiagnosticReport.kt @@ -25,6 +25,7 @@ fun buildDiagnosticReport(entries: List, crashReport: CrashRepo } entry.failureCode?.let { append(" code $it") } entry.requestId?.let { append(" request $it") } + entry.traceId?.let { append(" trace $it") } appendLine() entry.sabr?.let { appendLine(" SABR ${it.redactedSummary()}") diff --git a/app/src/main/java/dev/typetype/android/feature/settings/diagnostics/DiagnosticsScreen.kt b/app/src/main/java/dev/typetype/android/feature/settings/diagnostics/DiagnosticsScreen.kt index 371184c3..256a9549 100644 --- a/app/src/main/java/dev/typetype/android/feature/settings/diagnostics/DiagnosticsScreen.kt +++ b/app/src/main/java/dev/typetype/android/feature/settings/diagnostics/DiagnosticsScreen.kt @@ -196,8 +196,11 @@ private fun DiagnosticRow(entry: DiagnosticEntry) { ApplicationDiagnosticRow(entry, formatter) return } - val outcome = entry.statusCode?.let { stringResource(R.string.diagnostics_http_status, it) } - ?: stringResource(R.string.diagnostics_network_error) + val outcome = when (entry.method) { + "LOCAL", "APP" -> stringResource(R.string.diagnostics_local_event) + else -> entry.statusCode?.let { stringResource(R.string.diagnostics_http_status, it) } + ?: stringResource(R.string.diagnostics_network_error) + } Column( modifier = Modifier .fillMaxWidth() @@ -211,11 +214,19 @@ private fun DiagnosticRow(entry: DiagnosticEntry) { fontWeight = FontWeight.SemiBold, fontFamily = FontFamily.Monospace, ) - Text( - text = stringResource(R.string.diagnostics_result, outcome, entry.durationMillis), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + if (entry.method == "LOCAL" || entry.method == "APP") { + Text( + text = outcome, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } else { + Text( + text = stringResource(R.string.diagnostics_result, outcome, entry.durationMillis), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } Text( text = formatter.format(Date(entry.timestampEpochMillis)), style = MaterialTheme.typography.labelSmall, @@ -263,6 +274,32 @@ private fun DiagnosticRow(entry: DiagnosticEntry) { } } } + entry.traceId?.let { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = stringResource(R.string.diagnostics_trace_id, it), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontFamily = FontFamily.Monospace, + modifier = Modifier.weight(1f), + ) + IconButton( + onClick = { + copyPlainText( + context = context, + value = it, + labelRes = R.string.diagnostics_clipboard_label, + confirmationRes = R.string.diagnostics_trace_id_copied, + ) + }, + ) { + Icon( + imageVector = Icons.Filled.ContentCopy, + contentDescription = stringResource(R.string.diagnostics_copy_trace_id), + ) + } + } + } } } diff --git a/app/src/test/java/dev/typetype/android/feature/settings/diagnostics/DiagnosticReportTest.kt b/app/src/test/java/dev/typetype/android/feature/settings/diagnostics/DiagnosticReportTest.kt index 287514f6..cf0e08b0 100644 --- a/app/src/test/java/dev/typetype/android/feature/settings/diagnostics/DiagnosticReportTest.kt +++ b/app/src/test/java/dev/typetype/android/feature/settings/diagnostics/DiagnosticReportTest.kt @@ -25,6 +25,25 @@ class DiagnosticReportTest { assertTrue(report.contains("request safe-request-id")) } + @Test + fun reportIncludesRedactedPushTraceId() { + val entry = DiagnosticEntry( + timestampEpochMillis = 1_700_000_000_000, + method = "LOCAL", + route = "/push/notification/posted", + statusCode = null, + durationMillis = 0, + requestId = null, + traceId = "abcdef0123456789", + ) + + val report = buildDiagnosticReport(listOf(entry)) + + assertTrue(report.contains("LOCAL /push/notification/posted")) + assertTrue(report.contains("trace abcdef0123456789")) + assertFalse(report.contains("notifications disabled")) + } + @Test fun reportContainsOnlyRedactedSabrMetadata() { val entry = DiagnosticEntry( From b2786511b12b8b1d8eee53ea10fc89adf6b486e3 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Wed, 21 Oct 2026 15:45:57 +0200 Subject: [PATCH 6/7] fix: acknowledge received push deliveries --- .../diagnostics/PushDiagnosticRecorder.kt | 2 + .../data/network/TypeTypeNotificationsApi.kt | 6 +++ .../android/data/network/dto/PushDto.kt | 8 ++++ .../PushDeliveryAcknowledgementRepository.kt | 48 +++++++++++++++++++ .../notifications/PushNotificationsScreen.kt | 6 +++ .../services/push/PushDeliveryOutcome.kt | 14 ++++++ .../services/push/PushRegistrationManager.kt | 25 ++++++++++ .../services/push/TypeTypePushService.kt | 1 + app/src/main/res/values/strings.xml | 1 + .../services/push/PushDeliveryOutcomeTest.kt | 27 +++++++++++ 10 files changed, 138 insertions(+) create mode 100644 app/src/main/java/dev/typetype/android/data/push/PushDeliveryAcknowledgementRepository.kt create mode 100644 app/src/main/java/dev/typetype/android/services/push/PushDeliveryOutcome.kt create mode 100644 app/src/test/java/dev/typetype/android/services/push/PushDeliveryOutcomeTest.kt diff --git a/app/src/main/java/dev/typetype/android/data/diagnostics/PushDiagnosticRecorder.kt b/app/src/main/java/dev/typetype/android/data/diagnostics/PushDiagnosticRecorder.kt index 2f9288c0..cca9c8d7 100644 --- a/app/src/main/java/dev/typetype/android/data/diagnostics/PushDiagnosticRecorder.kt +++ b/app/src/main/java/dev/typetype/android/data/diagnostics/PushDiagnosticRecorder.kt @@ -66,4 +66,6 @@ enum class PushDiagnosticEvent(val route: String) { LocalNotificationPosted("/notifications/local/posted"), LocalNotificationSuppressed("/notifications/local/suppressed"), LocalNotificationFailed("/notifications/local/failed"), + DeliveryAcknowledgementSent("/push/delivery/acknowledged"), + DeliveryAcknowledgementFailed("/push/delivery/acknowledgement-failed"), } diff --git a/app/src/main/java/dev/typetype/android/data/network/TypeTypeNotificationsApi.kt b/app/src/main/java/dev/typetype/android/data/network/TypeTypeNotificationsApi.kt index cd268128..fbc9ed74 100644 --- a/app/src/main/java/dev/typetype/android/data/network/TypeTypeNotificationsApi.kt +++ b/app/src/main/java/dev/typetype/android/data/network/TypeTypeNotificationsApi.kt @@ -6,6 +6,7 @@ import dev.typetype.android.data.network.dto.ChannelNotificationPreferenceDto import dev.typetype.android.data.network.dto.ChannelNotificationPreferenceRequestDto import dev.typetype.android.data.network.dto.PushDeviceRegistrationRequestDto import dev.typetype.android.data.network.dto.PushDeviceRegistrationResponseDto +import dev.typetype.android.data.network.dto.PushDeliveryAcknowledgementRequestDto import dev.typetype.android.data.network.dto.UnreadNotificationsCountDto import retrofit2.Response import retrofit2.http.Body @@ -42,6 +43,11 @@ interface TypeTypeNotificationsApi { @Path("deviceId") deviceId: String, ): Response + @POST("notifications/push/deliveries/acknowledgement") + suspend fun acknowledgePushDelivery( + @Body request: PushDeliveryAcknowledgementRequestDto, + ): Response + @GET("notifications/channel-preferences") suspend fun channelNotificationPreferences(): Response> 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 77e70599..1adf1679 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 @@ -23,6 +23,14 @@ data class PushDeviceRegistrationResponseDto( val updatedAt: Long, ) +@Serializable +data class PushDeliveryAcknowledgementRequestDto( + val eventId: String, + val deviceId: String, + val outcome: String, + val reasonCode: String? = null, +) + @Serializable data class ChannelNotificationPreferenceRequestDto( val channelUrl: String, diff --git a/app/src/main/java/dev/typetype/android/data/push/PushDeliveryAcknowledgementRepository.kt b/app/src/main/java/dev/typetype/android/data/push/PushDeliveryAcknowledgementRepository.kt new file mode 100644 index 00000000..18c20d24 --- /dev/null +++ b/app/src/main/java/dev/typetype/android/data/push/PushDeliveryAcknowledgementRepository.kt @@ -0,0 +1,48 @@ +package dev.typetype.android.data.push + +import dev.typetype.android.data.account.AccountDao +import dev.typetype.android.data.account.AccountScope +import dev.typetype.android.data.network.TypeTypeApiHolder +import dev.typetype.android.data.network.dto.PushDeliveryAcknowledgementRequestDto +import dev.typetype.android.data.network.requireSuccessfulResponse +import javax.inject.Inject +import javax.inject.Singleton +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +@Singleton +class PushDeliveryAcknowledgementRepository @Inject constructor( + private val apiHolder: TypeTypeApiHolder, + private val accountDao: AccountDao, + private val registrationStore: PushRegistrationStore, +) { + suspend fun acknowledge( + scope: AccountScope, + eventId: String, + outcome: String, + reasonCode: String?, + ): Result = try { + val account = accountDao.get(scope.serverId, scope.accountId) + check(account != null && !account.isGuest) { "Push acknowledgements require an account" } + val deviceId = registrationStore.registrationOnce(scope)?.deviceId + ?: error("The push device registration is missing") + val api = apiHolder.require(scope) + val response = withContext(Dispatchers.IO) { + api.acknowledgePushDelivery( + PushDeliveryAcknowledgementRequestDto( + eventId = eventId, + deviceId = deviceId, + outcome = outcome, + reasonCode = reasonCode, + ), + ) + } + response.requireSuccessfulResponse() + Result.success(Unit) + } catch (cancelled: CancellationException) { + throw cancelled + } catch (failure: Exception) { + Result.failure(failure) + } +} diff --git a/app/src/main/java/dev/typetype/android/feature/settings/notifications/PushNotificationsScreen.kt b/app/src/main/java/dev/typetype/android/feature/settings/notifications/PushNotificationsScreen.kt index a351bb7c..4fc73e7e 100644 --- a/app/src/main/java/dev/typetype/android/feature/settings/notifications/PushNotificationsScreen.kt +++ b/app/src/main/java/dev/typetype/android/feature/settings/notifications/PushNotificationsScreen.kt @@ -98,6 +98,12 @@ internal fun PushNotificationsScreen( color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(20.dp, 4.dp), ) + Text( + text = stringResource(R.string.push_channel_bell_hint), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(20.dp, 4.dp), + ) if (state.capabilityEnabled && state.deviceCount > 0) { StatusRow( label = stringResource(R.string.push_devices_count, state.deviceCount, state.maxDevices), diff --git a/app/src/main/java/dev/typetype/android/services/push/PushDeliveryOutcome.kt b/app/src/main/java/dev/typetype/android/services/push/PushDeliveryOutcome.kt new file mode 100644 index 00000000..bb0eb69f --- /dev/null +++ b/app/src/main/java/dev/typetype/android/services/push/PushDeliveryOutcome.kt @@ -0,0 +1,14 @@ +package dev.typetype.android.services.push + +internal data class PushDeliveryOutcome( + val value: String, + val reasonCode: String?, +) + +internal fun NotificationPostResult.toPushDeliveryOutcome(): PushDeliveryOutcome = when (this) { + NotificationPostResult.Posted -> PushDeliveryOutcome("posted", null) + NotificationPostResult.NotificationsDisabled -> PushDeliveryOutcome("suppressed", "notifications_disabled") + NotificationPostResult.ChannelBlocked -> PushDeliveryOutcome("suppressed", "channel_blocked") + NotificationPostResult.PermissionDenied -> PushDeliveryOutcome("suppressed", "permission_denied") + NotificationPostResult.Failed -> PushDeliveryOutcome("failed", "notification_manager_unavailable") +} 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 f0b776c1..567b3d09 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 @@ -8,6 +8,7 @@ import dev.typetype.android.data.account.AccountScope import dev.typetype.android.data.diagnostics.PushDiagnosticEvent import dev.typetype.android.data.diagnostics.PushDiagnosticRecorder import dev.typetype.android.data.push.PushRegistrationStore +import dev.typetype.android.data.push.PushDeliveryAcknowledgementRepository import dev.typetype.android.data.push.pushInstanceName import dev.typetype.android.data.push.scopeFromInstanceName import dev.typetype.android.domain.push.PushRegistrationStatus @@ -29,6 +30,7 @@ class PushRegistrationManager @Inject constructor( @ApplicationContext private val context: Context, private val repository: PushRepository, private val registrationStore: PushRegistrationStore, + private val deliveryAcknowledgements: PushDeliveryAcknowledgementRepository, private val activeAccountScope: ActiveAccountScope, private val pushDiagnostics: PushDiagnosticRecorder, ) { @@ -161,6 +163,29 @@ class PushRegistrationManager @Inject constructor( } } + fun acknowledgeDelivery(scope: AccountScope, eventId: String, result: NotificationPostResult) { + val outcome = result.toPushDeliveryOutcome() + managerScope.launch { + deliveryAcknowledgements.acknowledge(scope, eventId, outcome.value, outcome.reasonCode).fold( + onSuccess = { + pushDiagnostics.record( + scope, + PushDiagnosticEvent.DeliveryAcknowledgementSent, + traceId = pushDiagnostics.traceId(eventId), + ) + }, + onFailure = { + pushDiagnostics.record( + scope, + PushDiagnosticEvent.DeliveryAcknowledgementFailed, + failureCode = "server_acknowledgement_failed", + traceId = pushDiagnostics.traceId(eventId), + ) + }, + ) + } + } + fun onRegistrationFailed( instance: String, failureCode: String = "unifiedpush_registration_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 a14db10b..6c103c04 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 @@ -102,6 +102,7 @@ class TypeTypePushService : PushService() { traceId = traceId, ) } + registrationManager.acknowledgeDelivery(scope, payload.eventId, result) } override fun onRegistrationFailed(reason: FailedReason, instance: String) { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 5a6f316b..00fcceb3 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1225,6 +1225,7 @@ Choose distributor Couldn\'t update push notifications The app also checks for new videos every 15 minutes + Push alerts are sent only for channels where you turned on the bell. Turn on new video notifications Turn off new video notifications Disable battery optimization for reliable notification delivery. diff --git a/app/src/test/java/dev/typetype/android/services/push/PushDeliveryOutcomeTest.kt b/app/src/test/java/dev/typetype/android/services/push/PushDeliveryOutcomeTest.kt new file mode 100644 index 00000000..a4ca893c --- /dev/null +++ b/app/src/test/java/dev/typetype/android/services/push/PushDeliveryOutcomeTest.kt @@ -0,0 +1,27 @@ +package dev.typetype.android.services.push + +import org.junit.Assert.assertEquals +import org.junit.Test + +class PushDeliveryOutcomeTest { + @Test + fun `posted notification is acknowledged as posted`() { + assertEquals(PushDeliveryOutcome("posted", null), NotificationPostResult.Posted.toPushDeliveryOutcome()) + } + + @Test + fun `disabled notification settings are acknowledged as suppressed`() { + assertEquals( + PushDeliveryOutcome("suppressed", "notifications_disabled"), + NotificationPostResult.NotificationsDisabled.toPushDeliveryOutcome(), + ) + } + + @Test + fun `notification manager failure remains retryable`() { + assertEquals( + PushDeliveryOutcome("failed", "notification_manager_unavailable"), + NotificationPostResult.Failed.toPushDeliveryOutcome(), + ) + } +} From a541befa0fa2c776b9f1ed8f53adbfa0d166fd9b Mon Sep 17 00:00:00 2001 From: Priveetee Date: Wed, 21 Oct 2026 15:52:38 +0200 Subject: [PATCH 7/7] build: prepare mobile beta 1.10.0-beta.4 --- app/build.gradle.kts | 4 ++-- release-notes/v1.10.0-beta.4.md | 10 ++++++++++ 2 files changed, 12 insertions(+), 2 deletions(-) create mode 100644 release-notes/v1.10.0-beta.4.md diff --git a/app/build.gradle.kts b/app/build.gradle.kts index cdcd7256..1a8f6d6f 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 = 11003 - versionName = "1.10.0-beta.3" + versionCode = 11004 + versionName = "1.10.0-beta.4" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" resValue("string", "app_name", "TypeType") } diff --git a/release-notes/v1.10.0-beta.4.md b/release-notes/v1.10.0-beta.4.md new file mode 100644 index 00000000..dcff4fbc --- /dev/null +++ b/release-notes/v1.10.0-beta.4.md @@ -0,0 +1,10 @@ +TypeType Android 1.10.0-beta.4 + +- Retry failed push registration automatically when the distributor or network + is temporarily unavailable. +- Recover notifications missed by push delivery through the local 15-minute + check. +- Classify incoming push messages and record delivery traces in diagnostics. +- Report whether a push notification was posted, suppressed, or failed so the + server can retry unconfirmed deliveries. +- Push alerts are sent only for channels where the bell is enabled.