Skip to content
Merged
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,7 @@ public class SignInCoordinator @Inject constructor(

/**
* `verify_credentials`, or `/oauth/userinfo` when the server cannot describe a brand-new account
* yet (Nextcloud Social answers 500 until its avatar cache job has run).
* yet: Nextcloud Social before 0.26.104 answered 500 until its avatar cache job had run.
*/
private suspend fun describe(pending: PendingAuthorization, base: HttpUrl, token: AccessToken): SignInResult {
val client = clients.create(base) { Credentials(token.value) }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ import social.aloha.core.data.answer
import social.aloha.core.database.AccountDao
import social.aloha.core.model.Account
import social.aloha.core.model.SignedInAccount
import social.aloha.core.network.ApiError
import social.aloha.core.network.ApiRequest
import social.aloha.core.network.endpoints.AccountEndpoints
import social.aloha.core.network.endpoints.CredentialEndpoints
Expand All @@ -21,8 +20,6 @@ import social.aloha.core.network.endpoints.CredentialsUpdate
* The reader's own profile as they edit it: loaded with its source (the note as written, the
* posting defaults), saved with only what changed, pictures removed where asked. What the server
* answers with becomes the stored account, so the switcher shows the new name and picture.
* Nextcloud Social can answer a save with a 500 after it made it; the profile it then has says
* whether it did.
*/
@Singleton
public class OwnProfile @Inject constructor(private val clients: ClientFactory, private val accounts: AccountDao) {
Expand All @@ -44,37 +41,19 @@ public class OwnProfile @Inject constructor(private val clients: ClientFactory,
CredentialEndpoints.deleteHeader().takeIf { removeHeader },
CredentialEndpoints.update(changes).takeIf { !changes.isEmpty },
)
val saved = run(reader, steps, changes) ?: load(reader)
val saved = run(reader, steps) ?: load(reader)
// the stored account is what the switcher and the shell draw, so they follow at once
(saved as? Answer.Got)?.value?.let { accounts.setProfile(reader.id, it.displayName, it.avatar, it.header) }
return saved
}

/** Makes [steps] in turn; the last answer, or the first refusal, which stops the rest; null for none. */
private suspend fun run(
reader: SignedInAccount,
steps: List<ApiRequest<Account>>,
changes: CredentialsUpdate,
): Answer<Account>? {
private suspend fun run(reader: SignedInAccount, steps: List<ApiRequest<Account>>): Answer<Account>? {
var last: Answer<Account>? = null
for (step in steps) {
last = answer(reader, step, changes)
last = clients.answer(reader, step)
if (last is Answer.Missed) break
}
return last
}

/** [step]'s answer, or a server error's that the profile shows was made regardless. */
private suspend fun answer(reader: SignedInAccount, step: ApiRequest<Account>, changes: CredentialsUpdate) =
clients.answer(reader, step).let { if (it.lost()) made(reader, changes) ?: it else it }

private fun Answer<Account>.lost() = (this as? Answer.Missed)?.error is ApiError.Server

/** The profile as it is, when it shows [changes] made in spite of the answer; null when it does not. */
private suspend fun made(reader: SignedInAccount, changes: CredentialsUpdate): Answer<Account>? {
val now = (load(reader) as? Answer.Got)?.value ?: return null
val shown = (changes.displayName == null || changes.displayName == now.displayName) &&
(changes.note == null || changes.note == now.source?.note)
return Answer.Got(now).takeIf { shown && changes.avatar == null && changes.header == null }
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
// SPDX-FileCopyrightText: 2026 Aloha Social contributors
// SPDX-License-Identifier: MIT

package social.aloha.core.data.profile

import android.content.Context
import androidx.test.core.app.ApplicationProvider
import java.util.concurrent.CopyOnWriteArrayList
import kotlinx.coroutines.runBlocking
import mockwebserver3.Dispatcher
import mockwebserver3.MockResponse
import mockwebserver3.MockWebServer
import mockwebserver3.RecordedRequest
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import social.aloha.core.data.Answer
import social.aloha.core.network.ApiError
import social.aloha.core.network.endpoints.CredentialsUpdate
import social.aloha.core.testing.SignedInFixture

@RunWith(RobolectricTestRunner::class)
class OwnProfileTest {
private val context = ApplicationProvider.getApplicationContext<Context>()
private val fixture = SignedInFixture(context)
private val asked = CopyOnWriteArrayList<String>()
private val server = MockWebServer().apply {
dispatcher = object : Dispatcher() {
override fun dispatch(request: RecordedRequest): MockResponse {
asked += "${request.method} ${request.url.encodedPath}"
return MockResponse.Builder().code(500).body("{}").build()
}
}
start()
}

@After
fun close() {
fixture.close()
server.close()
}

@Test
fun `a save the server fails is a failure, not looked up again`() = runBlocking {
val reader = fixture.signIn(server.url("/"))
val saved = fixture.ownProfile.save(reader, CredentialsUpdate(displayName = "Alice"), false, false)
assertTrue((saved as Answer.Missed).error is ApiError.Server)
assertEquals(listOf("PATCH /api/v1/accounts/update_credentials"), asked)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import kotlinx.coroutines.flow.Flow
*
* @property capabilitiesJson the detected server capabilities, refreshed every 24 hours.
* @property profilePending set while the server cannot describe a brand-new account yet (Nextcloud
* Social answers 500 from `verify_credentials` until its avatar cache job has run).
* Social before 0.26.104 answered 500 from `verify_credentials` until its avatar cache job had run).
*/
@Entity(tableName = "account")
public data class AccountEntity(
Expand Down
4 changes: 4 additions & 0 deletions core/model/src/main/kotlin/social/aloha/core/model/Account.kt
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ import kotlinx.serialization.Serializable
* @property acct `alice` for a local account, `alice@example.social` for a remote one.
* @property avatar none when the server sends `""` or `null`, which Nextcloud Social does for an
* account whose avatar it has not cached yet.
* @property avatarDefault the avatar is the server's stand-in, not a picture the account chose
* (Nextcloud Social says so; other servers do not, and it stays false there).
* @property header none as well when it is only the server's stand-in for "no header".
* @property source present only on the credentials routes, which answer for the account itself.
*/
@Serializable
Expand All @@ -25,6 +28,7 @@ public data class Account(
val uri: String? = null,
val avatar: String? = null,
val avatarStatic: String? = null,
val avatarDefault: Boolean = false,
val header: String? = null,
val headerStatic: String? = null,
val locked: Boolean = false,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import java.time.Instant
import kotlinx.serialization.KSerializer
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import social.aloha.core.model.Account
import social.aloha.core.model.AccountField
import social.aloha.core.model.AccountSource
Expand All @@ -17,6 +18,7 @@ import social.aloha.core.network.decoding.LenientInstantSerializer
import social.aloha.core.network.decoding.LenientIntSerializer
import social.aloha.core.network.decoding.LenientUrlSerializer
import social.aloha.core.network.decoding.LossyListSerializer
import social.aloha.core.network.decoding.OptionalBoolSerializer
import social.aloha.core.network.decoding.OrNullSerializer

@Serializable
Expand All @@ -32,6 +34,10 @@ internal data class AccountDto(
@SerialName("avatar_static") @Serializable(with = LenientUrlSerializer::class) val avatarStatic: String? = null,
@Serializable(with = LenientUrlSerializer::class) val header: String? = null,
@SerialName("header_static") @Serializable(with = LenientUrlSerializer::class) val headerStatic: String? = null,
@SerialName("avatar_default") @Serializable(with = OptionalBoolSerializer::class)
val avatarDefault: Boolean? = null,
@SerialName("header_default") @Serializable(with = OptionalBoolSerializer::class)
val headerDefault: Boolean? = null,
@Serializable(with = LenientBoolSerializer::class) val locked: Boolean = false,
@Serializable(with = LenientBoolSerializer::class) val bot: Boolean = false,
@Serializable(with = LenientBoolSerializer::class) val discoverable: Boolean = false,
Expand Down Expand Up @@ -85,8 +91,9 @@ internal fun AccountDto.toDomain(): Account {
uri = uri,
avatar = avatar,
avatarStatic = avatarStatic,
header = header,
headerStatic = headerStatic,
avatarDefault = avatarDefault == true,
header = header.takeUnless(::isPlaceholderHeader),
headerStatic = headerStatic.takeUnless(::isPlaceholderHeader),
locked = locked,
bot = bot,
discoverable = discoverable,
Expand All @@ -106,6 +113,20 @@ internal fun AccountDto.toDomain(): Account {
)
}

/**
* Whether [header] stands in for "no header", so that the profile draws its own empty banner and the
* editor offers no removal of a picture nobody chose. Mastodon's API requires a URL, so servers send
* a placeholder rather than none: Nextcloud Social says so with `header_default`; Mastodon does not,
* and its `headers/{style}/missing.png` is recognised by its address.
*/
private fun AccountDto.isPlaceholderHeader(header: String?): Boolean {
if (headerDefault == true) return true
val last = header?.toHttpUrlOrNull()?.pathSegments?.takeLast(MASTODON_MISSING_SEGMENTS) ?: return false
return last.size == MASTODON_MISSING_SEGMENTS && last[0] == "headers" && last[2] == "missing.png"
}

private const val MASTODON_MISSING_SEGMENTS = 3

internal fun AccountFieldDto.toDomain(): AccountField = AccountField(name, value, verifiedAt)

internal fun AccountSourceDto.toDomain(): AccountSource = AccountSource(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -85,10 +85,7 @@ public object CredentialEndpoints {
private fun account(endpoint: Endpoint): ApiRequest<Account> =
request(endpoint, AccountDto.serializer()) { it.toDomain() }

/**
* A form when no picture goes, multipart only with one: a PHP server reads a multipart body for
* `POST` alone, so Nextcloud Social answers a multipart `PATCH` with 200 and changes nothing.
*/
/** A form when no picture goes, multipart with one: the pictures, names and fields in one request. */
public fun update(changes: CredentialsUpdate): ApiRequest<Account> {
val parts = changes.parts()
val body = if (parts.any { it is Part.FileContent }) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,30 @@ class DtoDecodingTest {
assertEquals(null, decodeStatus(status(""", "quote_approval": "public"""")).quoteApproval)
}

@Test
fun `a placeholder header is no header, a picture the account set is kept`() {
fun account(json: String) = decodeStatus("""{"id": "1", "account": {"id": "2", "username": "alice", $json}}""")
.account
val placeholder = "https://cloud.example/apps-extra/social/img/header-missing.png"
assertNull(account(""""header": "$placeholder", "header_default": true""").header)
val chosen = "https://cloud.example/index.php/apps/social/media/02aa6a9a.png"
assertEquals(chosen, account(""""header": "$chosen", "header_default": false""").header)
assertNull(account(""""header": "https://mastodon.example/headers/original/missing.png"""").header)
val uploadedAsMissing = "https://pleroma.example/media/5f1c/missing.png"
assertEquals(uploadedAsMissing, account(""""header": "$uploadedAsMissing"""").header)
}

@Test
fun `an avatar the server stands in with is marked, and one without the flag is the account's own`() {
fun account(json: String) = decodeStatus("""{"id": "1", "account": {"id": "2", "username": "alice", $json}}""")
.account
val route = "https://cloud.example/index.php/avatar/alice/128"
val generated = account(""""avatar": "$route", "avatar_default": true""")
assertEquals(route, generated.avatar)
assertTrue(generated.avatarDefault)
assertFalse(account(""""avatar": "https://mastodon.example/a.png"""").avatarDefault)
}

@Test
fun `an account without acct falls back to its username`() {
val s = decodeStatus("""{"id": "1", "account": {"id": "2", "username": "bob", "avatar": null}}""")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import java.util.concurrent.CopyOnWriteArrayList
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Duration.Companion.seconds
import kotlin.time.TimeSource
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.Dispatchers
Expand Down Expand Up @@ -201,10 +202,12 @@ class SyncEngineTest {
@Test
fun `an active account is asked again after thirty seconds, and a manual one never on a timer`() = runBlocking {
val account = fixture.signIn(server.url("/"))
val started = TimeSource.Monotonic.markNow()
engine.poll(account, PollScope.Full)
val wait = engine.waitBeforeNext(account)!!
// a loaded test run takes seconds between the poll and this question; 30 s is told from 60 s and 10 min
assertTrue("$wait", wait > 20.seconds && wait <= 30.seconds)
// what is left of 30 s after however long a loaded run took to get here, never 60 s or 10 min
val taken = started.elapsedNow()
assertTrue("$wait after $taken", wait <= 30.seconds && wait >= 30.seconds - taken - 1.seconds)
settings.setPollFrequency(account.id, PollFrequency.Manual)
assertNull(engine.waitBeforeNext(account))
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import social.aloha.core.data.nextcloud.NextcloudConnection
import social.aloha.core.data.notifications.NotificationsRepository
import social.aloha.core.data.notifications.RaisedNotifications
import social.aloha.core.data.photos.Albums
import social.aloha.core.data.profile.OwnProfile
import social.aloha.core.data.sync.UnreadCounts
import social.aloha.core.data.sync.WidgetUpdates
import social.aloha.core.data.timeline.CacheSweeper
Expand Down Expand Up @@ -128,6 +129,8 @@ public class SignedInFixture(private val context: Context) : Closeable {
/** The reader's albums, their posts stored in [statuses]. */
public val albums: Albums by lazy { Albums(clients, statuses) }

public val ownProfile: OwnProfile by lazy { OwnProfile(clients, database.accountDao()) }

/** What sends posts, storing what the server made in an in-memory cache. */
public val sender: PostSender by lazy {
val statuses = StatusRepository(cache.value.statusDao(), clock)
Expand Down
8 changes: 5 additions & 3 deletions docs/02-server-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,18 @@

## On Android

This is the Apple app's specification, carried over as the product contract for Android. Where it names an Apple mechanism, read its Android counterpart from the table below; where it states a server fact, the facts below, verified against a live Nextcloud Social 0.26.97 instance, win.
This is the Apple app's specification, carried over as the product contract for Android. Where it names an Apple mechanism, read its Android counterpart from the table below; where it states a server fact, the facts below, verified against a live Nextcloud Social 0.26.97 instance, and where a row names a later version against 0.26.126 (AlohaSocial/social `cdd847797`, on 2026-10-06), win.

| Topic | Android |
|---|---|
| Credentials | A Social OAuth token, or a Nextcloud app password over HTTP Basic with `OCS-APIRequest: true`; the second also reaches memories, statistics, channels and migration. |
| Caching | Nextcloud Social 0.26.97 sends no `ETag` on the polling routes (`Cache-Control: no-store`). |
| Boosts | `reblog` is always `null`; a boost is not visible as a boost. |
| Media | `POST /api/v2/media` answers 200 synchronously; `focus` is stored; `hls_url` and video `meta` are empty without ffmpeg on the server. |
| Media | `POST /api/v2/media` answers 200 synchronously; `focus` is stored; `hls_url` and video `meta` are empty without ffmpeg on the server. Since 0.26.123 `image_size_limit` and `video_size_limit` are capped at what PHP accepts, which the composer's pre-flight checks against, and a refused upload says why: a `422` for a file too large (naming the limit) or an interrupted upload, which the attachment card shows; a `500` naming a server problem, retried like any server error. |
Comment thread
coderabbitai[bot] marked this conversation as resolved.
| Pagination | `Link` headers point at the app path even through the root rules; `limit=0` answers 400. `min_id` answers with the posts immediately above the anchor and `since_id` with the newest above it, as on Mastodon, so a refresh asks with `since_id` and a full page leaves a gap between it and the cache. |
| New accounts | `verify_credentials`, `accounts/lookup` and the first post answer 500 until the avatar cache job runs. |
| New accounts | Before 0.26.104 (seen on 0.26.97), `verify_credentials`, `accounts/lookup` and the first post answered 500 until the avatar cache job ran. On 0.26.119 an account created moments before answers `verify_credentials` and its first post with 200 (`accounts/lookup` not checked again). Sign-in still falls back to `/oauth/userinfo` on a 5xx, for older servers. A Nextcloud user without a Social account cannot authorize an app at all: the authorize page needs the account to exist. |
| Pictures | Before 0.26.119 a local account had `avatar: ""` and its Nextcloud avatar as `header`; 0.26.119 stopped that fallback without repairing what it had stored. Since 0.26.125 a repair step clears those headers, the avatar is the same on every route, the header placeholder is `img/header-missing.png`, and every Account carries `avatar_default` and `header_default` ([AlohaSocial/social#2487](https://github.com/AlohaSocial/social/issues/2487)). The app reads a banner the Account marks with `header_default: true`, and Mastodon's `headers/…/missing.png` (Mastodon has no such field), as no header, and Edit profile offers no removal of an `avatar_default` avatar. |
| Search and edits | Since 0.26.122 `api/v2/search` honours `account_id` and `offset`, so a profile's posts are searchable on Nextcloud Social as elsewhere; `/api/v2/instance` announces `api_versions.aloha_social: 1`. Since 0.26.121 each version in `statuses/{id}/history` carries its media, which the history sheet lists by description. |
Comment thread
coderabbitai[bot] marked this conversation as resolved.
| Places and reactions | `place_*` is never stored; reactions come only from `/statuses/{id}/reactions`. |
| Web-server rules | Show the server's own `contrib/webserver` files; the snippet described here is outdated. |
| Client | Plain OkHttp with kotlinx.serialization, one `ApiClient` per account base. Decoding is lenient (ids, URLs, booleans and dates in any of the shapes servers send) and lossy (a malformed row in a list is dropped and recorded, the rest of the page stays). |
Expand Down
Loading