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 @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, List<String>>.retryAfterMillis(
Expand All @@ -41,7 +41,9 @@ fun Map<String, List<String>>.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
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -55,4 +56,9 @@ interface TypeTypeNotificationsApi {
suspend fun setChannelNotificationPreference(
@Body request: ChannelNotificationPreferenceRequestDto,
): Response<ChannelNotificationPreferenceDto>

@PUT("notifications/channel-preferences/bulk")
suspend fun setChannelNotificationPreferences(
@Body request: ChannelNotificationPreferencesBulkRequestDto,
): Response<List<ChannelNotificationPreferenceDto>>
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,7 @@ interface TypeTypeVersionApi {

@GET("version/downloader")
suspend fun downloaderVersion(): Response<ComponentVersionDto>

@GET("version/recommender")
suspend fun recommenderVersion(): Response<ComponentVersionDto>
}
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,12 @@ data class ChannelNotificationPreferenceRequestDto(
val enabled: Boolean,
)

@Serializable
data class ChannelNotificationPreferencesBulkRequestDto(
val enabled: Boolean,
val channelUrls: List<String>,
)

@Serializable
data class ChannelNotificationPreferenceDto(
val channelUrl: String,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -84,6 +85,16 @@ class RemotePushRepository @Inject constructor(
response.requireSuccessfulResponse()
}

override suspend fun setChannelPreferences(channelUrls: List<String>, enabled: Boolean): Result<Unit> = 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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ internal suspend fun <T> transientPlaybackRequest(
request: suspend () -> Response<T>,
): Response<T> {
var httpErrorCount = 0
var serverWaitMs = 0L
var transportErrorCount = 0
var transportGeneration = network.snapshot().generation
while (true) {
Expand Down Expand Up @@ -51,6 +52,8 @@ internal suspend fun <T> 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)
}
Expand All @@ -59,3 +62,4 @@ internal suspend fun <T> 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
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,6 @@ interface PushRepository {
suspend fun channelPreferences(): Result<List<ChannelNotificationsPreference>>

suspend fun setChannelPreference(channelUrl: String, enabled: Boolean): Result<Unit>

suspend fun setChannelPreferences(channelUrls: List<String>, enabled: Boolean): Result<Unit>
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,5 @@ data class ComponentVersions(
val server: ComponentVersion? = null,
val token: ComponentVersion? = null,
val downloader: ComponentVersion? = null,
val recommender: ComponentVersion? = null,
)
Original file line number Diff line number Diff line change
Expand Up @@ -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())
}
}
1 change: 1 addition & 0 deletions core/resources/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -762,6 +762,7 @@
<string name="about_version_server">Server</string>
<string name="about_version_token">Token</string>
<string name="about_version_downloader">Downloader</string>
<string name="about_version_recommender">Recommender</string>
<string name="about_version_loading">Loading…</string>
<string name="about_version_unavailable">Unavailable</string>
<string name="about_server_url">Server URL</string>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) }
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<ChannelBell>, 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<ChannelBell>, 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 {
Expand Down Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions release-notes/v1.11.0-beta.7.md
Original file line number Diff line number Diff line change
@@ -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.