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
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,14 @@ data class UserDto(
* resolves it.
*/
val viewingPreference: String? = null,
/**
* How many notifications the bell tray holds before older ones drop off
* (`/help/settings`: "default is 20 and you can set any value from 10 to 40").
* Nullable because public/partial user payloads omit it;
* [com.interlinedlist.android.core.network.preferences.NotificationTrayLimitStore]
* resolves the absent case.
*/
val notificationTrayLimit: Int? = null,
)

/** Maps the wire model into the domain [User]. */
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
package com.interlinedlist.android.core.network.preferences

import com.interlinedlist.android.core.common.result.ApiResult
import com.interlinedlist.android.core.network.api.InterlinedListApi
import com.interlinedlist.android.core.network.error.safeApiCall
import kotlinx.serialization.json.Json
import javax.inject.Inject
import javax.inject.Singleton

/**
* Reads the account's `notificationTrayLimit` — how many notifications the tray
* holds — for the consumers that have to size themselves by it.
*
* It lives in `:core:network` for the same reason [ViewingPreferenceStore] does:
* **no feature module in this repo depends on another feature module**, yet the
* preference is written by `:feature:profile`'s Settings screen and read by
* `:feature:notifications` (the in-app list's page size and the system-tray group
* cap). A narrow accessor on the shared `GET /api/user` is the seam they can both
* reach.
*
* The value is cached for the process so a poll or a pull-to-refresh does not pay
* for an extra `GET /api/user` every time; `:feature:profile` calls [publish] after
* every settings read or write, so a change made in Settings takes effect at once
* instead of waiting for a restart.
*
* Consequence to be aware of: this is the **second** narrow account-preference
* accessor in `:core:network`, sitting alongside `:feature:profile`'s full
* `SettingsRepository`. Issue #104 ("Consolidate the two writers of account
* preferences behind one owner") already tracks untangling that ownership, and this
* store adds a reader to the same pile — so extend it rather than introducing a third
* mechanism.
*/
@Singleton
class NotificationTrayLimitStore @Inject constructor(
private val api: InterlinedListApi,
private val json: Json,
) {

/** Last known limit, or null until something reads or publishes one. */
@Volatile
private var cached: Int? = null

/**
* The limit to size the tray by: the cached value, otherwise a fresh read of
* `GET /api/user`.
*
* A failed read answers [DEFAULT] and is deliberately **not** cached, so the next
* caller retries — notifications must still load when the user endpoint is down,
* but one blip must not pin the limit for the whole process.
*/
suspend fun current(): Int {
cached?.let { return it }
return when (
val result = safeApiCall(json) { api.getCurrentUser().user.notificationTrayLimit }
) {
is ApiResult.Success -> publish(result.data)
is ApiResult.Failure -> DEFAULT
}
}

/**
* Records the limit the server reports, clamped to [RANGE], and returns the value
* that took effect. A null [limit] (the account has no stored value) resolves to
* [DEFAULT].
*
* Clamping matters because the server's own range is the contract the tray is
* sized by: a stored value from elsewhere must never make the list ask for a page
* the endpoint would refuse.
*/
fun publish(limit: Int?): Int =
(limit?.coerceIn(RANGE) ?: DEFAULT).also { cached = it }

companion object {
/**
* The limit a fresh account gets. `/help/settings`: "The default is 20 and you
* can set any value from 10 to 40" — matching the live `GET /api/user` value.
*/
const val DEFAULT: Int = 20

/**
* The values the server accepts, published in two places: `/help/settings`
* ("any value from 10 to 40") and `/help/api/notifications` ("the user's
* configured tray limit (default 20, clamped to 10-40)").
*/
val RANGE: IntRange = 10..40
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
package com.interlinedlist.android.core.network.preferences

import com.google.common.truth.Truth.assertThat
import com.interlinedlist.android.core.network.api.InterlinedListApi
import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.json.Json
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import org.junit.After
import org.junit.Before
import org.junit.Test
import retrofit2.Retrofit

/**
* The shared `notificationTrayLimit` accessor: the one number both the in-app
* notifications list and the system-tray group are sized by.
*
* The bounds come from the help centre, which publishes them twice — `/help/settings`
* ("The default is 20 and you can set any value from 10 to 40") and
* `/help/api/notifications` ("up to the user's configured tray limit (default 20,
* clamped to 10-40)") — and the default matches the live `GET /api/user`.
*/
class NotificationTrayLimitStoreTest {

private lateinit var server: MockWebServer
private lateinit var store: NotificationTrayLimitStore

// Mirrors the production Json (see NetworkModule).
private val json = Json {
ignoreUnknownKeys = true
explicitNulls = false
coerceInputValues = true
}

@Before
fun setUp() {
server = MockWebServer()
server.start()
store = newStore()
}

@After
fun tearDown() = server.shutdown()

/** A store with an empty cache, pointed at the test server. */
private fun newStore(): NotificationTrayLimitStore {
val api = Retrofit.Builder()
.baseUrl(server.url("/"))
.addConverterFactory(json.asConverterFactory("application/json".toMediaType()))
.build()
.create(InterlinedListApi::class.java)
return NotificationTrayLimitStore(api, json)
}

private fun enqueueUser(trayLimit: Int?) {
val field = trayLimit?.let { """, "notificationTrayLimit": $it""" } ?: ""
server.enqueue(
MockResponse().setBody("""{ "user": { "id": "u1", "username": "me"$field } }"""),
)
}

@Test
fun `current reads the limit from GET api user`() = runBlocking {
enqueueUser(40)

assertThat(store.current()).isEqualTo(40)
assertThat(server.takeRequest().path).isEqualTo("/api/user")
}

@Test
fun `an absent limit falls back to the documented default`() = runBlocking {
enqueueUser(null)

assertThat(store.current()).isEqualTo(20)
}

@Test
fun `the limit is read once and then served from the cache`() = runBlocking {
enqueueUser(30)

assertThat(store.current()).isEqualTo(30)
assertThat(store.current()).isEqualTo(30)

// A second GET would have no queued response; only one request was made.
assertThat(server.requestCount).isEqualTo(1)
}

@Test
fun `a failed read falls back to the default without caching it`() = runBlocking {
server.enqueue(MockResponse().setResponseCode(500).setBody("""{ "error": "boom" }"""))
enqueueUser(35)

// The tray still has a size to work with...
assertThat(store.current()).isEqualTo(20)
// ...and the blip is not pinned for the rest of the process.
assertThat(store.current()).isEqualTo(35)
}

@Test
fun `a stored value outside the server's range is clamped`() = runBlocking {
enqueueUser(400)
assertThat(store.current()).isEqualTo(40)

// A fresh store, so the clamp is exercised on the way in rather than from cache.
enqueueUser(1)
assertThat(newStore().current()).isEqualTo(10)
}

@Test
fun `a published limit takes effect without a request`() = runBlocking {
assertThat(store.publish(25)).isEqualTo(25)

assertThat(store.current()).isEqualTo(25)
assertThat(server.requestCount).isEqualTo(0)
}

@Test
fun `publishing an absent limit resolves to the default`() {
assertThat(store.publish(null)).isEqualTo(20)
}

@Test
fun `the documented bounds match the help centre`() {
assertThat(NotificationTrayLimitStore.DEFAULT).isEqualTo(20)
assertThat(NotificationTrayLimitStore.RANGE).isEqualTo(10..40)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,12 @@ package com.interlinedlist.android.feature.notifications.data
import com.interlinedlist.android.core.common.dispatcher.DispatcherProvider
import com.interlinedlist.android.core.common.result.ApiResult
import com.interlinedlist.android.core.network.error.safeApiCall
import com.interlinedlist.android.core.network.preferences.NotificationTrayLimitStore
import com.interlinedlist.android.feature.notifications.data.local.NotificationDao
import com.interlinedlist.android.feature.notifications.data.local.NotificationEntity
import com.interlinedlist.android.feature.notifications.data.local.toDomain
import com.interlinedlist.android.feature.notifications.data.local.toEntity
import com.interlinedlist.android.feature.notifications.data.remote.NotificationsApi
import com.interlinedlist.android.feature.notifications.data.remote.dto.PaginationDto
import com.interlinedlist.android.feature.notifications.data.remote.dto.toDomain
import com.interlinedlist.android.feature.notifications.domain.Notification
import kotlinx.coroutines.flow.Flow
Expand All @@ -18,9 +18,19 @@ import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import javax.inject.Inject

/**
* Notifications backed by Room, refreshed from the API.
*
* Every page is sized by the account's `notificationTrayLimit` rather than by a
* constant, so the app's list holds what the web's bell tray holds. The limit comes
* from [NotificationTrayLimitStore] in `:core:network` because the preference is
* written by `:feature:profile`'s Settings screen and no feature module here may
* depend on another.
*/
class DefaultNotificationsRepository @Inject constructor(
private val api: NotificationsApi,
private val notificationDao: NotificationDao,
private val trayLimitStore: NotificationTrayLimitStore,
private val json: Json,
private val dispatchers: DispatcherProvider,
) : NotificationsRepository {
Expand All @@ -31,14 +41,14 @@ class DefaultNotificationsRepository @Inject constructor(
override fun observeUnreadCount(): Flow<Int> = notificationDao.observeUnreadCount()

override suspend fun fetchLatest(): ApiResult<List<Notification>> = withContext(dispatchers.io) {
when (val result = safeCall { api.getNotifications(limit = PaginationDto.DEFAULT_LIMIT, offset = 0) }) {
when (val result = safeCall { api.getNotifications(limit = pageSize(), offset = 0) }) {
is ApiResult.Success -> ApiResult.Success(result.data.items.map { it.toDomain() })
is ApiResult.Failure -> result
}
}

override suspend fun refresh(): ApiResult<Boolean> = withContext(dispatchers.io) {
when (val result = safeCall { api.getNotifications(limit = PaginationDto.DEFAULT_LIMIT, offset = 0) }) {
when (val result = safeCall { api.getNotifications(limit = pageSize(), offset = 0) }) {
is ApiResult.Success -> {
val page = result.data
val entities = page.items.mapIndexed { index, dto ->
Expand All @@ -54,7 +64,7 @@ class DefaultNotificationsRepository @Inject constructor(

override suspend fun loadMore(currentCount: Int): ApiResult<Boolean> = withContext(dispatchers.io) {
when (val result = safeCall {
api.getNotifications(limit = PaginationDto.DEFAULT_LIMIT, offset = currentCount)
api.getNotifications(limit = pageSize(), offset = currentCount)
}) {
is ApiResult.Success -> {
val page = result.data
Expand Down Expand Up @@ -106,6 +116,13 @@ class DefaultNotificationsRepository @Inject constructor(

// --- helpers -----------------------------------------------------------

/**
* How many notifications one page holds: the account's tray limit. The endpoint's
* own `limit` accepts 1-50 (`/help/api/notifications`), which comfortably contains
* the 10-40 the tray limit is clamped to, so the preference can be sent verbatim.
*/
private suspend fun pageSize(): Int = trayLimitStore.current()

private suspend fun <T> safeCall(block: suspend () -> T): ApiResult<T> =
safeApiCall(json, block)
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.interlinedlist.android.feature.notifications.push

import com.interlinedlist.android.core.common.result.ApiResult
import com.interlinedlist.android.core.network.preferences.NotificationTrayLimitStore
import com.interlinedlist.android.feature.notifications.data.NotificationPreferencesRepository
import com.interlinedlist.android.feature.notifications.data.NotificationsRepository
import javax.inject.Inject
Expand All @@ -10,6 +11,12 @@ import javax.inject.Inject
* preferences, decide what is NEW and push-enabled (via the pure [NotificationPollProcessor]),
* raise the tray notifications, and advance the persisted last-seen marker.
*
* The account's `notificationTrayLimit` is what the system tray is sized by: the
* repository already fetches a page of that size, and this runner passes the same
* number to the raiser so the shade never shows more separate notifications than the
* user asked the tray to hold. The value is cached by [NotificationTrayLimitStore],
* so reading it here costs no second request.
*
* Holding this outside the `@HiltWorker` keeps it free of the Android `CoroutineWorker`
* superclass, so it is fully unit-testable; [NotificationsPollWorker] is a thin adapter
* that maps the [Result] onto WorkManager's outcome.
Expand All @@ -18,6 +25,7 @@ class NotificationPollRunner @Inject constructor(
private val notificationsRepository: NotificationsRepository,
private val preferencesRepository: NotificationPreferencesRepository,
private val lastSeenStore: LastSeenNotificationStore,
private val trayLimitStore: NotificationTrayLimitStore,
private val raiser: SystemNotificationRaiser,
) {

Expand Down Expand Up @@ -51,7 +59,7 @@ class NotificationPollRunner @Inject constructor(
)

if (outcome.toPost.isNotEmpty()) {
raiser.post(outcome.toPost)
raiser.post(outcome.toPost, maxIndividual = trayLimitStore.current())
}
// Advance the marker after posting, so a crash before posting doesn't skip items.
outcome.newLastSeenId?.let(lastSeenStore::setLastSeenId)
Expand Down
Loading
Loading