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 @@ -21,4 +21,10 @@ data class User(
* overrides it. Defaults to public, matching the server default.
*/
val defaultPubliclyVisible: Boolean = true,
/**
* Which messages the Home feed shows (Settings -> View Preferences on the
* web). The server applies it when it builds the feed; the client saves it and
* reloads. Defaults to [ViewingPreference.ALL], matching the server default.
*/
val viewingPreference: ViewingPreference = ViewingPreference.DEFAULT,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
package com.interlinedlist.android.core.model

/**
* Which messages the Home feed shows — the `viewingPreference` field of
* `GET /api/user`, written back with `PATCH /api/user/update`.
*
* The wire values are not in the OpenAPI spec (the field is typed as a bare
* `string`); they come from the server's own 400 on a bogus value:
* `viewingPreference must be one of: my_messages, all_messages, followers_only,
* following_only`. Nothing else is accepted.
*
* The **server** applies this preference when it builds the feed:
* `GET /api/messages` takes only `limit`, `offset`, `onlyMine` and `tag`
* (`/help/api/messages`), and search is documented as "scoped to your feed
* visibility (honors your `viewingPreference`)". So a client changes the feed by
* saving the preference and reloading, not by sending a filter parameter.
*
* [fromWire] is deliberately tolerant — casing, separators and the obvious
* shorthands all resolve — so an unexpected spelling degrades to the right
* selection instead of silently resetting the user's feed.
*/
enum class ViewingPreference(val wire: String) {
/** Your messages plus all public messages. */
ALL("all_messages"),

/** Only your own messages. */
MINE("my_messages"),

/** Messages from people you follow, plus your own. */
FOLLOWING("following_only"),

/** Messages from people who follow you, plus your own. */
FOLLOWERS("followers_only"),
;

companion object {
/** The API default for a new account, and the fallback for an unknown value. */
val DEFAULT = ALL

/** Parses a wire value, returning null when it matches no known option. */
fun fromWire(value: String?): ViewingPreference? {
val normalised = value?.lowercase()?.filter { it.isLetter() } ?: return null
return when (normalised) {
"allmessages", "all", "everyone" -> ALL
"mymessages", "mine", "my", "onlymine", "me" -> MINE
"followingonly", "following" -> FOLLOWING
"followersonly", "followers" -> FOLLOWERS
else -> null
}
}

/** Parses a wire value, falling back to [DEFAULT] when missing or unknown. */
fun fromWireOrDefault(value: String?): ViewingPreference = fromWire(value) ?: DEFAULT
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,11 @@ package com.interlinedlist.android.core.network.api
import com.interlinedlist.android.core.network.dto.CurrentUserResponse
import com.interlinedlist.android.core.network.dto.SyncTokenRequest
import com.interlinedlist.android.core.network.dto.SyncTokenResponse
import com.interlinedlist.android.core.network.dto.UpdateUserRequest
import com.interlinedlist.android.core.network.dto.UpdateUserResponse
import retrofit2.http.Body
import retrofit2.http.GET
import retrofit2.http.PATCH
import retrofit2.http.POST

/**
Expand All @@ -23,4 +26,12 @@ interface InterlinedListApi {
/** Returns the authenticated user, wrapped as `{ "user": ... }`, including `customerStatus`. */
@GET("api/user")
suspend fun getCurrentUser(): CurrentUserResponse

/**
* Applies a **partial** update to the current user's account fields and returns
* the updated user. Fields left null in [body] are omitted from the request, so
* one preference can be changed without touching the rest.
*/
@PATCH("api/user/update")
suspend fun updateUser(@Body body: UpdateUserRequest): UpdateUserResponse
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package com.interlinedlist.android.core.network.dto

import kotlinx.serialization.Serializable

/**
* A **partial** body for `PATCH /api/user/update`. Every field is optional and the
* shared Json uses `explicitNulls = false`, so an untouched field is omitted from
* the request entirely and can never clobber another preference.
*
* Only the fields a `:core:network` consumer actually needs are modelled — today
* that is `viewingPreference`, which the messages feed writes when the user picks a
* view. `:feature:profile` owns the full fifteen-field settings surface; see
* [com.interlinedlist.android.core.network.preferences.ViewingPreferenceStore] for
* why the two coexist.
*/
@Serializable
data class UpdateUserRequest(
val viewingPreference: String? = null,
)

/**
* Response to `PATCH /api/user/update`: the updated user, "same shape as
* `GET /api/user`" per `/help/api/users-and-profile`.
*
* Live, `GET /api/user` wraps the object as `{ "user": … }` while the help centre's
* example shows it inlined at the top level, so both are tolerated. Every field is
* optional: a thin acknowledgement body must not fail the call.
*/
@Serializable
data class UpdateUserResponse(
val user: UserDto? = null,
val viewingPreference: String? = null,
) {
/** The saved `viewingPreference`, wrapped or inlined, or null if not echoed. */
val savedViewingPreference: String? get() = user?.viewingPreference ?: viewingPreference
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package com.interlinedlist.android.core.network.dto

import com.interlinedlist.android.core.model.CustomerStatus
import com.interlinedlist.android.core.model.User
import com.interlinedlist.android.core.model.ViewingPreference
import kotlinx.serialization.Serializable

/** Wire model for the user object returned by the auth/user endpoints. */
Expand All @@ -17,6 +18,13 @@ data class UserDto(
val customerStatus: String? = null,
/** The account's default post visibility preference; public when absent. */
val defaultPubliclyVisible: Boolean = true,
/**
* Raw `viewingPreference` wire value (`all_messages`, `my_messages`,
* `following_only`, `followers_only`). Kept as a String here so an unknown
* server value deserialises rather than failing; [ViewingPreference.fromWireOrDefault]
* resolves it.
*/
val viewingPreference: String? = null,
)

/** Maps the wire model into the domain [User]. */
Expand All @@ -30,4 +38,5 @@ fun UserDto.toDomain(): User = User(
emailVerified = emailVerified,
customerStatus = CustomerStatus.fromApiValue(customerStatus),
defaultPubliclyVisible = defaultPubliclyVisible,
viewingPreference = ViewingPreference.fromWireOrDefault(viewingPreference),
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
package com.interlinedlist.android.core.network.preferences

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

/**
* Reads and writes the account's [ViewingPreference] — the one account field the
* messages feed has to own, because the feed is what the preference controls.
*
* It lives in `:core:network` rather than in a feature module because two features
* need it and **no feature module in this repo depends on another feature module**:
* `:feature:messages` reads and writes it from the in-feed switcher, while
* `:feature:profile`'s Settings screen writes it (among fourteen other fields)
* through its own `SettingsRepository`.
*
* Consequence to be aware of: `:feature:profile`'s `SettingsRepository` and this
* store are **two paths to the same `viewingPreference` field**, each with its own
* in-flight state. They should be consolidated into one owner (most likely a shared
* account-preferences module) once both surfaces have settled.
*/
@Singleton
class ViewingPreferenceStore @Inject constructor(
private val api: InterlinedListApi,
private val json: Json,
) {

/**
* The preference currently saved on the account, from `GET /api/user`. An
* absent or unrecognised value resolves to [ViewingPreference.DEFAULT] rather
* than failing — the feed always needs something to load with.
*/
suspend fun read(): ApiResult<ViewingPreference> = safeApiCall(json) {
ViewingPreference.fromWireOrDefault(api.getCurrentUser().user.viewingPreference)
}

/**
* Saves [preference] with a partial `PATCH /api/user/update`, so the web and
* Android agree on what the feed shows. Returns the value the server reports as
* saved (falling back to [preference] when the response does not echo it), so a
* server-side normalisation wins over what the caller asked for.
*/
suspend fun write(preference: ViewingPreference): ApiResult<ViewingPreference> {
val request = UpdateUserRequest(viewingPreference = preference.wire)
return when (val result = safeApiCall(json) { api.updateUser(request) }) {
is ApiResult.Success ->
ApiResult.Success(
ViewingPreference.fromWire(result.data.savedViewingPreference) ?: preference,
)
is ApiResult.Failure -> result
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
package com.interlinedlist.android.core.network.preferences

import com.google.common.truth.Truth.assertThat
import com.interlinedlist.android.core.common.result.ApiResult
import com.interlinedlist.android.core.common.result.AppError
import com.interlinedlist.android.core.model.ViewingPreference
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 `viewingPreference` accessor: reads the account preference from
* `GET /api/user` and saves it with a **partial** `PATCH /api/user/update`.
*
* The four wire values come from the server's own 400 (`viewingPreference must be
* one of: my_messages, all_messages, followers_only, following_only`) and the help
* centre's API reference (`/help/api/users-and-profile`), which lists
* `viewingPreference` among the fields `PATCH /api/user/update` accepts.
*/
class ViewingPreferenceStoreTest {

private lateinit var server: MockWebServer
private lateinit var store: ViewingPreferenceStore

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

@Before
fun setUp() {
server = MockWebServer()
server.start()
val api = Retrofit.Builder()
.baseUrl(server.url("/"))
.addConverterFactory(json.asConverterFactory("application/json".toMediaType()))
.build()
.create(InterlinedListApi::class.java)
store = ViewingPreferenceStore(api, json)
}

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

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

@Test
fun `read returns the account preference from GET api user`() = runBlocking {
enqueueUser("following_only")

val result = store.read()

assertThat((result as ApiResult.Success).data).isEqualTo(ViewingPreference.FOLLOWING)
assertThat(server.takeRequest().path).isEqualTo("/api/user")
}

@Test
fun `read maps every wire value the server accepts`() = runBlocking {
val expected = mapOf(
"all_messages" to ViewingPreference.ALL,
"my_messages" to ViewingPreference.MINE,
"following_only" to ViewingPreference.FOLLOWING,
"followers_only" to ViewingPreference.FOLLOWERS,
)
expected.forEach { (wire, preference) ->
enqueueUser(wire)
assertThat((store.read() as ApiResult.Success).data).isEqualTo(preference)
}
}

@Test
fun `an absent or unknown preference falls back to all messages`() = runBlocking {
enqueueUser(null)
assertThat((store.read() as ApiResult.Success).data).isEqualTo(ViewingPreference.ALL)

enqueueUser("something_new")
assertThat((store.read() as ApiResult.Success).data).isEqualTo(ViewingPreference.ALL)
}

@Test
fun `write PATCHes only the viewingPreference field`() = runBlocking {
enqueueUser("followers_only")

val result = store.write(ViewingPreference.FOLLOWERS)

assertThat((result as ApiResult.Success).data).isEqualTo(ViewingPreference.FOLLOWERS)
val request = server.takeRequest()
assertThat(request.method).isEqualTo("PATCH")
assertThat(request.path).isEqualTo("/api/user/update")
// Partial update: nothing but the one field, so no other preference is clobbered.
assertThat(request.body.readUtf8()).isEqualTo("""{"viewingPreference":"followers_only"}""")
}

@Test
fun `write sends the wire value of each preference`() = runBlocking {
val expected = mapOf(
ViewingPreference.ALL to "all_messages",
ViewingPreference.MINE to "my_messages",
ViewingPreference.FOLLOWING to "following_only",
ViewingPreference.FOLLOWERS to "followers_only",
)
expected.forEach { (preference, wire) ->
enqueueUser(wire)
store.write(preference)
assertThat(server.takeRequest().body.readUtf8())
.isEqualTo("""{"viewingPreference":"$wire"}""")
}
}

@Test
fun `write trusts the value the server echoes back`() = runBlocking {
// The server normalised the request to something else; server truth wins.
enqueueUser("all_messages")

val result = store.write(ViewingPreference.FOLLOWING)

assertThat((result as ApiResult.Success).data).isEqualTo(ViewingPreference.ALL)
}

@Test
fun `write falls back to the requested value when the echo omits it`() = runBlocking {
server.enqueue(MockResponse().setBody("""{ "user": { "id": "u1", "username": "me" } }"""))

val result = store.write(ViewingPreference.MINE)

assertThat((result as ApiResult.Success).data).isEqualTo(ViewingPreference.MINE)
}

@Test
fun `a rejected value surfaces the server error`() = runBlocking {
server.enqueue(
MockResponse().setResponseCode(400).setBody(
"""{ "error": "viewingPreference must be one of: my_messages, all_messages, followers_only, following_only", "code": "bad_request" }""",
),
)

val result = store.write(ViewingPreference.FOLLOWING)

val error = (result as ApiResult.Failure).error
assertThat(error).isInstanceOf(AppError.Unknown::class.java)
assertThat(error.message).contains("viewingPreference must be one of")
}
}
Loading
Loading