diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 2c63f1a1..1df6f50c 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -22,8 +22,8 @@ android { defaultConfig { applicationId = "dev.typetype.android" - versionCode = 11106 - versionName = "1.11.0-beta.6" + versionCode = 11107 + versionName = "1.11.0-beta.7" } signingConfigs { diff --git a/core/data/src/main/java/dev/typetype/android/data/network/TransientHttpRetry.kt b/core/data/src/main/java/dev/typetype/android/data/network/TransientHttpRetry.kt index e2e886e9..a92b0b3d 100644 --- a/core/data/src/main/java/dev/typetype/android/data/network/TransientHttpRetry.kt +++ b/core/data/src/main/java/dev/typetype/android/data/network/TransientHttpRetry.kt @@ -13,9 +13,9 @@ fun transientHttpRetryDelayMs( requestedDelayMs: Long?, ): Long? { if (errorCount > maximumRetries) return null + requestedDelayMs?.let { return it.coerceIn(MIN_REQUESTED_RETRY_DELAY_MS, MAX_REQUESTED_RETRY_DELAY_MS) } val exponent = (errorCount - 1).coerceIn(0, 3) - val backoffMs = DEFAULT_TRANSIENT_RETRY_MS * (1L shl exponent) - return (requestedDelayMs ?: backoffMs).coerceIn(0L, MAX_TRANSIENT_RETRY_DELAY_MS) + return (DEFAULT_TRANSIENT_RETRY_MS * (1L shl exponent)).coerceAtMost(MAX_TRANSIENT_RETRY_DELAY_MS) } fun Map>.retryAfterMillis( @@ -41,7 +41,9 @@ fun Map>.retryAfterMillis( } } -private val TRANSIENT_HTTP_STATUS_CODES = setOf(502, 503, 504) +private val TRANSIENT_HTTP_STATUS_CODES = setOf(429, 502, 503, 504) private const val RETRY_AFTER_HEADER = "Retry-After" private const val DEFAULT_TRANSIENT_RETRY_MS = 500L private const val MAX_TRANSIENT_RETRY_DELAY_MS = 3_000L +private const val MIN_REQUESTED_RETRY_DELAY_MS = 250L +private const val MAX_REQUESTED_RETRY_DELAY_MS = 30_000L diff --git a/core/data/src/main/java/dev/typetype/android/data/network/TypeTypeNotificationsApi.kt b/core/data/src/main/java/dev/typetype/android/data/network/TypeTypeNotificationsApi.kt index fbc9ed74..1f0fabcc 100644 --- a/core/data/src/main/java/dev/typetype/android/data/network/TypeTypeNotificationsApi.kt +++ b/core/data/src/main/java/dev/typetype/android/data/network/TypeTypeNotificationsApi.kt @@ -4,6 +4,7 @@ import dev.typetype.android.data.network.dto.MarkNotificationsReadResponseDto import dev.typetype.android.data.network.dto.NotificationsResponseDto import dev.typetype.android.data.network.dto.ChannelNotificationPreferenceDto import dev.typetype.android.data.network.dto.ChannelNotificationPreferenceRequestDto +import dev.typetype.android.data.network.dto.ChannelNotificationPreferencesBulkRequestDto import dev.typetype.android.data.network.dto.PushDeviceRegistrationRequestDto import dev.typetype.android.data.network.dto.PushDeviceRegistrationResponseDto import dev.typetype.android.data.network.dto.PushDeliveryAcknowledgementRequestDto @@ -55,4 +56,9 @@ interface TypeTypeNotificationsApi { suspend fun setChannelNotificationPreference( @Body request: ChannelNotificationPreferenceRequestDto, ): Response + + @PUT("notifications/channel-preferences/bulk") + suspend fun setChannelNotificationPreferences( + @Body request: ChannelNotificationPreferencesBulkRequestDto, + ): Response> } diff --git a/core/data/src/main/java/dev/typetype/android/data/network/TypeTypeVersionApi.kt b/core/data/src/main/java/dev/typetype/android/data/network/TypeTypeVersionApi.kt index 3c76cc02..eaf66ffb 100644 --- a/core/data/src/main/java/dev/typetype/android/data/network/TypeTypeVersionApi.kt +++ b/core/data/src/main/java/dev/typetype/android/data/network/TypeTypeVersionApi.kt @@ -16,4 +16,7 @@ interface TypeTypeVersionApi { @GET("version/downloader") suspend fun downloaderVersion(): Response + + @GET("version/recommender") + suspend fun recommenderVersion(): Response } diff --git a/core/data/src/main/java/dev/typetype/android/data/network/dto/PushDto.kt b/core/data/src/main/java/dev/typetype/android/data/network/dto/PushDto.kt index d38e2e2c..1c9be3f0 100644 --- a/core/data/src/main/java/dev/typetype/android/data/network/dto/PushDto.kt +++ b/core/data/src/main/java/dev/typetype/android/data/network/dto/PushDto.kt @@ -37,6 +37,12 @@ data class ChannelNotificationPreferenceRequestDto( val enabled: Boolean, ) +@Serializable +data class ChannelNotificationPreferencesBulkRequestDto( + val enabled: Boolean, + val channelUrls: List, +) + @Serializable data class ChannelNotificationPreferenceDto( val channelUrl: String, diff --git a/core/data/src/main/java/dev/typetype/android/data/push/RemotePushRepository.kt b/core/data/src/main/java/dev/typetype/android/data/push/RemotePushRepository.kt index 9a9593c5..0a2452e2 100644 --- a/core/data/src/main/java/dev/typetype/android/data/push/RemotePushRepository.kt +++ b/core/data/src/main/java/dev/typetype/android/data/push/RemotePushRepository.kt @@ -6,6 +6,7 @@ import dev.typetype.android.data.account.AccountScope import dev.typetype.android.data.account.ActiveAccountScope import dev.typetype.android.data.network.TypeTypeApiHolder import dev.typetype.android.data.network.dto.ChannelNotificationPreferenceRequestDto +import dev.typetype.android.data.network.dto.ChannelNotificationPreferencesBulkRequestDto import dev.typetype.android.data.network.dto.PushDeviceRegistrationRequestDto import dev.typetype.android.data.network.dto.toDomain import dev.typetype.android.data.network.requireSuccessfulResponse @@ -84,6 +85,16 @@ class RemotePushRepository @Inject constructor( response.requireSuccessfulResponse() } + override suspend fun setChannelPreferences(channelUrls: List, enabled: Boolean): Result = guarded { + val api = apiHolder.require(requireEligibleScope()) + val response = withContext(Dispatchers.IO) { + api.setChannelNotificationPreferences( + ChannelNotificationPreferencesBulkRequestDto(enabled = enabled, channelUrls = channelUrls), + ) + } + response.requireSuccessfulResponse() + } + private suspend fun requireEligibleScope(): AccountScope { val scope = activeAccountScope.require() val account = accountDao.get(scope.serverId, scope.accountId) diff --git a/core/data/src/main/java/dev/typetype/android/data/stream/TransientPlaybackRequest.kt b/core/data/src/main/java/dev/typetype/android/data/stream/TransientPlaybackRequest.kt index 158e972c..1f3e8310 100644 --- a/core/data/src/main/java/dev/typetype/android/data/stream/TransientPlaybackRequest.kt +++ b/core/data/src/main/java/dev/typetype/android/data/stream/TransientPlaybackRequest.kt @@ -14,6 +14,7 @@ internal suspend fun transientPlaybackRequest( request: suspend () -> Response, ): Response { var httpErrorCount = 0 + var serverWaitMs = 0L var transportErrorCount = 0 var transportGeneration = network.snapshot().generation while (true) { @@ -51,6 +52,8 @@ internal suspend fun transientPlaybackRequest( maximumRetries = MAX_SERVER_RESPONSE_RETRIES, requestedDelayMs = response.headers().toMultimap().retryAfterMillis(), ) ?: return response + if (serverWaitMs + retryDelayMs > MAX_SERVER_WAIT_MS) return response + serverWaitMs += retryDelayMs response.errorBody()?.close() pause(retryDelayMs) } @@ -59,3 +62,4 @@ internal suspend fun transientPlaybackRequest( private const val MAX_SERVER_RESPONSE_RETRIES = 6 private const val MAX_CONNECTED_TRANSPORT_RETRIES = 8 private const val MAX_OFFLINE_WAIT_MS = 10_000L +private const val MAX_SERVER_WAIT_MS = 60_000L diff --git a/core/data/src/main/java/dev/typetype/android/data/version/RemoteComponentVersionsRepository.kt b/core/data/src/main/java/dev/typetype/android/data/version/RemoteComponentVersionsRepository.kt index 4f819a94..4492da69 100644 --- a/core/data/src/main/java/dev/typetype/android/data/version/RemoteComponentVersionsRepository.kt +++ b/core/data/src/main/java/dev/typetype/android/data/version/RemoteComponentVersionsRepository.kt @@ -26,11 +26,13 @@ class RemoteComponentVersionsRepository @Inject constructor( val server = async { loadComponentVersion(api::serverVersion) } val token = async { loadComponentVersion(api::tokenVersion) } val downloader = async { loadComponentVersion(api::downloaderVersion) } + val recommender = async { loadComponentVersion(api::recommenderVersion) } ComponentVersions( frontend = frontend.await(), server = server.await(), token = token.await(), downloader = downloader.await(), + recommender = recommender.await(), ) } activeAccountScope.verify(scope) diff --git a/core/data/src/test/java/dev/typetype/android/data/network/TransientHttpRetryTest.kt b/core/data/src/test/java/dev/typetype/android/data/network/TransientHttpRetryTest.kt index 2cbc63a5..5f90574b 100644 --- a/core/data/src/test/java/dev/typetype/android/data/network/TransientHttpRetryTest.kt +++ b/core/data/src/test/java/dev/typetype/android/data/network/TransientHttpRetryTest.kt @@ -44,6 +44,7 @@ class TransientHttpRetryTest { "Retry-After" to listOf(Long.MAX_VALUE.toString()), ).retryAfterMillis() - assertEquals(3_000L, transientHttpRetryDelayMs(1, 3, delay)) + assertEquals(30_000L, transientHttpRetryDelayMs(1, 3, delay)) + assertEquals(250L, transientHttpRetryDelayMs(1, 3, 0L)) } } diff --git a/core/model/src/main/java/dev/typetype/android/domain/push/PushRepository.kt b/core/model/src/main/java/dev/typetype/android/domain/push/PushRepository.kt index d8fcb3f5..8c329e63 100644 --- a/core/model/src/main/java/dev/typetype/android/domain/push/PushRepository.kt +++ b/core/model/src/main/java/dev/typetype/android/domain/push/PushRepository.kt @@ -14,4 +14,6 @@ interface PushRepository { suspend fun channelPreferences(): Result> suspend fun setChannelPreference(channelUrl: String, enabled: Boolean): Result + + suspend fun setChannelPreferences(channelUrls: List, enabled: Boolean): Result } diff --git a/core/model/src/main/java/dev/typetype/android/domain/version/ComponentVersions.kt b/core/model/src/main/java/dev/typetype/android/domain/version/ComponentVersions.kt index 3e9e893d..730292bb 100644 --- a/core/model/src/main/java/dev/typetype/android/domain/version/ComponentVersions.kt +++ b/core/model/src/main/java/dev/typetype/android/domain/version/ComponentVersions.kt @@ -13,4 +13,5 @@ data class ComponentVersions( val server: ComponentVersion? = null, val token: ComponentVersion? = null, val downloader: ComponentVersion? = null, + val recommender: ComponentVersion? = null, ) diff --git a/core/playback/src/test/java/dev/typetype/android/services/SabrLoadErrorHandlingPolicyTest.kt b/core/playback/src/test/java/dev/typetype/android/services/SabrLoadErrorHandlingPolicyTest.kt index fd25fdd8..e32a777d 100644 --- a/core/playback/src/test/java/dev/typetype/android/services/SabrLoadErrorHandlingPolicyTest.kt +++ b/core/playback/src/test/java/dev/typetype/android/services/SabrLoadErrorHandlingPolicyTest.kt @@ -28,7 +28,7 @@ class SabrLoadErrorHandlingPolicyTest { assertEquals(250L, body.sabrResponseRetryAfterMs()) assertEquals(100L, sabrSegmentRetryDelayMs(1, 0L)) - assertEquals(3_000L, transientHttpRetryDelayMs(1, 3, 30_000L)) + assertEquals(30_000L, transientHttpRetryDelayMs(1, 3, 30_000L)) assertNull("""{"status":"preparing"}""".toByteArray().sabrResponseRetryAfterMs()) } } diff --git a/core/resources/src/main/res/values/strings.xml b/core/resources/src/main/res/values/strings.xml index fda11861..e0e0bb69 100644 --- a/core/resources/src/main/res/values/strings.xml +++ b/core/resources/src/main/res/values/strings.xml @@ -762,6 +762,7 @@ Server Token Downloader + Recommender Loading… Unavailable Server URL diff --git a/feature/settings/src/main/java/dev/typetype/android/feature/settings/about/AboutVersionsSection.kt b/feature/settings/src/main/java/dev/typetype/android/feature/settings/about/AboutVersionsSection.kt index 55567f11..a78ef9f3 100644 --- a/feature/settings/src/main/java/dev/typetype/android/feature/settings/about/AboutVersionsSection.kt +++ b/feature/settings/src/main/java/dev/typetype/android/feature/settings/about/AboutVersionsSection.kt @@ -55,6 +55,7 @@ internal fun AboutVersionsSection( VersionRow(R.string.about_version_server, versions?.server, isLoading) VersionRow(R.string.about_version_token, versions?.token, isLoading) VersionRow(R.string.about_version_downloader, versions?.downloader, isLoading) + versions?.recommender?.let { VersionRow(R.string.about_version_recommender, it, isLoading) } } } } diff --git a/feature/settings/src/main/java/dev/typetype/android/feature/settings/notifications/ChannelNotificationsViewModel.kt b/feature/settings/src/main/java/dev/typetype/android/feature/settings/notifications/ChannelNotificationsViewModel.kt index ab149605..993867cb 100644 --- a/feature/settings/src/main/java/dev/typetype/android/feature/settings/notifications/ChannelNotificationsViewModel.kt +++ b/feature/settings/src/main/java/dev/typetype/android/feature/settings/notifications/ChannelNotificationsViewModel.kt @@ -112,17 +112,51 @@ class ChannelNotificationsViewModel @Inject constructor( if (targets.isEmpty()) return viewModelScope.launch { _state.update { it.copy(bulkInFlight = true, bulkDone = 0, bulkTotal = targets.size, showError = false) } - val gate = Semaphore(BULK_PARALLELISM) - val results = coroutineScope { - targets.map { channel -> - async { - gate.withPermit { save(channel, enabled) } - .also { _state.update { it.copy(bulkDone = it.bulkDone + 1) } } - } - }.awaitAll() + val saved = when (saveAllAtOnce(targets, enabled)) { + BulkSave.Saved -> true + BulkSave.Failed -> false + BulkSave.Unsupported -> saveOneByOne(targets, enabled) } - _state.update { it.copy(bulkInFlight = false, showError = results.any { saved -> !saved }) } + _state.update { it.copy(bulkInFlight = false, showError = !saved) } + } + } + + private suspend fun saveAllAtOnce(targets: List, enabled: Boolean): BulkSave { + val urls = targets.mapTo(hashSetOf(), ChannelBell::channelUrl) + _state.update { state -> + state.copy(channels = state.channels.map { if (it.channelUrl in urls) it.copy(pending = true) else it }) + } + val failure = suspendRunCatching { + pushRepository.setChannelPreferences(urls.toList(), enabled).getOrThrow() + }.exceptionOrNull() + val result = when { + failure == null -> BulkSave.Saved + (failure as? CodedFailure)?.statusCode in BULK_UNSUPPORTED_STATUSES -> BulkSave.Unsupported + else -> BulkSave.Failed + } + _state.update { state -> + state.copy( + bulkDone = if (result == BulkSave.Saved) state.bulkTotal else state.bulkDone, + channels = state.channels.map { channel -> + if (channel.channelUrl !in urls) return@map channel + channel.copy(enabled = if (result == BulkSave.Saved) enabled else channel.enabled, pending = false) + }, + ) } + return result + } + + private suspend fun saveOneByOne(targets: List, enabled: Boolean): Boolean { + val gate = Semaphore(BULK_PARALLELISM) + val results = coroutineScope { + targets.map { channel -> + async { + gate.withPermit { save(channel, enabled) } + .also { _state.update { it.copy(bulkDone = it.bulkDone + 1) } } + } + }.awaitAll() + } + return results.all { it } } private suspend fun save(channel: ChannelBell, enabled: Boolean): Boolean { @@ -154,7 +188,10 @@ class ChannelNotificationsViewModel @Inject constructor( } } + private enum class BulkSave { Saved, Failed, Unsupported } + private companion object { + val BULK_UNSUPPORTED_STATUSES = setOf(404, 405) const val BULK_PARALLELISM = 4 const val MAX_ATTEMPTS = 5 const val HTTP_TOO_MANY_REQUESTS = 429 diff --git a/release-notes/v1.11.0-beta.7.md b/release-notes/v1.11.0-beta.7.md new file mode 100644 index 00000000..9ce0520f --- /dev/null +++ b/release-notes/v1.11.0-beta.7.md @@ -0,0 +1,5 @@ +TypeType Android 1.11.0-beta.7 + +- Show the recommender version on the About screen when the instance runs it. +- Wait as long as the server asks before retrying playback, and retry when the server is rate limited. +- Turn alerts on or off for every channel in a single request on servers that support it.