diff --git a/core/data/src/main/kotlin/social/aloha/core/data/SignInCoordinator.kt b/core/data/src/main/kotlin/social/aloha/core/data/SignInCoordinator.kt index 598b08da..4f8e3207 100644 --- a/core/data/src/main/kotlin/social/aloha/core/data/SignInCoordinator.kt +++ b/core/data/src/main/kotlin/social/aloha/core/data/SignInCoordinator.kt @@ -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) } diff --git a/core/data/src/main/kotlin/social/aloha/core/data/profile/OwnProfile.kt b/core/data/src/main/kotlin/social/aloha/core/data/profile/OwnProfile.kt index 7bbe2ef5..43933d72 100644 --- a/core/data/src/main/kotlin/social/aloha/core/data/profile/OwnProfile.kt +++ b/core/data/src/main/kotlin/social/aloha/core/data/profile/OwnProfile.kt @@ -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 @@ -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) { @@ -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>, - changes: CredentialsUpdate, - ): Answer? { + private suspend fun run(reader: SignedInAccount, steps: List>): Answer? { var last: Answer? = 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, changes: CredentialsUpdate) = - clients.answer(reader, step).let { if (it.lost()) made(reader, changes) ?: it else it } - - private fun Answer.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? { - 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 } - } } diff --git a/core/data/src/test/kotlin/social/aloha/core/data/profile/OwnProfileTest.kt b/core/data/src/test/kotlin/social/aloha/core/data/profile/OwnProfileTest.kt new file mode 100644 index 00000000..8653a72d --- /dev/null +++ b/core/data/src/test/kotlin/social/aloha/core/data/profile/OwnProfileTest.kt @@ -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() + private val fixture = SignedInFixture(context) + private val asked = CopyOnWriteArrayList() + 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) + } +} diff --git a/core/database/src/main/kotlin/social/aloha/core/database/AccountsDatabase.kt b/core/database/src/main/kotlin/social/aloha/core/database/AccountsDatabase.kt index fcb88cf9..ada5bb5a 100644 --- a/core/database/src/main/kotlin/social/aloha/core/database/AccountsDatabase.kt +++ b/core/database/src/main/kotlin/social/aloha/core/database/AccountsDatabase.kt @@ -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( diff --git a/core/model/src/main/kotlin/social/aloha/core/model/Account.kt b/core/model/src/main/kotlin/social/aloha/core/model/Account.kt index 7f1bb6ee..a5c90e22 100644 --- a/core/model/src/main/kotlin/social/aloha/core/model/Account.kt +++ b/core/model/src/main/kotlin/social/aloha/core/model/Account.kt @@ -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 @@ -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, diff --git a/core/network/src/main/kotlin/social/aloha/core/network/dto/AccountDto.kt b/core/network/src/main/kotlin/social/aloha/core/network/dto/AccountDto.kt index 1fb57e4d..65684828 100644 --- a/core/network/src/main/kotlin/social/aloha/core/network/dto/AccountDto.kt +++ b/core/network/src/main/kotlin/social/aloha/core/network/dto/AccountDto.kt @@ -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 @@ -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 @@ -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, @@ -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, @@ -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( diff --git a/core/network/src/main/kotlin/social/aloha/core/network/endpoints/AccountExtraEndpoints.kt b/core/network/src/main/kotlin/social/aloha/core/network/endpoints/AccountExtraEndpoints.kt index 4aa7a8be..5934e7aa 100644 --- a/core/network/src/main/kotlin/social/aloha/core/network/endpoints/AccountExtraEndpoints.kt +++ b/core/network/src/main/kotlin/social/aloha/core/network/endpoints/AccountExtraEndpoints.kt @@ -85,10 +85,7 @@ public object CredentialEndpoints { private fun account(endpoint: Endpoint): ApiRequest = 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 { val parts = changes.parts() val body = if (parts.any { it is Part.FileContent }) { diff --git a/core/network/src/test/kotlin/social/aloha/core/network/dto/DtoDecodingTest.kt b/core/network/src/test/kotlin/social/aloha/core/network/dto/DtoDecodingTest.kt index cbdca916..d9cc54fd 100644 --- a/core/network/src/test/kotlin/social/aloha/core/network/dto/DtoDecodingTest.kt +++ b/core/network/src/test/kotlin/social/aloha/core/network/dto/DtoDecodingTest.kt @@ -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}}""") diff --git a/core/sync/src/test/kotlin/social/aloha/core/sync/SyncEngineTest.kt b/core/sync/src/test/kotlin/social/aloha/core/sync/SyncEngineTest.kt index ac4ee73b..c4dc2fa2 100644 --- a/core/sync/src/test/kotlin/social/aloha/core/sync/SyncEngineTest.kt +++ b/core/sync/src/test/kotlin/social/aloha/core/sync/SyncEngineTest.kt @@ -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 @@ -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)) } diff --git a/core/testing/src/main/kotlin/social/aloha/core/testing/SignedInFixture.kt b/core/testing/src/main/kotlin/social/aloha/core/testing/SignedInFixture.kt index febabf5f..281b0b58 100644 --- a/core/testing/src/main/kotlin/social/aloha/core/testing/SignedInFixture.kt +++ b/core/testing/src/main/kotlin/social/aloha/core/testing/SignedInFixture.kt @@ -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 @@ -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) diff --git a/docs/02-server-api.md b/docs/02-server-api.md index 69ef63f5..7eb3be8b 100644 --- a/docs/02-server-api.md +++ b/docs/02-server-api.md @@ -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. | | 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. | | 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). | diff --git a/docs/14-status.md b/docs/14-status.md index 9823e3c1..9b2dd5f5 100644 --- a/docs/14-status.md +++ b/docs/14-status.md @@ -23,12 +23,14 @@ Each phase was one pull request into `main`. | 13 | [#28](https://github.com/AlohaSocial/Android/pull/28) | 2026-10-05 | A post card with a two-line header, hidden content that says what it hides, motion that Reduce motion stills, a thread with connectors and a reply bar, the profile header and tabs, notification cards, Black without tints and a colour per account, a viewer tinted by its picture, text size, times instead of ages, folded boosts, screens that slide, one skeleton and one empty state | | 14 | [#30](https://github.com/AlohaSocial/Android/pull/30) | 2026-10-05 | Home as the reader's pinned feeds with a switcher and Edit feeds, another server's own posts as a feed, quoting, boosting for a chosen audience and acting as another account, pausing notifications and choosing whose arrive, a richer composer (completion strip, detected language, attachment cards, alt-text page, draft resume and preview), search intents, hashtag curves, a profile's QR code and post search, the sign-in server preview, the catch-up page, focus mode, the picture-in-picture window's buttons | | 17 | [#32](https://github.com/AlohaSocial/Android/pull/32) | 2026-10-06 | Alt text drafted on the device, marked as AI generated until edited and checked before posting; a draft rewritten on the device with its mentions, hashtags and links kept; a thread summarised in a sheet and translated on one tap; an Intelligence section in Settings with each feature off until turned on and a privacy note per build. Alt-text drafts come in both builds, by open software; rewriting and summaries need Gemini Nano and are in the Play build only | +| 18 (in part) | [#33](https://github.com/AlohaSocial/Android/pull/33) | 2026-10-06 | The active account's stream where its server has one (Mastodon): edits, deletions, notifications and filter changes as they happen, polling slowed to a safety net meanwhile; uploads sent once per file, joined again by a reopened draft, cancellable from their notification, not cut off by a call timeout. Push through the Nextcloud push proxy and the sync etiquette review are still open | +| 19a | [#34](https://github.com/AlohaSocial/Android/pull/34) | 2026-10-06 | What Nextcloud Social 0.26.126 fixed, taken up by the app: a profile save answered with a server error is a failure (Social no longer answers 500 after storing part of a save, and answers 200 with what it stored when a late step fails); a profile's posts are searchable on Social too; the edit history lists each version's pictures by description; a placeholder header is no header (Social's `header_default`, Mastodon's `missing.png`) and a stand-in avatar is not offered for removal; a new account answers at once. Uploads meet the limit PHP really enforces, which Social now advertises | Between the phases: CI artifact retention ([#3](https://github.com/AlohaSocial/Android/pull/3)), the device benchmark and baseline profiles ([#4](https://github.com/AlohaSocial/Android/pull/4)), looking an account up by its id ([#10](https://github.com/AlohaSocial/Android/pull/10)) and who favourited and boosted a post ([#13](https://github.com/AlohaSocial/Android/pull/13)). ## In progress -Phase 18, sync: the active account's stream where its server has one, and uploads that are sent once per file, can be cancelled from their notification and are not cut off by a call timeout. Not merged yet. +Nothing at the moment. ## Not built @@ -40,7 +42,6 @@ Phase 18, sync: the active account's stream where its server has one, and upload | Push through the Nextcloud push proxy (FCM) | [08-notifications-sync.md](08-notifications-sync.md) §2 | Push is UnifiedPush in both flavours. FCM would be `gplay` only and needs the push proxy to allow the app | | Encrypted direct messages bound to the app lock | [11-safety-privacy-appstore.md](11-safety-privacy-appstore.md) | The app lock guards the screen, not the data | | Push from Nextcloud Social to Mastodon clients | [08-notifications-sync.md](08-notifications-sync.md) §2 | Social serves no Web Push API yet ([AlohaSocial/social#2479](https://github.com/AlohaSocial/social/issues/2479)); against it the app polls, and "Get notifications from" filters what polling finds | -| Searching one profile's posts on Nextcloud Social | [05-core-ui.md](05-core-ui.md) §5 | Social ignores `account_id` in search ([AlohaSocial/social#2481](https://github.com/AlohaSocial/social/issues/2481)), so the field is left out there | | A cross-account merged timeline | [00-overview.md](00-overview.md) §6 | Deferred past 1.0 by the specification itself | | Handoff and Spotlight counterparts | [00-overview.md](00-overview.md) §5 | No Android equivalent is planned yet | | A WebDAV browser for Nextcloud files | [07-composer.md](07-composer.md) §6 | The picker is a path field with recent paths | diff --git a/feature/profile/src/main/kotlin/social/aloha/feature/profile/EditProfileScreen.kt b/feature/profile/src/main/kotlin/social/aloha/feature/profile/EditProfileScreen.kt index fbb562b3..65f2f534 100644 --- a/feature/profile/src/main/kotlin/social/aloha/feature/profile/EditProfileScreen.kt +++ b/feature/profile/src/main/kotlin/social/aloha/feature/profile/EditProfileScreen.kt @@ -180,7 +180,7 @@ private fun Pictures(form: ProfileForm, onForm: (ProfileForm) -> Unit) { PictureButtons( R.string.profile_edit_header, R.string.profile_edit_remove_header, - form.header !is Picture.Kept || form.header.shown != null, + form.header !is Picture.Kept || form.header.chosen, { pickHeader.launch(image) }, ) { onForm(form.copy(header = Picture.Removed)) } Row(horizontalArrangement = Arrangement.spacedBy(AlohaSpacing.m)) { @@ -188,7 +188,7 @@ private fun Pictures(form: ProfileForm, onForm: (ProfileForm) -> Unit) { PictureButtons( R.string.profile_edit_avatar, R.string.profile_edit_remove_avatar, - form.avatar !is Picture.Kept || form.avatar.shown != null, + form.avatar !is Picture.Kept || form.avatar.chosen, { pickAvatar.launch(image) }, ) { onForm(form.copy(avatar = Picture.Removed)) } } diff --git a/feature/profile/src/main/kotlin/social/aloha/feature/profile/EditProfileViewModel.kt b/feature/profile/src/main/kotlin/social/aloha/feature/profile/EditProfileViewModel.kt index 48a086a3..6e2f05eb 100644 --- a/feature/profile/src/main/kotlin/social/aloha/feature/profile/EditProfileViewModel.kt +++ b/feature/profile/src/main/kotlin/social/aloha/feature/profile/EditProfileViewModel.kt @@ -37,7 +37,8 @@ import social.aloha.core.ui.extensionFor /** A picture of the profile: as it is on the server, one picked on the phone, or none. */ @Immutable internal sealed interface Picture { - data class Kept(val url: String?) : Picture + /** What the account has; [chosen] when it is a picture of its own, not the server's stand-in. */ + data class Kept(val url: String?, val chosen: Boolean = url != null) : Picture data class Picked(val uri: String) : Picture @@ -77,7 +78,7 @@ internal data class ProfileForm( indexable = account.indexable, privacy = source?.privacy ?: Visibility.Public, sensitive = source?.sensitive == true, - avatar = Picture.Kept(account.avatar), + avatar = Picture.Kept(account.avatar, chosen = !account.avatarDefault), header = Picture.Kept(account.header), ) } diff --git a/feature/profile/src/main/kotlin/social/aloha/feature/profile/ProfilePresentation.kt b/feature/profile/src/main/kotlin/social/aloha/feature/profile/ProfilePresentation.kt index 798f183c..26e12a4c 100644 --- a/feature/profile/src/main/kotlin/social/aloha/feature/profile/ProfilePresentation.kt +++ b/feature/profile/src/main/kotlin/social/aloha/feature/profile/ProfilePresentation.kt @@ -55,7 +55,7 @@ internal object ProfilePresentation { return ProfileHeader( author = mapper.author(account), emojis = account.emojis, - headerUrl = account.header?.takeUnless { it.endsWith("/missing.png") }, + headerUrl = account.header, note = StatusHtmlParser.parse(account.note, emojis = account.emojis).toAnnotatedString(colors), fields = account.fields.map { field -> ProfileHeader.Field( diff --git a/feature/profile/src/main/kotlin/social/aloha/feature/profile/ProfileScreen.kt b/feature/profile/src/main/kotlin/social/aloha/feature/profile/ProfileScreen.kt index 40f391c5..e1f3cb52 100644 --- a/feature/profile/src/main/kotlin/social/aloha/feature/profile/ProfileScreen.kt +++ b/feature/profile/src/main/kotlin/social/aloha/feature/profile/ProfileScreen.kt @@ -185,7 +185,7 @@ private fun Content( item(key = "highlights", contentType = "highlights") { Highlights(highlights) } } stickyHeader(key = "tabs", contentType = "tabs") { Tabs(state, pinned, timeline, choose) } - tabContent(state, actions, rowActions, search.takeIf { state.postSearch }) + tabContent(state, actions, rowActions, search) } } } diff --git a/feature/profile/src/main/kotlin/social/aloha/feature/profile/ProfileUiState.kt b/feature/profile/src/main/kotlin/social/aloha/feature/profile/ProfileUiState.kt index f8d9e6c7..0cf0a8a5 100644 --- a/feature/profile/src/main/kotlin/social/aloha/feature/profile/ProfileUiState.kt +++ b/feature/profile/src/main/kotlin/social/aloha/feature/profile/ProfileUiState.kt @@ -106,8 +106,6 @@ internal data class ProfileUiState( val actionFailed: Boolean = false, /** The reader's lists, once asked for, each saying whether the account is on it. */ val lists: List? = null, - /** The account's posts can be searched: the reader's server searches by account. */ - val postSearch: Boolean = false, /** The people the reader follows who follow this account too. */ val familiar: List = emptyList(), /** The handle the profile was opened by, shown while the account loads. */ diff --git a/feature/profile/src/main/kotlin/social/aloha/feature/profile/ProfileViewModel.kt b/feature/profile/src/main/kotlin/social/aloha/feature/profile/ProfileViewModel.kt index 74de1bbf..ae2fa2e3 100644 --- a/feature/profile/src/main/kotlin/social/aloha/feature/profile/ProfileViewModel.kt +++ b/feature/profile/src/main/kotlin/social/aloha/feature/profile/ProfileViewModel.kt @@ -416,8 +416,6 @@ internal class ProfileViewModel @AssistedInject constructor( lists = control.lists, familiar = control.familiar.map { Familiar(it.id, it.bestDisplayName, it.avatar) }, knownHandle = key.acct?.let { if (it.startsWith('@')) it else "@$it" }, - // Nextcloud Social reads no account_id, and would answer with everyone's posts - postSearch = !reader.capabilities.isNextcloudSocial, featured = ProfilePresentation.featured(control.pinned, control.featuredTags) { rows.rowFor(it, reader.serverAccountId, null) }, diff --git a/feature/profile/src/test/kotlin/social/aloha/feature/profile/EditProfileTest.kt b/feature/profile/src/test/kotlin/social/aloha/feature/profile/EditProfileTest.kt index a9f5c95f..9b7626d7 100644 --- a/feature/profile/src/test/kotlin/social/aloha/feature/profile/EditProfileTest.kt +++ b/feature/profile/src/test/kotlin/social/aloha/feature/profile/EditProfileTest.kt @@ -6,12 +6,22 @@ package social.aloha.feature.profile import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Test +import social.aloha.core.model.Account import social.aloha.core.model.AccountField import social.aloha.core.network.ApiError class EditProfileTest { private val original = ProfileForm(displayName = "Alice", note = "Surfs", avatar = Picture.Kept("https://x/a.png")) + @Test + fun `a stand-in avatar is shown but not offered for removal, a chosen one is`() { + val generated = Account("1", "alice", "alice", avatar = "https://x/avatar/alice/128", avatarDefault = true) + assertEquals(Picture.Kept("https://x/avatar/alice/128", chosen = false), ProfileForm.of(generated).avatar) + val own = generated.copy(avatar = "https://x/a.png", avatarDefault = false) + assertEquals(Picture.Kept("https://x/a.png", chosen = true), ProfileForm.of(own).avatar) + assertEquals(Picture.Kept(null, chosen = false), ProfileForm.of(own).header) + } + @Test fun `nothing changed sends nothing`() { assertTrue(changes(original, original, avatar = null, header = null).isEmpty) diff --git a/feature/profile/src/test/kotlin/social/aloha/feature/profile/ProfileScreenshotTest.kt b/feature/profile/src/test/kotlin/social/aloha/feature/profile/ProfileScreenshotTest.kt index 59a05415..3b1d5fb2 100644 --- a/feature/profile/src/test/kotlin/social/aloha/feature/profile/ProfileScreenshotTest.kt +++ b/feature/profile/src/test/kotlin/social/aloha/feature/profile/ProfileScreenshotTest.kt @@ -170,7 +170,7 @@ class ProfileScreenshotTest { val found = PostSearchUi("waves", rows = listOf(mapper.map(StatusSamples.post("

Waves at the point

"), "9"))) ProfileScreen( - profile().copy(postSearch = true), + profile(), NoActions, NoActions, listState = LazyListState(firstVisibleItemIndex = 2), diff --git a/feature/thread/src/main/kotlin/social/aloha/feature/thread/ThreadPresentation.kt b/feature/thread/src/main/kotlin/social/aloha/feature/thread/ThreadPresentation.kt index 05886326..a54c07c7 100644 --- a/feature/thread/src/main/kotlin/social/aloha/feature/thread/ThreadPresentation.kt +++ b/feature/thread/src/main/kotlin/social/aloha/feature/thread/ThreadPresentation.kt @@ -76,6 +76,7 @@ internal object ThreadPresentation { createdAt = edit.createdAt, spoiler = edit.spoilerText.takeIf { it.isNotBlank() }, body = StatusHtmlParser.parse(edit.content).toAnnotatedString(colors), + media = edit.mediaAttachments.map { it.description?.takeIf(String::isNotBlank) }, ) } } diff --git a/feature/thread/src/main/kotlin/social/aloha/feature/thread/ThreadScreen.kt b/feature/thread/src/main/kotlin/social/aloha/feature/thread/ThreadScreen.kt index d5cd4a1a..c1882a58 100644 --- a/feature/thread/src/main/kotlin/social/aloha/feature/thread/ThreadScreen.kt +++ b/feature/thread/src/main/kotlin/social/aloha/feature/thread/ThreadScreen.kt @@ -290,6 +290,18 @@ private fun History(versions: List, onDismiss: () -> Unit) { ) version.spoiler?.let { Text(it, style = MaterialTheme.typography.titleSmall) } Text(version.body, style = MaterialTheme.typography.bodyLarge) + // an edit may change only what the pictures say, which is then all that tells versions apart + version.media.forEachIndexed { index, description -> + Text( + stringResource( + R.string.thread_history_media, + index + 1, + description ?: stringResource(R.string.thread_history_no_description), + ), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } } } } diff --git a/feature/thread/src/main/kotlin/social/aloha/feature/thread/ThreadUiState.kt b/feature/thread/src/main/kotlin/social/aloha/feature/thread/ThreadUiState.kt index 690389cb..edfcd246 100644 --- a/feature/thread/src/main/kotlin/social/aloha/feature/thread/ThreadUiState.kt +++ b/feature/thread/src/main/kotlin/social/aloha/feature/thread/ThreadUiState.kt @@ -28,9 +28,14 @@ internal sealed interface ThreadItem { } } -/** One earlier version of an edited post, oldest first. */ +/** One earlier version of an edited post, oldest first; [media] holds each attachment's description, null for none. */ @Immutable -internal data class EditVersion(val createdAt: Instant, val spoiler: String?, val body: AnnotatedString) +internal data class EditVersion( + val createdAt: Instant, + val spoiler: String?, + val body: AnnotatedString, + val media: List = emptyList(), +) /** * What the thread screen shows. The focused post draws from the cache while the conversation loads; diff --git a/feature/thread/src/main/res/values/strings.xml b/feature/thread/src/main/res/values/strings.xml index a2220cf1..4a32666f 100644 --- a/feature/thread/src/main/res/values/strings.xml +++ b/feature/thread/src/main/res/values/strings.xml @@ -67,6 +67,10 @@ Edit history Original + + Attachment %1$d: %2$s + + no description You\'re offline. Showing what is saved on this device. diff --git a/feature/thread/src/test/kotlin/social/aloha/feature/thread/EditVersionsTest.kt b/feature/thread/src/test/kotlin/social/aloha/feature/thread/EditVersionsTest.kt new file mode 100644 index 00000000..7e5ef940 --- /dev/null +++ b/feature/thread/src/test/kotlin/social/aloha/feature/thread/EditVersionsTest.kt @@ -0,0 +1,33 @@ +// SPDX-FileCopyrightText: 2026 Aloha Social contributors +// SPDX-License-Identifier: MIT + +package social.aloha.feature.thread + +import androidx.compose.ui.graphics.Color +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import social.aloha.core.model.AttachmentKind +import social.aloha.core.model.MediaAttachment +import social.aloha.core.model.StatusEdit +import social.aloha.core.testing.StatusSamples +import social.aloha.core.ui.RichTextColors + +class EditVersionsTest { + @Test + fun `each version says what its pictures say, so an edit of a description alone shows`() { + fun edit(vararg descriptions: String?) = StatusEdit( + StatusSamples.post().account, + "

Aloha

", + mediaAttachments = descriptions.mapIndexed { i, text -> + MediaAttachment("$i", AttachmentKind.Image, "https://x/$i.png", description = text) + }, + ) + val versions = ThreadPresentation.versions(listOf(edit("Before", " "), edit("After", null)), COLORS) + assertEquals(listOf("Before", null), versions[0].media) + assertEquals(listOf("After", null), versions[1].media) + } + + private companion object { + val COLORS = RichTextColors(Color.Black, Color.Black, Color.Black) + } +} diff --git a/feature/thread/src/test/kotlin/social/aloha/feature/thread/ThreadScreenshotTest.kt b/feature/thread/src/test/kotlin/social/aloha/feature/thread/ThreadScreenshotTest.kt index 44eebbfa..314c87ee 100644 --- a/feature/thread/src/test/kotlin/social/aloha/feature/thread/ThreadScreenshotTest.kt +++ b/feature/thread/src/test/kotlin/social/aloha/feature/thread/ThreadScreenshotTest.kt @@ -155,7 +155,12 @@ class ThreadScreenshotTest { fun history() { val versions = listOf( EditVersion(StatusSamples.NOW.minusSeconds(3600), null, AnnotatedString("Aloha from the beach")), - EditVersion(StatusSamples.NOW, "Sunburn", AnnotatedString("Aloha from the beach! Bring sunscreen.")), + EditVersion( + StatusSamples.NOW, + "Sunburn", + AnnotatedString("Aloha from the beach! Bring sunscreen."), + listOf("Waves breaking at sunset", null), + ), ) compose.setContent { AlohaTheme(ThemeSettings(mode = ThemeMode.Light)) { diff --git a/feature/thread/src/test/screenshots/thread-history.png b/feature/thread/src/test/screenshots/thread-history.png index f4c93a83..e050ee76 100644 Binary files a/feature/thread/src/test/screenshots/thread-history.png and b/feature/thread/src/test/screenshots/thread-history.png differ