Skip to content

Commit 88271da

Browse files
committed
perf: switch every channel alert in one request when supported
1 parent 8b4d30a commit 88271da

5 files changed

Lines changed: 71 additions & 9 deletions

File tree

‎core/data/src/main/java/dev/typetype/android/data/network/TypeTypeNotificationsApi.kt‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import dev.typetype.android.data.network.dto.MarkNotificationsReadResponseDto
44
import dev.typetype.android.data.network.dto.NotificationsResponseDto
55
import dev.typetype.android.data.network.dto.ChannelNotificationPreferenceDto
66
import dev.typetype.android.data.network.dto.ChannelNotificationPreferenceRequestDto
7+
import dev.typetype.android.data.network.dto.ChannelNotificationPreferencesBulkRequestDto
78
import dev.typetype.android.data.network.dto.PushDeviceRegistrationRequestDto
89
import dev.typetype.android.data.network.dto.PushDeviceRegistrationResponseDto
910
import dev.typetype.android.data.network.dto.PushDeliveryAcknowledgementRequestDto
@@ -55,4 +56,9 @@ interface TypeTypeNotificationsApi {
5556
suspend fun setChannelNotificationPreference(
5657
@Body request: ChannelNotificationPreferenceRequestDto,
5758
): Response<ChannelNotificationPreferenceDto>
59+
60+
@PUT("notifications/channel-preferences/bulk")
61+
suspend fun setChannelNotificationPreferences(
62+
@Body request: ChannelNotificationPreferencesBulkRequestDto,
63+
): Response<List<ChannelNotificationPreferenceDto>>
5864
}

‎core/data/src/main/java/dev/typetype/android/data/network/dto/PushDto.kt‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,12 @@ data class ChannelNotificationPreferenceRequestDto(
3737
val enabled: Boolean,
3838
)
3939

40+
@Serializable
41+
data class ChannelNotificationPreferencesBulkRequestDto(
42+
val enabled: Boolean,
43+
val channelUrls: List<String>,
44+
)
45+
4046
@Serializable
4147
data class ChannelNotificationPreferenceDto(
4248
val channelUrl: String,

‎core/data/src/main/java/dev/typetype/android/data/push/RemotePushRepository.kt‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import dev.typetype.android.data.account.AccountScope
66
import dev.typetype.android.data.account.ActiveAccountScope
77
import dev.typetype.android.data.network.TypeTypeApiHolder
88
import dev.typetype.android.data.network.dto.ChannelNotificationPreferenceRequestDto
9+
import dev.typetype.android.data.network.dto.ChannelNotificationPreferencesBulkRequestDto
910
import dev.typetype.android.data.network.dto.PushDeviceRegistrationRequestDto
1011
import dev.typetype.android.data.network.dto.toDomain
1112
import dev.typetype.android.data.network.requireSuccessfulResponse
@@ -84,6 +85,16 @@ class RemotePushRepository @Inject constructor(
8485
response.requireSuccessfulResponse()
8586
}
8687

88+
override suspend fun setChannelPreferences(channelUrls: List<String>, enabled: Boolean): Result<Unit> = guarded {
89+
val api = apiHolder.require(requireEligibleScope())
90+
val response = withContext(Dispatchers.IO) {
91+
api.setChannelNotificationPreferences(
92+
ChannelNotificationPreferencesBulkRequestDto(enabled = enabled, channelUrls = channelUrls),
93+
)
94+
}
95+
response.requireSuccessfulResponse()
96+
}
97+
8798
private suspend fun requireEligibleScope(): AccountScope {
8899
val scope = activeAccountScope.require()
89100
val account = accountDao.get(scope.serverId, scope.accountId)

‎core/model/src/main/java/dev/typetype/android/domain/push/PushRepository.kt‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,4 +14,6 @@ interface PushRepository {
1414
suspend fun channelPreferences(): Result<List<ChannelNotificationsPreference>>
1515

1616
suspend fun setChannelPreference(channelUrl: String, enabled: Boolean): Result<Unit>
17+
18+
suspend fun setChannelPreferences(channelUrls: List<String>, enabled: Boolean): Result<Unit>
1719
}

‎feature/settings/src/main/java/dev/typetype/android/feature/settings/notifications/ChannelNotificationsViewModel.kt‎

Lines changed: 46 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -112,17 +112,51 @@ class ChannelNotificationsViewModel @Inject constructor(
112112
if (targets.isEmpty()) return
113113
viewModelScope.launch {
114114
_state.update { it.copy(bulkInFlight = true, bulkDone = 0, bulkTotal = targets.size, showError = false) }
115-
val gate = Semaphore(BULK_PARALLELISM)
116-
val results = coroutineScope {
117-
targets.map { channel ->
118-
async {
119-
gate.withPermit { save(channel, enabled) }
120-
.also { _state.update { it.copy(bulkDone = it.bulkDone + 1) } }
121-
}
122-
}.awaitAll()
115+
val saved = when (saveAllAtOnce(targets, enabled)) {
116+
BulkSave.Saved -> true
117+
BulkSave.Failed -> false
118+
BulkSave.Unsupported -> saveOneByOne(targets, enabled)
123119
}
124-
_state.update { it.copy(bulkInFlight = false, showError = results.any { saved -> !saved }) }
120+
_state.update { it.copy(bulkInFlight = false, showError = !saved) }
121+
}
122+
}
123+
124+
private suspend fun saveAllAtOnce(targets: List<ChannelBell>, enabled: Boolean): BulkSave {
125+
val urls = targets.mapTo(hashSetOf(), ChannelBell::channelUrl)
126+
_state.update { state ->
127+
state.copy(channels = state.channels.map { if (it.channelUrl in urls) it.copy(pending = true) else it })
128+
}
129+
val failure = suspendRunCatching {
130+
pushRepository.setChannelPreferences(urls.toList(), enabled).getOrThrow()
131+
}.exceptionOrNull()
132+
val result = when {
133+
failure == null -> BulkSave.Saved
134+
(failure as? CodedFailure)?.statusCode in BULK_UNSUPPORTED_STATUSES -> BulkSave.Unsupported
135+
else -> BulkSave.Failed
136+
}
137+
_state.update { state ->
138+
state.copy(
139+
bulkDone = if (result == BulkSave.Saved) state.bulkTotal else state.bulkDone,
140+
channels = state.channels.map { channel ->
141+
if (channel.channelUrl !in urls) return@map channel
142+
channel.copy(enabled = if (result == BulkSave.Saved) enabled else channel.enabled, pending = false)
143+
},
144+
)
125145
}
146+
return result
147+
}
148+
149+
private suspend fun saveOneByOne(targets: List<ChannelBell>, enabled: Boolean): Boolean {
150+
val gate = Semaphore(BULK_PARALLELISM)
151+
val results = coroutineScope {
152+
targets.map { channel ->
153+
async {
154+
gate.withPermit { save(channel, enabled) }
155+
.also { _state.update { it.copy(bulkDone = it.bulkDone + 1) } }
156+
}
157+
}.awaitAll()
158+
}
159+
return results.all { it }
126160
}
127161

128162
private suspend fun save(channel: ChannelBell, enabled: Boolean): Boolean {
@@ -154,7 +188,10 @@ class ChannelNotificationsViewModel @Inject constructor(
154188
}
155189
}
156190

191+
private enum class BulkSave { Saved, Failed, Unsupported }
192+
157193
private companion object {
194+
val BULK_UNSUPPORTED_STATUSES = setOf(404, 405)
158195
const val BULK_PARALLELISM = 4
159196
const val MAX_ATTEMPTS = 5
160197
const val HTTP_TOO_MANY_REQUESTS = 429

0 commit comments

Comments
 (0)