diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml
index 51ca6847..3d5d0f6f 100644
--- a/.github/workflows/checks.yml
+++ b/.github/workflows/checks.yml
@@ -61,7 +61,7 @@ jobs:
- name: Android platform 37
run: yes | "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" "platforms;android-37.0" "build-tools;37.0.0" > /dev/null
- name: Unit tests
- run: ./gradlew testGenericDebugUnitTest testGplayDebugUnitTest
+ run: ./gradlew alohaUnitTests
screenshot:
name: Screenshot comparison
@@ -80,7 +80,7 @@ jobs:
- name: Android platform 37
run: yes | "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" "platforms;android-37.0" "build-tools;37.0.0" > /dev/null
- name: Compare screenshots
- run: ./gradlew verifyRoborazziGenericDebug
+ run: ./gradlew alohaScreenshotTests
- name: Keep the differences
if: failure()
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
diff --git a/.github/workflows/ui.yml b/.github/workflows/ui.yml
index 25342e24..42254726 100644
--- a/.github/workflows/ui.yml
+++ b/.github/workflows/ui.yml
@@ -27,6 +27,12 @@ jobs:
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0
with:
persist-credentials: false
+ # the emulator's userdata partition needs about 7.4 GB, more than the runner has left after
+ # the release builds; these preinstalled toolchains are never used here
+ - name: Free disk space
+ run: |
+ sudo rm -rf /usr/share/dotnet /opt/ghc /usr/local/.ghcup /opt/hostedtoolcache/CodeQL
+ df -h /
- uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
distribution: temurin
@@ -40,12 +46,25 @@ jobs:
run: curl -fsSL "https://get.maestro.mobile.dev" | bash
- name: Release builds signed with the debug key for the emulator
run: ./gradlew assembleGenericRelease assembleGplayRelease -Paloha.signReleaseWithDebugKey=true
+ # built before the emulator starts, so the connected run below only installs and measures:
+ # R8 and a running emulator together exhaust the runner's memory
+ - name: Benchmark builds
+ run: ./gradlew :app:assembleGenericBenchmarkRelease :benchmark:assembleGenericBenchmarkRelease
- uses: reactivecircus/android-emulator-runner@a421e43855164a8197daf9d8d40fe71c6996bb0d # v2.38.0
with:
api-level: 36
arch: x86_64
target: google_apis
script: |
- adb install -r app/build/outputs/apk/generic/release/*.apk && "$HOME/.maestro/bin/maestro" test .maestro
- adb install -r app/build/outputs/apk/gplay/release/*.apk && "$HOME/.maestro/bin/maestro" test .maestro
+ adb install -r app/build/outputs/apk/generic/release/*.apk
+ "$HOME/.maestro/bin/maestro" test .maestro || { mkdir -p build/maestro && adb exec-out screencap -p > build/maestro/generic.png; adb shell uiautomator dump /sdcard/ui.xml && adb pull /sdcard/ui.xml build/maestro/generic.xml; exit 1; }
+ adb install -r app/build/outputs/apk/gplay/release/*.apk
+ "$HOME/.maestro/bin/maestro" test .maestro || { mkdir -p build/maestro && adb exec-out screencap -p > build/maestro/gplay.png; adb shell uiautomator dump /sdcard/ui.xml && adb pull /sdcard/ui.xml build/maestro/gplay.xml; exit 1; }
./gradlew :benchmark:connectedGenericBenchmarkReleaseAndroidTest -Pandroid.testInstrumentationRunnerArguments.androidx.benchmark.suppressErrors=EMULATOR
+ # the screen and its view hierarchy when a flow failed, to see what the assertion saw
+ - if: failure()
+ uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
+ with:
+ name: maestro-debug
+ path: build/maestro
+ if-no-files-found: ignore
diff --git a/.github/workflows/verification-metadata.yml b/.github/workflows/verification-metadata.yml
index 2691e597..9f0a2f2c 100644
--- a/.github/workflows/verification-metadata.yml
+++ b/.github/workflows/verification-metadata.yml
@@ -37,8 +37,7 @@ jobs:
run: >
./gradlew --write-verification-metadata sha256,pgp --export-keys
assembleGenericDebug assembleGplayDebug assembleGenericRelease assembleGplayRelease :benchmark:assemble
- detekt ktlintCheck lint alohaArchitectureCheck testGenericDebugUnitTest testGplayDebugUnitTest
- verifyRoborazziGenericDebug
+ detekt ktlintCheck lint alohaArchitectureCheck alohaUnitTests alohaScreenshotTests
- uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
name: verification-metadata
diff --git a/.maestro/smoke.yaml b/.maestro/smoke.yaml
index 7521a19d..2d9ba29e 100644
--- a/.maestro/smoke.yaml
+++ b/.maestro/smoke.yaml
@@ -1,17 +1,39 @@
# SPDX-FileCopyrightText: 2026 Aloha Social contributors
# SPDX-License-Identifier: MIT
#
-# Release-build smoke: the app starts past R8 and the navigation keys
-# survive serialisation. Grows with the app: sign-in against the mock, timeline.
+# Release-build smoke: the app starts past R8, and the accepted terms survive
+# a restart, so DataStore and the navigation keys work after shrinking.
+# Grows with the app: sign-in against the mock, timeline.
appId: social.aloha.android
---
- launchApp:
clearState: true
-- assertVisible: "Home"
-- tapOn: "Photos"
-- assertVisible: "Photos"
-- tapOn: "Notifications"
-- assertVisible: "Notifications"
+# a freshly booted CI emulator often shows "System UI isn't responding" over the app; wait it out
+# (text selectors match the whole text, so the pattern needs its wildcards)
+- retry:
+ maxRetries: 3
+ commands:
+ - runFlow:
+ when:
+ visible: ".*isn't responding.*"
+ commands:
+ - tapOn: "Wait"
+ - extendedWaitUntil:
+ visible: "Before you start"
+ timeout: 20000
+- tapOn: "Agree and continue"
+- assertVisible: "Add account"
- stopApp
- launchApp
-- assertVisible: "Home"
+- retry:
+ maxRetries: 3
+ commands:
+ - runFlow:
+ when:
+ visible: ".*isn't responding.*"
+ commands:
+ - tapOn: "Wait"
+ - extendedWaitUntil:
+ visible: "Add account"
+ timeout: 20000
+- assertNotVisible: "Before you start"
diff --git a/REUSE.toml b/REUSE.toml
index ce42ff49..0789702d 100644
--- a/REUSE.toml
+++ b/REUSE.toml
@@ -6,7 +6,7 @@ SPDX-PackageSupplier = "Aloha Social contributors"
SPDX-PackageDownloadLocation = "https://github.com/AlohaSocial/Android"
[[annotations]]
-path = ["**/*.md", "LICENSE", "docs/**", "**/src/test/screenshots/**", "config/**/*.json", ".maestro/**"]
+path = ["**/*.md", "LICENSE", "docs/**", "**/src/test/screenshots/**", "config/**/*.json", ".maestro/**", "core/testing/src/main/resources/fixtures/**", "core/database/schemas/**"]
precedence = "aggregate"
SPDX-FileCopyrightText = "2026 Aloha Social contributors"
SPDX-License-Identifier = "MIT"
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index c450f08d..2e9803e1 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -32,6 +32,10 @@ dependencies {
implementation(project(":core:designsystem"))
implementation(project(":core:navigation"))
implementation(project(":core:platform"))
+ implementation(project(":core:data"))
+ implementation(project(":core:network"))
+ implementation(project(":feature:signin"))
+ implementation(libs.androidx.hilt.lifecycle.viewmodel.compose)
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.core.splashscreen)
implementation(libs.androidx.activity.compose)
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index dc4bd2be..4a39fa06 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -32,11 +32,34 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/kotlin/social/aloha/android/AlohaRoot.kt b/app/src/main/kotlin/social/aloha/android/AlohaRoot.kt
new file mode 100644
index 00000000..fc384511
--- /dev/null
+++ b/app/src/main/kotlin/social/aloha/android/AlohaRoot.kt
@@ -0,0 +1,72 @@
+// SPDX-FileCopyrightText: 2026 Aloha Social contributors
+// SPDX-License-Identifier: MIT
+
+package social.aloha.android
+
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.statusBarsPadding
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.semantics.LiveRegionMode
+import androidx.compose.ui.semantics.liveRegion
+import androidx.compose.ui.semantics.semantics
+import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import social.aloha.core.designsystem.AlohaPreviews
+import social.aloha.core.designsystem.AlohaSpacing
+import social.aloha.core.designsystem.AlohaTheme
+import social.aloha.feature.signin.SignInEntry
+
+/** Sign-in until an account exists, the shell afterwards. */
+@Composable
+fun AlohaRoot(viewModel: AppViewModel = hiltViewModel()) {
+ val session by viewModel.session.collectAsStateWithLifecycle()
+ when (val current = session) {
+ AppSession.Loading -> Unit
+
+ AppSession.SigningIn -> SignInEntry()
+
+ is AppSession.SignedIn -> Column {
+ if (current.needsReauth) ReauthBanner(current.handle, onSignInAgain = viewModel::signInAgain)
+ AlohaApp()
+ }
+ }
+}
+
+/** The server refused the token: nothing is lost, and one tap starts a new sign-in. */
+@Composable
+internal fun ReauthBanner(handle: String, onSignInAgain: () -> Unit, modifier: Modifier = Modifier) {
+ Surface(color = MaterialTheme.colorScheme.errorContainer, modifier = modifier.fillMaxWidth()) {
+ Row(
+ modifier = Modifier
+ .statusBarsPadding()
+ .padding(horizontal = AlohaSpacing.m, vertical = AlohaSpacing.xs)
+ .semantics(mergeDescendants = true) { liveRegion = LiveRegionMode.Polite },
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Text(
+ stringResource(R.string.reauth_banner, handle),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onErrorContainer,
+ modifier = Modifier.weight(1f),
+ )
+ TextButton(onClick = onSignInAgain) { Text(stringResource(R.string.reauth_sign_in_again)) }
+ }
+ }
+}
+
+@AlohaPreviews
+@Composable
+private fun ReauthBannerPreview() {
+ AlohaTheme { ReauthBanner("alice", onSignInAgain = {}) }
+}
diff --git a/app/src/main/kotlin/social/aloha/android/AppModule.kt b/app/src/main/kotlin/social/aloha/android/AppModule.kt
new file mode 100644
index 00000000..6d9f4e37
--- /dev/null
+++ b/app/src/main/kotlin/social/aloha/android/AppModule.kt
@@ -0,0 +1,55 @@
+// SPDX-FileCopyrightText: 2026 Aloha Social contributors
+// SPDX-License-Identifier: MIT
+
+package social.aloha.android
+
+import android.content.Context
+import android.content.pm.verify.domain.DomainVerificationManager
+import android.content.pm.verify.domain.DomainVerificationUserState
+import android.os.Build
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.android.qualifiers.ApplicationContext
+import dagger.hilt.components.SingletonComponent
+import social.aloha.core.data.CleartextAllowed
+import social.aloha.core.data.RedirectUriProvider
+import social.aloha.core.network.di.UserAgent
+import social.aloha.core.network.oauth.OAuthIdentity
+
+@Module
+@InstallIn(SingletonComponent::class)
+object AppModule {
+ @Provides
+ @UserAgent
+ fun userAgent(): String =
+ "AlohaSocial/${BuildConfig.VERSION_NAME} (Android ${Build.VERSION.RELEASE}; +https://aloha.social)"
+
+ /** Debug builds may sign in to the plain-HTTP dev instance; the network security config agrees. */
+ @Provides
+ @CleartextAllowed
+ fun cleartextAllowed(): Boolean = BuildConfig.DEBUG
+
+ /**
+ * The verified App Link when this install is verified for aloha.social, so no other app can
+ * receive the code; otherwise, and always below Android 12, the custom scheme, where PKCE makes a
+ * hijacked code useless.
+ */
+ @Provides
+ fun redirectUriProvider(@ApplicationContext context: Context): RedirectUriProvider = RedirectUriProvider {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && appLinkVerified(context)) {
+ OAuthIdentity.APP_LINK_REDIRECT
+ } else {
+ OAuthIdentity.SCHEME_REDIRECT
+ }
+ }
+
+ @androidx.annotation.RequiresApi(Build.VERSION_CODES.S)
+ private fun appLinkVerified(context: Context): Boolean {
+ val manager = context.getSystemService(DomainVerificationManager::class.java) ?: return false
+ val state = manager.getDomainVerificationUserState(context.packageName) ?: return false
+ return state.hostToStateMap[APP_LINK_HOST] == DomainVerificationUserState.DOMAIN_STATE_VERIFIED
+ }
+
+ private const val APP_LINK_HOST = "aloha.social"
+}
diff --git a/app/src/main/kotlin/social/aloha/android/AppViewModel.kt b/app/src/main/kotlin/social/aloha/android/AppViewModel.kt
new file mode 100644
index 00000000..77254a27
--- /dev/null
+++ b/app/src/main/kotlin/social/aloha/android/AppViewModel.kt
@@ -0,0 +1,63 @@
+// SPDX-FileCopyrightText: 2026 Aloha Social contributors
+// SPDX-License-Identifier: MIT
+
+package social.aloha.android
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import dagger.hilt.android.lifecycle.HiltViewModel
+import javax.inject.Inject
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.filter
+import kotlinx.coroutines.flow.filterNotNull
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.launch
+import social.aloha.core.data.AccountMaintenance
+import social.aloha.core.data.AccountRepository
+import social.aloha.core.model.SignedInAccount
+
+/** What the root of the app shows. */
+sealed interface AppSession {
+ data object Loading : AppSession
+
+ data object SigningIn : AppSession
+
+ /** [needsReauth]: the server refused the token; the cache stays and a banner offers a new sign-in. */
+ data class SignedIn(val handle: String, val needsReauth: Boolean) : AppSession
+}
+
+@HiltViewModel
+class AppViewModel @Inject constructor(accounts: AccountRepository, maintenance: AccountMaintenance) : ViewModel() {
+ private val signingInAgain = MutableStateFlow(false)
+
+ init {
+ // once per launch, off the main thread: moved API bases are found and stale capabilities detected again
+ viewModelScope.launch { maintenance.checkAll() }
+ // a new sign-in that succeeded ends the request for one
+ viewModelScope.launch {
+ accounts.activeAccount.filterNotNull().filter { !it.needsReauth }.collect { signingInAgain.value = false }
+ }
+ }
+
+ val session: StateFlow =
+ combine(accounts.accounts, accounts.activeAccount, signingInAgain, ::sessionOf)
+ .stateIn(viewModelScope, SharingStarted.WhileSubscribed(STOP_TIMEOUT_MILLIS), AppSession.Loading)
+
+ fun signInAgain() {
+ signingInAgain.value = true
+ }
+
+ private fun sessionOf(all: List, active: SignedInAccount?, again: Boolean): AppSession = when {
+ active == null && all.isEmpty() -> AppSession.SigningIn
+ active == null -> AppSession.Loading
+ again && active.needsReauth -> AppSession.SigningIn
+ else -> AppSession.SignedIn(active.handle, active.needsReauth)
+ }
+
+ private companion object {
+ const val STOP_TIMEOUT_MILLIS = 5_000L
+ }
+}
diff --git a/app/src/main/kotlin/social/aloha/android/MainActivity.kt b/app/src/main/kotlin/social/aloha/android/MainActivity.kt
index fdfc081b..2e813442 100644
--- a/app/src/main/kotlin/social/aloha/android/MainActivity.kt
+++ b/app/src/main/kotlin/social/aloha/android/MainActivity.kt
@@ -19,7 +19,7 @@ class MainActivity : ComponentActivity() {
super.onCreate(savedInstanceState)
setContent {
AlohaTheme {
- AlohaApp()
+ AlohaRoot()
}
}
}
diff --git a/app/src/main/kotlin/social/aloha/android/OAuthRedirectActivity.kt b/app/src/main/kotlin/social/aloha/android/OAuthRedirectActivity.kt
new file mode 100644
index 00000000..a9dc9c0e
--- /dev/null
+++ b/app/src/main/kotlin/social/aloha/android/OAuthRedirectActivity.kt
@@ -0,0 +1,34 @@
+// SPDX-FileCopyrightText: 2026 Aloha Social contributors
+// SPDX-License-Identifier: MIT
+
+package social.aloha.android
+
+import android.content.Intent
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import dagger.hilt.android.AndroidEntryPoint
+import javax.inject.Inject
+import social.aloha.core.data.OAuthCallbackInbox
+
+/**
+ * Receives the OAuth callback and nothing else. The Custom Tab lives in the browser's task, so the
+ * callback lands here, outside the app's task: the URI goes straight to the in-process inbox, the
+ * main activity is brought forward without it, and this activity finishes without drawing.
+ */
+@AndroidEntryPoint
+class OAuthRedirectActivity : ComponentActivity() {
+ @Inject
+ lateinit var inbox: OAuthCallbackInbox
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ intent?.data?.let { inbox.deliver(it.toString()) }
+ startActivity(
+ Intent(
+ this,
+ MainActivity::class.java,
+ ).addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP),
+ )
+ finish()
+ }
+}
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index eaedba71..588e4dc5 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -18,4 +18,8 @@
Notifications
Profile
+
+ %s needs to sign in again. Nothing was lost.
+
+ Sign in again
diff --git a/app/src/test/kotlin/social/aloha/android/AppShellScreenshotTest.kt b/app/src/test/kotlin/social/aloha/android/AppShellScreenshotTest.kt
index 31454671..8e7b20c5 100644
--- a/app/src/test/kotlin/social/aloha/android/AppShellScreenshotTest.kt
+++ b/app/src/test/kotlin/social/aloha/android/AppShellScreenshotTest.kt
@@ -5,7 +5,9 @@ package social.aloha.android
import android.app.Application
import androidx.compose.ui.test.junit4.v2.createComposeRule
+import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.onRoot
+import androidx.compose.ui.test.performClick
import com.github.takahirom.roborazzi.RobolectricDeviceQualifiers
import com.github.takahirom.roborazzi.captureRoboImage
import org.junit.Rule
@@ -38,10 +40,16 @@ class AppShellScreenshotTest {
@Config(qualifiers = RobolectricDeviceQualifiers.Pixel7)
fun shellOnPhoneDark() = capture("shell-phone-dark", ThemeMode.Dark)
- private fun capture(name: String, mode: ThemeMode = ThemeMode.Light) {
+ /** Every destination's selected icon differs in shape, not only colour; Shorts is the one that did not. */
+ @Test
+ @Config(qualifiers = RobolectricDeviceQualifiers.Pixel7)
+ fun shellOnPhoneShortsSelected() = capture("shell-phone-shorts", select = "Shorts")
+
+ private fun capture(name: String, mode: ThemeMode = ThemeMode.Light, select: String? = null) {
compose.setContent {
AlohaTheme(ThemeSettings(mode = mode)) { AlohaApp() }
}
+ select?.let { compose.onNodeWithText(it).performClick() }
compose.onRoot().captureRoboImage("src/test/screenshots/$name.png")
}
}
diff --git a/app/src/test/screenshots/shell-phone-dark.png b/app/src/test/screenshots/shell-phone-dark.png
index 66e04039..da9bd04b 100644
Binary files a/app/src/test/screenshots/shell-phone-dark.png and b/app/src/test/screenshots/shell-phone-dark.png differ
diff --git a/app/src/test/screenshots/shell-phone-shorts.png b/app/src/test/screenshots/shell-phone-shorts.png
new file mode 100644
index 00000000..71040013
Binary files /dev/null and b/app/src/test/screenshots/shell-phone-shorts.png differ
diff --git a/app/src/test/screenshots/shell-phone.png b/app/src/test/screenshots/shell-phone.png
index 20095937..96707a2e 100644
Binary files a/app/src/test/screenshots/shell-phone.png and b/app/src/test/screenshots/shell-phone.png differ
diff --git a/app/src/test/screenshots/shell-tablet.png b/app/src/test/screenshots/shell-tablet.png
index 305e5b57..df448182 100644
Binary files a/app/src/test/screenshots/shell-tablet.png and b/app/src/test/screenshots/shell-tablet.png differ
diff --git a/build-logic/convention/build.gradle.kts b/build-logic/convention/build.gradle.kts
index 5950bb1d..ef62cba1 100644
--- a/build-logic/convention/build.gradle.kts
+++ b/build-logic/convention/build.gradle.kts
@@ -19,6 +19,7 @@ dependencies {
compileOnly(libs.detekt.gradle.plugin)
compileOnly(libs.ktlint.gradle.plugin)
compileOnly(libs.roborazzi.gradle.plugin)
+ compileOnly(libs.room.gradle.plugin)
}
gradlePlugin {
@@ -43,6 +44,10 @@ gradlePlugin {
id = "aloha.android.feature"
implementationClass = "AndroidFeatureConventionPlugin"
}
+ register("androidRoom") {
+ id = "aloha.android.room"
+ implementationClass = "AndroidRoomConventionPlugin"
+ }
register("jvmLibrary") {
id = "aloha.jvm.library"
implementationClass = "JvmLibraryConventionPlugin"
diff --git a/build-logic/convention/src/main/kotlin/AndroidApplicationConventionPlugin.kt b/build-logic/convention/src/main/kotlin/AndroidApplicationConventionPlugin.kt
index f427d390..1bd1a447 100644
--- a/build-logic/convention/src/main/kotlin/AndroidApplicationConventionPlugin.kt
+++ b/build-logic/convention/src/main/kotlin/AndroidApplicationConventionPlugin.kt
@@ -8,6 +8,7 @@ import org.gradle.kotlin.dsl.configure
import social.aloha.buildlogic.configureAndroid
import social.aloha.buildlogic.configureFlavors
import social.aloha.buildlogic.configureStaticAnalysis
+import social.aloha.buildlogic.registerUnitTestAggregate
import social.aloha.buildlogic.libs
import social.aloha.buildlogic.version
@@ -30,6 +31,7 @@ class AndroidApplicationConventionPlugin : Plugin {
}
}
configureStaticAnalysis()
+ registerUnitTestAggregate()
}
}
}
diff --git a/build-logic/convention/src/main/kotlin/AndroidLibraryConventionPlugin.kt b/build-logic/convention/src/main/kotlin/AndroidLibraryConventionPlugin.kt
index f3bd05a2..fc0bd3b4 100644
--- a/build-logic/convention/src/main/kotlin/AndroidLibraryConventionPlugin.kt
+++ b/build-logic/convention/src/main/kotlin/AndroidLibraryConventionPlugin.kt
@@ -4,9 +4,14 @@
import com.android.build.api.dsl.LibraryExtension
import org.gradle.api.Plugin
import org.gradle.api.Project
+import org.gradle.api.tasks.testing.Test
import org.gradle.kotlin.dsl.configure
+import org.gradle.kotlin.dsl.dependencies
import social.aloha.buildlogic.configureAndroid
import social.aloha.buildlogic.configureStaticAnalysis
+import social.aloha.buildlogic.library
+import social.aloha.buildlogic.libs
+import social.aloha.buildlogic.registerUnitTestAggregate
class AndroidLibraryConventionPlugin : Plugin {
override fun apply(target: Project) {
@@ -17,6 +22,21 @@ class AndroidLibraryConventionPlugin : Plugin {
testOptions.unitTests.isIncludeAndroidResources = true
}
configureStaticAnalysis()
+ registerUnitTestAggregate()
+ // JUnit 5 for plain unit tests; the vintage engine runs the JUnit 4 tests Robolectric needs
+ dependencies {
+ add("testImplementation", platform(libs.library("junit-bom")))
+ add("testImplementation", libs.library("junit-jupiter"))
+ add("testRuntimeOnly", libs.library("junit-vintage-engine"))
+ add("testRuntimeOnly", libs.library("junit-platform-launcher"))
+ }
+ tasks.withType(Test::class.java).configureEach {
+ useJUnitPlatform()
+ // a skeleton module has test resources from AGP but no tests yet
+ failOnNoDiscoveredTests.set(false)
+ // Robolectric's file-descriptor interceptor reaches into java.base on JDK 17+
+ jvmArgs("--add-exports=java.base/jdk.internal.access=ALL-UNNAMED")
+ }
}
}
}
diff --git a/build-logic/convention/src/main/kotlin/AndroidRoomConventionPlugin.kt b/build-logic/convention/src/main/kotlin/AndroidRoomConventionPlugin.kt
new file mode 100644
index 00000000..45415129
--- /dev/null
+++ b/build-logic/convention/src/main/kotlin/AndroidRoomConventionPlugin.kt
@@ -0,0 +1,29 @@
+// SPDX-FileCopyrightText: 2026 Aloha Social contributors
+// SPDX-License-Identifier: MIT
+
+import androidx.room.gradle.RoomExtension
+import org.gradle.api.Plugin
+import org.gradle.api.Project
+import org.gradle.kotlin.dsl.configure
+import org.gradle.kotlin.dsl.dependencies
+import social.aloha.buildlogic.library
+import social.aloha.buildlogic.libs
+
+/**
+ * Room with KSP. Schemas are exported to `schemas/` and committed, so every migration of the
+ * durable database is tested against the schema it starts from.
+ */
+class AndroidRoomConventionPlugin : Plugin {
+ override fun apply(target: Project) {
+ with(target) {
+ pluginManager.apply("androidx.room")
+ pluginManager.apply("com.google.devtools.ksp")
+ extensions.configure { schemaDirectory("$projectDir/schemas") }
+ dependencies {
+ add("api", libs.library("room-runtime"))
+ add("ksp", libs.library("room-compiler"))
+ add("testImplementation", libs.library("room-testing"))
+ }
+ }
+ }
+}
diff --git a/build-logic/convention/src/main/kotlin/JvmLibraryConventionPlugin.kt b/build-logic/convention/src/main/kotlin/JvmLibraryConventionPlugin.kt
index fa71c049..a09316fb 100644
--- a/build-logic/convention/src/main/kotlin/JvmLibraryConventionPlugin.kt
+++ b/build-logic/convention/src/main/kotlin/JvmLibraryConventionPlugin.kt
@@ -11,6 +11,7 @@ import social.aloha.buildlogic.configureKotlin
import social.aloha.buildlogic.configureStaticAnalysis
import social.aloha.buildlogic.library
import social.aloha.buildlogic.libs
+import social.aloha.buildlogic.registerUnitTestAggregate
/**
* A pure Kotlin module (`:core:model`, `:core:html`, …): no Android SDK on the
@@ -26,6 +27,7 @@ class JvmLibraryConventionPlugin : Plugin {
}
configureKotlin()
configureStaticAnalysis()
+ registerUnitTestAggregate()
dependencies {
add("testImplementation", platform(libs.library("junit-bom")))
add("testImplementation", libs.library("junit-jupiter"))
diff --git a/build-logic/convention/src/main/kotlin/ScreenshotConventionPlugin.kt b/build-logic/convention/src/main/kotlin/ScreenshotConventionPlugin.kt
index b6f25e13..f604f2dd 100644
--- a/build-logic/convention/src/main/kotlin/ScreenshotConventionPlugin.kt
+++ b/build-logic/convention/src/main/kotlin/ScreenshotConventionPlugin.kt
@@ -8,6 +8,7 @@ import org.gradle.api.tasks.testing.Test
import org.gradle.kotlin.dsl.dependencies
import social.aloha.buildlogic.library
import social.aloha.buildlogic.libs
+import social.aloha.buildlogic.registerScreenshotAggregate
/**
* Roborazzi screenshot tests on Robolectric (JUnit 4, which Robolectric's runner
@@ -17,6 +18,7 @@ class ScreenshotConventionPlugin : Plugin {
override fun apply(target: Project) {
with(target) {
pluginManager.apply("io.github.takahirom.roborazzi")
+ registerScreenshotAggregate()
extensions.getByType(CommonExtension::class.java).testOptions.unitTests.isIncludeAndroidResources = true
// Robolectric's file-descriptor interceptor reaches into java.base on JDK 17+
tasks.withType(Test::class.java).configureEach {
diff --git a/build-logic/convention/src/main/kotlin/social/aloha/buildlogic/Aggregates.kt b/build-logic/convention/src/main/kotlin/social/aloha/buildlogic/Aggregates.kt
new file mode 100644
index 00000000..9ecf1c3f
--- /dev/null
+++ b/build-logic/convention/src/main/kotlin/social/aloha/buildlogic/Aggregates.kt
@@ -0,0 +1,28 @@
+// SPDX-FileCopyrightText: 2026 Aloha Social contributors
+// SPDX-License-Identifier: MIT
+
+package social.aloha.buildlogic
+
+import org.gradle.api.Project
+
+/**
+ * `alohaUnitTests` runs the debug unit tests of this module, whatever its variants are called: the
+ * app's are per flavor, most libraries have one, a JVM module has `test`. CI runs this one name, so a
+ * module's tests cannot be skipped because its variant task is named differently.
+ */
+internal fun Project.registerUnitTestAggregate() {
+ tasks.register("alohaUnitTests") {
+ group = "verification"
+ description = "Runs this module's debug unit tests."
+ dependsOn(tasks.matching { it.name == "test" || (it.name.startsWith("test") && it.name.endsWith("DebugUnitTest")) })
+ }
+}
+
+/** `alohaScreenshotTests` verifies this module's Roborazzi images against the debug variants. */
+internal fun Project.registerScreenshotAggregate() {
+ tasks.register("alohaScreenshotTests") {
+ group = "verification"
+ description = "Verifies this module's screenshots."
+ dependsOn(tasks.matching { it.name.startsWith("verifyRoborazzi") && it.name.endsWith("Debug") })
+ }
+}
diff --git a/build.gradle.kts b/build.gradle.kts
index 8337fa0d..91a68a45 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -14,5 +14,6 @@ plugins {
alias(libs.plugins.ktlint) apply false
alias(libs.plugins.roborazzi) apply false
alias(libs.plugins.baselineprofile) apply false
+ alias(libs.plugins.room) apply false
alias(libs.plugins.aloha.architecture)
}
diff --git a/config/detekt/detekt.yml b/config/detekt/detekt.yml
index 994d9e08..4fe06b36 100644
--- a/config/detekt/detekt.yml
+++ b/config/detekt/detekt.yml
@@ -22,8 +22,12 @@ complexity:
thresholdInClasses: 15
thresholdInInterfaces: 15
thresholdInObjects: 15
+ # a class implementing an interface does not grow more responsibilities by doing so
+ ignoreOverridden: true
CyclomaticComplexMethod:
threshold: 10
+ # a flat table of single-expression branches is a lookup, not a tangle
+ ignoreSimpleWhenEntries: true
LongParameterList:
functionThreshold: 5
constructorThreshold: 7
@@ -39,6 +43,9 @@ naming:
constantPattern: '[A-Z][A-Za-z0-9_]*'
style:
+ ReturnCount:
+ # an early `?: return` guard reads better than nesting the rest of the function
+ excludeGuardClauses: true
MagicNumber:
ignorePropertyDeclaration: true
ignoreCompanionObjectPropertyDeclaration: true
diff --git a/config/size-baseline.json b/config/size-baseline.json
index 867266c4..9877d310 100644
--- a/config/size-baseline.json
+++ b/config/size-baseline.json
@@ -1,4 +1,4 @@
{
- "app-generic-release-unsigned.apk": 1110083,
- "app-gplay-release-unsigned.apk": 1110083
+ "app-generic-release-unsigned.apk": 1987649,
+ "app-gplay-release-unsigned.apk": 1987649
}
diff --git a/core/data/build.gradle.kts b/core/data/build.gradle.kts
index c60c878a..4b842208 100644
--- a/core/data/build.gradle.kts
+++ b/core/data/build.gradle.kts
@@ -3,4 +3,19 @@
plugins {
alias(libs.plugins.aloha.android.library)
+ alias(libs.plugins.aloha.android.hilt)
+ alias(libs.plugins.kotlin.serialization)
+}
+
+dependencies {
+ api(project(":core:model"))
+ api(project(":core:network"))
+ implementation(project(":core:database"))
+ implementation(project(":core:datastore"))
+ implementation(libs.kotlinx.serialization.json)
+ testImplementation(libs.kotlinx.coroutines.test)
+ testImplementation(libs.turbine)
+ testImplementation(libs.junit4)
+ testImplementation(libs.robolectric)
+ testImplementation(libs.androidx.test.core)
}
diff --git a/core/data/src/main/kotlin/social/aloha/core/data/AccountMaintenance.kt b/core/data/src/main/kotlin/social/aloha/core/data/AccountMaintenance.kt
new file mode 100644
index 00000000..882b8793
--- /dev/null
+++ b/core/data/src/main/kotlin/social/aloha/core/data/AccountMaintenance.kt
@@ -0,0 +1,63 @@
+// SPDX-FileCopyrightText: 2026 Aloha Social contributors
+// SPDX-License-Identifier: MIT
+
+package social.aloha.core.data
+
+import java.time.Clock
+import javax.inject.Inject
+import javax.inject.Singleton
+import okhttp3.HttpUrl
+import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
+import social.aloha.core.model.InstanceDescription
+import social.aloha.core.model.SignedInAccount
+import social.aloha.core.network.capabilities.CapabilityDetector
+import social.aloha.core.network.probe.ProbeResult
+import social.aloha.core.network.probe.ReprobeGate
+import social.aloha.core.network.probe.ServerAddress
+import social.aloha.core.network.probe.ServerProbe
+
+/**
+ * Keeps what the app knows about each server current. Once per launch every account's API base is
+ * asked for its instance; one that stopped answering is probed for anew, at most once an hour per
+ * host, because an administrator may have added or removed the rewrite rules. Capabilities are
+ * detected again when they are older than a day or the base moved. Accounts that need a new sign-in
+ * are left alone.
+ */
+@Singleton
+public class AccountMaintenance @Inject constructor(
+ private val accounts: AccountRepository,
+ private val probe: ServerProbe,
+ private val detector: CapabilityDetector,
+ private val gate: ReprobeGate,
+ private val clock: Clock,
+) {
+ public suspend fun checkAll() {
+ accounts.all().filter { !it.needsReauth }.forEach { check(it) }
+ }
+
+ /** Checks [account]'s API base, finding it again when it moved, and detects capabilities when due. */
+ public suspend fun check(account: SignedInAccount) {
+ val current = account.apiBase.toHttpUrlOrNull() ?: return
+ val answering = probe.instanceAt(current)
+ when {
+ answering == null -> findAgain(account, current)
+ account.capabilities.isStale(clock.instant()) -> detect(account, current, answering)
+ }
+ }
+
+ private suspend fun findAgain(account: SignedInAccount, current: HttpUrl) {
+ val moved = reprobed(current) ?: return
+ probe.instanceAt(moved)?.let { detect(account, moved, it) }
+ }
+
+ private suspend fun detect(account: SignedInAccount, base: HttpUrl, instance: InstanceDescription) {
+ val nodeInfo = probe.nodeInfo(base.newBuilder().encodedPath("/").build())
+ accounts.updateCapabilities(account.id, detector.detect(base, accounts.token(account.id), instance, nodeInfo))
+ }
+
+ private suspend fun reprobed(current: HttpUrl): HttpUrl? {
+ if (!gate.tryAcquire(current.host)) return null
+ val address = ServerAddress(current.newBuilder().encodedPath("/").build(), pathHint = null)
+ return (probe.discover(address) as? ProbeResult.Found)?.outcome?.apiBase
+ }
+}
diff --git a/core/data/src/main/kotlin/social/aloha/core/data/AccountRepository.kt b/core/data/src/main/kotlin/social/aloha/core/data/AccountRepository.kt
new file mode 100644
index 00000000..4448f5b3
--- /dev/null
+++ b/core/data/src/main/kotlin/social/aloha/core/data/AccountRepository.kt
@@ -0,0 +1,198 @@
+// SPDX-FileCopyrightText: 2026 Aloha Social contributors
+// SPDX-License-Identifier: MIT
+
+package social.aloha.core.data
+
+import java.time.Clock
+import java.time.Instant
+import java.util.UUID
+import javax.inject.Inject
+import javax.inject.Singleton
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.filterNotNull
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.onStart
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.serialization.SerializationException
+import kotlinx.serialization.json.Json
+import social.aloha.core.data.di.ApplicationScope
+import social.aloha.core.database.AccountDao
+import social.aloha.core.database.AccountEntity
+import social.aloha.core.database.ClientRegistrationEntity
+import social.aloha.core.datastore.AppPreferences
+import social.aloha.core.datastore.TokenVault
+import social.aloha.core.datastore.VaultKey
+import social.aloha.core.model.AccessToken
+import social.aloha.core.model.ClientRegistration
+import social.aloha.core.model.ServerCapabilities
+import social.aloha.core.model.SignedInAccount
+import social.aloha.core.network.Credentials
+
+/**
+ * Every account on this device and which one is active. Rows live in `accounts.db`, secrets in the
+ * vault; the two are written together and neither ever holds the other's data.
+ */
+@Singleton
+public class AccountRepository @Inject constructor(
+ private val dao: AccountDao,
+ private val vault: TokenVault,
+ private val preferences: AppPreferences,
+ private val clock: Clock,
+ @ApplicationScope scope: CoroutineScope,
+) {
+ private val json = Json { ignoreUnknownKeys = true }
+
+ /** The account list, collected once for the process; the vault is reconciled before the first rows. */
+ private val accountList: StateFlow?> = dao.observeAll()
+ .onStart { reconcileVault() }
+ .map { rows -> rows.map { it.toDomain() } }
+ .stateIn(scope, SharingStarted.Eagerly, initialValue = null)
+
+ /** Every account in the order the person arranged them. */
+ public val accounts: Flow> = accountList.filterNotNull()
+
+ /** The active account, falling back to the first one when none is marked active. */
+ public val activeAccount: StateFlow = combine(accounts, preferences.activeAccountId) { all, id ->
+ all.firstOrNull { it.id == id } ?: all.firstOrNull()
+ }.stateIn(scope, SharingStarted.Eagerly, initialValue = null)
+
+ /** The accounts as they are in the database right now, for work that must not act on a stale list. */
+ public suspend fun all(): List = dao.all().map { it.toDomain() }
+
+ /** Stores a newly signed-in account (or refreshes one signed in again) and makes it active. */
+ public suspend fun signedIn(account: NewAccount, token: AccessToken): SignedInAccount {
+ val existing = dao.find(account.host, account.serverAccountId)
+ val id = existing?.id ?: UUID.randomUUID().toString()
+ vault.put(VaultKey.AccessToken(id), token.value)
+ val entity = AccountEntity(
+ id = id,
+ instanceHost = account.host,
+ apiBase = account.capabilities.apiBase,
+ serverAccountId = account.serverAccountId,
+ handle = account.handle,
+ displayName = account.displayName,
+ avatarUrl = account.avatarUrl,
+ headerUrl = account.headerUrl,
+ capabilitiesJson = json.encodeToString(ServerCapabilities.serializer(), account.capabilities),
+ needsReauth = false,
+ profilePending = account.profilePending,
+ sortIndex = existing?.sortIndex ?: dao.nextSortIndex(),
+ addedAt = existing?.addedAt ?: clock.millis(),
+ nextcloudConnected = existing?.nextcloudConnected ?: false,
+ )
+ dao.upsert(entity)
+ preferences.setActiveAccountId(id)
+ return entity.toDomain()
+ }
+
+ public suspend fun activate(id: String) {
+ if (dao.get(id) != null) preferences.setActiveAccountId(id)
+ }
+
+ /** The server refused the token: keep the account and its cache, ask for a new sign-in. */
+ public suspend fun markNeedsReauth(id: String) {
+ dao.setNeedsReauth(id, needsReauth = true)
+ }
+
+ /** Stores freshly detected capabilities; their API base becomes the account's, which a re-probe may have moved. */
+ public suspend fun updateCapabilities(id: String, capabilities: ServerCapabilities) {
+ val row = dao.get(id) ?: return
+ dao.upsert(
+ row.copy(
+ apiBase = capabilities.apiBase,
+ capabilitiesJson = json.encodeToString(ServerCapabilities.serializer(), capabilities),
+ ),
+ )
+ }
+
+ /** Removes the account and its secrets; another account becomes active. Revocation is the caller's. */
+ public suspend fun remove(id: String) {
+ vault.remove(VaultKey.AccessToken(id))
+ vault.remove(VaultKey.AppPassword(id))
+ dao.delete(id)
+ if (preferences.activeAccountId.first() == id) preferences.setActiveAccountId(dao.all().firstOrNull()?.id)
+ }
+
+ public suspend fun token(id: String): AccessToken? = vault.get(VaultKey.AccessToken(id))?.let {
+ AccessToken(it, "")
+ }
+
+ public suspend fun credentials(id: String): Credentials = Credentials(
+ bearerToken = vault.get(VaultKey.AccessToken(id)),
+ nextcloudBasic = vault.get(VaultKey.AppPassword(id)),
+ )
+
+ /** The app's OAuth client on [host], registered once per server on this device. */
+ public suspend fun registration(host: String): ClientRegistration? {
+ val row = dao.registration(host) ?: return null
+ val secret = vault.get(VaultKey.ClientSecret(host)) ?: return null
+ return ClientRegistration(row.clientId, secret, row.scopes)
+ }
+
+ public suspend fun saveRegistration(host: String, registration: ClientRegistration) {
+ vault.put(VaultKey.ClientSecret(host), registration.clientSecret)
+ dao.upsertRegistration(
+ ClientRegistrationEntity(host, registration.clientId, registration.scopes, clock.millis()),
+ )
+ }
+
+ /** Forgets a registration the server no longer knows (a 401 `unknown client_id` on exchange). */
+ public suspend fun forgetRegistration(host: String) {
+ vault.remove(VaultKey.ClientSecret(host))
+ dao.deleteRegistration(host)
+ }
+
+ /**
+ * A vault that could not be decrypted, or accounts restored from a backup without their tokens,
+ * leave every affected account needing a new sign-in; nothing is deleted.
+ */
+ internal suspend fun reconcileVault() {
+ if (vault.wasLost()) {
+ dao.markAllNeedReauth()
+ return
+ }
+ dao.all().filter { !it.needsReauth && vault.get(VaultKey.AccessToken(it.id)) == null }
+ .forEach { dao.setNeedsReauth(it.id, needsReauth = true) }
+ }
+
+ private fun AccountEntity.toDomain(): SignedInAccount = SignedInAccount(
+ id = id,
+ host = instanceHost,
+ apiBase = apiBase,
+ serverAccountId = serverAccountId,
+ handle = handle,
+ displayName = displayName,
+ avatarUrl = avatarUrl,
+ headerUrl = headerUrl,
+ capabilities = capabilities(),
+ needsReauth = needsReauth,
+ profilePending = profilePending,
+ addedAt = Instant.ofEpochMilli(addedAt),
+ nextcloudConnected = nextcloudConnected,
+ )
+
+ private fun AccountEntity.capabilities(): ServerCapabilities = capabilitiesJson?.let {
+ try {
+ json.decodeFromString(ServerCapabilities.serializer(), it)
+ } catch (_: SerializationException) {
+ null
+ }
+ } ?: ServerCapabilities.minimal(apiBase)
+}
+
+/** What sign-in learned about a new account. */
+public data class NewAccount(
+ val host: String,
+ val serverAccountId: String,
+ val handle: String,
+ val displayName: String,
+ val avatarUrl: String?,
+ val headerUrl: String?,
+ val capabilities: ServerCapabilities,
+ val profilePending: Boolean,
+)
diff --git a/core/data/src/main/kotlin/social/aloha/core/data/ClientFactory.kt b/core/data/src/main/kotlin/social/aloha/core/data/ClientFactory.kt
new file mode 100644
index 00000000..726220e3
--- /dev/null
+++ b/core/data/src/main/kotlin/social/aloha/core/data/ClientFactory.kt
@@ -0,0 +1,40 @@
+// SPDX-FileCopyrightText: 2026 Aloha Social contributors
+// SPDX-License-Identifier: MIT
+
+package social.aloha.core.data
+
+import javax.inject.Inject
+import javax.inject.Singleton
+import kotlinx.coroutines.CoroutineDispatcher
+import okhttp3.HttpUrl
+import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
+import okhttp3.OkHttpClient
+import social.aloha.core.model.SignedInAccount
+import social.aloha.core.network.ApiClient
+import social.aloha.core.network.Credentials
+import social.aloha.core.network.RateLimiter
+import social.aloha.core.network.di.IoDispatcher
+
+/** Builds the API client for an account, or for a base and credentials not stored yet. */
+@Singleton
+public class ClientFactory @Inject constructor(
+ private val http: OkHttpClient,
+ private val rateLimiter: RateLimiter,
+ @param:IoDispatcher private val ioDispatcher: CoroutineDispatcher,
+ private val accounts: AccountRepository,
+) {
+ public fun create(
+ apiBase: HttpUrl,
+ onUnauthorised: suspend () -> Unit = {},
+ credentials: suspend () -> Credentials,
+ ): ApiClient =
+ ApiClient(apiBase, { credentials() }, http, rateLimiter, ioDispatcher, onUnauthorised = onUnauthorised)
+
+ /** The client for a signed-in account: a 401 marks the account as needing a new sign-in. */
+ public fun forAccount(account: SignedInAccount): ApiClient? {
+ val base = account.apiBase.toHttpUrlOrNull() ?: return null
+ return create(base, onUnauthorised = {
+ accounts.markNeedsReauth(account.id)
+ }) { accounts.credentials(account.id) }
+ }
+}
diff --git a/core/data/src/main/kotlin/social/aloha/core/data/OAuthCallbackInbox.kt b/core/data/src/main/kotlin/social/aloha/core/data/OAuthCallbackInbox.kt
new file mode 100644
index 00000000..d8a0401d
--- /dev/null
+++ b/core/data/src/main/kotlin/social/aloha/core/data/OAuthCallbackInbox.kt
@@ -0,0 +1,35 @@
+// SPDX-FileCopyrightText: 2026 Aloha Social contributors
+// SPDX-License-Identifier: MIT
+
+package social.aloha.core.data
+
+import javax.inject.Inject
+import javax.inject.Qualifier
+import javax.inject.Singleton
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+
+/**
+ * Hands the OAuth callback from the activity that received it to the sign-in screen. Held as state
+ * until consumed, so a callback that arrives while the screen is being recreated is not lost.
+ */
+@Singleton
+public class OAuthCallbackInbox @Inject constructor() {
+ private val latest = MutableStateFlow(null)
+
+ public val callback: StateFlow = latest.asStateFlow()
+
+ public fun deliver(uri: String) {
+ latest.value = uri
+ }
+
+ public fun consume(uri: String) {
+ latest.compareAndSet(uri, null)
+ }
+}
+
+/** Whether a typed `http://` server is accepted: true only in debug builds, for the local dev instance. */
+@Qualifier
+@Retention(AnnotationRetention.BINARY)
+public annotation class CleartextAllowed
diff --git a/core/data/src/main/kotlin/social/aloha/core/data/ServerFinder.kt b/core/data/src/main/kotlin/social/aloha/core/data/ServerFinder.kt
new file mode 100644
index 00000000..05f4b9b3
--- /dev/null
+++ b/core/data/src/main/kotlin/social/aloha/core/data/ServerFinder.kt
@@ -0,0 +1,107 @@
+// SPDX-FileCopyrightText: 2026 Aloha Social contributors
+// SPDX-License-Identifier: MIT
+
+package social.aloha.core.data
+
+import javax.inject.Inject
+import javax.inject.Singleton
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.withContext
+import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
+import social.aloha.core.network.ApiError
+import social.aloha.core.network.di.IoDispatcher
+import social.aloha.core.network.probe.CandidateKind
+import social.aloha.core.network.probe.ProbeCandidate
+import social.aloha.core.network.probe.ProbeResult
+import social.aloha.core.network.probe.ServerAddress
+import social.aloha.core.network.probe.ServerProbe
+import social.aloha.core.network.tls.ClientCertificateAliases
+import social.aloha.core.network.tls.UserTrustStore
+import social.aloha.core.network.tls.sha256Fingerprint
+
+/**
+ * Finds a server's API from what a person typed, and keeps the TLS decisions they make about it: a
+ * certificate they trust for that host, a client certificate they present to it.
+ */
+@Singleton
+public class ServerFinder @Inject constructor(
+ private val probe: ServerProbe,
+ private val trustStore: UserTrustStore,
+ private val clientCertificates: ClientCertificateAliases,
+ @param:CleartextAllowed private val cleartextAllowed: Boolean,
+ @param:IoDispatcher private val ioDispatcher: CoroutineDispatcher,
+) {
+ /** Looks for the server a person named: a host, a URL, a path or a handle. */
+ public suspend fun find(typed: String): ServerLookup {
+ val address = ServerAddress.parse(typed, cleartextAllowed) ?: return when {
+ !cleartextAllowed && ServerAddress.parse(
+ typed,
+ allowCleartext = true,
+ ) != null -> ServerLookup.InsecureAddress
+
+ else -> ServerLookup.InvalidAddress
+ }
+ return probe.discover(address).toLookup()
+ }
+
+ /** Looks at exactly the API address a person typed by hand. */
+ public suspend fun findAt(apiAddress: String): ServerLookup {
+ val text = apiAddress.trim().let { if (it.contains("://")) it else "https://$it" }
+ val url = text.toHttpUrlOrNull()
+ return when {
+ url == null -> ServerLookup.InvalidAddress
+ !url.isHttps && !cleartextAllowed -> ServerLookup.InsecureAddress
+ else -> probe.discover(url).toLookup()
+ }
+ }
+
+ /** The host a person named, for the TLS decisions made about it; null when it cannot be an address. */
+ public fun hostOf(typed: String): String? = ServerAddress.parse(typed, cleartextAllowed)?.origin?.host
+
+ /** Trusts [certificate] for its host from now on; written to app-private storage off the caller's thread. */
+ public suspend fun trust(certificate: ServerCertificate) {
+ withContext(ioDispatcher) { trustStore.trust(certificate.host, certificate.leaf) }
+ }
+
+ /** Presents the KeyChain certificate [alias] to [host] from now on, or none when [alias] is null. */
+ public suspend fun useClientCertificate(host: String, alias: String?) {
+ withContext(ioDispatcher) { clientCertificates.set(host, alias) }
+ }
+
+ private fun ProbeResult.toLookup(): ServerLookup = when (this) {
+ is ProbeResult.Found -> ServerLookup.Found(DiscoveredServer(outcome))
+
+ is ProbeResult.NothingAnswered -> ServerLookup.NothingAnswered(attempted.map { it.toTried() })
+
+ is ProbeResult.Unreachable -> ServerLookup.Unreachable
+
+ is ProbeResult.UntrustedCertificate ->
+ error.toCertificate()?.let(ServerLookup::UntrustedCertificate) ?: ServerLookup.NothingAnswered(emptyList())
+ }
+
+ private fun ProbeCandidate.toTried(): TriedAddress = TriedAddress(
+ url = base.toString(),
+ reason = when (kind) {
+ CandidateKind.AuthorizationServerIssuer -> TriedBecause.Discovery
+ CandidateKind.DomainRoot -> TriedBecause.DomainRoot
+ CandidateKind.AppPath -> TriedBecause.AppPath
+ CandidateKind.PrettyAppPath -> TriedBecause.PrettyAppPath
+ CandidateKind.TypedPath -> TriedBecause.TypedPath
+ CandidateKind.TypedAppPath -> TriedBecause.TypedAppPath
+ CandidateKind.Manual -> TriedBecause.Manual
+ },
+ )
+}
+
+/** The leaf certificate of an untrusted chain, described for a person, or null without one. */
+internal fun ApiError.UntrustedCertificate.toCertificate(): ServerCertificate? {
+ val leaf = chain.firstOrNull() ?: return null
+ return ServerCertificate(
+ host = host,
+ subject = leaf.subjectX500Principal.name,
+ issuer = leaf.issuerX500Principal.name,
+ validUntil = leaf.notAfter.toInstant(),
+ sha256 = leaf.sha256Fingerprint(),
+ leaf = leaf,
+ )
+}
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
new file mode 100644
index 00000000..2b86c0e5
--- /dev/null
+++ b/core/data/src/main/kotlin/social/aloha/core/data/SignInCoordinator.kt
@@ -0,0 +1,189 @@
+// SPDX-FileCopyrightText: 2026 Aloha Social contributors
+// SPDX-License-Identifier: MIT
+
+package social.aloha.core.data
+
+import javax.inject.Inject
+import javax.inject.Singleton
+import kotlinx.serialization.SerializationException
+import kotlinx.serialization.json.Json
+import okhttp3.HttpUrl
+import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
+import social.aloha.core.datastore.TokenVault
+import social.aloha.core.datastore.VaultKey
+import social.aloha.core.model.AccessToken
+import social.aloha.core.model.Account
+import social.aloha.core.model.ServerCapabilities
+import social.aloha.core.network.ApiError
+import social.aloha.core.network.ApiResult
+import social.aloha.core.network.Credentials
+import social.aloha.core.network.capabilities.CapabilityDetector
+import social.aloha.core.network.endpoints.AccountEndpoints
+import social.aloha.core.network.endpoints.InstanceEndpoints
+import social.aloha.core.network.errorOrNull
+import social.aloha.core.network.oauth.OAuthCallback
+import social.aloha.core.network.oauth.OAuthClient
+import social.aloha.core.network.oauth.OAuthEndpoints
+import social.aloha.core.network.oauth.PendingAuthorization
+import social.aloha.core.network.oauth.authorizationUrl
+import social.aloha.core.network.valueOrNull
+
+/**
+ * Sign-in from a discovered server to a stored, active account: register the app once per server,
+ * open the authorisation page, exchange the code that comes back, describe the account, detect what
+ * the server can do. The sign-in in flight is kept in the vault, so it survives the process dying
+ * while the browser tab is open.
+ */
+@Singleton
+public class SignInCoordinator @Inject constructor(
+ private val accounts: AccountRepository,
+ private val vault: TokenVault,
+ private val oauth: OAuthClient,
+ private val detector: CapabilityDetector,
+ private val redirects: RedirectUriProvider,
+ private val clients: ClientFactory,
+) {
+ private val json = Json { ignoreUnknownKeys = true }
+
+ /** Registers where needed and returns the page to open in the browser. */
+ public suspend fun beginAuthorization(server: DiscoveredServer): Authorization {
+ val outcome = server.outcome
+ val base = outcome.apiBase
+ val registration = accounts.registration(base.host) ?: when (val registered = oauth.register(base)) {
+ is ApiResult.Success -> registered.value.also { accounts.saveRegistration(base.host, it) }
+ is ApiResult.Failure -> return Authorization.Failed(registered.error.toProblem())
+ }
+ val metadata = outcome.authorizationServer ?: oauth.metadata(base)
+ val (url, started) =
+ authorizationUrl(OAuthEndpoints.from(metadata, base), base, registration.clientId, redirects.redirectUri())
+ val pending = started.copy(nodeInfo = outcome.nodeInfo)
+ vault.put(VaultKey.PendingAuthorization, json.encodeToString(PendingAuthorization.serializer(), pending))
+ return Authorization.Started(url.toString())
+ }
+
+ /** Whether a sign-in is waiting for its callback, e.g. after the process was restarted. */
+ public suspend fun hasPendingAuthorization(): Boolean = pending() != null
+
+ /** The page of the sign-in waiting for its callback, to open it again; null without one. */
+ public suspend fun pendingAuthorizationUrl(): String? = pending()?.authorizationUrl
+
+ public suspend fun cancel() {
+ vault.remove(VaultKey.PendingAuthorization)
+ }
+
+ /** Finishes the sign-in with the callback URI the browser handed back. Never retried automatically. */
+ public suspend fun complete(callbackUri: String): SignInResult {
+ val pending = pending() ?: return SignInResult.NotForUs
+ return when (val callback = OAuthCallback.parse(callbackUri, pending.state)) {
+ OAuthCallback.StateMismatch -> SignInResult.NotForUs
+ is OAuthCallback.Denied -> refused(callback.reason).also { cancel() }
+ OAuthCallback.MissingCode -> refused(null).also { cancel() }
+ is OAuthCallback.Code -> exchange(pending, callback.code).also { cancel() }
+ }
+ }
+
+ private fun refused(reason: String?) = SignInResult.Failed(SignInProblem.Refused(reason))
+
+ private suspend fun exchange(pending: PendingAuthorization, code: String): SignInResult {
+ val base = pending.apiBase.toHttpUrlOrNull() ?: return SignInResult.NotForUs
+ val tokenEndpoint = pending.tokenEndpoint.toHttpUrlOrNull() ?: return SignInResult.NotForUs
+ val registration = accounts.registration(base.host) ?: return SignInResult.NotForUs
+ return when (val token = oauth.exchange(tokenEndpoint, registration, pending, code)) {
+ is ApiResult.Success -> describe(pending, base, token.value)
+
+ is ApiResult.Failure -> {
+ if (token.error is ApiError.Unauthorised) accounts.forgetRegistration(base.host)
+ SignInResult.Failed(token.error.toProblem())
+ }
+ }
+ }
+
+ /**
+ * `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).
+ */
+ private suspend fun describe(pending: PendingAuthorization, base: HttpUrl, token: AccessToken): SignInResult {
+ val client = clients.create(base) { Credentials(token.value) }
+ val instance = client.execute(InstanceEndpoints.v2()).valueOrNull()
+ ?: client.execute(InstanceEndpoints.v1()).valueOrNull()
+ val me = client.execute(AccountEndpoints.verifyCredentials())
+ val newAccount = when (me) {
+ is ApiResult.Success -> me.value.toNewAccount(base)
+ is ApiResult.Failure -> fromUserInfo(base, pending, token, me.error)
+ }
+ if (instance == null || newAccount == null) {
+ return SignInResult.Failed((me.errorOrNull() ?: ApiError.NotFound).toProblem())
+ }
+ val capabilities = detector.detect(base, token, instance, pending.nodeInfo)
+ return SignInResult.SignedIn(accounts.signedIn(newAccount.copy(capabilities = capabilities), token))
+ }
+
+ private fun Account.toNewAccount(base: HttpUrl) = NewAccount(
+ host = base.host,
+ serverAccountId = id,
+ handle = acct,
+ displayName = bestDisplayName,
+ avatarUrl = avatar,
+ headerUrl = header,
+ capabilities = placeholder(base),
+ profilePending = false,
+ )
+
+ private suspend fun fromUserInfo(
+ base: HttpUrl,
+ pending: PendingAuthorization,
+ token: AccessToken,
+ error: ApiError,
+ ): NewAccount? {
+ if (error !is ApiError.Server || error.status < SERVER_ERROR) return null
+ val userinfo = pending.userinfoEndpoint.toHttpUrlOrNull() ?: return null
+ val info = (oauth.userInfo(userinfo, token) as? ApiResult.Success)?.value ?: return null
+ val handle = info.preferredUsername ?: info.subject.substringAfterLast('@')
+ return NewAccount(
+ base.host,
+ info.subject,
+ handle,
+ info.name ?: handle,
+ null,
+ null,
+ placeholder(base),
+ profilePending = true,
+ )
+ }
+
+ private fun placeholder(base: HttpUrl) = ServerCapabilities.minimal(base.toString())
+
+ private suspend fun pending(): PendingAuthorization? = vault.get(VaultKey.PendingAuthorization)?.let {
+ try {
+ json.decodeFromString(PendingAuthorization.serializer(), it)
+ } catch (_: SerializationException) {
+ null
+ }
+ }
+
+ private companion object {
+ const val SERVER_ERROR = 500
+ }
+}
+
+/** A server's own words are kept when they are words, not an HTML error page. */
+internal fun ApiError.toProblem(): SignInProblem = when (this) {
+ is ApiError.UntrustedCertificate -> toCertificate()?.let(SignInProblem::UntrustedCertificate)
+ ?: SignInProblem.Offline
+
+ is ApiError.Transport, is ApiError.RateLimited -> SignInProblem.Offline
+
+ is ApiError.Unauthorised -> SignInProblem.Refused(message)
+
+ is ApiError.Forbidden -> SignInProblem.Refused(message)
+
+ is ApiError.Unprocessable -> SignInProblem.ServerProblem(message)
+
+ is ApiError.Server -> SignInProblem.ServerProblem(
+ body?.takeUnless {
+ it.isBlank() || it.trimStart().startsWith("<")
+ },
+ )
+
+ else -> SignInProblem.ServerProblem(null)
+}
diff --git a/core/data/src/main/kotlin/social/aloha/core/data/SignInTypes.kt b/core/data/src/main/kotlin/social/aloha/core/data/SignInTypes.kt
new file mode 100644
index 00000000..a0ed14cd
--- /dev/null
+++ b/core/data/src/main/kotlin/social/aloha/core/data/SignInTypes.kt
@@ -0,0 +1,97 @@
+// SPDX-FileCopyrightText: 2026 Aloha Social contributors
+// SPDX-License-Identifier: MIT
+
+package social.aloha.core.data
+
+import java.security.cert.X509Certificate
+import java.time.Instant
+import social.aloha.core.model.SignedInAccount
+import social.aloha.core.network.probe.ProbeOutcome
+
+/** Which redirect URI this install can receive; the app decides from its App Link verification state. */
+public fun interface RedirectUriProvider {
+ public fun redirectUri(): String
+}
+
+/** What looking for a server found. */
+public sealed interface ServerLookup {
+ public data class Found(val server: DiscoveredServer) : ServerLookup
+
+ /** Something answered, but nothing like a Mastodon API; [tried] is every address, in the order of preference. */
+ public data class NothingAnswered(val tried: List) : ServerLookup
+
+ /** No address got any answer: a typo, a server that is down, or no network. */
+ public data object Unreachable : ServerLookup
+
+ /** The server presented a certificate nobody trusts; the person may trust it and look again. */
+ public data class UntrustedCertificate(val certificate: ServerCertificate) : ServerLookup
+
+ /** The text cannot be a server address. */
+ public data object InvalidAddress : ServerLookup
+
+ /** The address is fine but asks for `http://`, which this build does not accept. */
+ public data object InsecureAddress : ServerLookup
+}
+
+/**
+ * A server whose API was found; hand it back to [SignInCoordinator.beginAuthorization]. Its texts are
+ * trimmed: mastodon.social ends its description with blank lines.
+ */
+public class DiscoveredServer internal constructor(internal val outcome: ProbeOutcome) {
+ public val apiBase: String get() = outcome.apiBase.toString()
+ public val host: String get() = outcome.apiBase.host
+ public val title: String get() = outcome.instance.title.trim().ifBlank { outcome.instance.domain }
+ public val domain: String get() = outcome.instance.domain
+ public val description: String
+ get() = outcome.instance.shortDescription.trim().ifBlank { outcome.instance.description.trim() }
+ public val userCount: Int? get() = outcome.instance.userCount
+ public val rules: List get() = outcome.instance.rules.map { it.text.trim() }
+ public val isNextcloudSocial: Boolean get() = outcome.nodeInfo?.isNextcloudSocial == true
+}
+
+/** One address the lookup tried, and why. */
+public data class TriedAddress(val url: String, val reason: TriedBecause)
+
+public enum class TriedBecause { Discovery, DomainRoot, AppPath, PrettyAppPath, TypedPath, TypedAppPath, Manual }
+
+/** A certificate a server presented that neither the system nor the person trusts yet. */
+public class ServerCertificate internal constructor(
+ public val host: String,
+ public val subject: String,
+ public val issuer: String,
+ public val validUntil: Instant,
+ public val sha256: String,
+ internal val leaf: X509Certificate,
+)
+
+/** Why a step of the sign-in did not work, in terms the screen can word. */
+public sealed interface SignInProblem {
+ /** The server could not be reached. */
+ public data object Offline : SignInProblem
+
+ /** The person or the server refused; [reason] is the server's own wording. */
+ public data class Refused(val reason: String?) : SignInProblem
+
+ /** The server answered with a problem; [detail] is its own words when it gave some. */
+ public data class ServerProblem(val detail: String?) : SignInProblem
+
+ public data class UntrustedCertificate(val certificate: ServerCertificate) : SignInProblem
+}
+
+/** How starting the authorisation went. */
+public sealed interface Authorization {
+ /** Open [url] in the browser. */
+ public data class Started(val url: String) : Authorization
+
+ public data class Failed(val problem: SignInProblem) : Authorization
+}
+
+/** How finishing a sign-in ended. */
+public sealed interface SignInResult {
+ public data class SignedIn(val account: SignedInAccount) : SignInResult
+
+ /** The callback did not belong to the sign-in in flight, or none was in flight: nothing was exchanged. */
+ public data object NotForUs : SignInResult
+
+ public data class Failed(val problem: SignInProblem) : SignInResult
+}
diff --git a/core/data/src/main/kotlin/social/aloha/core/data/di/DataModule.kt b/core/data/src/main/kotlin/social/aloha/core/data/di/DataModule.kt
new file mode 100644
index 00000000..bdb14d32
--- /dev/null
+++ b/core/data/src/main/kotlin/social/aloha/core/data/di/DataModule.kt
@@ -0,0 +1,28 @@
+// SPDX-FileCopyrightText: 2026 Aloha Social contributors
+// SPDX-License-Identifier: MIT
+
+package social.aloha.core.data.di
+
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.components.SingletonComponent
+import javax.inject.Qualifier
+import javax.inject.Singleton
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+
+/** A scope that lives as long as the process, for state shared across screens. */
+@Qualifier
+@Retention(AnnotationRetention.BINARY)
+public annotation class ApplicationScope
+
+@Module
+@InstallIn(SingletonComponent::class)
+internal object DataModule {
+ @Provides
+ @Singleton
+ @ApplicationScope
+ fun applicationScope(): CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
+}
diff --git a/core/database/build.gradle.kts b/core/database/build.gradle.kts
index c60c878a..9d636981 100644
--- a/core/database/build.gradle.kts
+++ b/core/database/build.gradle.kts
@@ -3,4 +3,14 @@
plugins {
alias(libs.plugins.aloha.android.library)
+ alias(libs.plugins.aloha.android.hilt)
+ alias(libs.plugins.aloha.android.room)
+}
+
+dependencies {
+ api(libs.kotlinx.coroutines.core)
+ testImplementation(libs.junit4)
+ testImplementation(libs.robolectric)
+ testImplementation(libs.androidx.test.core)
+ testImplementation(libs.kotlinx.coroutines.test)
}
diff --git a/core/database/schemas/social.aloha.core.database.AccountsDatabase/1.json b/core/database/schemas/social.aloha.core.database.AccountsDatabase/1.json
new file mode 100644
index 00000000..27cd9700
--- /dev/null
+++ b/core/database/schemas/social.aloha.core.database.AccountsDatabase/1.json
@@ -0,0 +1,142 @@
+{
+ "formatVersion": 1,
+ "database": {
+ "version": 1,
+ "identityHash": "f994e56e63139ca05f212c603055681e",
+ "entities": [
+ {
+ "tableName": "account",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `instanceHost` TEXT NOT NULL, `apiBase` TEXT NOT NULL, `serverAccountId` TEXT NOT NULL, `handle` TEXT NOT NULL, `displayName` TEXT NOT NULL, `avatarUrl` TEXT, `headerUrl` TEXT, `capabilitiesJson` TEXT, `needsReauth` INTEGER NOT NULL, `profilePending` INTEGER NOT NULL, `sortIndex` INTEGER NOT NULL, `addedAt` INTEGER NOT NULL, `nextcloudConnected` INTEGER NOT NULL, PRIMARY KEY(`id`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "instanceHost",
+ "columnName": "instanceHost",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "apiBase",
+ "columnName": "apiBase",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "serverAccountId",
+ "columnName": "serverAccountId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "handle",
+ "columnName": "handle",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "displayName",
+ "columnName": "displayName",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "avatarUrl",
+ "columnName": "avatarUrl",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "headerUrl",
+ "columnName": "headerUrl",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "capabilitiesJson",
+ "columnName": "capabilitiesJson",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "needsReauth",
+ "columnName": "needsReauth",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "profilePending",
+ "columnName": "profilePending",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "sortIndex",
+ "columnName": "sortIndex",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "addedAt",
+ "columnName": "addedAt",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "nextcloudConnected",
+ "columnName": "nextcloudConnected",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ }
+ },
+ {
+ "tableName": "client_registration",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`host` TEXT NOT NULL, `clientId` TEXT NOT NULL, `scopes` TEXT NOT NULL, `registeredAt` INTEGER NOT NULL, PRIMARY KEY(`host`))",
+ "fields": [
+ {
+ "fieldPath": "host",
+ "columnName": "host",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "clientId",
+ "columnName": "clientId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "scopes",
+ "columnName": "scopes",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "registeredAt",
+ "columnName": "registeredAt",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "host"
+ ]
+ }
+ }
+ ],
+ "setupQueries": [
+ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
+ "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'f994e56e63139ca05f212c603055681e')"
+ ]
+ }
+}
\ No newline at end of file
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
new file mode 100644
index 00000000..decda976
--- /dev/null
+++ b/core/database/src/main/kotlin/social/aloha/core/database/AccountsDatabase.kt
@@ -0,0 +1,100 @@
+// SPDX-FileCopyrightText: 2026 Aloha Social contributors
+// SPDX-License-Identifier: MIT
+
+package social.aloha.core.database
+
+import androidx.room.Dao
+import androidx.room.Database
+import androidx.room.Entity
+import androidx.room.PrimaryKey
+import androidx.room.Query
+import androidx.room.RoomDatabase
+import androidx.room.Upsert
+import kotlinx.coroutines.flow.Flow
+
+/**
+ * One signed-in account. Never holds a secret: the token and any app password live in the vault,
+ * keyed by [id]. A restored backup of this table without its vault lists every account as needing a
+ * new sign-in.
+ *
+ * @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).
+ */
+@Entity(tableName = "account")
+public data class AccountEntity(
+ @PrimaryKey val id: String,
+ val instanceHost: String,
+ val apiBase: String,
+ val serverAccountId: String,
+ val handle: String,
+ val displayName: String,
+ val avatarUrl: String?,
+ val headerUrl: String?,
+ val capabilitiesJson: String?,
+ val needsReauth: Boolean,
+ val profilePending: Boolean,
+ val sortIndex: Int,
+ val addedAt: Long,
+ val nextcloudConnected: Boolean,
+)
+
+/**
+ * The app's OAuth client on one server. The client secret lives in the vault, keyed by [host].
+ */
+@Entity(tableName = "client_registration")
+public data class ClientRegistrationEntity(
+ @PrimaryKey val host: String,
+ val clientId: String,
+ val scopes: String,
+ val registeredAt: Long,
+)
+
+@Dao
+public interface AccountDao {
+ @Query("SELECT * FROM account ORDER BY sortIndex, addedAt")
+ public fun observeAll(): Flow>
+
+ @Query("SELECT * FROM account ORDER BY sortIndex, addedAt")
+ public suspend fun all(): List
+
+ @Query("SELECT * FROM account WHERE id = :id")
+ public suspend fun get(id: String): AccountEntity?
+
+ @Query("SELECT * FROM account WHERE instanceHost = :host AND serverAccountId = :serverAccountId")
+ public suspend fun find(host: String, serverAccountId: String): AccountEntity?
+
+ @Upsert
+ public suspend fun upsert(account: AccountEntity)
+
+ @Query("DELETE FROM account WHERE id = :id")
+ public suspend fun delete(id: String)
+
+ @Query("UPDATE account SET needsReauth = :needsReauth WHERE id = :id")
+ public suspend fun setNeedsReauth(id: String, needsReauth: Boolean)
+
+ @Query("UPDATE account SET needsReauth = 1")
+ public suspend fun markAllNeedReauth()
+
+ @Query("SELECT COALESCE(MAX(sortIndex), -1) + 1 FROM account")
+ public suspend fun nextSortIndex(): Int
+
+ @Query("SELECT * FROM client_registration WHERE host = :host")
+ public suspend fun registration(host: String): ClientRegistrationEntity?
+
+ @Upsert
+ public suspend fun upsertRegistration(registration: ClientRegistrationEntity)
+
+ @Query("DELETE FROM client_registration WHERE host = :host")
+ public suspend fun deleteRegistration(host: String)
+}
+
+/** `accounts.db`: durable, versioned, migrated and never destroyed. */
+@Database(entities = [AccountEntity::class, ClientRegistrationEntity::class], version = 1, exportSchema = true)
+public abstract class AccountsDatabase : RoomDatabase() {
+ public abstract fun accountDao(): AccountDao
+
+ public companion object {
+ public const val FILE_NAME: String = "accounts.db"
+ }
+}
diff --git a/core/database/src/main/kotlin/social/aloha/core/database/DatabaseModule.kt b/core/database/src/main/kotlin/social/aloha/core/database/DatabaseModule.kt
new file mode 100644
index 00000000..ef64b7ed
--- /dev/null
+++ b/core/database/src/main/kotlin/social/aloha/core/database/DatabaseModule.kt
@@ -0,0 +1,25 @@
+// SPDX-FileCopyrightText: 2026 Aloha Social contributors
+// SPDX-License-Identifier: MIT
+
+package social.aloha.core.database
+
+import android.content.Context
+import androidx.room.Room
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.android.qualifiers.ApplicationContext
+import dagger.hilt.components.SingletonComponent
+import javax.inject.Singleton
+
+@Module
+@InstallIn(SingletonComponent::class)
+internal object DatabaseModule {
+ @Provides
+ @Singleton
+ fun accountsDatabase(@ApplicationContext context: Context): AccountsDatabase =
+ Room.databaseBuilder(context, AccountsDatabase::class.java, AccountsDatabase.FILE_NAME).build()
+
+ @Provides
+ fun accountDao(database: AccountsDatabase): AccountDao = database.accountDao()
+}
diff --git a/core/database/src/test/kotlin/social/aloha/core/database/AccountDaoTest.kt b/core/database/src/test/kotlin/social/aloha/core/database/AccountDaoTest.kt
new file mode 100644
index 00000000..90735918
--- /dev/null
+++ b/core/database/src/test/kotlin/social/aloha/core/database/AccountDaoTest.kt
@@ -0,0 +1,86 @@
+// SPDX-FileCopyrightText: 2026 Aloha Social contributors
+// SPDX-License-Identifier: MIT
+
+package social.aloha.core.database
+
+import androidx.room.Room
+import androidx.test.core.app.ApplicationProvider
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.test.runTest
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+
+@RunWith(RobolectricTestRunner::class)
+class AccountDaoTest {
+ private lateinit var database: AccountsDatabase
+ private val dao get() = database.accountDao()
+
+ @Before
+ fun open() {
+ database =
+ Room.inMemoryDatabaseBuilder(
+ ApplicationProvider.getApplicationContext(),
+ AccountsDatabase::class.java,
+ ).build()
+ }
+
+ @After
+ fun close() {
+ database.close()
+ }
+
+ private fun account(id: String, sortIndex: Int, needsReauth: Boolean = false) = AccountEntity(
+ id = id,
+ instanceHost = "cloud.example",
+ apiBase = "https://cloud.example/index.php/apps/social/",
+ serverAccountId = "server-$id",
+ handle = "alice",
+ displayName = "Alice",
+ avatarUrl = null,
+ headerUrl = null,
+ capabilitiesJson = null,
+ needsReauth = needsReauth,
+ profilePending = false,
+ sortIndex = sortIndex,
+ addedAt = 0,
+ nextcloudConnected = false,
+ )
+
+ @Test
+ fun `accounts come back in sort order`() = runTest {
+ dao.upsert(account("b", 1))
+ dao.upsert(account("a", 0))
+ assertEquals(listOf("a", "b"), dao.observeAll().first().map { it.id })
+ assertEquals(2, dao.nextSortIndex())
+ }
+
+ @Test
+ fun `marking every account for a new sign-in keeps the accounts`() = runTest {
+ dao.upsert(account("a", 0))
+ dao.upsert(account("b", 1))
+ dao.markAllNeedReauth()
+ assertTrue(dao.all().all { it.needsReauth })
+ assertEquals(2, dao.all().size)
+ }
+
+ @Test
+ fun `an account is found by host and server id, and deleted by id`() = runTest {
+ dao.upsert(account("a", 0))
+ assertEquals("a", dao.find("cloud.example", "server-a")?.id)
+ dao.delete("a")
+ assertNull(dao.get("a"))
+ }
+
+ @Test
+ fun `a registration is stored per host`() = runTest {
+ dao.upsertRegistration(ClientRegistrationEntity("cloud.example", "client-1", "read write follow push", 0))
+ assertEquals("client-1", dao.registration("cloud.example")?.clientId)
+ assertNull(dao.registration("other.example"))
+ }
+}
diff --git a/core/database/src/test/kotlin/social/aloha/core/database/NoSecretsInDatabaseTest.kt b/core/database/src/test/kotlin/social/aloha/core/database/NoSecretsInDatabaseTest.kt
new file mode 100644
index 00000000..e1e67c64
--- /dev/null
+++ b/core/database/src/test/kotlin/social/aloha/core/database/NoSecretsInDatabaseTest.kt
@@ -0,0 +1,20 @@
+// SPDX-FileCopyrightText: 2026 Aloha Social contributors
+// SPDX-License-Identifier: MIT
+
+package social.aloha.core.database
+
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+/** Secrets live only in the vault; a column whose name suggests one fails this test. */
+class NoSecretsInDatabaseTest {
+ private val forbidden = listOf("token", "secret", "password", "verifier", "code")
+
+ @Test
+ fun `no entity has a field that could hold a secret`() {
+ val fields = listOf(AccountEntity::class.java, ClientRegistrationEntity::class.java)
+ .flatMap { entity -> entity.declaredFields.map { "${entity.simpleName}.${it.name}" } }
+ val offending = fields.filter { field -> forbidden.any { field.substringAfter('.').lowercase().contains(it) } }
+ assertTrue("fields that look like secrets: $offending", offending.isEmpty())
+ }
+}
diff --git a/core/datastore/build.gradle.kts b/core/datastore/build.gradle.kts
index c60c878a..6ebd66d4 100644
--- a/core/datastore/build.gradle.kts
+++ b/core/datastore/build.gradle.kts
@@ -3,4 +3,14 @@
plugins {
alias(libs.plugins.aloha.android.library)
+ alias(libs.plugins.aloha.android.hilt)
+ alias(libs.plugins.kotlin.serialization)
+}
+
+dependencies {
+ api(libs.androidx.datastore)
+ api(libs.androidx.datastore.preferences)
+ api(libs.kotlinx.coroutines.core)
+ implementation(libs.kotlinx.serialization.json)
+ testImplementation(libs.kotlinx.coroutines.test)
}
diff --git a/core/datastore/src/main/kotlin/social/aloha/core/datastore/AppPreferences.kt b/core/datastore/src/main/kotlin/social/aloha/core/datastore/AppPreferences.kt
new file mode 100644
index 00000000..c2efc656
--- /dev/null
+++ b/core/datastore/src/main/kotlin/social/aloha/core/datastore/AppPreferences.kt
@@ -0,0 +1,33 @@
+// SPDX-FileCopyrightText: 2026 Aloha Social contributors
+// SPDX-License-Identifier: MIT
+
+package social.aloha.core.datastore
+
+import androidx.datastore.core.DataStore
+import androidx.datastore.preferences.core.Preferences
+import androidx.datastore.preferences.core.edit
+import androidx.datastore.preferences.core.intPreferencesKey
+import androidx.datastore.preferences.core.stringPreferencesKey
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.map
+
+/** App-wide settings that are not secrets: which account is active, which terms were accepted. */
+public class AppPreferences(private val store: DataStore) {
+ public val activeAccountId: Flow = store.data.map { it[ACTIVE_ACCOUNT] }
+
+ /** The version of the terms the person accepted; 0 before they accepted any. */
+ public val acceptedTermsVersion: Flow = store.data.map { it[ACCEPTED_TERMS] ?: 0 }
+
+ public suspend fun setActiveAccountId(id: String?) {
+ store.edit { if (id == null) it.remove(ACTIVE_ACCOUNT) else it[ACTIVE_ACCOUNT] = id }
+ }
+
+ public suspend fun acceptTerms(version: Int) {
+ store.edit { it[ACCEPTED_TERMS] = version }
+ }
+
+ private companion object {
+ val ACTIVE_ACCOUNT = stringPreferencesKey("active_account_id")
+ val ACCEPTED_TERMS = intPreferencesKey("accepted_terms_version")
+ }
+}
diff --git a/core/datastore/src/main/kotlin/social/aloha/core/datastore/DataStoreModule.kt b/core/datastore/src/main/kotlin/social/aloha/core/datastore/DataStoreModule.kt
new file mode 100644
index 00000000..09717987
--- /dev/null
+++ b/core/datastore/src/main/kotlin/social/aloha/core/datastore/DataStoreModule.kt
@@ -0,0 +1,54 @@
+// SPDX-FileCopyrightText: 2026 Aloha Social contributors
+// SPDX-License-Identifier: MIT
+
+package social.aloha.core.datastore
+
+import android.content.Context
+import androidx.datastore.core.CorruptionException
+import androidx.datastore.core.DataStoreFactory
+import androidx.datastore.core.Serializer
+import androidx.datastore.preferences.core.PreferenceDataStoreFactory
+import androidx.datastore.preferences.preferencesDataStoreFile
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.android.qualifiers.ApplicationContext
+import dagger.hilt.components.SingletonComponent
+import java.io.File
+import java.io.InputStream
+import java.io.OutputStream
+import javax.inject.Singleton
+import kotlinx.coroutines.Dispatchers
+
+/** The vault's bytes, stored as they are; the content is ciphertext. */
+internal object BlobSerializer : Serializer {
+ override val defaultValue: ByteArray = ByteArray(0)
+
+ override suspend fun readFrom(input: InputStream): ByteArray = try {
+ input.readBytes()
+ } catch (e: java.io.IOException) {
+ throw CorruptionException("unreadable vault", e)
+ }
+
+ override suspend fun writeTo(t: ByteArray, output: OutputStream) {
+ output.write(t)
+ }
+}
+
+@Module
+@InstallIn(SingletonComponent::class)
+internal object DataStoreModule {
+ // files/vault is excluded from cloud backup and device transfer by the app's backup rules.
+ @Provides
+ @Singleton
+ fun tokenVault(@ApplicationContext context: Context): TokenVault = TokenVault(
+ store = DataStoreFactory.create(BlobSerializer) { File(context.filesDir, "vault/secrets.bin") },
+ cipher = KeystoreCipher(),
+ ioDispatcher = Dispatchers.IO,
+ )
+
+ @Provides
+ @Singleton
+ fun appPreferences(@ApplicationContext context: Context): AppPreferences =
+ AppPreferences(PreferenceDataStoreFactory.create { context.preferencesDataStoreFile("app") })
+}
diff --git a/core/datastore/src/main/kotlin/social/aloha/core/datastore/KeystoreCipher.kt b/core/datastore/src/main/kotlin/social/aloha/core/datastore/KeystoreCipher.kt
new file mode 100644
index 00000000..074ced51
--- /dev/null
+++ b/core/datastore/src/main/kotlin/social/aloha/core/datastore/KeystoreCipher.kt
@@ -0,0 +1,85 @@
+// SPDX-FileCopyrightText: 2026 Aloha Social contributors
+// SPDX-License-Identifier: MIT
+
+package social.aloha.core.datastore
+
+import android.os.Build
+import android.security.keystore.KeyGenParameterSpec
+import android.security.keystore.KeyProperties
+import android.security.keystore.StrongBoxUnavailableException
+import androidx.annotation.RequiresApi
+import java.security.GeneralSecurityException
+import java.security.KeyStore
+import java.security.ProviderException
+import javax.crypto.Cipher
+import javax.crypto.KeyGenerator
+import javax.crypto.SecretKey
+import javax.crypto.spec.GCMParameterSpec
+
+/**
+ * AES-256-GCM with a key that never leaves the AndroidKeyStore, in StrongBox where the device has
+ * one. No user authentication is required, so background work can read the tokens. The blob is the
+ * IV length, the IV and the ciphertext with its tag.
+ */
+internal class KeystoreCipher(private val alias: String = "aloha-vault") : SecretCipher {
+ // loaded on first use, on the vault's I/O dispatcher, not when the vault is created
+ private val keyStore: KeyStore by lazy { KeyStore.getInstance(PROVIDER).apply { load(null) } }
+
+ override fun encrypt(plain: ByteArray): ByteArray = guarded {
+ val cipher = Cipher.getInstance(TRANSFORMATION)
+ cipher.init(Cipher.ENCRYPT_MODE, key())
+ val iv = cipher.iv
+ byteArrayOf(iv.size.toByte()) + iv + cipher.doFinal(plain)
+ }
+
+ override fun decrypt(blob: ByteArray): ByteArray = guarded {
+ val ivLength = blob.firstOrNull()?.toInt() ?: throw GeneralSecurityException("empty blob")
+ if (ivLength <= 0 || blob.size <= 1 + ivLength) throw GeneralSecurityException("truncated blob")
+ val key = keyStore.getKey(alias, null) as? SecretKey ?: throw GeneralSecurityException("vault key is gone")
+ val cipher = Cipher.getInstance(TRANSFORMATION)
+ cipher.init(Cipher.DECRYPT_MODE, key, GCMParameterSpec(TAG_BITS, blob, 1, ivLength))
+ cipher.doFinal(blob, 1 + ivLength, blob.size - 1 - ivLength)
+ }
+
+ override fun reset() {
+ guarded { if (keyStore.containsAlias(alias)) keyStore.deleteEntry(alias) }
+ }
+
+ private fun key(): SecretKey = keyStore.getKey(alias, null) as? SecretKey
+ ?: if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) generateInStrongBox() else generate(strongBox = false)
+
+ /** StrongBox where the device has one; a device without it says so with an exception, and gets a TEE key. */
+ @RequiresApi(Build.VERSION_CODES.P)
+ private fun generateInStrongBox(): SecretKey = try {
+ generate(strongBox = true)
+ } catch (_: StrongBoxUnavailableException) {
+ generate(strongBox = false)
+ }
+
+ private fun generate(strongBox: Boolean): SecretKey {
+ val spec = KeyGenParameterSpec.Builder(alias, KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT)
+ .setBlockModes(KeyProperties.BLOCK_MODE_GCM)
+ .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
+ .setKeySize(KEY_BITS)
+ .setUserAuthenticationRequired(false)
+ .apply { if (strongBox && Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) setIsStrongBoxBacked(true) }
+ .build()
+ return KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, PROVIDER).apply { init(spec) }.generateKey()
+ }
+
+ /** Keystore failures surface as runtime exceptions on some devices; the vault expects checked ones. */
+ private inline fun guarded(block: () -> T): T = try {
+ block()
+ } catch (e: ProviderException) {
+ throw GeneralSecurityException(e)
+ } catch (e: IllegalStateException) {
+ throw GeneralSecurityException(e)
+ }
+
+ private companion object {
+ const val PROVIDER = "AndroidKeyStore"
+ const val TRANSFORMATION = "AES/GCM/NoPadding"
+ const val KEY_BITS = 256
+ const val TAG_BITS = 128
+ }
+}
diff --git a/core/datastore/src/main/kotlin/social/aloha/core/datastore/TokenVault.kt b/core/datastore/src/main/kotlin/social/aloha/core/datastore/TokenVault.kt
new file mode 100644
index 00000000..c756127f
--- /dev/null
+++ b/core/datastore/src/main/kotlin/social/aloha/core/datastore/TokenVault.kt
@@ -0,0 +1,116 @@
+// SPDX-FileCopyrightText: 2026 Aloha Social contributors
+// SPDX-License-Identifier: MIT
+
+package social.aloha.core.datastore
+
+import androidx.datastore.core.DataStore
+import java.security.GeneralSecurityException
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+import kotlinx.coroutines.withContext
+import kotlinx.serialization.SerializationException
+import kotlinx.serialization.builtins.MapSerializer
+import kotlinx.serialization.builtins.serializer
+import kotlinx.serialization.json.Json
+
+/** Where a secret belongs. */
+public sealed class VaultKey(internal val name: String) {
+ public class AccessToken(accountId: String) : VaultKey("token:$accountId")
+
+ public class AppPassword(accountId: String) : VaultKey("app-password:$accountId")
+
+ public class ClientSecret(host: String) : VaultKey("client-secret:${host.lowercase()}")
+
+ /** The sign-in in flight: state, PKCE verifier and API base, kept only while the browser tab is open. */
+ public data object PendingAuthorization : VaultKey("pending-authorization")
+}
+
+/** Encrypts and decrypts the vault blob; the Keystore implementation in production, a fake in tests. */
+public interface SecretCipher {
+ public fun encrypt(plain: ByteArray): ByteArray
+
+ /** Throws a [GeneralSecurityException] when the key is gone or the blob was not written with it. */
+ public fun decrypt(blob: ByteArray): ByteArray
+
+ /** Drops the key, so the next [encrypt] creates a new one. */
+ public fun reset()
+}
+
+/**
+ * The only place secrets live: tokens, client secrets, app passwords and the sign-in in flight. One
+ * blob, encrypted with a device-bound AndroidKeyStore key and excluded from backup, so a restored
+ * backup can never use it.
+ *
+ * When the blob cannot be decrypted (an OEM invalidated the key, StrongBox lost it in an OS update)
+ * the vault starts again empty and reports it once through [wasLost]: every account then needs a new
+ * sign-in, and nothing crashes.
+ *
+ * The blob is decrypted once per process and kept in memory under the lock; Keystore calls are IPC to
+ * the keystore daemon (StrongBox takes far longer), so they run on [ioDispatcher], never the caller's.
+ */
+public class TokenVault(
+ private val store: DataStore,
+ private val cipher: SecretCipher,
+ private val ioDispatcher: CoroutineDispatcher,
+) {
+ private val mutex = Mutex()
+ private var lost = false
+ private var secrets: Map? = null
+ private val json = Json
+ private val serializer = MapSerializer(String.serializer(), String.serializer())
+
+ public suspend fun get(key: VaultKey): String? = mutex.withLock { read()[key.name] }
+
+ public suspend fun put(key: VaultKey, value: String) {
+ mutex.withLock { write(read() + (key.name to value)) }
+ }
+
+ public suspend fun remove(key: VaultKey) {
+ mutex.withLock { write(read() - key.name) }
+ }
+
+ /** True once after the vault had to start again because its contents could not be read. */
+ public suspend fun wasLost(): Boolean = mutex.withLock {
+ read()
+ lost.also { lost = false }
+ }
+
+ private suspend fun read(): Map =
+ secrets ?: withContext(ioDispatcher) { decrypt() }.also { secrets = it }
+
+ private suspend fun decrypt(): Map {
+ val blob = store.data.first()
+ if (blob.isEmpty()) return emptyMap()
+ return try {
+ json.decodeFromString(serializer, cipher.decrypt(blob).decodeToString())
+ } catch (_: GeneralSecurityException) {
+ startAgain()
+ } catch (_: SerializationException) {
+ startAgain()
+ } catch (_: IllegalArgumentException) {
+ startAgain()
+ }
+ }
+
+ private suspend fun startAgain(): Map {
+ lost = true
+ cipher.reset()
+ store.updateData { EMPTY }
+ return emptyMap()
+ }
+
+ private suspend fun write(updated: Map) {
+ withContext(ioDispatcher) {
+ val plain = json.encodeToString(serializer, updated).toByteArray()
+ val blob = if (updated.isEmpty()) EMPTY else cipher.encrypt(plain)
+ store.updateData { blob }
+ }
+ secrets = updated
+ }
+
+ private companion object {
+ val EMPTY = ByteArray(0)
+ }
+}
diff --git a/core/datastore/src/test/kotlin/social/aloha/core/datastore/TokenVaultTest.kt b/core/datastore/src/test/kotlin/social/aloha/core/datastore/TokenVaultTest.kt
new file mode 100644
index 00000000..414c56fa
--- /dev/null
+++ b/core/datastore/src/test/kotlin/social/aloha/core/datastore/TokenVaultTest.kt
@@ -0,0 +1,112 @@
+// SPDX-FileCopyrightText: 2026 Aloha Social contributors
+// SPDX-License-Identifier: MIT
+
+package social.aloha.core.datastore
+
+import androidx.datastore.core.DataStore
+import java.security.GeneralSecurityException
+import kotlin.random.Random
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.test.runTest
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Assertions.assertFalse
+import org.junit.jupiter.api.Assertions.assertNull
+import org.junit.jupiter.api.Assertions.assertTrue
+import org.junit.jupiter.api.Test
+
+class TokenVaultTest {
+ /** The bytes the vault hands to DataStore, kept in memory so the test can look at them. */
+ private class MemoryStore : DataStore {
+ val bytes = MutableStateFlow(ByteArray(0))
+ override val data: Flow = bytes
+
+ override suspend fun updateData(transform: suspend (t: ByteArray) -> ByteArray): ByteArray =
+ transform(bytes.value).also { bytes.value = it }
+ }
+
+ /** XOR with a key that can be "lost", which is what an invalidated Keystore key looks like to the vault. */
+ private class FakeCipher : SecretCipher {
+ var key: Byte? = null
+ var lose = false
+
+ override fun encrypt(plain: ByteArray): ByteArray {
+ val k = key ?: Random.nextInt(1, 127).toByte().also { key = it }
+ return byteArrayOf(MAGIC) + plain.map { (it.toInt() xor k.toInt()).toByte() }
+ }
+
+ override fun decrypt(blob: ByteArray): ByteArray {
+ val k = key?.takeUnless { lose } ?: throw GeneralSecurityException("key invalidated")
+ if (blob.first() != MAGIC) throw GeneralSecurityException("not ours")
+ return blob.drop(1).map { (it.toInt() xor k.toInt()).toByte() }.toByteArray()
+ }
+
+ override fun reset() {
+ key = null
+ lose = false
+ }
+
+ companion object {
+ const val MAGIC: Byte = 42
+ }
+ }
+
+ private val store = MemoryStore()
+
+ private fun vault(cipher: SecretCipher) = TokenVault(store, cipher, Dispatchers.Unconfined)
+
+ @Test
+ fun `secrets round-trip and are not stored in the clear`() = runTest {
+ val vault = vault(FakeCipher())
+ vault.put(VaultKey.AccessToken("a1"), "token-value")
+ vault.put(VaultKey.ClientSecret("Cloud.Example"), "client-secret-value")
+ assertEquals("token-value", vault.get(VaultKey.AccessToken("a1")))
+ assertEquals("client-secret-value", vault.get(VaultKey.ClientSecret("cloud.example")))
+ assertFalse(store.bytes.value.decodeToString().contains("token-value"))
+ }
+
+ @Test
+ fun `removing a secret leaves the others`() = runTest {
+ val vault = vault(FakeCipher())
+ vault.put(VaultKey.AccessToken("a1"), "one")
+ vault.put(VaultKey.AccessToken("a2"), "two")
+ vault.remove(VaultKey.AccessToken("a1"))
+ assertNull(vault.get(VaultKey.AccessToken("a1")))
+ assertEquals("two", vault.get(VaultKey.AccessToken("a2")))
+ }
+
+ @Test
+ fun `a lost key empties the vault once, reports it once, and the vault keeps working`() = runTest {
+ val cipher = FakeCipher()
+ val vault = vault(cipher)
+ vault.put(VaultKey.AccessToken("a1"), "one")
+ cipher.lose = true
+ // the next process reads the blob afresh, and the key is gone
+ val restarted = vault(cipher)
+ assertNull(restarted.get(VaultKey.AccessToken("a1")))
+ assertTrue(restarted.wasLost())
+ assertFalse(restarted.wasLost())
+ restarted.put(VaultKey.AccessToken("a1"), "again")
+ assertEquals("again", restarted.get(VaultKey.AccessToken("a1")))
+ }
+
+ @Test
+ fun `an empty vault is not a lost vault`() = runTest {
+ assertFalse(vault(FakeCipher()).wasLost())
+ }
+
+ @Test
+ fun `the blob is decrypted once per process, not on every read`() = runTest {
+ val inner = FakeCipher()
+ var decryptions = 0
+ val counting = object : SecretCipher by inner {
+ override fun decrypt(blob: ByteArray): ByteArray = inner.decrypt(blob).also { decryptions++ }
+ }
+ vault(counting).put(VaultKey.AccessToken("a1"), "one")
+ decryptions = 0
+ val restarted = vault(counting)
+ repeat(5) { restarted.get(VaultKey.AccessToken("a1")) }
+ assertEquals(1, decryptions)
+ }
+}
diff --git a/core/designsystem/src/main/kotlin/social/aloha/core/designsystem/AlohaIcons.kt b/core/designsystem/src/main/kotlin/social/aloha/core/designsystem/AlohaIcons.kt
index 324ee803..7b6ce40c 100644
--- a/core/designsystem/src/main/kotlin/social/aloha/core/designsystem/AlohaIcons.kt
+++ b/core/designsystem/src/main/kotlin/social/aloha/core/designsystem/AlohaIcons.kt
@@ -5,16 +5,16 @@ package social.aloha.core.designsystem
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AccountCircle
+import androidx.compose.material.icons.filled.AmpStories
import androidx.compose.material.icons.filled.Home
import androidx.compose.material.icons.filled.Notifications
import androidx.compose.material.icons.filled.PhotoLibrary
-import androidx.compose.material.icons.filled.Slideshow
import androidx.compose.material.icons.filled.VideoLibrary
import androidx.compose.material.icons.outlined.AccountCircle
+import androidx.compose.material.icons.outlined.AmpStories
import androidx.compose.material.icons.outlined.Home
import androidx.compose.material.icons.outlined.Notifications
import androidx.compose.material.icons.outlined.PhotoLibrary
-import androidx.compose.material.icons.outlined.Slideshow
import androidx.compose.material.icons.outlined.VideoLibrary
import androidx.compose.ui.graphics.vector.ImageVector
@@ -32,8 +32,10 @@ public object AlohaIcons {
public val PhotosSelected: ImageVector = Icons.Filled.PhotoLibrary
public val Video: ImageVector = Icons.Outlined.VideoLibrary
public val VideoSelected: ImageVector = Icons.Filled.VideoLibrary
- public val Shorts: ImageVector = Icons.Outlined.Slideshow
- public val ShortsSelected: ImageVector = Icons.Filled.Slideshow
+
+ // stacked portrait cards, as on Apple; Slideshow looks the same filled and outlined
+ public val Shorts: ImageVector = Icons.Outlined.AmpStories
+ public val ShortsSelected: ImageVector = Icons.Filled.AmpStories
public val Notifications: ImageVector = Icons.Outlined.Notifications
public val NotificationsSelected: ImageVector = Icons.Filled.Notifications
public val Profile: ImageVector = Icons.Outlined.AccountCircle
diff --git a/core/model/build.gradle.kts b/core/model/build.gradle.kts
index 92134b40..83be338d 100644
--- a/core/model/build.gradle.kts
+++ b/core/model/build.gradle.kts
@@ -3,4 +3,10 @@
plugins {
alias(libs.plugins.aloha.jvm.library)
+ alias(libs.plugins.kotlin.serialization)
+}
+
+dependencies {
+ // domain types are persisted as JSON (capabilities on the account row, status payloads in the cache)
+ api(libs.kotlinx.serialization.json)
}
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
new file mode 100644
index 00000000..2aed941a
--- /dev/null
+++ b/core/model/src/main/kotlin/social/aloha/core/model/Account.kt
@@ -0,0 +1,78 @@
+// SPDX-FileCopyrightText: 2026 Aloha Social contributors
+// SPDX-License-Identifier: MIT
+
+package social.aloha.core.model
+
+import java.time.Instant
+import kotlinx.serialization.Serializable
+
+/**
+ * An account on this or another server.
+ *
+ * @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 source present only on the credentials routes, which answer for the account itself.
+ */
+@Serializable
+public data class Account(
+ val id: String,
+ val username: String,
+ val acct: String,
+ val displayName: String = "",
+ val note: String = "",
+ val url: String? = null,
+ val uri: String? = null,
+ val avatar: String? = null,
+ val avatarStatic: String? = null,
+ val header: String? = null,
+ val headerStatic: String? = null,
+ val locked: Boolean = false,
+ val bot: Boolean = false,
+ val discoverable: Boolean = true,
+ val suspended: Boolean = false,
+ val limited: Boolean = false,
+ @Serializable(with = InstantSerializer::class) val createdAt: Instant? = null,
+ val followersCount: Int = 0,
+ val followingCount: Int = 0,
+ val statusesCount: Int = 0,
+ @Serializable(with = InstantSerializer::class) val lastStatusAt: Instant? = null,
+ val fields: List = emptyList(),
+ val emojis: List = emptyList(),
+ val source: AccountSource? = null,
+ val moved: Account? = null,
+) {
+ /** What a person reads; never empty, it falls back through the handle. */
+ val bestDisplayName: String
+ get() = displayName.ifEmpty { username.ifEmpty { acct } }
+
+ /** The handle as a person writes it, with a leading `@`. */
+ val qualifiedHandle: String get() = "@$acct"
+
+ /** The account's server: the part of [acct] after `@`, else the host of [url]. */
+ val host: String?
+ get() {
+ val at = acct.indexOf('@')
+ if (at >= 0) return acct.substring(at + 1)
+ return hostOf(url)
+ }
+}
+
+@Serializable
+public data class AccountField(
+ val name: String,
+ val value: String,
+ @Serializable(with = InstantSerializer::class) val verifiedAt: Instant? = null,
+) {
+ val isVerified: Boolean get() = verifiedAt != null
+}
+
+@Serializable
+public data class AccountSource(
+ val note: String? = null,
+ val fields: List? = null,
+ val privacy: Visibility? = null,
+ val sensitive: Boolean = false,
+ val language: String? = null,
+ val followRequestsCount: Int = 0,
+)
diff --git a/core/model/src/main/kotlin/social/aloha/core/model/AccountExtras.kt b/core/model/src/main/kotlin/social/aloha/core/model/AccountExtras.kt
new file mode 100644
index 00000000..f2a365ca
--- /dev/null
+++ b/core/model/src/main/kotlin/social/aloha/core/model/AccountExtras.kt
@@ -0,0 +1,113 @@
+// SPDX-FileCopyrightText: 2026 Aloha Social contributors
+// SPDX-License-Identifier: MIT
+
+package social.aloha.core.model
+
+import java.time.Instant
+import kotlinx.serialization.Serializable
+
+/*
+ * The account-side surfaces Nextcloud Social serves beyond Mastodon's client API: authorized apps,
+ * the portfolio page, migration, statistics and channels.
+ */
+
+/** One application that holds a token for the viewer's account. */
+@Serializable
+public data class AuthorizedApp(
+ val id: String,
+ val name: String = "",
+ val website: String? = null,
+ @Serializable(with = InstantSerializer::class) val createdAt: Instant? = null,
+ @Serializable(with = InstantSerializer::class) val signedIn: Instant? = null,
+ @Serializable(with = InstantSerializer::class) val lastUsedAt: Instant? = null,
+ val scopes: List = emptyList(),
+)
+
+/** What a video belongs to everywhere but here: PeerTube's `Group`. */
+@Serializable
+public data class VideoChannel(
+ val id: String,
+ val handle: String = "",
+ val name: String = "",
+ val description: String = "",
+ val url: String? = null,
+ val videosCount: Int = 0,
+ @Serializable(with = InstantSerializer::class) val createdAt: Instant? = null,
+)
+
+/** Where a portfolio picture was taken, when the post carried a place. */
+@Serializable
+public data class PortfolioPlace(val name: String? = null, val country: String? = null)
+
+/** One picture on a portfolio page: a status, reduced to what the page shows. */
+@Serializable
+public data class PortfolioPost(
+ val id: String,
+ val content: String? = null,
+ @Serializable(with = InstantSerializer::class) val createdAt: Instant? = null,
+ val url: String? = null,
+ val mediaAttachments: List = emptyList(),
+ val place: PortfolioPlace? = null,
+) {
+ /** The first picture, which is what a portfolio shows. */
+ val picture: MediaAttachment?
+ get() = mediaAttachments.firstOrNull { it.type == AttachmentKind.Image } ?: mediaAttachments.firstOrNull()
+}
+
+@Serializable
+public enum class PortfolioLayout(override val wire: String) : WireValue {
+ Grid("grid"),
+ Rows("rows"),
+ ;
+
+ public companion object {
+ public fun fromWire(raw: String?): PortfolioLayout = entries.fromWire(raw, Grid)
+ }
+}
+
+@Serializable
+public enum class PortfolioSource(override val wire: String) : WireValue {
+ Recent("recent"),
+ Collection("collection"),
+ ;
+
+ public companion object {
+ public fun fromWire(raw: String?): PortfolioSource = entries.fromWire(raw, Recent)
+ }
+}
+
+/**
+ * The viewer's own portfolio settings, as `GET/POST /api/v1.1/portfolio` exchange them.
+ *
+ * @property posts the pictures the page would show, where the server sends them along.
+ */
+@Serializable
+public data class PortfolioSettings(
+ val active: Boolean = false,
+ val title: String = "",
+ val intro: String = "",
+ val layout: PortfolioLayout = PortfolioLayout.Grid,
+ val source: PortfolioSource = PortfolioSource.Recent,
+ val collectionId: String? = null,
+ val showCaptions: Boolean = true,
+ val showPlaces: Boolean = true,
+ val showDates: Boolean = true,
+ val showAvatar: Boolean = true,
+ val url: String? = null,
+ val posts: List = emptyList(),
+)
+
+/** A published portfolio page, readable by anybody with the address. Absent switches mean shown. */
+@Serializable
+public data class PortfolioPage(
+ val title: String = "",
+ val intro: String? = null,
+ val handle: String? = null,
+ val avatar: String? = null,
+ val layout: PortfolioLayout = PortfolioLayout.Grid,
+ val showCaptions: Boolean = true,
+ val showPlaces: Boolean = true,
+ val showDates: Boolean = true,
+ val showAvatar: Boolean = true,
+ val posts: List = emptyList(),
+)
diff --git a/core/model/src/main/kotlin/social/aloha/core/model/AnnualReport.kt b/core/model/src/main/kotlin/social/aloha/core/model/AnnualReport.kt
new file mode 100644
index 00000000..b2b81cd2
--- /dev/null
+++ b/core/model/src/main/kotlin/social/aloha/core/model/AnnualReport.kt
@@ -0,0 +1,117 @@
+// SPDX-FileCopyrightText: 2026 Aloha Social contributors
+// SPDX-License-Identifier: MIT
+
+package social.aloha.core.model
+
+import kotlinx.serialization.Serializable
+
+/** How an account was used over a year, in one word: Mastodon's archetypes, served verbatim. */
+public enum class AnnualArchetype(override val wire: String) : WireValue {
+ /** Wrote almost nothing. */
+ Lurker("lurker"),
+
+ /** Mostly boosted other people. */
+ Booster("booster"),
+
+ /** Mostly asked questions. */
+ Pollster("pollster"),
+
+ /** Mostly answered other people. */
+ Replier("replier"),
+
+ /** Mostly wrote their own posts, and was read. */
+ Oracle("oracle"),
+
+ /** A value this app does not know. */
+ Unknown("__unknown"),
+ ;
+
+ public companion object {
+ public fun fromWire(raw: String?): AnnualArchetype = entries.fromWire(raw, Unknown)
+ }
+}
+
+/** One month of the year: what was written in it, and who arrived. [month] is 1 to 12. */
+@Serializable
+public data class AnnualMonth(val month: Int, val statuses: Int = 0, val followers: Int = 0)
+
+/** A hashtag the account used, and how often. */
+@Serializable
+public data class AnnualHashtag(val name: String, val count: Int = 0)
+
+/**
+ * The three posts of the year, each an id into [WrappedAnnualReports.statuses]. Any of them may be
+ * absent: a year with no boosted post has no `by_reblogs`.
+ */
+@Serializable
+public data class AnnualTopStatuses(
+ val byReblogs: String? = null,
+ val byReplies: String? = null,
+ val byFavourites: String? = null,
+) {
+ /** The distinct ids, in the order the report presents them. */
+ val ids: List get() = listOfNotNull(byReblogs, byReplies, byFavourites).distinct()
+}
+
+/** What the year held. */
+@Serializable
+public data class AnnualReportData(
+ val archetype: AnnualArchetype = AnnualArchetype.Unknown,
+ val timeSeries: List = emptyList(),
+ val topHashtags: List = emptyList(),
+ val topStatuses: AnnualTopStatuses = AnnualTopStatuses(),
+) {
+ /** The busiest month with any posts, for the line that summarises the chart. */
+ val busiestMonth: AnnualMonth? get() = timeSeries.filter { it.statuses > 0 }.maxByOrNull { it.statuses }
+
+ val totalStatuses: Int get() = timeSeries.sumOf { it.statuses }
+
+ val totalFollowers: Int get() = timeSeries.sumOf { it.followers }
+}
+
+/**
+ * One year's report: Mastodon's `#Wrapstodon`, which Nextcloud Social serves at
+ * `/api/v1/annual_reports` without a web page of its own.
+ *
+ * @property shareUrl Mastodon's public page for the report; Nextcloud Social has none and sends null.
+ */
+@Serializable
+public data class AnnualReport(
+ val year: Int,
+ val data: AnnualReportData = AnnualReportData(),
+ val schemaVersion: Int = 1,
+ val shareUrl: String? = null,
+ val accountId: String = "",
+) {
+ val id: Int get() = year
+}
+
+/**
+ * The reports plus the accounts and statuses they name, so the year's best posts draw without a
+ * second round of requests.
+ */
+@Serializable
+public data class WrappedAnnualReports(
+ val annualReports: List = emptyList(),
+ val accounts: List = emptyList(),
+ val statuses: List = emptyList(),
+) {
+ /** The status behind one of a report's top-status ids. */
+ public fun status(id: String?): Status? = id?.let { wanted -> statuses.firstOrNull { it.id == wanted } }
+}
+
+/** Whether a year has a report. Nextcloud Social never answers `generating`: its report is a query, not a job. */
+public enum class AnnualReportState(override val wire: String) : WireValue {
+ Available("available"),
+ Pending("pending"),
+ Generating("generating"),
+ Ineligible("ineligible"),
+
+ /** A value this app does not know. */
+ Unknown("__unknown"),
+ ;
+
+ public companion object {
+ public fun fromWire(raw: String?): AnnualReportState = entries.fromWire(raw, Unknown)
+ }
+}
diff --git a/core/model/src/main/kotlin/social/aloha/core/model/Card.kt b/core/model/src/main/kotlin/social/aloha/core/model/Card.kt
new file mode 100644
index 00000000..224b895c
--- /dev/null
+++ b/core/model/src/main/kotlin/social/aloha/core/model/Card.kt
@@ -0,0 +1,30 @@
+// SPDX-FileCopyrightText: 2026 Aloha Social contributors
+// SPDX-License-Identifier: MIT
+
+package social.aloha.core.model
+
+import kotlinx.serialization.Serializable
+
+/** A link preview. */
+@Serializable
+public data class Card(
+ val url: String? = null,
+ val title: String = "",
+ val description: String = "",
+ val type: String = "link",
+ val authorName: String? = null,
+ val authorUrl: String? = null,
+ val providerName: String? = null,
+ val providerUrl: String? = null,
+ val image: String? = null,
+ val blurhash: String? = null,
+ val width: Int? = null,
+ val height: Int? = null,
+ val html: String? = null,
+) {
+ /** What to print under the headline: the provider, else the link's host. */
+ val displayProvider: String
+ get() = providerName?.takeIf { it.isNotEmpty() }
+ ?: hostOf(url)
+ ?: ""
+}
diff --git a/core/model/src/main/kotlin/social/aloha/core/model/CharacterCount.kt b/core/model/src/main/kotlin/social/aloha/core/model/CharacterCount.kt
new file mode 100644
index 00000000..8a75efd4
--- /dev/null
+++ b/core/model/src/main/kotlin/social/aloha/core/model/CharacterCount.kt
@@ -0,0 +1,51 @@
+// SPDX-FileCopyrightText: 2026 Aloha Social contributors
+// SPDX-License-Identifier: MIT
+
+package social.aloha.core.model
+
+import java.text.BreakIterator
+
+/**
+ * Mastodon's character counting in one place. A URL costs a fixed number of characters however long
+ * it is, and the content warning counts against the same budget as the body. Everything is counted in
+ * grapheme clusters, the unit a person counts, never in UTF-16 units, so an emoji counts as one.
+ */
+public object CharacterCount {
+ /** What the server's own linkifier treats as a URL. */
+ private val urlPattern = Regex("""https?://[^\s<]+""")
+
+ public fun count(text: String, spoilerText: String, limits: ServerLimits): Int =
+ weighted(text, limits) + graphemes(spoilerText)
+
+ public fun remaining(text: String, spoilerText: String, limits: ServerLimits): Int =
+ limits.maxStatusCharacters - count(text, spoilerText, limits)
+
+ /** The body's cost, with each URL charged the flat rate. */
+ private fun weighted(text: String, limits: ServerLimits): Int {
+ val urls = urlPattern.findAll(text).map { it.value }.toList()
+ return graphemes(text) - urls.sumOf(::graphemes) + urls.size * limits.charactersReservedPerUrl
+ }
+
+ /** The number of user-perceived characters in [text]. */
+ public fun graphemes(text: String): Int {
+ if (text.isEmpty()) return 0
+ val iterator = BreakIterator.getCharacterInstance()
+ iterator.setText(text)
+ var count = 0
+ while (iterator.next() != BreakIterator.DONE) count++
+ return count
+ }
+
+ /** The first [maximum] user-perceived characters of [text], so an emoji is never cut in half. */
+ public fun prefix(text: String, maximum: Int): String {
+ val iterator = BreakIterator.getCharacterInstance()
+ iterator.setText(text)
+ var end = 0
+ repeat(maximum) {
+ val next = iterator.next()
+ if (next == BreakIterator.DONE) return text
+ end = next
+ }
+ return text.substring(0, end)
+ }
+}
diff --git a/core/model/src/main/kotlin/social/aloha/core/model/ContentClassifier.kt b/core/model/src/main/kotlin/social/aloha/core/model/ContentClassifier.kt
new file mode 100644
index 00000000..4beb2cec
--- /dev/null
+++ b/core/model/src/main/kotlin/social/aloha/core/model/ContentClassifier.kt
@@ -0,0 +1,121 @@
+// SPDX-FileCopyrightText: 2026 Aloha Social contributors
+// SPDX-License-Identifier: MIT
+
+package social.aloha.core.model
+
+/**
+ * Decides what a status is, so the media modes can route it. No server supports Shorts natively, so
+ * these rules are the contract; everything else asks this.
+ */
+public object ContentClassifier {
+ /**
+ * A short is at most this long, in seconds. Generous on purpose: it catches the vertical video that
+ * is a short in everything but duration.
+ */
+ public const val MAXIMUM_SHORT_DURATION: Double = 180.0
+
+ /** Under this, duration alone makes a short regardless of shape. */
+ public const val UNCONDITIONAL_SHORT_DURATION: Double = 60.0
+
+ /** Portrait or square; anything wider is a video, not a short. */
+ public const val MAXIMUM_SHORT_ASPECT: Double = 1.0
+
+ public val shortHashtags: Set = setOf("loops", "short", "shorts", "reel", "reels")
+
+ public enum class Shortness {
+ Short,
+ NotShort,
+
+ /**
+ * Neither duration nor dimensions were available. The caller stores [ContentKind.Undetermined]
+ * and reclassifies once a player reports real values.
+ */
+ Undetermined,
+ }
+
+ public fun classify(status: Status): ContentKind {
+ val displayed = status.displayed
+ val attachments = displayed.mediaAttachments
+ val videoLike = attachments.filter { it.type.isVideoLike }
+ return when {
+ attachments.isEmpty() -> displayed.withoutMedia()
+ attachments.all { it.type == AttachmentKind.Audio } -> ContentKind.Audio
+ videoLike.size == 1 && attachments.size == 1 -> shortness(videoLike.single(), displayed).kind
+ videoLike.isNotEmpty() -> ContentKind.Video
+ attachments.all { it.type == AttachmentKind.Image } -> ContentKind.Photo
+ else -> ContentKind.Text
+ }
+ }
+
+ private val AttachmentKind.isVideoLike: Boolean get() = this == AttachmentKind.Video || this == AttachmentKind.Gifv
+
+ /** A post without media is news when it carries a link card, text otherwise. */
+ private fun Status.withoutMedia(): ContentKind = if (card != null) ContentKind.News else ContentKind.Text
+
+ private val Shortness.kind: ContentKind
+ get() = when (this) {
+ Shortness.Short -> ContentKind.Short
+ Shortness.NotShort -> ContentKind.Video
+ Shortness.Undetermined -> ContentKind.Undetermined
+ }
+
+ /**
+ * A shorts hashtag is the author saying what they made: it promotes a clip whose shape says
+ * otherwise, but is checked after the duration ceiling, so it never rescues a clip that is too long.
+ */
+ public fun shortness(attachment: MediaAttachment, status: Status): Shortness {
+ val duration = attachment.duration
+ val tagged = status.tags.any { it.name.lowercase() in shortHashtags }
+ return when {
+ duration != null && duration > MAXIMUM_SHORT_DURATION -> Shortness.NotShort
+ tagged -> Shortness.Short
+ duration != null && duration <= UNCONDITIONAL_SHORT_DURATION -> Shortness.Short
+ else -> byShape(hasDuration = duration != null, aspect = attachment.aspectRatio)
+ }
+ }
+
+ /**
+ * Between the two duration ceilings shape decides. Without a duration, landscape is still never a
+ * short, but portrait needs a duration to settle.
+ */
+ private fun byShape(hasDuration: Boolean, aspect: Double?): Shortness = when {
+ aspect == null -> Shortness.Undetermined
+ aspect > MAXIMUM_SHORT_ASPECT -> Shortness.NotShort
+ hasDuration -> Shortness.Short
+ else -> Shortness.Undetermined
+ }
+
+ /**
+ * Reclassifies once a player has reported the real [observed] duration and dimensions of an
+ * attachment the server described incompletely.
+ */
+ public fun reclassify(status: Status, attachmentId: String, observed: MediaDimensions): ContentKind {
+ val displayed = status.displayed
+ val patched = displayed.copy(
+ mediaAttachments = displayed.mediaAttachments.map { attachment ->
+ if (attachment.id == attachmentId) attachment.observed(observed) else attachment
+ },
+ )
+ return classify(patched)
+ }
+
+ private fun MediaAttachment.observed(observed: MediaDimensions): MediaAttachment {
+ val meta = meta ?: MediaMeta()
+ val width = observed.width
+ val height = observed.height
+ val original = (meta.original ?: MediaDimensions()).copy(
+ duration = observed.duration,
+ width = width,
+ height = height,
+ aspect = if (width != null && height != null && height > 0) width.toDouble() / height else null,
+ )
+ return copy(meta = meta.copy(original = original))
+ }
+
+ /** The aspect ratio to lay out in before anything has loaded, so nothing shifts when the bytes arrive. */
+ public fun layoutAspect(attachments: List): Double = when {
+ attachments.isEmpty() -> MediaAttachment.DEFAULT_ASPECT
+ attachments.size > 1 -> 1.0
+ else -> attachments.first().displayAspectRatio
+ }
+}
diff --git a/core/model/src/main/kotlin/social/aloha/core/model/ContentMode.kt b/core/model/src/main/kotlin/social/aloha/core/model/ContentMode.kt
new file mode 100644
index 00000000..f78d21cc
--- /dev/null
+++ b/core/model/src/main/kotlin/social/aloha/core/model/ContentMode.kt
@@ -0,0 +1,59 @@
+// SPDX-FileCopyrightText: 2026 Aloha Social contributors
+// SPDX-License-Identifier: MIT
+
+package social.aloha.core.model
+
+import kotlinx.serialization.Serializable
+
+/**
+ * A top-level content destination. Each mode owns its timeline key and cache, its source selection,
+ * its layout and gestures, and its Explore surface; modes are not filters over one list.
+ */
+@Serializable
+public enum class FeedMode {
+ Home,
+ Photos,
+ Video,
+ Shorts,
+ News,
+ Audio,
+ ;
+
+ /** Off by default; enabled in Settings. */
+ public val isOptional: Boolean get() = this == News || this == Audio
+
+ /** The stable key used in cache keys and settings. */
+ public val key: String get() = name.lowercase()
+
+ public companion object {
+ public val defaultEnabled: List = listOf(Home, Photos, Video, Shorts)
+ }
+}
+
+/** What a status is, decided once on insert and persisted. */
+@Serializable
+public enum class ContentKind {
+ Text,
+ Photo,
+ Video,
+ Short,
+ Audio,
+ News,
+
+ /**
+ * The server described the media too little to decide; the decision waits until a player reports
+ * real dimensions. Provisionally excluded from Shorts.
+ */
+ Undetermined,
+ ;
+
+ /** Whether a status of this kind appears in [mode]. A short is also a video. */
+ public fun belongs(mode: FeedMode): Boolean = when (mode) {
+ FeedMode.Home -> true
+ FeedMode.Photos -> this == Photo
+ FeedMode.Video -> this == Video || this == Short
+ FeedMode.Shorts -> this == Short
+ FeedMode.News -> this == News
+ FeedMode.Audio -> this == Audio
+ }
+}
diff --git a/core/model/src/main/kotlin/social/aloha/core/model/Conversations.kt b/core/model/src/main/kotlin/social/aloha/core/model/Conversations.kt
new file mode 100644
index 00000000..bf678c8a
--- /dev/null
+++ b/core/model/src/main/kotlin/social/aloha/core/model/Conversations.kt
@@ -0,0 +1,79 @@
+// SPDX-FileCopyrightText: 2026 Aloha Social contributors
+// SPDX-License-Identifier: MIT
+
+package social.aloha.core.model
+
+import java.time.Instant
+import kotlinx.serialization.Serializable
+
+@Serializable
+public data class StatusContext(val ancestors: List = emptyList(), val descendants: List = emptyList())
+
+@Serializable
+public data class Conversation(
+ val id: String,
+ val accounts: List = emptyList(),
+ val unread: Boolean = false,
+ val lastStatus: Status? = null,
+)
+
+@Serializable
+public data class Marker(
+ val lastReadId: String,
+ val version: Int = 0,
+ @Serializable(with = InstantSerializer::class) val updatedAt: Instant? = null,
+)
+
+/** The markers of one account; the server answers `{}` for an account with none yet. */
+@Serializable
+public data class MarkerSet(val home: Marker? = null, val notifications: Marker? = null)
+
+@Serializable
+public data class UnreadCount(val count: Int)
+
+/** A list of accounts the viewer curates. */
+@Serializable
+public data class AccountList(
+ val id: String,
+ val title: String,
+ val repliesPolicy: String? = null,
+ val exclusive: Boolean = false,
+)
+
+@Serializable
+public data class SearchResults(
+ val accounts: List = emptyList(),
+ val statuses: List = emptyList(),
+ val hashtags: List = emptyList(),
+) {
+ val isEmpty: Boolean get() = accounts.isEmpty() && statuses.isEmpty() && hashtags.isEmpty()
+}
+
+@Serializable
+public data class Suggestion(val account: Account, val source: String? = null, val sources: List? = null) {
+ val id: String get() = account.id
+}
+
+@Serializable
+public data class Announcement(
+ val id: String,
+ val content: String = "",
+ val published: Boolean = false,
+ val read: Boolean = false,
+ @Serializable(with = InstantSerializer::class) val publishedAt: Instant? = null,
+ @Serializable(with = InstantSerializer::class) val updatedAt: Instant? = null,
+ val allDay: Boolean = false,
+ @Serializable(with = InstantSerializer::class) val endsAt: Instant? = null,
+ val reactions: List = emptyList(),
+)
+
+/** A reaction readers left on an announcement; [url] is set for a custom emoji. */
+@Serializable
+public data class AnnouncementReaction(
+ val name: String,
+ val count: Int = 0,
+ val me: Boolean = false,
+ val url: String? = null,
+) {
+ val id: String get() = name
+}
diff --git a/core/model/src/main/kotlin/social/aloha/core/model/CustomEmoji.kt b/core/model/src/main/kotlin/social/aloha/core/model/CustomEmoji.kt
new file mode 100644
index 00000000..e782f771
--- /dev/null
+++ b/core/model/src/main/kotlin/social/aloha/core/model/CustomEmoji.kt
@@ -0,0 +1,25 @@
+// SPDX-FileCopyrightText: 2026 Aloha Social contributors
+// SPDX-License-Identifier: MIT
+
+package social.aloha.core.model
+
+import kotlinx.serialization.Serializable
+
+/**
+ * A server's custom emoji.
+ *
+ * @property staticUrl the still frame. Nextcloud Social serves the same file as [url], since no
+ * separate still rendering of an upload exists.
+ * @property category omitted rather than null when there is none; the picker groups by it.
+ */
+@Serializable
+public data class CustomEmoji(
+ val shortcode: String,
+ val url: String? = null,
+ val staticUrl: String? = null,
+ val visibleInPicker: Boolean = true,
+ val category: String? = null,
+) {
+ /** The still image for a reader who turned animations off; [url] where the server has no other. */
+ val stillUrl: String? get() = staticUrl ?: url
+}
diff --git a/core/model/src/main/kotlin/social/aloha/core/model/Directories.kt b/core/model/src/main/kotlin/social/aloha/core/model/Directories.kt
new file mode 100644
index 00000000..97582e3b
--- /dev/null
+++ b/core/model/src/main/kotlin/social/aloha/core/model/Directories.kt
@@ -0,0 +1,98 @@
+// SPDX-FileCopyrightText: 2026 Aloha Social contributors
+// SPDX-License-Identifier: MIT
+
+package social.aloha.core.model
+
+import kotlinx.serialization.Serializable
+
+/*
+ * Nextcloud Social's discovery surfaces: starter packs, other servers' directories, the follow
+ * graph and Pixelfed's discover categories. All optional, and the routes most likely to change shape
+ * between server versions.
+ */
+
+/**
+ * A curated bundle of accounts to follow in one go.
+ *
+ * @property handles the handles the pack names, before resolution.
+ * @property size how many accounts the pack holds; the list route sends the number without resolving anybody.
+ * @property accounts resolved on the single-pack route only.
+ */
+@Serializable
+public data class StarterPack(
+ val slug: String,
+ val name: String,
+ val description: String = "",
+ val handles: List = emptyList(),
+ val size: Int = 0,
+ val accounts: List = emptyList(),
+) {
+ val id: String get() = slug
+}
+
+/** One directory the server asks when searching beyond itself. */
+@Serializable
+public data class DirectorySource(val host: String, val kind: String = "", val label: String = host) {
+ val id: String get() = host
+}
+
+/** What one source said when asked: how many it gave, or why nothing. */
+@Serializable
+public data class DirectorySourceReport(
+ val host: String,
+ val status: String? = null,
+ val error: String? = null,
+ val count: Int = 0,
+) {
+ val id: String get() = host
+}
+
+/** `GET /api/v1/directories/search`: people found across the sources. */
+@Serializable
+public data class DirectorySearchResults(
+ val accounts: List = emptyList(),
+ val sources: List = emptyList(),
+)
+
+/** `GET /api/v1/directories/hashtags`: what other servers discuss. */
+@Serializable
+public data class DirectoryHashtagResults(
+ val hashtags: List = emptyList(),
+ val sources: List = emptyList(),
+)
+
+/**
+ * Somebody the people the reader follows also follow.
+ *
+ * @property count how many of the reader's follows follow this account.
+ * @property via a few of the people who do, where the server names them.
+ */
+@Serializable
+public data class FollowGraphSuggestion(
+ val account: Account,
+ val count: Int = 0,
+ val via: List = emptyList(),
+) {
+ val id: String get() = account.id
+}
+
+/**
+ * `GET /api/v1/follow_graph`.
+ *
+ * @property asked how many of the reader's follows the server asked.
+ * @property needs how many follows a walk needs before it is worth anything.
+ */
+@Serializable
+public data class FollowGraph(
+ val suggestions: List = emptyList(),
+ val asked: Int = 0,
+ val needs: Int = 0,
+)
+
+/** `GET /api/v1/follow_graph/status`: whether a walk is worth the requests. */
+@Serializable
+public data class FollowGraphStatus(val isWorthwhile: Boolean, val following: Int = 0, val needs: Int = 0)
+
+/** A curated subject with the hashtags, normalised, that make it up. */
+@Serializable
+public data class DiscoverCategory(val id: String, val name: String, val hashtags: List = emptyList())
diff --git a/core/model/src/main/kotlin/social/aloha/core/model/Enums.kt b/core/model/src/main/kotlin/social/aloha/core/model/Enums.kt
new file mode 100644
index 00000000..4c6205c4
--- /dev/null
+++ b/core/model/src/main/kotlin/social/aloha/core/model/Enums.kt
@@ -0,0 +1,154 @@
+// SPDX-FileCopyrightText: 2026 Aloha Social contributors
+// SPDX-License-Identifier: MIT
+
+package social.aloha.core.model
+
+import kotlinx.serialization.Serializable
+
+@Serializable
+public enum class Visibility(override val wire: String) : WireValue {
+ Public("public"),
+ Unlisted("unlisted"),
+ Private("private"),
+ Direct("direct"),
+
+ /** A value this app does not know. Treated as the most restrictive, never as public. */
+ Unknown("__unknown"),
+ ;
+
+ /** How restrictive this is, ascending. A reply is never less restrictive than the post it answers. */
+ public val restrictiveness: Int
+ get() = when (this) {
+ Public -> 0
+ Unlisted -> 1
+ Private -> 2
+ Direct, Unknown -> 3
+ }
+
+ public val isUnknown: Boolean get() = this == Unknown
+
+ public companion object {
+ public fun fromWire(raw: String?): Visibility = entries.fromWire(raw, Unknown)
+
+ public fun mostRestrictive(lhs: Visibility, rhs: Visibility): Visibility =
+ if (lhs.restrictiveness >= rhs.restrictiveness) lhs else rhs
+ }
+}
+
+@Serializable
+public enum class AttachmentKind(override val wire: String) : WireValue {
+ Image("image"),
+ Video("video"),
+ Gifv("gifv"),
+ Audio("audio"),
+
+ /** Mastodon's own `unknown`: a file the server accepted but cannot show, such as a PDF. */
+ UnsupportedFile("unknown"),
+
+ /** A value this app does not know. */
+ Unknown("__unknown"),
+ ;
+
+ public val isVisualMedia: Boolean get() = this == Image || this == Video || this == Gifv
+
+ public val isPlayable: Boolean get() = this == Video || this == Gifv || this == Audio
+
+ public companion object {
+ /** A missing type is Mastodon's `unknown`; a type this app does not know is [Unknown]. */
+ public fun fromWire(raw: String?): AttachmentKind =
+ if (raw == null) UnsupportedFile else entries.fromWire(raw, Unknown)
+ }
+}
+
+/** The notification types Nextcloud Social serves, plus the rest of Mastodon's. */
+@Serializable
+public enum class NotificationKind(override val wire: String) : WireValue {
+ Mention("mention"),
+ Reblog("reblog"),
+ Favourite("favourite"),
+ Follow("follow"),
+ FollowRequest("follow_request"),
+ Poll("poll"),
+ Status("status"),
+ Update("update"),
+ ModerationWarning("moderation_warning"),
+ SeveredRelationships("severed_relationships"),
+ AdminSignUp("admin.sign_up"),
+ AdminReport("admin.report"),
+ AnnualReport("annual_report"),
+ Unknown("__unknown"),
+ ;
+
+ /**
+ * Whether several of these collapse into one row. Mentions never do: two people writing are two
+ * things to read. Nothing about a poll, an edit or a moderation decision groups either.
+ */
+ public val groups: Boolean get() = this == Favourite || this == Reblog || this == Follow
+
+ public val isUnknown: Boolean get() = this == Unknown
+
+ public companion object {
+ public fun fromWire(raw: String?): NotificationKind = entries.fromWire(raw, Unknown)
+ }
+}
+
+@Serializable
+public enum class FilterAction(override val wire: String) : WireValue {
+ Warn("warn"),
+ Hide("hide"),
+ Blur("blur"),
+ Unknown("__unknown"),
+ ;
+
+ public companion object {
+ /** A missing action is `warn`, as Mastodon defines it. */
+ public fun fromWire(raw: String?): FilterAction = if (raw == null) Warn else entries.fromWire(raw, Unknown)
+ }
+}
+
+@Serializable
+public enum class FilterContext(override val wire: String) : WireValue {
+ Home("home"),
+ Notifications("notifications"),
+ Public("public"),
+ Thread("thread"),
+ Account("account"),
+ Unknown("__unknown"),
+ ;
+
+ public companion object {
+ public fun fromWire(raw: String?): FilterContext = entries.fromWire(raw, Unknown)
+ }
+}
+
+/**
+ * How sensitive media is shown. Nextcloud Social serves PeerTube's three NSFW policies under the
+ * names Mastodon already has for the same states.
+ */
+@Serializable
+public enum class SensitiveMediaPolicy(override val wire: String) : WireValue {
+ /** Drawn as is. */
+ ShowAll("show_all"),
+
+ /** Covered, blurhash showing, one press away. */
+ Blur("default"),
+
+ /** Not drawn and no button to draw it; opening the post is what it takes. */
+ HideAll("hide_all"),
+ Unknown("__unknown"),
+ ;
+
+ public val allowsAutomaticReveal: Boolean get() = this == ShowAll
+
+ public val drawsAtAll: Boolean get() = this != HideAll
+
+ public companion object {
+ /**
+ * The value that puts an account back to following the instance's policy, which differs from
+ * choosing whatever the instance happens to do today.
+ */
+ public const val FOLLOW_INSTANCE: String = ""
+
+ public fun fromWire(raw: String?): SensitiveMediaPolicy = entries.fromWire(raw, Unknown)
+ }
+}
diff --git a/core/model/src/main/kotlin/social/aloha/core/model/Filter.kt b/core/model/src/main/kotlin/social/aloha/core/model/Filter.kt
new file mode 100644
index 00000000..58181ec7
--- /dev/null
+++ b/core/model/src/main/kotlin/social/aloha/core/model/Filter.kt
@@ -0,0 +1,30 @@
+// SPDX-FileCopyrightText: 2026 Aloha Social contributors
+// SPDX-License-Identifier: MIT
+
+package social.aloha.core.model
+
+import java.time.Instant
+import kotlinx.serialization.Serializable
+
+/**
+ * A Mastodon 4.x v2 filter. Nextcloud Social applies filters on the server; the app applies them
+ * again locally, so a filter takes effect over cached content at once and stops when it expires.
+ */
+@Serializable
+public data class Filter(
+ val id: String,
+ val title: String = "",
+ val context: List = emptyList(),
+ @Serializable(with = InstantSerializer::class) val expiresAt: Instant? = null,
+ val filterAction: FilterAction = FilterAction.Warn,
+ val keywords: List = emptyList(),
+ val statuses: List = emptyList(),
+) {
+ public fun isExpired(now: Instant): Boolean = expiresAt?.let { !it.isAfter(now) } ?: false
+}
+
+@Serializable
+public data class FilterKeyword(val id: String, val keyword: String, val wholeWord: Boolean = false)
+
+@Serializable
+public data class FilterStatus(val id: String, val statusId: String)
diff --git a/core/model/src/main/kotlin/social/aloha/core/model/Instance.kt b/core/model/src/main/kotlin/social/aloha/core/model/Instance.kt
new file mode 100644
index 00000000..267d75fe
--- /dev/null
+++ b/core/model/src/main/kotlin/social/aloha/core/model/Instance.kt
@@ -0,0 +1,59 @@
+// SPDX-FileCopyrightText: 2026 Aloha Social contributors
+// SPDX-License-Identifier: MIT
+
+package social.aloha.core.model
+
+import java.time.Instant
+import kotlinx.serialization.Serializable
+
+/**
+ * A server's description of itself: `/api/v2/instance`, with `/api/v1/instance/` folded into the same
+ * shape so one persisted type can be fed by either.
+ *
+ * @property streamingUrl null when there is no streaming; Nextcloud Social says so with an empty `urls`.
+ * @property vapidKey `""` on Nextcloud Social, which is the server saying not to offer Web Push.
+ * @property apiVersions `{"mastodon": 3}` on a 4.3-compatible server, read instead of a version string
+ * that says nothing on a fork.
+ */
+@Serializable
+public data class InstanceDescription(
+ val domain: String,
+ val title: String = "",
+ val version: String = "",
+ val sourceUrl: String? = null,
+ val shortDescription: String = "",
+ val description: String = "",
+ val thumbnail: String? = null,
+ val languages: List = emptyList(),
+ val rules: List = emptyList(),
+ val contactAccount: Account? = null,
+ val contactEmail: String? = null,
+ val registrationsEnabled: Boolean = false,
+ val approvalRequired: Boolean = false,
+ val userCount: Int? = null,
+ val statusCount: Int? = null,
+ val domainCount: Int? = null,
+ val streamingUrl: String? = null,
+ val vapidKey: String? = null,
+ val translationEnabled: Boolean = false,
+ val apiVersions: Map = emptyMap(),
+ val limits: ServerLimits = ServerLimits.MastodonDefaults,
+) {
+ /** The Mastodon API generation this server implements, where it says. */
+ val mastodonApiVersion: Int? get() = apiVersions["mastodon"]
+
+ val hasStreaming: Boolean get() = streamingUrl != null
+
+ /** An empty key is the server saying not to offer Web Push. */
+ val hasWebPush: Boolean get() = !vapidKey.isNullOrEmpty()
+}
+
+@Serializable
+public data class InstanceRule(val id: String, val text: String, val hint: String? = null)
+
+/** A published page such as the privacy policy or the terms of service. */
+@Serializable
+public data class InstanceDocument(
+ val content: String,
+ @Serializable(with = InstantSerializer::class) val updatedAt: Instant? = null,
+)
diff --git a/core/model/src/main/kotlin/social/aloha/core/model/InstantSerializer.kt b/core/model/src/main/kotlin/social/aloha/core/model/InstantSerializer.kt
new file mode 100644
index 00000000..9e3b201b
--- /dev/null
+++ b/core/model/src/main/kotlin/social/aloha/core/model/InstantSerializer.kt
@@ -0,0 +1,23 @@
+// SPDX-FileCopyrightText: 2026 Aloha Social contributors
+// SPDX-License-Identifier: MIT
+
+package social.aloha.core.model
+
+import java.time.Instant
+import kotlinx.serialization.KSerializer
+import kotlinx.serialization.descriptors.PrimitiveKind
+import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
+import kotlinx.serialization.descriptors.SerialDescriptor
+import kotlinx.serialization.encoding.Decoder
+import kotlinx.serialization.encoding.Encoder
+
+/** Persists an [Instant] as ISO 8601, for domain types stored as JSON on this device. */
+public object InstantSerializer : KSerializer {
+ override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("social.aloha.Instant", PrimitiveKind.STRING)
+
+ override fun serialize(encoder: Encoder, value: Instant) {
+ encoder.encodeString(value.toString())
+ }
+
+ override fun deserialize(decoder: Decoder): Instant = Instant.parse(decoder.decodeString())
+}
diff --git a/core/model/src/main/kotlin/social/aloha/core/model/Interests.kt b/core/model/src/main/kotlin/social/aloha/core/model/Interests.kt
new file mode 100644
index 00000000..81061025
--- /dev/null
+++ b/core/model/src/main/kotlin/social/aloha/core/model/Interests.kt
@@ -0,0 +1,85 @@
+// SPDX-FileCopyrightText: 2026 Aloha Social contributors
+// SPDX-License-Identifier: MIT
+
+package social.aloha.core.model
+
+import java.time.Instant
+import kotlinx.serialization.Serializable
+
+/**
+ * The reader's hashtag interests on Nextcloud Social: what the server has learned, what it is
+ * still weighing, and the switches that govern the learning.
+ *
+ * @property thin true when the server has too little to go on yet.
+ */
+@Serializable
+public data class InterestsState(
+ val settings: InterestSettings = InterestSettings(),
+ val interests: List = emptyList(),
+ val candidates: List = emptyList(),
+ val thin: Boolean = false,
+)
+
+@Serializable
+public data class InterestSettings(
+ val learning: Boolean = true,
+ val paused: Boolean = false,
+ val languages: List = emptyList(),
+)
+
+/** One hashtag the server ranks, with how strongly. */
+@Serializable
+public data class InterestTag(val tag: String, val score: Double = 0.0, val pinned: Boolean = false) {
+ val id: String get() = tag.lowercase()
+}
+
+/**
+ * A feed outside the fediverse (RSS, Atom, a YouTube channel) that the server reads for the viewer.
+ * Entries link out; nothing is copied.
+ *
+ * @property error the server's last word on reading the feed; null when it read fine.
+ */
+@Serializable
+public data class SubscriptionFeed(
+ val id: String,
+ val url: String? = null,
+ val title: String = "",
+ val siteUrl: String? = null,
+ val entryCount: Int = 0,
+ val error: String? = null,
+ @Serializable(with = InstantSerializer::class) val lastReadAt: Instant? = null,
+) {
+ /** The title, or the site's host, or the feed's address. */
+ val displayTitle: String
+ get() = title.ifEmpty { hostOf(siteUrl) ?: hostOf(url) ?: url ?: id }
+}
+
+/** One published entry from a subscribed feed. */
+@Serializable
+public data class SubscriptionEntry(
+ val id: String,
+ val title: String = "",
+ val url: String? = null,
+ val summary: String? = null,
+ @Serializable(with = InstantSerializer::class) val publishedAt: Instant? = null,
+ val feedTitle: String? = null,
+)
+
+/** The weekly recap: how much the reader posted this week against last. Counts are zero while it is off. */
+@Serializable
+public data class WeeklyRecap(val enabled: Boolean, val thisWeek: Int = 0, val lastWeek: Int = 0)
+
+/** One of the reader's own posts a moderator has not looked at yet. */
+@Serializable
+public data class HeldPost(
+ val id: String,
+ @Serializable(with = InstantSerializer::class) val createdAt: Instant? = null,
+ val spoilerText: String = "",
+ val text: String = "",
+ val mediaCount: Int = 0,
+ val reason: String? = null,
+)
+
+/** `GET /api/v1/review`: the held posts and the reasons the server may give. */
+@Serializable
+public data class HeldPostsPage(val held: List = emptyList(), val reasons: List = emptyList())
diff --git a/core/model/src/main/kotlin/social/aloha/core/model/MediaAttachment.kt b/core/model/src/main/kotlin/social/aloha/core/model/MediaAttachment.kt
new file mode 100644
index 00000000..7a7261e0
--- /dev/null
+++ b/core/model/src/main/kotlin/social/aloha/core/model/MediaAttachment.kt
@@ -0,0 +1,72 @@
+// SPDX-FileCopyrightText: 2026 Aloha Social contributors
+// SPDX-License-Identifier: MIT
+
+package social.aloha.core.model
+
+import kotlinx.serialization.Serializable
+
+/**
+ * A file attached to a status.
+ *
+ * @property previewUrl for a video, its poster frame served as an image.
+ * @property description the alt text.
+ * @property hlsUrl the master playlist of a local video's transcoding ladder on Nextcloud Social, where
+ * the administrator enabled it. Never a replacement for [url]: the whole file is always there too.
+ */
+@Serializable
+public data class MediaAttachment(
+ val id: String,
+ val type: AttachmentKind,
+ val url: String? = null,
+ val previewUrl: String? = null,
+ val remoteUrl: String? = null,
+ val description: String? = null,
+ val blurhash: String? = null,
+ val meta: MediaMeta? = null,
+ val hlsUrl: String? = null,
+) {
+ val hasAltText: Boolean get() = !description.isNullOrBlank()
+
+ /** Width over height, where the server said; null defers Shorts classification. */
+ val aspectRatio: Double? get() = meta?.original?.resolvedAspect
+
+ /** Seconds, where the server said. */
+ val duration: Double? get() = meta?.original?.duration
+
+ /** A box to lay out in before the bytes arrive, so nothing shifts. */
+ val displayAspectRatio: Double get() = aspectRatio ?: DEFAULT_ASPECT
+
+ public companion object {
+ public const val DEFAULT_ASPECT: Double = 4.0 / 3.0
+ }
+}
+
+@Serializable
+public data class MediaMeta(
+ val original: MediaDimensions? = null,
+ val small: MediaDimensions? = null,
+ val focus: MediaFocus? = null,
+)
+
+@Serializable
+public data class MediaDimensions(
+ val width: Int? = null,
+ val height: Int? = null,
+ val size: String? = null,
+ val aspect: Double? = null,
+ val duration: Double? = null,
+ val frameRate: String? = null,
+ val bitrate: Int? = null,
+) {
+ /** The server's own `aspect` where positive, else width over height; null when neither is known. */
+ val resolvedAspect: Double?
+ get() {
+ if (aspect != null && aspect > 0) return aspect
+ if (width == null || height == null || height <= 0) return null
+ return width.toDouble() / height
+ }
+}
+
+/** A focal point, each axis from -1 to 1 with y up. */
+@Serializable
+public data class MediaFocus(val x: Double, val y: Double)
diff --git a/core/model/src/main/kotlin/social/aloha/core/model/Migration.kt b/core/model/src/main/kotlin/social/aloha/core/model/Migration.kt
new file mode 100644
index 00000000..9a7d47e5
--- /dev/null
+++ b/core/model/src/main/kotlin/social/aloha/core/model/Migration.kt
@@ -0,0 +1,42 @@
+// SPDX-FileCopyrightText: 2026 Aloha Social contributors
+// SPDX-License-Identifier: MIT
+
+package social.aloha.core.model
+
+import kotlinx.serialization.Serializable
+
+/** What to paste into the old server's "move to" box. */
+@Serializable
+public data class MigrationAnnouncement(val handle: String, val address: String)
+
+/** Which of some handles exist somewhere, one entry per handle. */
+@Serializable
+public data class MigrationLookup(val entries: List = emptyList())
+
+@Serializable
+public data class MigrationEntry(val handle: String, val found: Boolean, val account: Account? = null) {
+ val id: String get() = handle
+}
+
+/**
+ * A server's account of what an import did. The keys vary by kind (`followed`, `skipped`,
+ * `imported`, …), so they are kept as they came, sorted by key.
+ */
+@Serializable
+public data class MigrationReport(val lines: List = emptyList(), val log: List = emptyList())
+
+@Serializable
+public data class MigrationLine(val key: String, val value: String)
+
+/** The lists an account can take somewhere else or bring here. */
+public enum class MigrationListKind(override val wire: String) : WireValue {
+ Following("following"),
+ Followers("followers"),
+ Blocks("blocks"),
+ Mutes("mutes"),
+ Lists("lists"),
+ ;
+
+ /** The route an import of this kind lives at: following goes in as `follows`, the rest keep their name. */
+ public val importPath: String get() = if (this == Following) "follows" else wire
+}
diff --git a/core/model/src/main/kotlin/social/aloha/core/model/Moderation.kt b/core/model/src/main/kotlin/social/aloha/core/model/Moderation.kt
new file mode 100644
index 00000000..8fc2e8b1
--- /dev/null
+++ b/core/model/src/main/kotlin/social/aloha/core/model/Moderation.kt
@@ -0,0 +1,116 @@
+// SPDX-FileCopyrightText: 2026 Aloha Social contributors
+// SPDX-License-Identifier: MIT
+
+package social.aloha.core.model
+
+import java.time.Instant
+import kotlinx.serialization.Serializable
+
+/**
+ * An account as a moderator sees it: Mastodon's `Admin::Account`, served by Nextcloud Social at
+ * `/api/v1/admin/accounts`. An account there is a Nextcloud user or a cached remote actor, so the
+ * login fields Mastodon has (email, IP, locale, role) come back empty and are not modelled.
+ *
+ * @property domain null for a local account.
+ * @property account the public account entity, which is what a row draws.
+ */
+@Serializable
+public data class AdminAccount(
+ val id: String,
+ val username: String,
+ val domain: String? = null,
+ @Serializable(with = InstantSerializer::class) val createdAt: Instant? = null,
+ val confirmed: Boolean = true,
+ val approved: Boolean = true,
+ val disabled: Boolean = false,
+ val silenced: Boolean = false,
+ val suspended: Boolean = false,
+ val sensitized: Boolean = false,
+ val account: Account? = null,
+) {
+ val isLocal: Boolean get() = domain.isNullOrEmpty()
+
+ /** The one word for the account's standing: the strongest thing true of it. */
+ val standing: AdminStanding
+ get() = when {
+ suspended -> AdminStanding.Suspended
+ silenced -> AdminStanding.Silenced
+ sensitized -> AdminStanding.Sensitized
+ else -> AdminStanding.Active
+ }
+
+ /** `@user@host` for a remote account, `@user` for a local one. */
+ val handle: String get() = if (domain.isNullOrEmpty()) "@$username" else "@$username@$domain"
+}
+
+public enum class AdminStanding { Active, Silenced, Suspended, Sensitized }
+
+/**
+ * What a moderator can do to an account: the `type` of `POST /api/v1/admin/accounts/{id}/action`.
+ * Nextcloud Social acts on these three.
+ */
+public enum class AdminAccountAction(override val wire: String) : WireValue {
+ /** Records the decision and changes nothing. */
+ None("none"),
+
+ /** The account's posts stop reaching people who do not follow it. */
+ Silence("silence"),
+
+ /** Nothing of the account is delivered or shown. */
+ Suspend("suspend"),
+}
+
+/** A report as a moderator sees it: Mastodon's `Admin::Report`. */
+@Serializable
+public data class AdminReport(
+ val id: String,
+ val actionTaken: Boolean = false,
+ @Serializable(with = InstantSerializer::class) val actionTakenAt: Instant? = null,
+ val category: String = "",
+ val comment: String = "",
+ val forwarded: Boolean = false,
+ @Serializable(with = InstantSerializer::class) val createdAt: Instant? = null,
+ @Serializable(with = InstantSerializer::class) val updatedAt: Instant? = null,
+ /** Who reported. */
+ val account: Account? = null,
+ /** Who was reported. */
+ val targetAccount: Account? = null,
+ val assignedAccount: Account? = null,
+ val actionTakenByAccount: Account? = null,
+ val statuses: List = emptyList(),
+) {
+ val isAssigned: Boolean get() = assignedAccount != null
+}
+
+/**
+ * One week of `/api/v1/instance/activity`. Every value arrives as a string. `logins` and
+ * `registrations` are always zero on Nextcloud Social: an account there is a Nextcloud user, so no
+ * registration exists to count.
+ *
+ * @property week Unix seconds at the Monday the week began.
+ */
+@Serializable
+public data class InstanceActivityWeek(
+ val week: Long,
+ val statuses: Int = 0,
+ val logins: Int = 0,
+ val registrations: Int = 0,
+) {
+ val id: Long get() = week
+
+ val startsAt: Instant get() = Instant.ofEpochSecond(week)
+}
+
+/**
+ * A server this instance refuses, from `/api/v1/instance/domain_blocks`, which is empty unless the
+ * administrator publishes the list.
+ */
+@Serializable
+public data class PublicDomainBlock(
+ val domain: String,
+ val digest: String = "",
+ val severity: String = "suspend",
+ val comment: String = "",
+) {
+ val id: String get() = digest.ifEmpty { domain }
+}
diff --git a/core/model/src/main/kotlin/social/aloha/core/model/NextcloudTheme.kt b/core/model/src/main/kotlin/social/aloha/core/model/NextcloudTheme.kt
new file mode 100644
index 00000000..c0d9b9fb
--- /dev/null
+++ b/core/model/src/main/kotlin/social/aloha/core/model/NextcloudTheme.kt
@@ -0,0 +1,47 @@
+// SPDX-FileCopyrightText: 2026 Aloha Social contributors
+// SPDX-License-Identifier: MIT
+
+package social.aloha.core.model
+
+import kotlinx.serialization.Serializable
+
+/**
+ * The colour a Nextcloud is wearing, read from its public `theming` capability at the Nextcloud root
+ * (not the Mastodon API base). An administrator who themed their Nextcloud has already chosen the
+ * colour their people know the server by.
+ *
+ * Every hex value is normalised to `#rrggbb` or absent.
+ *
+ * @property elementBrightHex the primary adjusted to stay legible on a light background.
+ * @property elementDarkHex the primary adjusted to stay legible on a dark background.
+ * @property textHex what Nextcloud puts on the primary colour: `#ffffff` or `#000000`.
+ */
+@Serializable
+public data class NextcloudTheme(
+ val name: String = "",
+ val slogan: String = "",
+ val colourHex: String? = null,
+ val elementBrightHex: String? = null,
+ val elementDarkHex: String? = null,
+ val textHex: String? = null,
+) {
+ /** Whether this says anything about a colour; with the Theming app disabled the keys are absent. */
+ val hasColour: Boolean get() = colourHex != null
+
+ /** The variant for a background of this kind, falling back to the raw colour the administrator chose. */
+ public fun hex(onDarkBackground: Boolean): String? =
+ (if (onDarkBackground) elementDarkHex else elementBrightHex) ?: colourHex
+
+ public companion object {
+ private const val SHORT_HEX = 3
+ private const val LONG_HEX = 6
+
+ /** `#abc` and `abcdef` both become six-digit `#aabbcc`; anything else is null, not a colour nobody chose. */
+ public fun normalise(raw: String?): String? {
+ val text = raw?.trim()?.lowercase()?.removePrefix("#").orEmpty()
+ val expanded = if (text.length == SHORT_HEX) text.map { "$it$it" }.joinToString("") else text
+ val valid = expanded.length == LONG_HEX && expanded.all { it in '0'..'9' || it in 'a'..'f' }
+ return if (valid) "#$expanded" else null
+ }
+ }
+}
diff --git a/core/model/src/main/kotlin/social/aloha/core/model/NodeInfo.kt b/core/model/src/main/kotlin/social/aloha/core/model/NodeInfo.kt
new file mode 100644
index 00000000..8352323f
--- /dev/null
+++ b/core/model/src/main/kotlin/social/aloha/core/model/NodeInfo.kt
@@ -0,0 +1,44 @@
+// SPDX-FileCopyrightText: 2026 Aloha Social contributors
+// SPDX-License-Identifier: MIT
+
+package social.aloha.core.model
+
+import kotlinx.serialization.Serializable
+
+/**
+ * NodeInfo 2.x, which names the server software. Nextcloud Social serves it at the domain root
+ * whether or not the client-API rewrite rules are installed, which makes it the cross-check for
+ * API-base discovery.
+ */
+@Serializable
+public data class NodeInfo(
+ val version: String,
+ val softwareName: String,
+ val softwareVersion: String,
+ val repository: String? = null,
+ val homepage: String? = null,
+ val protocols: List = emptyList(),
+ val openRegistrations: Boolean = false,
+) {
+ /** Nextcloud Social identifies as `nextcloud-social`; older releases as `Nextcloud Social`. */
+ val isNextcloudSocial: Boolean
+ get() = softwareName.lowercase().let { it.contains("nextcloud") && it.contains("social") }
+}
+
+/**
+ * RFC 8414 authorization server metadata (`/.well-known/oauth-authorization-server`). On Nextcloud
+ * Social the [issuer] and every endpoint name the app path, with or without the root rewrite rules.
+ */
+@Serializable
+public data class AuthorizationServerMetadata(
+ val issuer: String,
+ val authorizationEndpoint: String? = null,
+ val tokenEndpoint: String? = null,
+ val revocationEndpoint: String? = null,
+ val userinfoEndpoint: String? = null,
+ val codeChallengeMethods: List? = null,
+ val scopes: List? = null,
+) {
+ /** PKCE with S256 is assumed where the server does not say; it is the only method sent. */
+ val supportsPkceS256: Boolean get() = codeChallengeMethods?.contains("S256") ?: true
+}
diff --git a/core/model/src/main/kotlin/social/aloha/core/model/Notification.kt b/core/model/src/main/kotlin/social/aloha/core/model/Notification.kt
new file mode 100644
index 00000000..0c8948ea
--- /dev/null
+++ b/core/model/src/main/kotlin/social/aloha/core/model/Notification.kt
@@ -0,0 +1,51 @@
+// SPDX-FileCopyrightText: 2026 Aloha Social contributors
+// SPDX-License-Identifier: MIT
+
+package social.aloha.core.model
+
+import java.time.Instant
+import kotlinx.serialization.Serializable
+
+/** A v1 notification: one row per event. [createdAt] is [Instant.EPOCH] when the server did not say. */
+@Serializable
+public data class Notification(
+ val id: String,
+ val type: NotificationKind,
+ val account: Account,
+ @Serializable(with = InstantSerializer::class) val createdAt: Instant = Instant.EPOCH,
+ val status: Status? = null,
+)
+
+/**
+ * A page of grouped notifications (Mastodon 4.3). Forty favourites of one post are one group and
+ * one copy of the post, where v1 sends forty rows and forty copies.
+ */
+@Serializable
+public data class GroupedNotifications(
+ val accounts: List = emptyList(),
+ val statuses: List = emptyList(),
+ val notificationGroups: List = emptyList(),
+) {
+ public fun account(id: String): Account? = accounts.firstOrNull { it.id == id }
+
+ public fun status(id: String): Status? = statuses.firstOrNull { it.id == id }
+}
+
+/**
+ * One group of notifications.
+ *
+ * @property groupKey built from what the group is (`favourite-{status id}`, `follow-all`), never from
+ * the ids in it, so it names the same group after more arrive. Ungrouped kinds are `ungrouped-{id}`.
+ */
+@Serializable
+public data class NotificationGroup(
+ val groupKey: String,
+ val notificationsCount: Int,
+ val type: NotificationKind,
+ val mostRecentNotificationId: String,
+ val pageMinId: String? = null,
+ val pageMaxId: String? = null,
+ @Serializable(with = InstantSerializer::class) val latestPageNotificationAt: Instant? = null,
+ val sampleAccountIds: List = emptyList(),
+ val statusId: String? = null,
+)
diff --git a/core/model/src/main/kotlin/social/aloha/core/model/OAuth.kt b/core/model/src/main/kotlin/social/aloha/core/model/OAuth.kt
new file mode 100644
index 00000000..07844c93
--- /dev/null
+++ b/core/model/src/main/kotlin/social/aloha/core/model/OAuth.kt
@@ -0,0 +1,32 @@
+// SPDX-FileCopyrightText: 2026 Aloha Social contributors
+// SPDX-License-Identifier: MIT
+
+package social.aloha.core.model
+
+import kotlinx.serialization.Serializable
+
+/**
+ * The app's OAuth client on one server. `POST /api/v1/apps` always creates a new client on Nextcloud
+ * Social, so a registration is made once per server on this device and reused for every account
+ * there. The secret belongs in the token vault only.
+ */
+@Serializable
+public data class ClientRegistration(val clientId: String, val clientSecret: String, val scopes: String) {
+ override fun toString(): String = "ClientRegistration(clientId=$clientId, scopes=$scopes)"
+}
+
+/**
+ * A bearer token. There is no refresh token: a token lives about a year from its last use, and a 401
+ * means signing in again.
+ */
+@Serializable
+public data class AccessToken(val value: String, val scope: String) {
+ override fun toString(): String = "AccessToken(scope=$scope)"
+}
+
+/**
+ * `/oauth/userinfo`: who the token belongs to. Nextcloud Social answers it even for a brand-new
+ * account whose `verify_credentials` still fails with a 500 because its avatar is not cached yet.
+ */
+@Serializable
+public data class OAuthUserInfo(val subject: String, val preferredUsername: String?, val name: String?)
diff --git a/core/model/src/main/kotlin/social/aloha/core/model/Poll.kt b/core/model/src/main/kotlin/social/aloha/core/model/Poll.kt
new file mode 100644
index 00000000..b948b3fb
--- /dev/null
+++ b/core/model/src/main/kotlin/social/aloha/core/model/Poll.kt
@@ -0,0 +1,39 @@
+// SPDX-FileCopyrightText: 2026 Aloha Social contributors
+// SPDX-License-Identifier: MIT
+
+package social.aloha.core.model
+
+import java.time.Instant
+import kotlinx.serialization.Serializable
+
+@Serializable
+public data class Poll(
+ val id: String,
+ @Serializable(with = InstantSerializer::class) val expiresAt: Instant? = null,
+ val expired: Boolean = false,
+ val multiple: Boolean = false,
+ val votesCount: Int = 0,
+ val votersCount: Int? = null,
+ val voted: Boolean = false,
+ val ownVotes: List = emptyList(),
+ val options: List = emptyList(),
+ val emojis: List = emptyList(),
+) {
+ /** The denominator for a percentage: voters for a multiple-choice poll, votes otherwise. */
+ val participantCount: Int get() = if (multiple && votersCount != null) votersCount else votesCount
+
+ /**
+ * Results show once the poll is closed or the reader has voted; before that they would tell
+ * people how to vote.
+ */
+ val showsResults: Boolean get() = expired || voted
+
+ /** The share of participants who chose the option at [index], from 0 to 1. */
+ public fun shareOfOptionAt(index: Int): Double {
+ val votes = options.getOrNull(index)?.votesCount
+ return if (votes == null || participantCount <= 0) 0.0 else votes.toDouble() / participantCount
+ }
+}
+
+@Serializable
+public data class PollOption(val title: String, val votesCount: Int? = null)
diff --git a/core/model/src/main/kotlin/social/aloha/core/model/Preferences.kt b/core/model/src/main/kotlin/social/aloha/core/model/Preferences.kt
new file mode 100644
index 00000000..d613064d
--- /dev/null
+++ b/core/model/src/main/kotlin/social/aloha/core/model/Preferences.kt
@@ -0,0 +1,62 @@
+// SPDX-FileCopyrightText: 2026 Aloha Social contributors
+// SPDX-License-Identifier: MIT
+
+package social.aloha.core.model
+
+import java.time.Instant
+import kotlinx.serialization.Serializable
+
+/**
+ * The account's posting and reading preferences.
+ *
+ * @property expandMedia real on Nextcloud Social, with PeerTube's three NSFW policies under Mastodon's
+ * names. A client that cannot read it guesses, and posts publicly for someone whose default is
+ * followers-only.
+ */
+@Serializable
+public data class Preferences(
+ val defaultVisibility: Visibility? = null,
+ val defaultSensitive: Boolean = false,
+ val defaultLanguage: String? = null,
+ val expandMedia: SensitiveMediaPolicy? = null,
+ val expandSpoilers: Boolean = false,
+)
+
+/** What happens to notifications from each kind of stranger. Missing decisions are `accept`. */
+@Serializable
+public data class NotificationPolicy(
+ val forNotFollowing: PolicyDecision = PolicyDecision.Accept,
+ val forNotFollowers: PolicyDecision = PolicyDecision.Accept,
+ val forNewAccounts: PolicyDecision = PolicyDecision.Accept,
+ val forPrivateMentions: PolicyDecision = PolicyDecision.Accept,
+ val forLimitedAccounts: PolicyDecision = PolicyDecision.Accept,
+ val summary: NotificationPolicySummary? = null,
+)
+
+@Serializable
+public enum class PolicyDecision(override val wire: String) : WireValue {
+ Accept("accept"),
+ Filter("filter"),
+ Drop("drop"),
+ Unknown("__unknown"),
+ ;
+
+ public companion object {
+ /** A missing decision is `accept`. */
+ public fun fromWire(raw: String?): PolicyDecision = if (raw == null) Accept else entries.fromWire(raw, Unknown)
+ }
+}
+
+@Serializable
+public data class NotificationPolicySummary(val pendingRequestsCount: Int = 0, val pendingNotificationsCount: Int = 0)
+
+/** Notifications held back from one account by the policy. */
+@Serializable
+public data class NotificationRequest(
+ val id: String,
+ val account: Account,
+ @Serializable(with = InstantSerializer::class) val createdAt: Instant? = null,
+ @Serializable(with = InstantSerializer::class) val updatedAt: Instant? = null,
+ val notificationsCount: Int = 0,
+ val lastStatus: Status? = null,
+)
diff --git a/core/model/src/main/kotlin/social/aloha/core/model/Profile.kt b/core/model/src/main/kotlin/social/aloha/core/model/Profile.kt
new file mode 100644
index 00000000..11eb9caa
--- /dev/null
+++ b/core/model/src/main/kotlin/social/aloha/core/model/Profile.kt
@@ -0,0 +1,96 @@
+// SPDX-FileCopyrightText: 2026 Aloha Social contributors
+// SPDX-License-Identifier: MIT
+
+package social.aloha.core.model
+
+import java.time.Instant
+import kotlinx.serialization.Serializable
+
+/**
+ * Nextcloud Social's twelve-week posting rhythm for a local account. Remote accounts answer
+ * `available: false`, and the profile draws nothing.
+ *
+ * @property since when the account was created.
+ * @property weeks one post count per week, oldest first; twelve entries when available.
+ * @property hashtagNames the account's hashtags, when the server includes them here.
+ */
+@Serializable
+public data class ProfileHighlights(
+ val available: Boolean,
+ @Serializable(with = InstantSerializer::class) val since: Instant? = null,
+ val weeks: List = emptyList(),
+ val hashtagNames: List = emptyList(),
+) {
+ val total: Int get() = weeks.sum()
+
+ /** The one-line reading of the chart, so nobody has to read the chart. */
+ public enum class Rhythm { Quiet, Busier, Slowing, Steady }
+
+ /** The last four weeks against the average four weeks before them. */
+ val rhythm: Rhythm
+ get() {
+ if (weeks.size < RECENT_WEEKS) return if (total == 0) Rhythm.Quiet else Rhythm.Steady
+ val recent = weeks.takeLast(RECENT_WEEKS).sum()
+ val earlier = weeks.dropLast(RECENT_WEEKS).sum()
+ val earlierPerFour = earlier.toDouble() / maxOf(1, weeks.size - RECENT_WEEKS) * RECENT_WEEKS
+ return when {
+ recent == 0 -> Rhythm.Quiet
+ recent > earlierPerFour * BUSIER -> Rhythm.Busier
+ recent < earlierPerFour * SLOWER -> Rhythm.Slowing
+ else -> Rhythm.Steady
+ }
+ }
+
+ private companion object {
+ const val RECENT_WEEKS = 4
+ const val BUSIER = 1.5
+ const val SLOWER = 0.5
+ }
+}
+
+/** For one account asked about: the people the reader follows who also follow it. */
+@Serializable
+public data class FamiliarFollowers(val id: String, val accounts: List = emptyList())
+
+/** One person tagged in a photo (Pixelfed's `tagged_people`), sent as an account or a bare handle. */
+@Serializable
+public data class TaggedPerson(val id: String, val acct: String, val displayName: String? = null)
+
+/** Mastodon's list `replies_policy`: whose replies a list timeline shows. */
+@Serializable
+public enum class ListRepliesPolicy(override val wire: String) : WireValue {
+ Followed("followed"),
+ List("list"),
+ None("none"),
+}
+
+/** One animated picture from the instance's own library, so no search leaves the server. */
+@Serializable
+public data class GifEntry(
+ val slug: String,
+ val title: String,
+ val url: String? = null,
+ val previewUrl: String? = null,
+ val width: Int? = null,
+ val height: Int? = null,
+) {
+ val id: String get() = slug
+
+ /** Width over height, square when the server did not say. */
+ val aspectRatio: Double
+ get() = if (width != null && height != null && height > 0) width.toDouble() / height else 1.0
+}
+
+/** A page of the GIF library, with the credit the server asks to be shown. */
+@Serializable
+public data class GifLibrary(
+ val gifs: List = emptyList(),
+ val total: Int = 0,
+ val attribution: String? = null,
+)
+
+/** A group account the viewer may post as. */
+@Serializable
+public data class TeamAccount(val handle: String, val name: String, val avatar: String? = null) {
+ val id: String get() = handle
+}
diff --git a/core/model/src/main/kotlin/social/aloha/core/model/Relationship.kt b/core/model/src/main/kotlin/social/aloha/core/model/Relationship.kt
new file mode 100644
index 00000000..d6a3fd1f
--- /dev/null
+++ b/core/model/src/main/kotlin/social/aloha/core/model/Relationship.kt
@@ -0,0 +1,34 @@
+// SPDX-FileCopyrightText: 2026 Aloha Social contributors
+// SPDX-License-Identifier: MIT
+
+package social.aloha.core.model
+
+import java.time.Instant
+import kotlinx.serialization.Serializable
+
+/**
+ * The viewer's relationship to one account.
+ *
+ * @property showingReblogs honoured by Nextcloud Social as part of the timeline query, so it is real.
+ * True when the server does not say.
+ * @property muteExpiresAt when a timed mute ends, where the server says.
+ */
+@Serializable
+public data class Relationship(
+ val id: String,
+ val following: Boolean = false,
+ val followedBy: Boolean = false,
+ val blocking: Boolean = false,
+ val blockedBy: Boolean = false,
+ val muting: Boolean = false,
+ val mutingNotifications: Boolean = false,
+ val requested: Boolean = false,
+ val requestedBy: Boolean = false,
+ val domainBlocking: Boolean = false,
+ val endorsed: Boolean = false,
+ val notifying: Boolean = false,
+ val showingReblogs: Boolean = true,
+ val note: String? = null,
+ val languages: List? = null,
+ @Serializable(with = InstantSerializer::class) val muteExpiresAt: Instant? = null,
+)
diff --git a/core/model/src/main/kotlin/social/aloha/core/model/ServerCapabilities.kt b/core/model/src/main/kotlin/social/aloha/core/model/ServerCapabilities.kt
new file mode 100644
index 00000000..6680d3f8
--- /dev/null
+++ b/core/model/src/main/kotlin/social/aloha/core/model/ServerCapabilities.kt
@@ -0,0 +1,98 @@
+// SPDX-FileCopyrightText: 2026 Aloha Social contributors
+// SPDX-License-Identifier: MIT
+
+package social.aloha.core.model
+
+import java.time.Duration
+import java.time.Instant
+import kotlinx.serialization.Serializable
+
+/**
+ * What one account's server can actually do. Computed at sign-in, refreshed on launch when stale,
+ * persisted. Never inferred from a hostname, and a capability that cannot be determined is absent:
+ * the app is fully usable with every flag false.
+ *
+ * @property apiBase the resolved prefix Mastodon routes hang off, ending in `/`; not always the root.
+ * @property softwareName lowercased, from NodeInfo: `nextcloud-social`, `mastodon`, …
+ * @property theme the colour this Nextcloud wears; null on Mastodon and with Theming disabled.
+ * @property detectedAt [Instant.EPOCH] until detection ran, which makes the capabilities stale.
+ */
+@Serializable
+public data class ServerCapabilities(
+ val apiBase: String,
+ val softwareName: String = "",
+ val softwareVersion: String = "",
+ val mastodonApiVersion: Int? = null,
+ val streamingUrl: String? = null,
+ val webPushVapidKey: String? = null,
+ val groupedNotifications: Boolean = false,
+ val notificationPolicy: Boolean = false,
+ val filtersV2: Boolean = false,
+ val editHistory: Boolean = false,
+ val translation: Boolean = false,
+ val translationLanguages: Map> = emptyMap(),
+ val onlyMediaFilter: Boolean = false,
+ val onlyVideoFilter: Boolean = false,
+ val onlyNewsFilter: Boolean = false,
+ val hlsLadder: Boolean = false,
+ val watchPositions: Boolean = false,
+ val stories: Boolean = false,
+ val collections: Boolean = false,
+ val emojiReactions: Boolean = false,
+ val quotePosts: Boolean = false,
+ val mediaFromNextcloudFiles: Boolean = false,
+ val preferencesWrite: Boolean = false,
+ val limits: ServerLimits = ServerLimits.MastodonDefaults,
+ val theme: NextcloudTheme? = null,
+ @Serializable(with = InstantSerializer::class) val detectedAt: Instant = Instant.EPOCH,
+) {
+ /**
+ * Where the Nextcloud itself lives, as opposed to where its Mastodon API is served. Without the root
+ * rewrite rules the API base is `…/index.php/apps/social/` while the OCS routes stay above it;
+ * stripping the app prefix is the only way back up, since nothing announces the root.
+ */
+ val nextcloudRoot: String
+ get() {
+ val index = apiBase.indexOf(APP_PREFIX)
+ return if (index < 0) apiBase else apiBase.substring(0, index)
+ }
+
+ val isNextcloudSocial: Boolean get() = "nextcloud" in softwareName && "social" in softwareName
+
+ /** Which sync tier applies. They compose: streaming serves the foreground, polling the rest. */
+ val syncTier: SyncTier
+ get() = when {
+ !webPushVapidKey.isNullOrEmpty() -> SyncTier.WebPush
+ streamingUrl != null -> SyncTier.Streaming
+ else -> SyncTier.Polling
+ }
+
+ public fun isStale(now: Instant, maximumAge: Duration = Duration.ofDays(1)): Boolean =
+ Duration.between(detectedAt, now) > maximumAge
+
+ /**
+ * These capabilities promoted by what [statuses] show, since nothing announces them: an `hls_url`
+ * on an attachment, a non-empty `reactions` list, a quoted post. Once true they stay true.
+ */
+ public fun latched(statuses: List): ServerCapabilities {
+ val shown = statuses.map { it.displayed }
+ return copy(
+ hlsLadder = hlsLadder || shown.any { status -> status.mediaAttachments.any { it.hlsUrl != null } },
+ emojiReactions = emojiReactions || shown.any { !it.reactions.isNullOrEmpty() },
+ quotePosts = quotePosts || shown.any { it.quoteId != null },
+ )
+ }
+
+ /** Whether a mode has anything to source from; News has no device-side equivalent, so it is hidden. */
+ public fun supports(mode: FeedMode): Boolean = mode != FeedMode.News || onlyNewsFilter
+
+ public companion object {
+ private const val APP_PREFIX = "index.php/apps/social/"
+
+ /** The most conservative statement about a server: it speaks the Mastodon API at [apiBase]. */
+ public fun minimal(apiBase: String): ServerCapabilities = ServerCapabilities(apiBase = apiBase)
+ }
+}
+
+@Serializable
+public enum class SyncTier { WebPush, Streaming, Polling }
diff --git a/core/model/src/main/kotlin/social/aloha/core/model/ServerIds.kt b/core/model/src/main/kotlin/social/aloha/core/model/ServerIds.kt
new file mode 100644
index 00000000..4a3cc8ce
--- /dev/null
+++ b/core/model/src/main/kotlin/social/aloha/core/model/ServerIds.kt
@@ -0,0 +1,18 @@
+// SPDX-FileCopyrightText: 2026 Aloha Social contributors
+// SPDX-License-Identifier: MIT
+
+package social.aloha.core.model
+
+/**
+ * Orders server ids. Ids are decimal strings that exceed 2^53 (Nextcloud Social's are
+ * `unix_ms × 10^6 + 6 random digits`), so they are compared by length and then lexically, and never
+ * parsed into a number. Non-numeric ids compare the same way, which keeps the order total.
+ */
+public object ServerIds : Comparator {
+ override fun compare(a: String, b: String): Int =
+ if (a.length != b.length) a.length.compareTo(b.length) else a.compareTo(b)
+
+ public fun isNewer(candidate: String, than: String): Boolean = compare(candidate, than) > 0
+
+ public fun newest(ids: Iterable): String? = ids.maxWithOrNull(this)
+}
diff --git a/core/model/src/main/kotlin/social/aloha/core/model/ServerLimits.kt b/core/model/src/main/kotlin/social/aloha/core/model/ServerLimits.kt
new file mode 100644
index 00000000..0d391757
--- /dev/null
+++ b/core/model/src/main/kotlin/social/aloha/core/model/ServerLimits.kt
@@ -0,0 +1,60 @@
+// SPDX-FileCopyrightText: 2026 Aloha Social contributors
+// SPDX-License-Identifier: MIT
+
+package social.aloha.core.model
+
+import kotlinx.serialization.Serializable
+
+/**
+ * The server's own statement of what it accepts. The composer's counter, the media picker's limits and
+ * the upload pre-flight all read from here; Mastodon's defaults are the last resort, used only when
+ * both instance routes fail.
+ *
+ * @property imageSizeLimit bytes, for every file. 10 MB by default on Nextcloud Social, which reads an
+ * image whole into memory to strip and resize it.
+ * @property videoSizeLimit bytes, for video only. 2048 MB by default on Nextcloud Social, which copies a
+ * video a chunk at a time and never holds it, hence a [Long].
+ * @property minPollExpiration seconds.
+ * @property maxPollExpiration seconds.
+ */
+@Serializable
+public data class ServerLimits(
+ val maxStatusCharacters: Int,
+ val maxMediaAttachments: Int,
+ val charactersReservedPerUrl: Int,
+ val imageSizeLimit: Long,
+ val videoSizeLimit: Long,
+ val supportedMimeTypes: List,
+ val maxPollOptions: Int,
+ val maxPollOptionCharacters: Int,
+ val minPollExpiration: Long,
+ val maxPollExpiration: Long,
+ val maxFeaturedTags: Int,
+) {
+ /** The ceiling for one file: the video limit when the server treats it as video. */
+ public fun sizeLimit(mimeType: String): Long = if (mimeType.startsWith("video/")) videoSizeLimit else imageSizeLimit
+
+ /** Whether the server accepts [mimeType]; an empty list means it did not say, so anything goes. */
+ public fun accepts(mimeType: String): Boolean = supportedMimeTypes.isEmpty() || mimeType in supportedMimeTypes
+
+ public companion object {
+ private const val MEGABYTE = 1024L * 1024L
+
+ public val MastodonDefaults: ServerLimits = ServerLimits(
+ maxStatusCharacters = 500,
+ maxMediaAttachments = 4,
+ charactersReservedPerUrl = 23,
+ imageSizeLimit = 10 * MEGABYTE,
+ videoSizeLimit = 40 * MEGABYTE,
+ supportedMimeTypes = listOf(
+ "image/jpeg", "image/png", "image/gif", "image/webp", "image/heic", "image/heif",
+ "video/mp4", "video/quicktime", "video/webm", "audio/mpeg", "audio/mp4", "audio/ogg",
+ ),
+ maxPollOptions = 4,
+ maxPollOptionCharacters = 50,
+ minPollExpiration = 300,
+ maxPollExpiration = 2_629_746,
+ maxFeaturedTags = 10,
+ )
+ }
+}
diff --git a/core/model/src/main/kotlin/social/aloha/core/model/SignedInAccount.kt b/core/model/src/main/kotlin/social/aloha/core/model/SignedInAccount.kt
new file mode 100644
index 00000000..ee2ffe52
--- /dev/null
+++ b/core/model/src/main/kotlin/social/aloha/core/model/SignedInAccount.kt
@@ -0,0 +1,31 @@
+// SPDX-FileCopyrightText: 2026 Aloha Social contributors
+// SPDX-License-Identifier: MIT
+
+package social.aloha.core.model
+
+import java.time.Instant
+
+/**
+ * An account this device is signed in to. [id] is local and stable; [serverAccountId] is the
+ * server's own id for the person.
+ *
+ * @property needsReauth the server answered 401 (tokens have no refresh), or the vault could not be
+ * read after a restore: the account stays, its cache stays, and a new sign-in resumes it.
+ * @property profilePending the server could not describe the account yet; Nextcloud Social answers
+ * 500 from `verify_credentials` for a new account until its avatar cache job has run.
+ */
+public data class SignedInAccount(
+ val id: String,
+ val host: String,
+ val apiBase: String,
+ val serverAccountId: String,
+ val handle: String,
+ val displayName: String,
+ val avatarUrl: String?,
+ val headerUrl: String?,
+ val capabilities: ServerCapabilities,
+ val needsReauth: Boolean,
+ val profilePending: Boolean,
+ val addedAt: Instant,
+ val nextcloudConnected: Boolean,
+)
diff --git a/core/model/src/main/kotlin/social/aloha/core/model/Statistics.kt b/core/model/src/main/kotlin/social/aloha/core/model/Statistics.kt
new file mode 100644
index 00000000..6c9fb77b
--- /dev/null
+++ b/core/model/src/main/kotlin/social/aloha/core/model/Statistics.kt
@@ -0,0 +1,66 @@
+// SPDX-FileCopyrightText: 2026 Aloha Social contributors
+// SPDX-License-Identifier: MIT
+
+package social.aloha.core.model
+
+import kotlinx.serialization.Serializable
+
+/** `{"2026-01": 12, "2026-02": 7}`: numbers by key. */
+@Serializable
+public data class NumberMap(val values: Map = emptyMap()) {
+ public operator fun get(key: String): Double? = values[key]
+
+ /** Sorted by key, which for months is chronological. */
+ val sorted: List> get() = values.toSortedMap().toList()
+}
+
+@Serializable
+public data class NamedCount(val name: String, val count: Int) {
+ val id: String get() = name
+}
+
+/** Somebody the reader trades replies with. */
+@Serializable
+public data class ConversationPartner(val account: String, val replies: Int) {
+ val id: String get() = account
+}
+
+/** `GET /api/v1/statistics`. Every map is empty where the server sent nothing. */
+@Serializable
+public data class AccountStatistics(
+ val account: StatisticsAccount? = null,
+ val window: StatisticsWindow? = null,
+ val posts: NumberMap = NumberMap(),
+ val engagement: NumberMap = NumberMap(),
+ val rates: NumberMap = NumberMap(),
+ val visibility: NumberMap = NumberMap(),
+ val consistency: NumberMap = NumberMap(),
+ val media: NumberMap = NumberMap(),
+ val byMonth: NumberMap = NumberMap(),
+ val engagementByMonth: NumberMap = NumberMap(),
+ val activity: StatisticsActivity? = null,
+ val partners: StatisticsPartners? = null,
+ val languages: List = emptyList(),
+ val domains: List = emptyList(),
+ val hashtags: List = emptyList(),
+)
+
+@Serializable
+public data class StatisticsAccount(val acct: String, val followers: Int = 0, val following: Int = 0)
+
+/** How much history the numbers cover, and whether the server stopped counting early. */
+@Serializable
+public data class StatisticsWindow(val days: Int = 0, val counted: Int = 0, val capped: Boolean = false)
+
+@Serializable
+public data class StatisticsActivity(
+ val originals: NumberMap = NumberMap(),
+ val replies: NumberMap = NumberMap(),
+ val boosts: NumberMap = NumberMap(),
+)
+
+@Serializable
+public data class StatisticsPartners(
+ val inbound: List = emptyList(),
+ val outbound: List = emptyList(),
+)
diff --git a/core/model/src/main/kotlin/social/aloha/core/model/Status.kt b/core/model/src/main/kotlin/social/aloha/core/model/Status.kt
new file mode 100644
index 00000000..0ecc6b61
--- /dev/null
+++ b/core/model/src/main/kotlin/social/aloha/core/model/Status.kt
@@ -0,0 +1,147 @@
+// SPDX-FileCopyrightText: 2026 Aloha Social contributors
+// SPDX-License-Identifier: MIT
+
+package social.aloha.core.model
+
+import java.time.Instant
+import kotlinx.serialization.Serializable
+
+/**
+ * A post.
+ *
+ * @property content restricted HTML, parsed off the main thread and cached by content hash.
+ * @property createdAt [Instant.EPOCH] when the server did not say.
+ * @property visibility [Visibility.Unknown] when the server sent nothing or a value this app does not
+ * know, which is treated as the most restrictive, never as public.
+ * @property poll present-or-null on Nextcloud Social; other servers omit it.
+ * @property reactions emoji reactions where the server supports them. Nextcloud Social always sends
+ * an empty list here and serves the real reactions only from `/statuses/{id}/reactions`.
+ * @property quoteApprovalPolicy who may quote this post (`public`, `followers`, `nobody`), where the
+ * server says.
+ * @property archived off the profile, not deleted; null when the server does not say.
+ * @property video the PeerTube-shaped facts Nextcloud Social carries on a video post.
+ */
+@Serializable
+public data class Status(
+ val id: String,
+ val account: Account,
+ val uri: String = "",
+ val url: String? = null,
+ @Serializable(with = InstantSerializer::class) val createdAt: Instant = Instant.EPOCH,
+ @Serializable(with = InstantSerializer::class) val editedAt: Instant? = null,
+ val content: String = "",
+ val spoilerText: String = "",
+ val visibility: Visibility = Visibility.Public,
+ val sensitive: Boolean = false,
+ val language: String? = null,
+ val repliesCount: Int = 0,
+ val reblogsCount: Int = 0,
+ val favouritesCount: Int = 0,
+ val favourited: Boolean = false,
+ val reblogged: Boolean = false,
+ val bookmarked: Boolean = false,
+ val pinned: Boolean = false,
+ val muted: Boolean = false,
+ val inReplyToId: String? = null,
+ val inReplyToAccountId: String? = null,
+ val reblog: Status? = null,
+ val mediaAttachments: List = emptyList(),
+ val mentions: List = emptyList(),
+ val tags: List = emptyList(),
+ val emojis: List = emptyList(),
+ val poll: Poll? = null,
+ val card: Card? = null,
+ val application: ApplicationSummary? = null,
+ val text: String? = null,
+ val filtered: List? = null,
+ val reactions: List? = null,
+ val quoteId: String? = null,
+ val quote: QuotedStatus? = null,
+ val quoteApprovalPolicy: String? = null,
+ val dislikesCount: Int = 0,
+ val disliked: Boolean = false,
+ val archived: Boolean? = null,
+ val place: StatusPlace? = null,
+ val video: VideoDetails? = null,
+) {
+ /** The status whose content is drawn: for a boost that is the boosted post. */
+ val displayed: Status get() = reblog ?: this
+
+ val isBoost: Boolean get() = reblog != null
+
+ /** The account that boosted, when this row is a boost. */
+ val booster: Account? get() = if (isBoost) account else null
+
+ val hasContentWarning: Boolean get() = spoilerText.isNotBlank()
+
+ val isEdited: Boolean get() = editedAt != null
+
+ /** The quoted post, when the server embedded it. */
+ val quotedStatus: Status? get() = quote?.quotedStatus
+
+ val hasMedia: Boolean get() = mediaAttachments.isNotEmpty()
+
+ /** True when at least one attachment has no alt text; the composer warns before posting. */
+ val hasUndescribedMedia: Boolean get() = mediaAttachments.any { !it.hasAltText }
+
+ /** Whether the viewer wrote the drawn post. */
+ public fun isOwn(viewerAccountId: String): Boolean = displayed.account.id == viewerAccountId
+
+ /** A reply is never less restrictive than what it answers. */
+ public fun replyVisibility(default: Visibility): Visibility =
+ Visibility.mostRestrictive(default, displayed.visibility)
+
+ /** Everyone a reply addresses, the author first, without the replier, without duplicates. */
+ public fun replyMentions(excludingViewerAcct: String?): List {
+ val target = displayed
+ return (listOf(target.account.acct) + target.mentions.map { it.acct })
+ .filter { it.isNotEmpty() && it != excludingViewerAcct }
+ .distinct()
+ }
+}
+
+@Serializable
+public data class Mention(val id: String, val username: String, val acct: String, val url: String? = null)
+
+@Serializable
+public data class StatusTag(val name: String, val url: String? = null)
+
+@Serializable
+public data class Reaction(
+ val name: String,
+ val count: Int,
+ val me: Boolean = false,
+ val url: String? = null,
+ val staticUrl: String? = null,
+)
+
+@Serializable
+public data class FilterResult(
+ val filter: Filter,
+ val keywordMatches: List? = null,
+ val statusMatches: List? = null,
+)
+
+@Serializable
+public data class ApplicationSummary(val name: String, val website: String? = null)
+
+/**
+ * A quoted post as the server embeds it.
+ *
+ * @property state `accepted`, `pending`, `revoked`, …, or null where the server sends the status bare.
+ */
+@Serializable
+public data class QuotedStatus(val state: String? = null, val quotedStatus: Status? = null)
+
+/** A place a post was tagged with. */
+@Serializable
+public data class StatusPlace(
+ val id: String,
+ val name: String = "",
+ val country: String? = null,
+ val latitude: Double? = null,
+ val longitude: Double? = null,
+) {
+ /** "Name, Country", or just the name. */
+ val label: String get() = if (country.isNullOrEmpty()) name else "$name, $country"
+}
diff --git a/core/model/src/main/kotlin/social/aloha/core/model/StatusEdits.kt b/core/model/src/main/kotlin/social/aloha/core/model/StatusEdits.kt
new file mode 100644
index 00000000..28b93ac8
--- /dev/null
+++ b/core/model/src/main/kotlin/social/aloha/core/model/StatusEdits.kt
@@ -0,0 +1,50 @@
+// SPDX-FileCopyrightText: 2026 Aloha Social contributors
+// SPDX-License-Identifier: MIT
+
+package social.aloha.core.model
+
+import java.time.Instant
+import kotlinx.serialization.Serializable
+
+/** A server translation of a status. */
+@Serializable
+public data class Translation(
+ val content: String,
+ val spoilerText: String? = null,
+ val detectedSourceLanguage: String? = null,
+ val provider: String? = null,
+)
+
+/** One revision of an edited status. [createdAt] is [Instant.EPOCH] when the server did not say. */
+@Serializable
+public data class StatusEdit(
+ val account: Account,
+ val content: String = "",
+ val spoilerText: String = "",
+ val sensitive: Boolean = false,
+ @Serializable(with = InstantSerializer::class) val createdAt: Instant = Instant.EPOCH,
+ val mediaAttachments: List = emptyList(),
+)
+
+/** The original plain text of a status, which an edit composer loads instead of the rendered HTML. */
+@Serializable
+public data class StatusSource(val id: String, val text: String = "", val spoilerText: String = "")
+
+/** A status queued for later. [scheduledAt] is [Instant.EPOCH] when the server did not say. */
+@Serializable
+public data class ScheduledStatus(
+ val id: String,
+ val params: ScheduledStatusParams,
+ @Serializable(with = InstantSerializer::class) val scheduledAt: Instant = Instant.EPOCH,
+ val mediaAttachments: List = emptyList(),
+)
+
+@Serializable
+public data class ScheduledStatusParams(
+ val text: String? = null,
+ val visibility: Visibility? = null,
+ val spoilerText: String? = null,
+ val sensitive: Boolean = false,
+ val language: String? = null,
+ val inReplyToId: String? = null,
+)
diff --git a/core/model/src/main/kotlin/social/aloha/core/model/StatusExtras.kt b/core/model/src/main/kotlin/social/aloha/core/model/StatusExtras.kt
new file mode 100644
index 00000000..88e4788a
--- /dev/null
+++ b/core/model/src/main/kotlin/social/aloha/core/model/StatusExtras.kt
@@ -0,0 +1,69 @@
+// SPDX-FileCopyrightText: 2026 Aloha Social contributors
+// SPDX-License-Identifier: MIT
+
+package social.aloha.core.model
+
+import java.time.Instant
+import kotlinx.serialization.Serializable
+
+/**
+ * Where one post got to: one row per server it was sent to, and the totals. Author only.
+ *
+ * @property retention how long the server keeps the rows, in seconds.
+ */
+@Serializable
+public data class DeliveryReport(
+ val total: Int,
+ val delivered: Int,
+ val sending: Int = 0,
+ val waiting: Int = 0,
+ val failing: Int = 0,
+ val abandoned: Int = 0,
+ val retention: Int = DEFAULT_RETENTION,
+ val instances: List = emptyList(),
+) {
+ public companion object {
+ public const val DEFAULT_RETENTION: Int = 604_800
+ }
+}
+
+@Serializable
+public data class DeliveryInstance(
+ val host: String,
+ val state: DeliveryState,
+ val tries: Int = 1,
+ @Serializable(with = InstantSerializer::class) val last: Instant? = null,
+) {
+ val id: String get() = host
+}
+
+@Serializable
+public enum class DeliveryState(override val wire: String) : WireValue {
+ Delivered("delivered"),
+ Sending("sending"),
+ Waiting("waiting"),
+ Failing("failing"),
+ Abandoned("abandoned"),
+ ;
+
+ public companion object {
+ /** A missing or unknown state is `waiting`. */
+ public fun fromWire(raw: String?): DeliveryState = entries.fromWire(raw, Waiting)
+ }
+}
+
+/** Who may quote a post. */
+@Serializable
+public enum class QuoteApprovalPolicy(override val wire: String) : WireValue {
+ Public("public"),
+ Followers("followers"),
+ Nobody("nobody"),
+}
+
+/**
+ * The body of creating or changing a collection.
+ *
+ * @property visibility `public`, `private` or `draft`, as Pixelfed names them.
+ */
+@Serializable
+public data class CollectionDraft(val title: String, val description: String = "", val visibility: String = "public")
diff --git a/core/model/src/main/kotlin/social/aloha/core/model/Story.kt b/core/model/src/main/kotlin/social/aloha/core/model/Story.kt
new file mode 100644
index 00000000..f989bc09
--- /dev/null
+++ b/core/model/src/main/kotlin/social/aloha/core/model/Story.kt
@@ -0,0 +1,130 @@
+// SPDX-FileCopyrightText: 2026 Aloha Social contributors
+// SPDX-License-Identifier: MIT
+
+package social.aloha.core.model
+
+import java.time.Duration
+import java.time.Instant
+import kotlinx.serialization.Serializable
+
+/**
+ * One picture or video that stops existing after a day (Pixelfed's stories, as Nextcloud Social
+ * serves them). A story has no timeline row: it is not a post.
+ *
+ * @property account the poster where the server embeds them. Nextcloud Social's own story routes
+ * name the poster by [accountUri] only, so this is null there and the caller resolves the account.
+ * @property accountUri the poster's ActivityPub id, which is what Nextcloud Social sends as `account_id`.
+ * @property duration seconds on screen; the server clamps it to 3–30.
+ * @property viewCount how many accounts watched, told to the poster only.
+ */
+@Serializable
+public data class Story(
+ val id: String,
+ val account: Account? = null,
+ val accountUri: String? = null,
+ val url: String? = null,
+ val previewUrl: String? = null,
+ val type: AttachmentKind = AttachmentKind.Image,
+ val caption: String? = null,
+ val duration: Double = DEFAULT_DURATION,
+ @Serializable(with = InstantSerializer::class) val publishedAt: Instant? = null,
+ @Serializable(with = InstantSerializer::class) val expiresAt: Instant? = null,
+ val seen: Boolean = false,
+ val viewCount: Int? = null,
+) {
+ /**
+ * Whether the story may still be shown. Never longer than a day from [insertedAt] (or from
+ * [publishedAt]) whatever the sender claimed, since the promise of the feature is that the thing
+ * goes away. A story without either timestamp is not live.
+ */
+ public fun isLive(now: Instant, insertedAt: Instant? = null): Boolean {
+ val ceiling = (insertedAt ?: publishedAt)?.plus(LIFETIME)
+ val end = listOfNotNull(expiresAt, ceiling).minOrNull() ?: return false
+ return now < end
+ }
+
+ public companion object {
+ public const val DEFAULT_DURATION: Double = 5.0
+ public val LIFETIME: Duration = Duration.ofDays(1)
+ }
+}
+
+/**
+ * The stories rail, whichever shape the server chose: a flat array, or Pixelfed's `{self, nodes}`
+ * with one node per account.
+ *
+ * @property own the viewer's own, when the server separates them.
+ * @property others everybody else's, in the order the server ranked the accounts.
+ */
+@Serializable
+public data class StoryCarousel(val own: List = emptyList(), val others: List = emptyList())
+
+/** One reaction or comment on a story, told only to the story's poster. */
+@Serializable
+public data class StoryReaction(
+ val id: String,
+ val account: Account,
+ val reaction: String? = null,
+ val comment: String? = null,
+ @Serializable(with = InstantSerializer::class) val createdAt: Instant? = null,
+)
+
+/**
+ * One entry of `/api/v1/videos/continue`: where the reader got to in a video. Never federated,
+ * never shown to anybody else.
+ *
+ * @property status the post where the server sent it whole. Nextcloud Social sends posts without
+ * their `account`, which a status cannot be built from, so the caller fetches it by [statusId].
+ */
+@Serializable
+public data class ContinueWatchingItem(
+ val statusId: String,
+ val position: Double = 0.0,
+ val duration: Double = 0.0,
+ @Serializable(with = InstantSerializer::class) val updatedAt: Instant? = null,
+ val status: Status? = null,
+) {
+ val id: String get() = statusId
+
+ /** How far through, between 0 and 1. */
+ val fraction: Double get() = if (duration > 0) (position / duration).coerceIn(0.0, 1.0) else 0.0
+}
+
+/** When a watch position is worth reporting, and when a video counts as finished. */
+public object WatchPositionRules {
+ /** Below this nothing is reported: a video somebody opened and closed is noise. */
+ public const val MINIMUM_REPORTABLE_SECONDS: Double = 10.0
+
+ /** Past this the server forgets the position rather than bookmarking the credits; the client mirrors it. */
+ public const val COMPLETION_FRACTION: Double = 0.95
+
+ /** Reports are coalesced: never more than one in this many seconds. */
+ public const val MINIMUM_REPORT_GAP_SECONDS: Double = 5.0
+
+ /** How often a playing video reports; the server allows 600 reports a minute. */
+ public const val REPORT_INTERVAL_SECONDS: Double = 10.0
+
+ public fun shouldReport(position: Double, duration: Double): Boolean =
+ position >= MINIMUM_REPORTABLE_SECONDS && duration > 0
+
+ public fun isComplete(position: Double, duration: Double): Boolean =
+ duration > 0 && position / duration >= COMPLETION_FRACTION
+}
+
+/**
+ * A Pixelfed album, read and written where the server supports it. Named so it never shadows
+ * Kotlin's own `Collection`.
+ *
+ * @property visibility `public`, `private` or `draft`, as Pixelfed names them.
+ */
+@Serializable
+public data class MediaCollection(
+ val id: String,
+ val title: String = "",
+ val description: String? = null,
+ val url: String? = null,
+ val postCount: Int = 0,
+ val thumbnail: String? = null,
+ val visibility: String? = null,
+ @Serializable(with = InstantSerializer::class) val updatedAt: Instant? = null,
+)
diff --git a/core/model/src/main/kotlin/social/aloha/core/model/Tag.kt b/core/model/src/main/kotlin/social/aloha/core/model/Tag.kt
new file mode 100644
index 00000000..f1e5313b
--- /dev/null
+++ b/core/model/src/main/kotlin/social/aloha/core/model/Tag.kt
@@ -0,0 +1,61 @@
+// SPDX-FileCopyrightText: 2026 Aloha Social contributors
+// SPDX-License-Identifier: MIT
+
+package social.aloha.core.model
+
+import kotlinx.serialization.Serializable
+
+/**
+ * A hashtag.
+ *
+ * @property following present on the followed-tag and single-tag routes, absent from the public
+ * trending route, which has no viewer to answer for.
+ */
+@Serializable
+public data class Tag(
+ val name: String,
+ val url: String? = null,
+ val history: List = emptyList(),
+ val following: Boolean? = null,
+) {
+ val id: String get() = name.lowercase()
+
+ /**
+ * Nextcloud Social sends a single bucket and always `accounts: "0"`, since it counts uses and not
+ * distinct accounts. A sparkline from one point misleads, so the UI shows uses only when this is true.
+ */
+ val hasOnlyOneBucket: Boolean get() = history.size <= 1
+
+ val totalUses: Int get() = history.sumOf { it.usesCount }
+
+ public companion object {
+ private const val MAX_LENGTH = 127
+
+ /**
+ * The server's own normalisation: no leading `#`, trimmed, lowercased, cut to the 127 characters
+ * its column holds. `#NextCloud` and `nextcloud` are one tag to follow, look up and unfollow.
+ * Null when nothing remains.
+ */
+ public fun normalise(raw: String): String? {
+ val value = raw.trim().trimStart('#').trim().lowercase()
+ return value.takeIf { it.isNotEmpty() }?.take(MAX_LENGTH)
+ }
+ }
+}
+
+@Serializable
+public data class TagHistory(val day: String, val uses: String, val accounts: String) {
+ val usesCount: Int get() = uses.toIntOrNull() ?: 0
+
+ val accountsCount: Int get() = accounts.toIntOrNull() ?: 0
+}
+
+/** A hashtag an account features on its profile. */
+@Serializable
+public data class FeaturedTag(
+ val id: String,
+ val name: String,
+ val url: String? = null,
+ val statusesCount: Int = 0,
+ @Serializable(with = InstantSerializer::class) val lastStatusAt: java.time.Instant? = null,
+)
diff --git a/core/model/src/main/kotlin/social/aloha/core/model/Timeline.kt b/core/model/src/main/kotlin/social/aloha/core/model/Timeline.kt
new file mode 100644
index 00000000..bd84b6b9
--- /dev/null
+++ b/core/model/src/main/kotlin/social/aloha/core/model/Timeline.kt
@@ -0,0 +1,158 @@
+// SPDX-FileCopyrightText: 2026 Aloha Social contributors
+// SPDX-License-Identifier: MIT
+
+package social.aloha.core.model
+
+import kotlinx.serialization.Serializable
+
+/** Where a timeline's rows come from. */
+@Serializable
+public sealed interface TimelineSource {
+ /** The persisted part of the cache key; never changes for a source. */
+ public val storageKey: String
+
+ /**
+ * The `{timeline}` segment of `/api/v1/timelines/{timeline}/`, or null for the sources with a
+ * route of their own (a list timeline is a name and an id, so it cannot be a name here).
+ */
+ public val pathSegment: String? get() = null
+
+ /** Whether reading it needs a signed-in viewer. */
+ public val requiresViewer: Boolean get() = true
+
+ @Serializable
+ public data object Home : TimelineSource {
+ override val storageKey: String get() = "home"
+ override val pathSegment: String get() = "home"
+ }
+
+ /** The public timeline narrowed to this instance with `local=true`. */
+ @Serializable
+ public data object Local : TimelineSource {
+ override val storageKey: String get() = "public:local"
+ override val pathSegment: String get() = "public"
+ override val requiresViewer: Boolean get() = false
+ }
+
+ @Serializable
+ public data object Federated : TimelineSource {
+ override val storageKey: String get() = "public:federated"
+ override val pathSegment: String get() = "public"
+ override val requiresViewer: Boolean get() = false
+ }
+
+ @Serializable
+ public data object Direct : TimelineSource {
+ override val storageKey: String get() = "direct"
+ override val pathSegment: String get() = "direct"
+ }
+
+ @Serializable
+ public data object Favourites : TimelineSource {
+ override val storageKey: String get() = "favourites"
+ override val pathSegment: String get() = "favourites"
+ }
+
+ @Serializable
+ public data object Bookmarks : TimelineSource {
+ override val storageKey: String get() = "bookmarks"
+ }
+
+ @Serializable
+ public data object Trending : TimelineSource {
+ override val storageKey: String get() = "trending"
+ override val requiresViewer: Boolean get() = false
+ }
+
+ @Serializable
+ public data class List(val id: String) : TimelineSource {
+ override val storageKey: String get() = "list:$id"
+ }
+
+ @Serializable
+ public data class Hashtag(val name: String) : TimelineSource {
+ override val storageKey: String get() = "tag:${name.lowercase()}"
+ override val requiresViewer: Boolean get() = false
+ }
+
+ @Serializable
+ public data class Account(val id: String, val includeReplies: Boolean, val onlyMedia: Boolean) : TimelineSource {
+ override val storageKey: String
+ get() = "account:$id:${if (includeReplies) 1 else 0}:${if (onlyMedia) 1 else 0}"
+ }
+}
+
+/**
+ * A mode plus a source: the cache key, which is why Photos-of-home and Home never share rows.
+ */
+@Serializable
+public data class TimelineKey(val mode: FeedMode, val source: TimelineSource) {
+ val storageKey: String get() = "${mode.key}:${source.storageKey}"
+
+ public companion object {
+ public fun home(source: TimelineSource = TimelineSource.Home): TimelineKey = TimelineKey(FeedMode.Home, source)
+ }
+}
+
+/**
+ * The narrowings Nextcloud Social accepts beyond Mastodon's parameters. `only_video` is the narrower
+ * of the first two and wins when both are set, since every video is media.
+ */
+@Serializable
+public data class TimelineFilters(
+ val onlyMedia: Boolean = false,
+ val onlyVideo: Boolean = false,
+ val onlyNews: Boolean = false,
+) {
+ val isEmpty: Boolean get() = !onlyMedia && !onlyVideo && !onlyNews
+
+ public companion object {
+ public val None: TimelineFilters = TimelineFilters()
+
+ /**
+ * What to ask the server for, given the mode and what the server supports. An absent capability
+ * yields no parameter; the caller then filters on the device and over-fetches.
+ */
+ public fun forMode(mode: FeedMode, capabilities: ServerCapabilities): TimelineFilters = when (mode) {
+ FeedMode.Home -> None
+
+ FeedMode.Photos, FeedMode.Audio -> TimelineFilters(onlyMedia = capabilities.onlyMediaFilter)
+
+ FeedMode.Video, FeedMode.Shorts ->
+ if (capabilities.onlyVideoFilter) {
+ TimelineFilters(onlyVideo = true)
+ } else {
+ TimelineFilters(onlyMedia = capabilities.onlyMediaFilter)
+ }
+
+ FeedMode.News -> TimelineFilters(onlyNews = capabilities.onlyNewsFilter)
+ }
+ }
+}
+
+/**
+ * How hard the client works to fill a page when the server cannot narrow for it, capped at
+ * [MAXIMUM_UPSTREAM_PAGES] upstream pages per visible page.
+ */
+public object OverFetch {
+ public const val MAXIMUM_UPSTREAM_PAGES: Int = 5
+
+ @Suppress("MagicNumber") // the table is the specification
+ public fun multiplier(mode: FeedMode, serverFilters: TimelineFilters): Int {
+ if (!serverFilters.isEmpty) return 1
+ return when (mode) {
+ FeedMode.Home -> 1
+
+ FeedMode.Photos -> 3
+
+ FeedMode.Video -> 5
+
+ FeedMode.Shorts -> 8
+
+ FeedMode.News -> 1
+
+ // hidden entirely without server support
+ FeedMode.Audio -> 5
+ }
+ }
+}
diff --git a/core/model/src/main/kotlin/social/aloha/core/model/Urls.kt b/core/model/src/main/kotlin/social/aloha/core/model/Urls.kt
new file mode 100644
index 00000000..eb492890
--- /dev/null
+++ b/core/model/src/main/kotlin/social/aloha/core/model/Urls.kt
@@ -0,0 +1,14 @@
+// SPDX-FileCopyrightText: 2026 Aloha Social contributors
+// SPDX-License-Identifier: MIT
+
+package social.aloha.core.model
+
+import java.net.URI
+import java.net.URISyntaxException
+
+/** The host of [url], or null when there is none or the text is not a URL. */
+internal fun hostOf(url: String?): String? = try {
+ url?.let { URI(it).host }
+} catch (_: URISyntaxException) {
+ null
+}
diff --git a/core/model/src/main/kotlin/social/aloha/core/model/VideoDetails.kt b/core/model/src/main/kotlin/social/aloha/core/model/VideoDetails.kt
new file mode 100644
index 00000000..46262b7f
--- /dev/null
+++ b/core/model/src/main/kotlin/social/aloha/core/model/VideoDetails.kt
@@ -0,0 +1,88 @@
+// SPDX-FileCopyrightText: 2026 Aloha Social contributors
+// SPDX-License-Identifier: MIT
+
+package social.aloha.core.model
+
+import java.util.Locale
+import kotlinx.serialization.Serializable
+
+/**
+ * What Nextcloud Social knows about a video beyond the attachment: the PeerTube-shaped facts it
+ * carries on a status as `video`.
+ *
+ * @property support the author's "support me" text, where they wrote one.
+ * @property download whether the author allows downloading; null when the server did not say.
+ */
+@Serializable
+public data class VideoDetails(
+ val views: Int = 0,
+ val likes: Int = 0,
+ val dislikes: Int = 0,
+ val category: String? = null,
+ val language: String? = null,
+ val licence: String? = null,
+ val live: Boolean = false,
+ val support: String? = null,
+ val download: Boolean? = null,
+ val chapters: List = emptyList(),
+)
+
+/** One chapter of a video; [start] in seconds from the beginning. */
+@Serializable
+public data class VideoChapter(val start: Double, val title: String)
+
+/**
+ * Chapters written into a description the way people write them: a clock at the start of a line,
+ * then the title (`0:00 Intro`, `01:02:03 - Much later`).
+ */
+public object VideoChapters {
+ private const val SECONDS_PER_MINUTE = 60
+ private const val SECONDS_PER_HOUR = 3600
+ private const val MINIMUM_CHAPTERS = 2
+ private val separators = setOf('-', '–', '—', ':', '•')
+ private val clockPattern = Regex("""(\d{1,4}):(\d{1,2})(?::(\d{1,2}))?""")
+
+ /**
+ * The chapters in a plain-text description in order of appearance, or an empty list when there
+ * are fewer than two: one timestamp is a reference, not a table of contents. A timestamp that
+ * runs backwards is a mention and is skipped.
+ */
+ public fun parse(plainText: String): List {
+ val ordered = mutableListOf()
+ for (chapter in plainText.lines().mapNotNull(::chapterOrNull)) {
+ val last = ordered.lastOrNull()?.start
+ if (last == null || chapter.start > last) ordered += chapter
+ }
+ return if (ordered.size >= MINIMUM_CHAPTERS) ordered else emptyList()
+ }
+
+ private fun chapterOrNull(rawLine: String): VideoChapter? {
+ val line = rawLine.trim()
+ val space = line.indexOfFirst { it == ' ' || it == '\t' }
+ val start = if (space > 0) seconds(line.substring(0, space)) else null
+ val title = if (space > 0) line.substring(space).trimStart { it in separators || it.isWhitespace() } else ""
+ return if (start != null && title.isNotEmpty()) VideoChapter(start, title) else null
+ }
+
+ /** `m:ss`, `mm:ss` or `h:mm:ss` in seconds; anything else is null. Only the leading unit may exceed 59. */
+ public fun seconds(clock: String): Double? {
+ val units = clockPattern.matchEntire(clock)?.groupValues?.drop(1)?.filter {
+ it.isNotEmpty()
+ }?.map { it.toInt() }
+ val valid = units != null && units.drop(1).all { it < SECONDS_PER_MINUTE }
+ return if (valid) units.fold(0) { total, unit -> total * SECONDS_PER_MINUTE + unit }.toDouble() else null
+ }
+
+ /** Seconds as a chapter list writes them: `1:02`, `1:02:03`. */
+ public fun clock(seconds: Double): String {
+ val total = maxOf(0, Math.round(seconds).toInt())
+ val hours = total / SECONDS_PER_HOUR
+ val minutes = total % SECONDS_PER_HOUR / SECONDS_PER_MINUTE
+ val remainder = total % SECONDS_PER_MINUTE
+ return if (hours > 0) {
+ String.format(Locale.ROOT, "%d:%02d:%02d", hours, minutes, remainder)
+ } else {
+ String.format(Locale.ROOT, "%d:%02d", minutes, remainder)
+ }
+ }
+}
diff --git a/core/model/src/main/kotlin/social/aloha/core/model/WireValue.kt b/core/model/src/main/kotlin/social/aloha/core/model/WireValue.kt
new file mode 100644
index 00000000..3a24de2a
--- /dev/null
+++ b/core/model/src/main/kotlin/social/aloha/core/model/WireValue.kt
@@ -0,0 +1,18 @@
+// SPDX-FileCopyrightText: 2026 Aloha Social contributors
+// SPDX-License-Identifier: MIT
+
+package social.aloha.core.model
+
+import kotlin.enums.EnumEntries
+
+/** An enum case with the spelling the server uses for it. */
+public interface WireValue {
+ public val wire: String
+}
+
+/**
+ * The case whose [WireValue.wire] is [raw], or [unknown]. Never throws: a value the app does not
+ * know renders as a generic case; it never crashes and never vanishes.
+ */
+internal fun EnumEntries.fromWire(raw: String?, unknown: E): E where E : Enum, E : WireValue =
+ firstOrNull { it != unknown && it.wire == raw } ?: unknown
diff --git a/core/model/src/test/kotlin/social/aloha/core/model/AnnualAndModerationTest.kt b/core/model/src/test/kotlin/social/aloha/core/model/AnnualAndModerationTest.kt
new file mode 100644
index 00000000..250c052e
--- /dev/null
+++ b/core/model/src/test/kotlin/social/aloha/core/model/AnnualAndModerationTest.kt
@@ -0,0 +1,79 @@
+// SPDX-FileCopyrightText: 2026 Aloha Social contributors
+// SPDX-License-Identifier: MIT
+
+package social.aloha.core.model
+
+import java.time.Instant
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Assertions.assertFalse
+import org.junit.jupiter.api.Assertions.assertNull
+import org.junit.jupiter.api.Assertions.assertTrue
+import org.junit.jupiter.api.Test
+
+class AnnualAndModerationTest {
+ @Test
+ fun `an admin account's standing is the strongest thing true of it`() {
+ val suspended = AdminAccount("1", "bob", domain = "other.example", silenced = true, suspended = true)
+ assertEquals(AdminStanding.Suspended, suspended.standing)
+ assertFalse(suspended.isLocal)
+ assertEquals("@bob@other.example", suspended.handle)
+
+ val silenced = AdminAccount("2", "eve", silenced = true)
+ assertEquals(AdminStanding.Silenced, silenced.standing)
+ assertTrue(silenced.isLocal)
+ assertEquals("@eve", silenced.handle)
+
+ assertEquals(AdminStanding.Sensitized, AdminAccount("3", "ada", sensitized = true).standing)
+ assertEquals(AdminStanding.Active, AdminAccount("4", "ada").standing)
+ }
+
+ @Test
+ fun `a report is assigned only when somebody took it`() {
+ assertFalse(AdminReport("7").isAssigned)
+ assertTrue(AdminReport("7", assignedAccount = Account("1", "ada", "ada")).isAssigned)
+ }
+
+ @Test
+ fun `an activity week starts at its Unix second and keys by it`() {
+ val week = InstanceActivityWeek(1_767_139_200)
+ assertEquals(Instant.ofEpochSecond(1_767_139_200), week.startsAt)
+ assertEquals(1_767_139_200L, week.id)
+ }
+
+ @Test
+ fun `a published domain block keys off its digest, the domain when there is none`() {
+ assertEquals("abc", PublicDomainBlock("bad.example", digest = "abc").id)
+ assertEquals("bad.example", PublicDomainBlock("bad.example").id)
+ }
+
+ @Test
+ fun `the year's totals and busiest month come from the time series`() {
+ val data =
+ AnnualReportData(timeSeries = listOf(AnnualMonth(1, 3, 1), AnnualMonth(2, 11, 0), AnnualMonth(3, 0, 0)))
+ assertEquals(14, data.totalStatuses)
+ assertEquals(1, data.totalFollowers)
+ assertEquals(2, data.busiestMonth?.month)
+ assertNull(AnnualReportData(timeSeries = listOf(AnnualMonth(1))).busiestMonth)
+ }
+
+ @Test
+ fun `the three best posts are distinct ids, in order, without the absent one`() {
+ assertEquals(listOf("110"), AnnualTopStatuses(byReblogs = "110", byFavourites = "110").ids)
+ assertEquals(listOf("1", "2"), AnnualTopStatuses("1", "2", "1").ids)
+ }
+
+ @Test
+ fun `a wrapped report finds the status behind a top-status id`() {
+ val wrapped = WrappedAnnualReports()
+ assertNull(wrapped.status(null))
+ assertNull(wrapped.status("9"))
+ }
+
+ @Test
+ fun `unknown wire values are unknown, never an exception`() {
+ assertEquals(AnnualArchetype.Oracle, AnnualArchetype.fromWire("oracle"))
+ assertEquals(AnnualArchetype.Unknown, AnnualArchetype.fromWire("sphinx"))
+ assertEquals(AnnualReportState.Available, AnnualReportState.fromWire("available"))
+ assertEquals(AnnualReportState.Unknown, AnnualReportState.fromWire("melting"))
+ }
+}
diff --git a/core/model/src/test/kotlin/social/aloha/core/model/CapabilityLatchTest.kt b/core/model/src/test/kotlin/social/aloha/core/model/CapabilityLatchTest.kt
new file mode 100644
index 00000000..48f04504
--- /dev/null
+++ b/core/model/src/test/kotlin/social/aloha/core/model/CapabilityLatchTest.kt
@@ -0,0 +1,49 @@
+// SPDX-FileCopyrightText: 2026 Aloha Social contributors
+// SPDX-License-Identifier: MIT
+
+package social.aloha.core.model
+
+import org.junit.jupiter.api.Assertions.assertFalse
+import org.junit.jupiter.api.Assertions.assertTrue
+import org.junit.jupiter.api.Test
+
+class CapabilityLatchTest {
+ private val none = ServerCapabilities.minimal("https://cloud.example/index.php/apps/social/")
+
+ @Test
+ fun `hls_url, reactions and quotes latch on first sighting`() {
+ val hls =
+ status(
+ listOf(
+ MediaAttachment(
+ "m1",
+ AttachmentKind.Video,
+ hlsUrl = "https://cloud.example/media/hls/x/master.m3u8",
+ ),
+ ),
+ )
+ val reacted = status().copy(reactions = listOf(Reaction("🎉", 1)))
+ val quoting = status().copy(quoteId = "q1")
+ val latched = none.latched(listOf(hls, reacted, quoting))
+ assertTrue(latched.hlsLadder)
+ assertTrue(latched.emojiReactions)
+ assertTrue(latched.quotePosts)
+ }
+
+ @Test
+ fun `an empty reactions list and plain statuses latch nothing`() {
+ val latched = none.latched(listOf(status(), status().copy(reactions = emptyList())))
+ assertFalse(latched.hlsLadder || latched.emojiReactions || latched.quotePosts)
+ }
+
+ @Test
+ fun `a latched capability stays latched`() {
+ assertTrue(none.copy(quotePosts = true).latched(listOf(status())).quotePosts)
+ }
+
+ @Test
+ fun `a boost is judged by the post it carries`() {
+ val boost = status().copy(id = "b", reblog = status().copy(quoteId = "q"))
+ assertTrue(none.latched(listOf(boost)).quotePosts)
+ }
+}
diff --git a/core/model/src/test/kotlin/social/aloha/core/model/CharacterCountTest.kt b/core/model/src/test/kotlin/social/aloha/core/model/CharacterCountTest.kt
new file mode 100644
index 00000000..53ef2ce3
--- /dev/null
+++ b/core/model/src/test/kotlin/social/aloha/core/model/CharacterCountTest.kt
@@ -0,0 +1,55 @@
+// SPDX-FileCopyrightText: 2026 Aloha Social contributors
+// SPDX-License-Identifier: MIT
+
+package social.aloha.core.model
+
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Test
+
+class CharacterCountTest {
+ private val limits = ServerLimits.MastodonDefaults.copy(maxStatusCharacters = 500, charactersReservedPerUrl = 23)
+
+ private fun count(text: String, spoiler: String = "") = CharacterCount.count(text, spoiler, limits)
+
+ @Test
+ fun `plain text counts as itself`() {
+ assertEquals(5, count("Hello"))
+ }
+
+ @Test
+ fun `a url costs the flat rate however long it is`() {
+ val short = "See https://a.test"
+ val long = "See https://example.test/a/very/long/path?with=query&more=parameters#fragment"
+ assertEquals(count(short), count(long))
+ assertEquals(27, count(short))
+ }
+
+ @Test
+ fun `emoji and non-BMP characters count as one each`() {
+ assertEquals(1, count("👋"))
+ assertEquals(1, count("👨👩👧👦"))
+ assertEquals(25, count("👋 https://example.test/x"))
+ }
+
+ @Test
+ fun `the content warning shares the body's budget`() {
+ assertEquals(7, count("Hello", spoiler = "CW"))
+ assertEquals(493, CharacterCount.remaining("Hello", "CW", limits))
+ }
+
+ @Test
+ fun `several urls each cost the flat rate`() {
+ assertEquals(47, count("https://a.test https://b.test"))
+ }
+
+ @Test
+ fun `over-length is a negative remainder`() {
+ assertEquals(-100, CharacterCount.remaining("a".repeat(600), "", limits))
+ }
+
+ @Test
+ fun `counting holds at the nextcloud ceiling`() {
+ val nextcloud = limits.copy(maxStatusCharacters = 5000)
+ assertEquals(0, CharacterCount.remaining("é".repeat(5000), "", nextcloud))
+ }
+}
diff --git a/core/model/src/test/kotlin/social/aloha/core/model/ContentClassifierTest.kt b/core/model/src/test/kotlin/social/aloha/core/model/ContentClassifierTest.kt
new file mode 100644
index 00000000..8ea7bd8a
--- /dev/null
+++ b/core/model/src/test/kotlin/social/aloha/core/model/ContentClassifierTest.kt
@@ -0,0 +1,185 @@
+// SPDX-FileCopyrightText: 2026 Aloha Social contributors
+// SPDX-License-Identifier: MIT
+
+package social.aloha.core.model
+
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Assertions.assertFalse
+import org.junit.jupiter.api.Assertions.assertTrue
+import org.junit.jupiter.api.Test
+
+internal val alice = Account(id = "1", username = "alice", acct = "alice@example.test", displayName = "Alice")
+
+internal fun attachment(
+ type: AttachmentKind,
+ id: String = "m1",
+ duration: Double? = null,
+ width: Int? = null,
+ height: Int? = null,
+): MediaAttachment = MediaAttachment(
+ id = id,
+ type = type,
+ url = "https://example.test/$id",
+ meta = if (duration != null || width != null) {
+ MediaMeta(original = MediaDimensions(width = width, height = height, duration = duration))
+ } else {
+ null
+ },
+)
+
+internal fun status(
+ attachments: List = emptyList(),
+ tags: List = emptyList(),
+ card: Card? = null,
+): Status = Status(
+ id = "s1",
+ account = alice,
+ mediaAttachments = attachments,
+ tags = tags.map {
+ StatusTag(it)
+ },
+ card = card,
+)
+
+class ContentClassifierTest {
+ @Test
+ fun `text with no attachments and no card is text`() {
+ assertEquals(ContentKind.Text, ContentClassifier.classify(status()))
+ }
+
+ @Test
+ fun `a status carrying only a link card is news`() {
+ val card = Card(url = "https://example.test/article", title = "Something")
+ assertEquals(ContentKind.News, ContentClassifier.classify(status(card = card)))
+ }
+
+ @Test
+ fun `images only is a photo post`() {
+ val s = status(
+ listOf(
+ attachment(AttachmentKind.Image, "a", width = 1200, height = 800),
+ attachment(AttachmentKind.Image, "b", width = 1200, height = 800),
+ ),
+ )
+ assertEquals(ContentKind.Photo, ContentClassifier.classify(s))
+ }
+
+ @Test
+ fun `audio only is audio`() {
+ assertEquals(
+ ContentKind.Audio,
+ ContentClassifier.classify(status(listOf(attachment(AttachmentKind.Audio, duration = 1800.0)))),
+ )
+ }
+
+ @Test
+ fun `a single portrait clip under three minutes is a short`() {
+ val s = status(listOf(attachment(AttachmentKind.Video, duration = 25.0, width = 1080, height = 1920)))
+ assertEquals(ContentKind.Short, ContentClassifier.classify(s))
+ }
+
+ @Test
+ fun `anything at or under sixty seconds is a short regardless of shape`() {
+ val s = status(listOf(attachment(AttachmentKind.Video, duration = 45.0, width = 1920, height = 1080)))
+ assertEquals(ContentKind.Short, ContentClassifier.classify(s))
+ }
+
+ @Test
+ fun `a landscape clip between one and three minutes is a video`() {
+ val s = status(listOf(attachment(AttachmentKind.Video, duration = 120.0, width = 1920, height = 1080)))
+ assertEquals(ContentKind.Video, ContentClassifier.classify(s))
+ }
+
+ @Test
+ fun `a long portrait video is still a video`() {
+ val s = status(listOf(attachment(AttachmentKind.Video, duration = 2400.0, width = 1080, height = 1920)))
+ assertEquals(ContentKind.Video, ContentClassifier.classify(s))
+ }
+
+ @Test
+ fun `a shorts hashtag promotes a clip whose shape says otherwise`() {
+ val s = status(listOf(attachment(AttachmentKind.Video, width = 1920, height = 1080)), tags = listOf("shorts"))
+ assertEquals(ContentKind.Short, ContentClassifier.classify(s))
+ }
+
+ @Test
+ fun `a hashtag cannot rescue a clip that is simply too long`() {
+ val s =
+ status(
+ listOf(attachment(AttachmentKind.Video, duration = 600.0, width = 1080, height = 1920)),
+ tags = listOf("loops"),
+ )
+ assertEquals(ContentKind.Video, ContentClassifier.classify(s))
+ }
+
+ @Test
+ fun `missing meta defers the decision`() {
+ // Nextcloud Social without ffmpeg sends empty meta for every video
+ assertEquals(
+ ContentKind.Undetermined,
+ ContentClassifier.classify(status(listOf(attachment(AttachmentKind.Video)))),
+ )
+ }
+
+ @Test
+ fun `landscape without a duration is settled as video`() {
+ val s = status(listOf(attachment(AttachmentKind.Video, width = 1920, height = 1080)))
+ assertEquals(ContentKind.Video, ContentClassifier.classify(s))
+ }
+
+ @Test
+ fun `reclassification settles a deferred decision once a player reports`() {
+ val s = status(listOf(attachment(AttachmentKind.Video, id = "v")))
+ assertEquals(
+ ContentKind.Short,
+ ContentClassifier.reclassify(s, "v", MediaDimensions(width = 1080, height = 1920, duration = 18.0)),
+ )
+ assertEquals(
+ ContentKind.Video,
+ ContentClassifier.reclassify(s, "v", MediaDimensions(width = 1920, height = 1080, duration = 900.0)),
+ )
+ }
+
+ @Test
+ fun `a boost is classified by what it boosts`() {
+ val inner = status(listOf(attachment(AttachmentKind.Video, duration = 20.0, width = 1080, height = 1920)))
+ val boost = Status(id = "boost", account = alice, reblog = inner)
+ assertEquals(ContentKind.Short, ContentClassifier.classify(boost))
+ }
+
+ @Test
+ fun `mixed video and images is a video post`() {
+ val s = status(
+ listOf(
+ attachment(AttachmentKind.Image, "a", width = 100, height = 100),
+ attachment(AttachmentKind.Video, "b", duration = 30.0, width = 1080, height = 1920),
+ ),
+ )
+ assertEquals(ContentKind.Video, ContentClassifier.classify(s))
+ }
+
+ @Test
+ fun `kinds route into the right modes`() {
+ assertTrue(ContentKind.Short.belongs(FeedMode.Shorts))
+ assertTrue(ContentKind.Short.belongs(FeedMode.Video))
+ assertFalse(ContentKind.Video.belongs(FeedMode.Shorts))
+ assertTrue(ContentKind.Photo.belongs(FeedMode.Photos))
+ assertTrue(ContentKind.Text.belongs(FeedMode.Home))
+ assertFalse(ContentKind.Text.belongs(FeedMode.Photos))
+ }
+
+ @Test
+ fun `layout aspect is square for several attachments and the attachment's own for one`() {
+ assertEquals(MediaAttachment.DEFAULT_ASPECT, ContentClassifier.layoutAspect(emptyList()))
+ assertEquals(
+ 1.0,
+ ContentClassifier.layoutAspect(
+ listOf(attachment(AttachmentKind.Image, "a"), attachment(AttachmentKind.Image, "b")),
+ ),
+ )
+ assertEquals(
+ 0.5,
+ ContentClassifier.layoutAspect(listOf(attachment(AttachmentKind.Image, width = 100, height = 200))),
+ )
+ }
+}
diff --git a/core/model/src/test/kotlin/social/aloha/core/model/EntitiesTest.kt b/core/model/src/test/kotlin/social/aloha/core/model/EntitiesTest.kt
new file mode 100644
index 00000000..284ff1bc
--- /dev/null
+++ b/core/model/src/test/kotlin/social/aloha/core/model/EntitiesTest.kt
@@ -0,0 +1,142 @@
+// SPDX-FileCopyrightText: 2026 Aloha Social contributors
+// SPDX-License-Identifier: MIT
+
+package social.aloha.core.model
+
+import java.time.Instant
+import kotlinx.serialization.json.Json
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Assertions.assertFalse
+import org.junit.jupiter.api.Assertions.assertNull
+import org.junit.jupiter.api.Assertions.assertTrue
+import org.junit.jupiter.api.Test
+
+class EntitiesTest {
+ @Test
+ fun `an unrecognised notification type is unknown and does not group`() {
+ val kind = NotificationKind.fromWire("something.new")
+ assertTrue(kind.isUnknown)
+ assertFalse(kind.groups)
+ assertEquals(NotificationKind.FollowRequest, NotificationKind.fromWire("follow_request"))
+ }
+
+ @Test
+ fun `visibility fails closed`() {
+ val v = Visibility.fromWire("local_only")
+ assertTrue(v.isUnknown)
+ assertEquals(Visibility.Direct.restrictiveness, v.restrictiveness)
+ assertEquals(v, Visibility.mostRestrictive(Visibility.Public, v))
+ assertEquals(Visibility.Unknown, Visibility.fromWire(null))
+ assertEquals(Visibility.Unknown, Visibility.fromWire("__unknown"))
+ }
+
+ @Test
+ fun `sensitive media policy keeps PeerTube's three states under Mastodon's names`() {
+ assertTrue(SensitiveMediaPolicy.fromWire("show_all").allowsAutomaticReveal)
+ assertTrue(SensitiveMediaPolicy.fromWire("default").drawsAtAll)
+ assertFalse(SensitiveMediaPolicy.fromWire("hide_all").drawsAtAll)
+ }
+
+ @Test
+ fun `attachment kinds tell Mastodon's unknown from a value the app does not know`() {
+ assertEquals(AttachmentKind.UnsupportedFile, AttachmentKind.fromWire("unknown"))
+ assertEquals(AttachmentKind.UnsupportedFile, AttachmentKind.fromWire(null))
+ assertEquals(AttachmentKind.Unknown, AttachmentKind.fromWire("hologram"))
+ }
+
+ @Test
+ fun `a reply is never less restrictive than what it answers`() {
+ val private = status().copy(visibility = Visibility.Private)
+ assertEquals(Visibility.Private, private.replyVisibility(Visibility.Public))
+ assertEquals(Visibility.Direct, private.replyVisibility(Visibility.Direct))
+ }
+
+ @Test
+ fun `reply mentions put the author first and leave out the replier and duplicates`() {
+ val s = status().copy(
+ mentions = listOf(
+ Mention("2", "bob", "bob@b.test"),
+ Mention("3", "me", "me"),
+ Mention("1", "alice", "alice@example.test"),
+ ),
+ )
+ assertEquals(listOf("alice@example.test", "bob@b.test"), s.replyMentions(excludingViewerAcct = "me"))
+ }
+
+ @Test
+ fun `a boost shows the boosted post and names the booster`() {
+ val inner = status()
+ val booster = alice.copy(id = "9", acct = "bob")
+ val boost = Status(id = "b", account = booster, reblog = inner)
+ assertEquals(inner, boost.displayed)
+ assertEquals(booster, boost.booster)
+ assertNull(inner.booster)
+ }
+
+ @Test
+ fun `a display name falls back through the handle`() {
+ assertEquals("alice", Account(id = "1", username = "alice", acct = "alice").bestDisplayName)
+ assertEquals("a@x", Account(id = "1", username = "", acct = "a@x").bestDisplayName)
+ assertEquals("x.test", Account(id = "1", username = "a", acct = "a@x.test").host)
+ assertEquals("cloud.test", Account(id = "1", username = "a", acct = "a", url = "https://cloud.test/@a").host)
+ }
+
+ @Test
+ fun `hashtags normalise to what the server stores`() {
+ assertEquals("nextcloud", Tag.normalise(" #NextCloud "))
+ assertEquals("swift", Tag.normalise("##Swift"))
+ assertNull(Tag.normalise("#"))
+ assertEquals(127, Tag.normalise("a".repeat(200))?.length)
+ }
+
+ @Test
+ fun `a poll hides results until voted or closed and shares by the right denominator`() {
+ val poll =
+ Poll(id = "p", multiple = true, votesCount = 10, votersCount = 5, options = listOf(PollOption("a", 4)))
+ assertFalse(poll.showsResults)
+ assertTrue(poll.copy(voted = true).showsResults)
+ assertEquals(0.8, poll.shareOfOptionAt(0))
+ assertEquals(0.0, poll.shareOfOptionAt(3))
+ assertEquals(0.4, poll.copy(multiple = false).shareOfOptionAt(0))
+ }
+
+ @Test
+ fun `a filter stops applying the moment it expires`() {
+ val now = Instant.parse("2026-09-29T12:00:00Z")
+ assertFalse(Filter(id = "f").isExpired(now))
+ assertTrue(Filter(id = "f", expiresAt = now).isExpired(now))
+ assertFalse(Filter(id = "f", expiresAt = now.plusSeconds(1)).isExpired(now))
+ }
+
+ @Test
+ fun `theme hex values normalise or vanish`() {
+ assertEquals("#aabbcc", NextcloudTheme.normalise("#ABC"))
+ assertEquals("#0082c9", NextcloudTheme.normalise("0082c9"))
+ assertNull(NextcloudTheme.normalise("blue"))
+ assertNull(NextcloudTheme.normalise(""))
+ val theme = NextcloudTheme(colourHex = "#0082c9", elementDarkHex = "#4ba3de")
+ assertEquals("#4ba3de", theme.hex(onDarkBackground = true))
+ assertEquals("#0082c9", theme.hex(onDarkBackground = false))
+ }
+
+ @Test
+ fun `chapters parse from a description the way people write them`() {
+ val chapters = VideoChapters.parse(
+ "Intro text\n0:00 Intro\n1:02 - The first thing\n0:30 a mention\n01:02:03 – Much later\n",
+ )
+ assertEquals(listOf(0.0, 62.0, 3723.0), chapters.map { it.start })
+ assertEquals("The first thing", chapters[1].title)
+ assertTrue(VideoChapters.parse("see 1:02 for the goal").isEmpty())
+ assertNull(VideoChapters.seconds("1:60"))
+ assertEquals("1:02:03", VideoChapters.clock(3723.0))
+ assertEquals("1:02", VideoChapters.clock(62.0))
+ }
+
+ @Test
+ fun `a status survives persistence as json`() {
+ val s = status(
+ listOf(attachment(AttachmentKind.Video, duration = 3.0, width = 1, height = 2)),
+ ).copy(createdAt = Instant.parse("2026-09-29T10:00:00Z"))
+ assertEquals(s, Json.decodeFromString(Status.serializer(), Json.encodeToString(Status.serializer(), s)))
+ }
+}
diff --git a/core/model/src/test/kotlin/social/aloha/core/model/ExtensionsTest.kt b/core/model/src/test/kotlin/social/aloha/core/model/ExtensionsTest.kt
new file mode 100644
index 00000000..4ef0070c
--- /dev/null
+++ b/core/model/src/test/kotlin/social/aloha/core/model/ExtensionsTest.kt
@@ -0,0 +1,119 @@
+// SPDX-FileCopyrightText: 2026 Aloha Social contributors
+// SPDX-License-Identifier: MIT
+
+package social.aloha.core.model
+
+import java.time.Duration
+import java.time.Instant
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Assertions.assertFalse
+import org.junit.jupiter.api.Assertions.assertTrue
+import org.junit.jupiter.api.Test
+
+class ExtensionsTest {
+ private val published = Instant.parse("2026-09-28T10:00:00Z")
+
+ @Test
+ fun `a story is live until it expires`() {
+ val story = Story("1", publishedAt = published, expiresAt = published.plus(Duration.ofHours(12)))
+ assertTrue(story.isLive(published.plus(Duration.ofHours(11))))
+ assertFalse(story.isLive(published.plus(Duration.ofHours(13))))
+ }
+
+ @Test
+ fun `a story never outlives a day from insert whatever the sender claimed`() {
+ val story = Story("1", publishedAt = published, expiresAt = published.plus(Duration.ofDays(30)))
+ assertFalse(story.isLive(published.plus(Duration.ofHours(25))))
+ val insertedLater = published.plus(Duration.ofHours(2))
+ assertTrue(story.isLive(published.plus(Duration.ofHours(25)), insertedAt = insertedLater))
+ }
+
+ @Test
+ fun `a story without any timestamp is not live`() {
+ assertFalse(Story("1").isLive(published))
+ }
+
+ @Test
+ fun `nothing under ten seconds is worth reporting and past 95 percent is finished`() {
+ assertFalse(WatchPositionRules.shouldReport(position = 4.0, duration = 600.0))
+ assertTrue(WatchPositionRules.shouldReport(position = 12.0, duration = 600.0))
+ assertFalse(WatchPositionRules.shouldReport(position = 12.0, duration = 0.0))
+ assertTrue(WatchPositionRules.isComplete(position = 990.0, duration = 1000.0))
+ assertFalse(WatchPositionRules.isComplete(position = 300.0, duration = 1000.0))
+ }
+
+ @Test
+ fun `continue watching progress stays between 0 and 1`() {
+ assertEquals(0.25, ContinueWatchingItem("1", position = 25.0, duration = 100.0).fraction)
+ assertEquals(1.0, ContinueWatchingItem("1", position = 150.0, duration = 100.0).fraction)
+ assertEquals(0.0, ContinueWatchingItem("1", position = 10.0, duration = 0.0).fraction)
+ }
+
+ @Test
+ fun `the highlights rhythm reads the last four weeks against the rest`() {
+ assertEquals(ProfileHighlights.Rhythm.Quiet, ProfileHighlights(true, weeks = List(12) { 0 }).rhythm)
+ assertEquals(
+ ProfileHighlights.Rhythm.Busier,
+ ProfileHighlights(
+ true,
+ weeks = List(8) {
+ 1
+ } + List(4) { 5 },
+ ).rhythm,
+ )
+ assertEquals(
+ ProfileHighlights.Rhythm.Slowing,
+ ProfileHighlights(
+ true,
+ weeks = List(8) {
+ 5
+ } + List(4) { 1 },
+ ).rhythm,
+ )
+ assertEquals(ProfileHighlights.Rhythm.Steady, ProfileHighlights(true, weeks = List(12) { 3 }).rhythm)
+ assertEquals(ProfileHighlights.Rhythm.Steady, ProfileHighlights(true, weeks = listOf(2, 1)).rhythm)
+ assertEquals(13, ProfileHighlights(true, weeks = List(11) { 0 } + 13).total)
+ }
+
+ @Test
+ fun `a GIF without dimensions is laid out square`() {
+ assertEquals(1.0, GifEntry("cat", "cat").aspectRatio)
+ assertEquals(2.0, GifEntry("cat", "cat", width = 200, height = 100).aspectRatio)
+ }
+
+ @Test
+ fun `a feed without a title is named by its site's host`() {
+ assertEquals("example.org", SubscriptionFeed("1", siteUrl = "https://example.org/blog").displayTitle)
+ assertEquals("feeds.example", SubscriptionFeed("1", url = "https://feeds.example/rss").displayTitle)
+ assertEquals("Blog", SubscriptionFeed("1", title = "Blog", url = "https://feeds.example/rss").displayTitle)
+ assertEquals("1", SubscriptionFeed("1").displayTitle)
+ }
+
+ @Test
+ fun `a portfolio post shows its first picture`() {
+ val video = MediaAttachment("v", AttachmentKind.Video)
+ val picture = MediaAttachment("p", AttachmentKind.Image)
+ assertEquals("p", PortfolioPost("1", mediaAttachments = listOf(video, picture)).picture?.id)
+ assertEquals("v", PortfolioPost("1", mediaAttachments = listOf(video)).picture?.id)
+ }
+
+ @Test
+ fun `a number map sorts by key, which for months is chronological`() {
+ val map = NumberMap(mapOf("2026-03" to 1.0, "2026-01" to 4.0, "2026-02" to 2.0))
+ assertEquals(listOf("2026-01", "2026-02", "2026-03"), map.sorted.map { it.first })
+ assertEquals(4.0, map["2026-01"])
+ }
+
+ @Test
+ fun `following imports go in as follows and the rest keep their name`() {
+ assertEquals("follows", MigrationListKind.Following.importPath)
+ assertEquals("blocks", MigrationListKind.Blocks.importPath)
+ }
+
+ @Test
+ fun `a text prefix never cuts an emoji in half`() {
+ assertEquals("🎉".repeat(20), CharacterCount.prefix("🎉".repeat(40), 20))
+ assertEquals("short", CharacterCount.prefix("short", 20))
+ assertEquals("👩👩👧", CharacterCount.prefix("👩👩👧👩👩👧", 1))
+ }
+}
diff --git a/core/model/src/test/kotlin/social/aloha/core/model/TimelineAndCapabilitiesTest.kt b/core/model/src/test/kotlin/social/aloha/core/model/TimelineAndCapabilitiesTest.kt
new file mode 100644
index 00000000..9b07a693
--- /dev/null
+++ b/core/model/src/test/kotlin/social/aloha/core/model/TimelineAndCapabilitiesTest.kt
@@ -0,0 +1,129 @@
+// SPDX-FileCopyrightText: 2026 Aloha Social contributors
+// SPDX-License-Identifier: MIT
+
+package social.aloha.core.model
+
+import java.time.Duration
+import java.time.Instant
+import kotlinx.serialization.json.Json
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Assertions.assertFalse
+import org.junit.jupiter.api.Assertions.assertNotEquals
+import org.junit.jupiter.api.Assertions.assertTrue
+import org.junit.jupiter.api.Test
+
+class TimelineAndCapabilitiesTest {
+ private fun capabilities(nextcloud: Boolean) = ServerCapabilities(
+ apiBase = "https://example.test/",
+ onlyMediaFilter = nextcloud,
+ onlyVideoFilter = nextcloud,
+ onlyNewsFilter = nextcloud,
+ )
+
+ @Test
+ fun `video mode asks for only_video where the server has it`() {
+ val filters = TimelineFilters.forMode(FeedMode.Video, capabilities(nextcloud = true))
+ assertTrue(filters.onlyVideo)
+ assertEquals(1, OverFetch.multiplier(FeedMode.Video, filters))
+ }
+
+ @Test
+ fun `without server narrowing Shorts over-fetches hardest`() {
+ val filters = TimelineFilters.forMode(FeedMode.Shorts, capabilities(nextcloud = false))
+ assertTrue(filters.isEmpty)
+ assertEquals(8, OverFetch.multiplier(FeedMode.Shorts, filters))
+ }
+
+ @Test
+ fun `news is hidden on a server without only_news`() {
+ assertFalse(capabilities(nextcloud = false).supports(FeedMode.News))
+ assertTrue(capabilities(nextcloud = true).supports(FeedMode.News))
+ }
+
+ @Test
+ fun `timeline keys keep modes from sharing rows`() {
+ val home = TimelineKey(FeedMode.Home, TimelineSource.Home)
+ val photosOfHome = TimelineKey(FeedMode.Photos, TimelineSource.Home)
+ assertNotEquals(home.storageKey, photosOfHome.storageKey)
+ assertEquals("photos:home", photosOfHome.storageKey)
+ assertEquals("home:tag:nextcloud", TimelineKey.home(TimelineSource.Hashtag("NextCloud")).storageKey)
+ assertEquals(
+ "home:account:7:0:1",
+ TimelineKey.home(TimelineSource.Account("7", includeReplies = false, onlyMedia = true)).storageKey,
+ )
+ }
+
+ @Test
+ fun `a timeline key survives persistence`() {
+ val key = TimelineKey(FeedMode.Shorts, TimelineSource.List("3"))
+ assertEquals(
+ key,
+ Json.decodeFromString(TimelineKey.serializer(), Json.encodeToString(TimelineKey.serializer(), key)),
+ )
+ }
+
+ @Test
+ fun `an empty vapid key and no streaming url mean polling`() {
+ val c = ServerCapabilities(apiBase = "https://cloud.example/", webPushVapidKey = "")
+ assertEquals(SyncTier.Polling, c.syncTier)
+ }
+
+ @Test
+ fun `a streaming url upgrades to streaming`() {
+ val c = ServerCapabilities(apiBase = "https://m.example/", streamingUrl = "wss://m.example/api/v1/streaming")
+ assertEquals(SyncTier.Streaming, c.syncTier)
+ }
+
+ @Test
+ fun `a real vapid key wins over streaming`() {
+ val c =
+ ServerCapabilities(
+ apiBase = "https://m.example/",
+ streamingUrl = "wss://m.example/x",
+ webPushVapidKey = "BNcT",
+ )
+ assertEquals(SyncTier.WebPush, c.syncTier)
+ }
+
+ @Test
+ fun `the nextcloud root is the api base without the app prefix`() {
+ assertEquals(
+ "https://cloud.example/",
+ ServerCapabilities.minimal("https://cloud.example/index.php/apps/social/").nextcloudRoot,
+ )
+ assertEquals("https://cloud.example/", ServerCapabilities.minimal("https://cloud.example/").nextcloudRoot)
+ }
+
+ @Test
+ fun `capabilities go stale after a day and are stale before detection`() {
+ val now = Instant.parse("2026-09-29T12:00:00Z")
+ assertTrue(ServerCapabilities.minimal("https://x.test/").isStale(now))
+ val fresh = ServerCapabilities(apiBase = "https://x.test/", detectedAt = now.minus(Duration.ofHours(23)))
+ assertFalse(fresh.isStale(now))
+ assertTrue(fresh.copy(detectedAt = now.minus(Duration.ofHours(25))).isStale(now))
+ }
+
+ @Test
+ fun `nextcloud social is recognised by its software name`() {
+ assertTrue(ServerCapabilities(apiBase = "https://x.test/", softwareName = "nextcloud-social").isNextcloudSocial)
+ assertFalse(ServerCapabilities(apiBase = "https://x.test/", softwareName = "mastodon").isNextcloudSocial)
+ }
+
+ @Test
+ fun `limits pick the video ceiling for video only`() {
+ val limits = ServerLimits.MastodonDefaults.copy(imageSizeLimit = 10L shl 20, videoSizeLimit = 2048L shl 20)
+ assertEquals(2048L shl 20, limits.sizeLimit("video/mp4"))
+ assertEquals(10L shl 20, limits.sizeLimit("image/png"))
+ assertTrue(limits.accepts("image/png"))
+ assertFalse(limits.accepts("application/x-msdownload"))
+ assertTrue(limits.copy(supportedMimeTypes = emptyList()).accepts("anything/at-all"))
+ }
+
+ @Test
+ fun `ids order by length then lexically, never as numbers`() {
+ assertTrue(ServerIds.isNewer("1790637085797595891", "999999999999999999"))
+ assertTrue(ServerIds.isNewer("1790637085797595892", "1790637085797595891"))
+ assertEquals("100", ServerIds.newest(listOf("99", "100", "7")))
+ assertEquals(0, ServerIds.compare("42", "42"))
+ }
+}
diff --git a/core/network/build.gradle.kts b/core/network/build.gradle.kts
index c60c878a..472e92de 100644
--- a/core/network/build.gradle.kts
+++ b/core/network/build.gradle.kts
@@ -3,4 +3,16 @@
plugins {
alias(libs.plugins.aloha.android.library)
+ alias(libs.plugins.aloha.android.hilt)
+ alias(libs.plugins.kotlin.serialization)
+}
+
+dependencies {
+ api(project(":core:model"))
+ api(libs.okhttp)
+ api(libs.kotlinx.coroutines.core)
+ implementation(libs.kotlinx.serialization.json)
+ testImplementation(libs.okhttp.mockwebserver)
+ testImplementation(libs.okhttp.tls)
+ testImplementation(libs.kotlinx.coroutines.test)
}
diff --git a/core/network/src/main/kotlin/social/aloha/core/network/AlohaHttp.kt b/core/network/src/main/kotlin/social/aloha/core/network/AlohaHttp.kt
new file mode 100644
index 00000000..afec60dc
--- /dev/null
+++ b/core/network/src/main/kotlin/social/aloha/core/network/AlohaHttp.kt
@@ -0,0 +1,46 @@
+// SPDX-FileCopyrightText: 2026 Aloha Social contributors
+// SPDX-License-Identifier: MIT
+
+package social.aloha.core.network
+
+import java.io.File
+import java.util.concurrent.TimeUnit
+import javax.net.ssl.KeyManager
+import javax.net.ssl.SSLContext
+import javax.net.ssl.X509TrustManager
+import okhttp3.Cache
+import okhttp3.OkHttpClient
+
+/** Builds the one OkHttp client the app shares for the API, images and media. */
+public object AlohaHttp {
+ private const val CACHE_BYTES = 64L * 1024 * 1024
+ private const val CONNECT_TIMEOUT_SECONDS = 15L
+ private const val READ_TIMEOUT_SECONDS = 30L
+ private const val CALL_TIMEOUT_SECONDS = 120L
+
+ /**
+ * @param cacheDirectory the HTTP cache. Nextcloud Social sends `no-store` on its polling routes,
+ * so the cache only helps where a server sends validators; it revalidates by itself then.
+ * @param keyManager presents a client certificate where the person chose one.
+ */
+ public fun client(
+ cacheDirectory: File,
+ userAgent: String,
+ trustManager: X509TrustManager,
+ keyManager: KeyManager?,
+ ): OkHttpClient {
+ val tls = SSLContext.getInstance("TLS").apply {
+ init(keyManager?.let { arrayOf(it) }, arrayOf(trustManager), null)
+ }
+ return OkHttpClient.Builder()
+ .sslSocketFactory(tls.socketFactory, trustManager)
+ .cache(Cache(cacheDirectory, CACHE_BYTES))
+ .connectTimeout(CONNECT_TIMEOUT_SECONDS, TimeUnit.SECONDS)
+ .readTimeout(READ_TIMEOUT_SECONDS, TimeUnit.SECONDS)
+ .callTimeout(CALL_TIMEOUT_SECONDS, TimeUnit.SECONDS)
+ .addInterceptor { chain ->
+ chain.proceed(chain.request().newBuilder().header("User-Agent", userAgent).build())
+ }
+ .build()
+ }
+}
diff --git a/core/network/src/main/kotlin/social/aloha/core/network/ApiClient.kt b/core/network/src/main/kotlin/social/aloha/core/network/ApiClient.kt
new file mode 100644
index 00000000..a160be1c
--- /dev/null
+++ b/core/network/src/main/kotlin/social/aloha/core/network/ApiClient.kt
@@ -0,0 +1,84 @@
+// SPDX-FileCopyrightText: 2026 Aloha Social contributors
+// SPDX-License-Identifier: MIT
+
+package social.aloha.core.network
+
+import kotlinx.coroutines.CoroutineDispatcher
+import okhttp3.HttpUrl
+import okhttp3.OkHttpClient
+
+/** The credentials of one account, read afresh for every request so a new sign-in takes effect at once. */
+public data class Credentials(val bearerToken: String?, val nextcloudBasic: String? = null) {
+ override fun toString(): String = "Credentials(bearer=${bearerToken != null}, nextcloud=${nextcloudBasic != null})"
+}
+
+public fun interface CredentialSource {
+ public suspend fun current(): Credentials
+}
+
+/** Told what lossy decoding dropped, so the loss is logged rather than silent. */
+public fun interface DecodingFailureListener {
+ public fun onDropped(path: String, failures: List)
+}
+
+/**
+ * One account's view of its server: the resolved API base, its credentials and the shared HTTP
+ * client and rate limiter. Returns [ApiResult]s; it never retries a write.
+ */
+public class ApiClient internal constructor(
+ public val apiBase: HttpUrl,
+ private val credentials: CredentialSource,
+ private val executor: RequestExecutor,
+ private val onUnauthorised: suspend () -> Unit,
+) {
+ /**
+ * @param onUnauthorised told when the server answers 401 to a request that carried this account's
+ * credential: the token was revoked and the account needs a new sign-in.
+ */
+ public constructor(
+ apiBase: HttpUrl,
+ credentials: CredentialSource,
+ http: OkHttpClient,
+ rateLimiter: RateLimiter,
+ ioDispatcher: CoroutineDispatcher,
+ failureListener: DecodingFailureListener = DecodingFailureListener { _, _ -> },
+ onUnauthorised: suspend () -> Unit = {},
+ ) : this(apiBase, credentials, RequestExecutor(http, rateLimiter, ioDispatcher, failureListener), onUnauthorised)
+
+ public suspend fun execute(request: ApiRequest): ApiResult =
+ perform(request, request.endpoint.resolve(apiBase)).map { it.decoded.value }
+
+ /** A page from [request]; [limit] is what the request asked for, used where the server sends no `Link`. */
+ public suspend fun page(request: ApiRequest>, limit: Int): ApiResult> =
+ perform(request, request.endpoint.resolve(apiBase)).map { it.toPage(limit) }
+
+ /**
+ * The page a `Link` cursor points at, followed verbatim so every filter of the first request
+ * survives. A cursor may only point at the API base's own origin: a link elsewhere is refused, so
+ * a hostile server cannot steer the token to another host.
+ */
+ public suspend fun page(following: HttpUrl, like: ApiRequest>, limit: Int): ApiResult> {
+ if (!following.sameOrigin(apiBase)) return ApiResult.Failure(ApiError.ForeignCursor(following.host))
+ val get = ApiRequest(like.endpoint.copy(method = HttpMethod.GET, body = Body.None), like.decoder)
+ return perform(get, following).map { it.toPage(limit) }
+ }
+
+ private fun Answer>.toPage(limit: Int) =
+ Paginated(decoded.value, link, rawCount = decoded.rawCount ?: decoded.value.size, requestedLimit = limit)
+
+ private suspend fun perform(request: ApiRequest, url: HttpUrl): ApiResult> {
+ val authorization = authorization(request.endpoint.authentication)
+ ?: return ApiResult.Failure(ApiError.Unauthorised(message = null))
+ val result = executor.execute(request, url, authorization)
+ val revoked = (result as? ApiResult.Failure)?.error is ApiError.Unauthorised && authorization.isNotEmpty()
+ if (revoked) onUnauthorised()
+ return result
+ }
+
+ /** The `Authorization` value, `""` for a public route, or null when the route's credential is missing. */
+ private suspend fun authorization(authentication: Authentication): String? = when (authentication) {
+ Authentication.None, Authentication.NextcloudPublic -> ""
+ Authentication.Bearer -> credentials.current().bearerToken?.let { "Bearer $it" }
+ Authentication.NextcloudSession -> credentials.current().nextcloudBasic
+ }
+}
diff --git a/core/network/src/main/kotlin/social/aloha/core/network/ApiRequest.kt b/core/network/src/main/kotlin/social/aloha/core/network/ApiRequest.kt
new file mode 100644
index 00000000..a008bc49
--- /dev/null
+++ b/core/network/src/main/kotlin/social/aloha/core/network/ApiRequest.kt
@@ -0,0 +1,79 @@
+// SPDX-FileCopyrightText: 2026 Aloha Social contributors
+// SPDX-License-Identifier: MIT
+
+package social.aloha.core.network
+
+import kotlinx.serialization.KSerializer
+import kotlinx.serialization.json.Json
+import kotlinx.serialization.json.JsonArray
+import kotlinx.serialization.json.JsonObject
+import kotlinx.serialization.json.jsonArray
+import social.aloha.core.network.decoding.DecodingFailures
+import social.aloha.core.network.decoding.decodeOrRecord
+
+/** One element a lossy array dropped, kept so the loss is logged rather than silent. */
+public data class DecodingFailure(val index: Int, val typeName: String, val reason: String)
+
+/**
+ * An [Endpoint] together with how its response becomes a domain value. The wire DTO and its mapping
+ * stay inside this module: callers only ever see the domain type [T].
+ */
+public class ApiRequest internal constructor(
+ public val endpoint: Endpoint,
+ internal val decoder: ResponseDecoder,
+)
+
+/** What decoding one body produced. [rawCount] is the number of rows the server sent, before lossy decoding. */
+internal data class Decoded(val value: T, val rawCount: Int?, val failures: List)
+
+internal fun interface ResponseDecoder {
+ fun decode(json: Json, body: String): Decoded
+}
+
+/** The one JSON configuration for every server payload. */
+internal val AlohaJson: Json = Json {
+ ignoreUnknownKeys = true
+ explicitNulls = false
+ coerceInputValues = true
+}
+
+/** A single object mapped to its domain type. */
+internal fun request(endpoint: Endpoint, serializer: KSerializer, map: (D) -> T): ApiRequest =
+ ApiRequest(endpoint) { json, body ->
+ val (value, failures) = DecodingFailures.collect { map(json.decodeFromString(serializer, body)) }
+ Decoded(value, rawCount = null, failures = failures)
+ }
+
+/**
+ * A top-level array, decoded lossily: an element that fails is dropped and recorded, and
+ * [Decoded.rawCount] still counts it, so "a page shorter than the limit is the last one" reflects
+ * what the server sent.
+ */
+internal fun listRequest(endpoint: Endpoint, serializer: KSerializer, map: (D) -> T): ApiRequest> =
+ ApiRequest(endpoint) { json, body ->
+ val array = json.parseToJsonElement(body) as? JsonArray ?: JsonArray(emptyList())
+ val (value, failures) = DecodingFailures.collect {
+ array.mapIndexedNotNull { index, element -> json.decodeOrRecord(serializer, element, index)?.let(map) }
+ }
+ Decoded(value, rawCount = array.size, failures = failures)
+ }
+
+/**
+ * An object whose page is one of its array members, e.g. grouped notifications, where
+ * `notification_groups` is what the limit counts.
+ */
+internal fun pagedObjectRequest(
+ endpoint: Endpoint,
+ serializer: KSerializer,
+ pageMember: String,
+ map: (D) -> T,
+): ApiRequest = ApiRequest(endpoint) { json, body ->
+ val element = json.parseToJsonElement(body)
+ val rawCount = (element as? JsonObject)?.get(pageMember)?.let { runCatching { it.jsonArray.size }.getOrNull() }
+ val (value, failures) = DecodingFailures.collect { map(json.decodeFromJsonElement(serializer, element)) }
+ Decoded(value, rawCount = rawCount, failures = failures)
+}
+
+/** A route whose body carries nothing the app reads (`{}` or empty). */
+internal fun unitRequest(endpoint: Endpoint): ApiRequest =
+ ApiRequest(endpoint) { _, _ -> Decoded(Unit, rawCount = null, failures = emptyList()) }
diff --git a/core/network/src/main/kotlin/social/aloha/core/network/ApiResult.kt b/core/network/src/main/kotlin/social/aloha/core/network/ApiResult.kt
new file mode 100644
index 00000000..c124e96c
--- /dev/null
+++ b/core/network/src/main/kotlin/social/aloha/core/network/ApiResult.kt
@@ -0,0 +1,107 @@
+// SPDX-FileCopyrightText: 2026 Aloha Social contributors
+// SPDX-License-Identifier: MIT
+
+package social.aloha.core.network
+
+import java.io.IOException
+import java.security.cert.X509Certificate
+import kotlin.time.Duration
+import kotlin.time.Duration.Companion.seconds
+
+/**
+ * The outcome of one request. Cancellation is not a result: it propagates as a
+ * `CancellationException`, so structured concurrency keeps working.
+ */
+public sealed interface ApiResult {
+ public data class Success(val value: T) : ApiResult
+
+ public data class Failure(val error: ApiError) : ApiResult
+}
+
+public inline fun ApiResult.map(transform: (T) -> R): ApiResult = when (this) {
+ is ApiResult.Success -> ApiResult.Success(transform(value))
+ is ApiResult.Failure -> this
+}
+
+public fun ApiResult.valueOrNull(): T? = (this as? ApiResult.Success)?.value
+
+public fun ApiResult.errorOrNull(): ApiError? = (this as? ApiResult.Failure)?.error
+
+/** Why a request failed. The one place an HTTP response becomes a typed error is [ApiError.from]. */
+public sealed interface ApiError {
+ /**
+ * 401: the token was revoked or never presented (Nextcloud Social answers
+ * `{"error":"the access_token was revoked"}`). There is no refresh token: the account needs a
+ * new sign-in, its cached data stays.
+ */
+ public data class Unauthorised(val message: String?) : ApiError
+
+ /** 403; [insufficientScope] when the server said so in `WWW-Authenticate`. */
+ public data class Forbidden(val message: String?, val insufficientScope: Boolean) : ApiError
+
+ /** 404 or 410. */
+ public data object NotFound : ApiError
+
+ /** 422: validation; [message] is written for a person and is shown verbatim when present. */
+ public data class Unprocessable(val message: String?) : ApiError
+
+ /** 429; [retryAfter] from `Retry-After` when the server sent one. */
+ public data class RateLimited(val retryAfter: Duration?) : ApiError
+
+ /** Any other non-2xx answer, including a non-JSON body from a PHP upload limit. */
+ public data class Server(val status: Int, val body: String?) : ApiError
+
+ /** The body did not have the shape of the route's entity. */
+ public data class Decoding(val context: String, val reason: String) : ApiError
+
+ /** The server presented a certificate that neither the system nor the person trusts. */
+ public data class UntrustedCertificate(val host: String, val chain: List) : ApiError
+
+ /** A `Link` cursor pointed at another origin than the API base; it was not followed. */
+ public data class ForeignCursor(val host: String) : ApiError
+
+ /** A path segment from server data would have resolved as `.` or `..`; nothing was sent. */
+ public data class UnsafePath(val path: String) : ApiError
+
+ /** No answer: DNS, connection, TLS or timeout. */
+ public data class Transport(val cause: IOException) : ApiError
+
+ /** Whether retrying the identical request could plausibly succeed. Writes are never retried automatically. */
+ public val isTransient: Boolean
+ get() = when (this) {
+ is RateLimited, is Transport -> true
+ is Server -> status >= SERVER_ERROR
+ else -> false
+ }
+
+ /** The account needs a new sign-in: a state, not a failure. */
+ public val requiresReauthentication: Boolean get() = this is Unauthorised
+
+ public companion object {
+ private const val UNAUTHORISED = 401
+ private const val FORBIDDEN = 403
+ private const val NOT_FOUND = 404
+ private const val GONE = 410
+ private const val UNPROCESSABLE = 422
+ private const val TOO_MANY_REQUESTS = 429
+ private const val SERVER_ERROR = 500
+ private val SUCCESS = 200..299
+
+ internal fun from(status: Int, message: String?, rawBody: String?, headers: (String) -> String?): ApiError? {
+ if (status in SUCCESS) return null
+ return when (status) {
+ UNAUTHORISED -> Unauthorised(message)
+ FORBIDDEN -> forbidden(message, headers("WWW-Authenticate"))
+ NOT_FOUND, GONE -> NotFound
+ UNPROCESSABLE -> Unprocessable(message ?: rawBody?.takeIf { it.isNotBlank() })
+ TOO_MANY_REQUESTS -> RateLimited(headers("Retry-After")?.let(::parseRetryAfter))
+ else -> Server(status, message ?: rawBody)
+ }
+ }
+
+ private fun forbidden(message: String?, authenticate: String?) =
+ Forbidden(message, insufficientScope = authenticate?.contains("insufficient_scope") == true)
+
+ private fun parseRetryAfter(raw: String): Duration? = raw.trim().toLongOrNull()?.seconds
+ }
+}
diff --git a/core/network/src/main/kotlin/social/aloha/core/network/Endpoint.kt b/core/network/src/main/kotlin/social/aloha/core/network/Endpoint.kt
new file mode 100644
index 00000000..7a9d819a
--- /dev/null
+++ b/core/network/src/main/kotlin/social/aloha/core/network/Endpoint.kt
@@ -0,0 +1,91 @@
+// SPDX-FileCopyrightText: 2026 Aloha Social contributors
+// SPDX-License-Identifier: MIT
+
+package social.aloha.core.network
+
+import java.io.File
+
+public enum class HttpMethod { GET, POST, PUT, PATCH, DELETE }
+
+/** Which credential a route takes. */
+public enum class Authentication {
+ /** A public route: no credential is sent even when the account has one. */
+ None,
+
+ /** The Social OAuth bearer token. */
+ Bearer,
+
+ /**
+ * The Nextcloud app password as HTTP Basic, sent with `OCS-APIRequest: true`, without which
+ * Nextcloud Social answers 401 and non-OCS writes answer 412 "CSRF check failed".
+ */
+ NextcloudSession,
+
+ /** A public Nextcloud OCS route: no credential, but `OCS-APIRequest: true`, which OCS requires of every call. */
+ NextcloudPublic,
+}
+
+public data class QueryItem(val name: String, val value: String)
+
+/** A request body. */
+public sealed interface Body {
+ public data object None : Body
+
+ public data class Form(val fields: List) : Body
+
+ public data class Json(val text: String) : Body
+
+ public data class Multipart(val parts: List) : Body
+}
+
+/** One part of a multipart body; files are streamed from disk, never held in memory. */
+public sealed interface Part {
+ public val name: String
+
+ public data class Field(override val name: String, val value: String) : Part
+
+ public data class FileContent(
+ override val name: String,
+ val file: File,
+ val fileName: String,
+ val mimeType: String,
+ ) : Part
+}
+
+/**
+ * A request described relative to an account's API base. Endpoints are built only by the
+ * `*Endpoints` factory objects in `social.aloha.core.network.endpoints`, never by concatenating paths
+ * at a call site.
+ *
+ * @property path relative to the API base, without a leading slash.
+ * @property idempotencyKey sent as `Idempotency-Key`, which Nextcloud Social honours: two posts with
+ * the same key return the same status, so a write that timed out can be retried safely.
+ */
+public data class Endpoint(
+ val path: String,
+ val method: HttpMethod = HttpMethod.GET,
+ val query: List = emptyList(),
+ val body: Body = Body.None,
+ val authentication: Authentication = Authentication.Bearer,
+ val idempotencyKey: String? = null,
+) {
+ init {
+ require(!path.startsWith("/")) { "Endpoint paths are relative to the API base: $path" }
+ }
+
+ public fun adding(items: List): Endpoint = copy(query = query + items)
+}
+
+/** `name=value`, or nothing when [value] is null. */
+public fun queryOf(name: String, value: String?): List = value?.let { listOf(QueryItem(name, it)) }.orEmpty()
+
+/** Mastodon's array parameters: `types[]=mention&types[]=follow`. */
+public fun repeatedQuery(name: String, values: Collection): List =
+ values.map { QueryItem("$name[]", it) }
+
+/**
+ * A flag is sent only when true: an explicit `only_video=false` is noise, and some forks read any
+ * presence as true.
+ */
+public fun flagQuery(name: String, value: Boolean): List =
+ if (value) listOf(QueryItem(name, "true")) else emptyList()
diff --git a/core/network/src/main/kotlin/social/aloha/core/network/HttpSupport.kt b/core/network/src/main/kotlin/social/aloha/core/network/HttpSupport.kt
new file mode 100644
index 00000000..51ca7f55
--- /dev/null
+++ b/core/network/src/main/kotlin/social/aloha/core/network/HttpSupport.kt
@@ -0,0 +1,124 @@
+// SPDX-FileCopyrightText: 2026 Aloha Social contributors
+// SPDX-License-Identifier: MIT
+
+package social.aloha.core.network
+
+import java.io.IOException
+import kotlin.coroutines.resume
+import kotlinx.coroutines.suspendCancellableCoroutine
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+import okhttp3.Call
+import okhttp3.Callback
+import okhttp3.FormBody
+import okhttp3.HttpUrl
+import okhttp3.MediaType.Companion.toMediaType
+import okhttp3.MultipartBody
+import okhttp3.OkHttpClient
+import okhttp3.Request
+import okhttp3.RequestBody
+import okhttp3.RequestBody.Companion.asRequestBody
+import okhttp3.RequestBody.Companion.toRequestBody
+import okhttp3.Response
+import social.aloha.core.network.tls.UntrustedServerCertificateException
+
+/** Sends [request], suspending until the answer arrives; cancelling the coroutine cancels the call. */
+internal suspend fun OkHttpClient.send(request: Request): HttpOutcome = suspendCancellableCoroutine { continuation ->
+ val call = newCall(request)
+ continuation.invokeOnCancellation { call.cancel() }
+ call.enqueue(
+ object : Callback {
+ override fun onResponse(call: Call, response: Response) {
+ continuation.resume(HttpOutcome.Answered(response)) { _, _, _ -> response.close() }
+ }
+
+ override fun onFailure(call: Call, e: IOException) {
+ continuation.resume(HttpOutcome.Failed(e.toApiError()))
+ }
+ },
+ )
+}
+
+/**
+ * A TLS failure caused by a certificate nobody trusts becomes [ApiError.UntrustedCertificate]. OkHttp
+ * reports the last route it tried and keeps the others as suppressed exceptions, so both the cause
+ * chain and the suppressed ones are searched.
+ */
+internal fun IOException.toApiError(): ApiError =
+ findUntrusted(this, depth = 0)?.let { ApiError.UntrustedCertificate(it.host, it.chain) } ?: ApiError.Transport(this)
+
+private fun findUntrusted(throwable: Throwable?, depth: Int): UntrustedServerCertificateException? {
+ if (throwable == null || depth > MAX_CAUSE_DEPTH) return null
+ if (throwable is UntrustedServerCertificateException) return throwable
+ return findUntrusted(throwable.cause, depth + 1)
+ ?: throwable.suppressed.firstNotNullOfOrNull { findUntrusted(it, depth + 1) }
+}
+
+private const val MAX_CAUSE_DEPTH = 8
+
+internal fun Endpoint.resolve(base: HttpUrl): HttpUrl {
+ val builder = base.newBuilder()
+ if (path.isNotEmpty()) builder.addPathSegments(path)
+ query.forEach { builder.addQueryParameter(it.name, it.value) }
+ return builder.build()
+}
+
+internal fun HttpUrl.sameOrigin(other: HttpUrl): Boolean =
+ scheme == other.scheme && host == other.host && port == other.port
+
+private val OCS_ROUTES = setOf(Authentication.NextcloudSession, Authentication.NextcloudPublic)
+
+private val JSON_MEDIA = "application/json; charset=utf-8".toMediaType()
+
+internal fun Request.Builder.applyEndpoint(endpoint: Endpoint, authorization: String): Request.Builder {
+ header("Accept", "application/json")
+ if (authorization.isNotEmpty()) header("Authorization", authorization)
+ if (endpoint.authentication in OCS_ROUTES) header("OCS-APIRequest", "true")
+ endpoint.idempotencyKey?.let { header("Idempotency-Key", it) }
+ return method(endpoint.method, endpoint.body)
+}
+
+private fun Request.Builder.method(method: HttpMethod, body: Body): Request.Builder = when (method) {
+ HttpMethod.GET -> get()
+ HttpMethod.DELETE -> if (body == Body.None) delete() else delete(body.toRequestBody())
+ HttpMethod.POST -> post(body.toRequestBody())
+ HttpMethod.PUT -> put(body.toRequestBody())
+ HttpMethod.PATCH -> patch(body.toRequestBody())
+}
+
+private fun Body.toRequestBody(): RequestBody = when (this) {
+ Body.None -> ByteArray(0).toRequestBody()
+ is Body.Json -> text.toRequestBody(JSON_MEDIA)
+ is Body.Form -> formBody(fields)
+ is Body.Multipart -> MultipartBody.Builder().setType(MultipartBody.FORM).apply { parts.forEach(::addPart) }.build()
+}
+
+private fun formBody(fields: List): RequestBody {
+ val builder = FormBody.Builder()
+ fields.forEach { builder.add(it.name, it.value) }
+ return builder.build()
+}
+
+private fun MultipartBody.Builder.addPart(part: Part) {
+ when (part) {
+ is Part.Field -> addFormDataPart(part.name, part.value)
+
+ is Part.FileContent ->
+ addFormDataPart(part.name, part.fileName, part.file.asRequestBody(part.mimeType.toMediaType()))
+ }
+}
+
+@Serializable
+private data class ErrorPayload(
+ val error: String? = null,
+ @SerialName("error_description") val errorDescription: String? = null,
+)
+
+/** Mastodon-shaped errors are `{"error": …, "error_description": …}`; anything else has no message. */
+internal fun errorMessage(body: String): String? = try {
+ AlohaJson.decodeFromString(ErrorPayload.serializer(), body).let { it.errorDescription ?: it.error }
+} catch (_: kotlinx.serialization.SerializationException) {
+ null
+} catch (_: IllegalArgumentException) {
+ null
+}
diff --git a/core/network/src/main/kotlin/social/aloha/core/network/LinkHeader.kt b/core/network/src/main/kotlin/social/aloha/core/network/LinkHeader.kt
new file mode 100644
index 00000000..2e6988fe
--- /dev/null
+++ b/core/network/src/main/kotlin/social/aloha/core/network/LinkHeader.kt
@@ -0,0 +1,74 @@
+// SPDX-FileCopyrightText: 2026 Aloha Social contributors
+// SPDX-License-Identifier: MIT
+
+package social.aloha.core.network
+
+import okhttp3.HttpUrl
+import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
+
+/**
+ * The `Link` header, the only cursor the app reads. Every filter of the original request survives
+ * into both links, so a page is fetched by following the URL verbatim, never by rebuilding it.
+ * Nextcloud Social points the links at the app path even when the request came in through the root
+ * rules.
+ */
+public data class LinkHeader(val next: HttpUrl? = null, val previous: HttpUrl? = null) {
+ val isEmpty: Boolean get() = next == null && previous == null
+
+ public companion object {
+ public fun parse(raw: String?): LinkHeader {
+ if (raw.isNullOrBlank()) return LinkHeader()
+ val links = splitEntries(raw).mapNotNull(::link).toMap()
+ return LinkHeader(next = links["next"], previous = links["prev"] ?: links["previous"])
+ }
+
+ /** One `; rel="x"` entry as relation to URL, or null when it is malformed. */
+ private fun link(entry: String): Pair? {
+ val match = ENTRY.find(entry) ?: return null
+ val url = match.groupValues[1].trim().toHttpUrlOrNull() ?: return null
+ return relation(match.groupValues[2])?.let { it to url }
+ }
+
+ private val ENTRY = Regex("<([^>]*)>(.*)")
+
+ /** Entries are comma-separated, but a URL may contain a comma, so only commas outside `<…>` split. */
+ private fun splitEntries(raw: String): List {
+ val entries = mutableListOf()
+ val current = StringBuilder()
+ var insideBrackets = false
+ for (character in raw) {
+ when {
+ character == '<' -> insideBrackets = true
+ character == '>' -> insideBrackets = false
+ }
+ if (character == ',' && !insideBrackets) {
+ entries += current.toString()
+ current.clear()
+ } else {
+ current.append(character)
+ }
+ }
+ if (current.isNotBlank()) entries += current.toString()
+ return entries
+ }
+
+ private fun relation(parameters: String): String? = parameters.split(';')
+ .map { it.trim() }
+ .firstOrNull { it.lowercase().startsWith("rel") && it.contains('=') }
+ ?.substringAfter('=')
+ ?.trim()
+ ?.trim('"', '\'')
+ ?.lowercase()
+ }
+}
+
+/**
+ * A decoded page and its cursors.
+ *
+ * @property rawCount how many rows the server sent before lossy decoding dropped any, which is what
+ * decides "a page shorter than the limit is the last one" on routes that send no `Link` header.
+ */
+public data class Paginated(val items: List, val link: LinkHeader, val rawCount: Int, val requestedLimit: Int) {
+ /** Trust the header first; fall back to the count only where the server sends no header (`/blocks`, `/mutes`). */
+ val mayHaveMore: Boolean get() = if (!link.isEmpty) link.next != null else rawCount >= requestedLimit
+}
diff --git a/core/network/src/main/kotlin/social/aloha/core/network/RateLimiter.kt b/core/network/src/main/kotlin/social/aloha/core/network/RateLimiter.kt
new file mode 100644
index 00000000..d3c968f1
--- /dev/null
+++ b/core/network/src/main/kotlin/social/aloha/core/network/RateLimiter.kt
@@ -0,0 +1,97 @@
+// SPDX-FileCopyrightText: 2026 Aloha Social contributors
+// SPDX-License-Identifier: MIT
+
+package social.aloha.core.network
+
+import kotlin.math.min
+import kotlin.random.Random
+import kotlin.time.Duration
+import kotlin.time.Duration.Companion.milliseconds
+import kotlin.time.Duration.Companion.minutes
+import kotlin.time.Duration.Companion.seconds
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+
+/**
+ * A token bucket per host, shared by every account on that host, so five accounts on one small
+ * instance behave like one. A 429 blocks the host until `Retry-After`, or 20–60 s when the server
+ * sent none.
+ *
+ * @param nowMillis the clock in milliseconds, injected so tests run on virtual time.
+ */
+public class RateLimiter(
+ private val nowMillis: () -> Long,
+ private val capacity: Double = DEFAULT_CAPACITY,
+ private val refillPerSecond: Double = DEFAULT_REFILL_PER_SECOND,
+ private val random: Random = Random.Default,
+) {
+ private class Bucket(var tokens: Double, var lastRefillMillis: Long, var blockedUntilMillis: Long = 0)
+
+ private val mutex = Mutex()
+ private val buckets = mutableMapOf()
+
+ /** Suspends until [host] accepts another request. */
+ public suspend fun acquire(host: String) {
+ while (true) {
+ val wait = mutex.withLock { reserve(host) }
+ if (wait <= Duration.ZERO) return
+ delay(minOf(wait, MAXIMUM_SINGLE_WAIT))
+ }
+ }
+
+ private fun reserve(host: String): Duration {
+ val now = nowMillis()
+ val bucket = buckets.getOrPut(host) { Bucket(capacity, now) }
+ if (bucket.blockedUntilMillis > now) return (bucket.blockedUntilMillis - now).milliseconds
+ val elapsedSeconds = (now - bucket.lastRefillMillis) / MILLIS_PER_SECOND
+ bucket.tokens = min(capacity, bucket.tokens + elapsedSeconds * refillPerSecond)
+ bucket.lastRefillMillis = now
+ val available = bucket.tokens >= 1
+ if (available) bucket.tokens -= 1
+ val refillMillis = ((1 - bucket.tokens) / refillPerSecond * MILLIS_PER_SECOND).toLong()
+ return if (available) Duration.ZERO else refillMillis.milliseconds
+ }
+
+ /** Records a 429 from [host]. */
+ public suspend fun noteRateLimited(host: String, retryAfter: Duration?) {
+ val delay =
+ retryAfter
+ ?: random.nextLong(FALLBACK_MIN.inWholeMilliseconds, FALLBACK_MAX.inWholeMilliseconds).milliseconds
+ mutex.withLock {
+ val now = nowMillis()
+ val bucket = buckets.getOrPut(host) { Bucket(0.0, now) }
+ bucket.tokens = 0.0
+ bucket.lastRefillMillis = now
+ bucket.blockedUntilMillis = now + delay.inWholeMilliseconds
+ }
+ }
+
+ public suspend fun isBlocked(host: String): Boolean =
+ mutex.withLock { (buckets[host]?.blockedUntilMillis ?: 0) > nowMillis() }
+
+ private companion object {
+ const val DEFAULT_CAPACITY = 30.0
+ const val DEFAULT_REFILL_PER_SECOND = 3.0
+ const val MILLIS_PER_SECOND = 1000.0
+ val MAXIMUM_SINGLE_WAIT = 1.minutes
+ val FALLBACK_MIN = 20.seconds
+ val FALLBACK_MAX = 60.seconds
+ }
+}
+
+/** Exponential backoff with full jitter, capped: retries and the poll scheduler's failure path. */
+public object Backoff {
+ public fun delay(
+ attempt: Int,
+ base: Duration = 1.seconds,
+ cap: Duration = 15.minutes,
+ random: Random = Random.Default,
+ ): Duration {
+ val exponent = attempt.coerceIn(0, MAXIMUM_EXPONENT)
+ val ceiling = minOf(cap, base * (1L shl exponent).toDouble())
+ return random.nextLong(0, ceiling.inWholeMilliseconds + 1).milliseconds
+ }
+
+ private const val MAXIMUM_EXPONENT = 30
+}
diff --git a/core/network/src/main/kotlin/social/aloha/core/network/RequestExecutor.kt b/core/network/src/main/kotlin/social/aloha/core/network/RequestExecutor.kt
new file mode 100644
index 00000000..bf9a48af
--- /dev/null
+++ b/core/network/src/main/kotlin/social/aloha/core/network/RequestExecutor.kt
@@ -0,0 +1,96 @@
+// SPDX-FileCopyrightText: 2026 Aloha Social contributors
+// SPDX-License-Identifier: MIT
+
+package social.aloha.core.network
+
+import kotlin.time.Duration.Companion.seconds
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.withContext
+import kotlinx.serialization.SerializationException
+import okhttp3.HttpUrl
+import okhttp3.OkHttpClient
+import okhttp3.Request
+import okhttp3.Response
+
+internal sealed interface HttpOutcome {
+ class Answered(val response: Response) : HttpOutcome
+
+ class Failed(val error: ApiError) : HttpOutcome
+}
+
+/** A decoded answer and the cursor that came with it. */
+internal class Answer(val decoded: Decoded, val link: LinkHeader)
+
+/**
+ * Sends one request and turns the answer into an [ApiResult]: the rate limiter first, then the
+ * status mapped to an [ApiError], then the body decoded off the caller's thread. Shared by the
+ * per-account client, OAuth, the probe and capability detection so all of them fail the same way.
+ */
+internal class RequestExecutor(
+ private val http: OkHttpClient,
+ private val rateLimiter: RateLimiter,
+ private val ioDispatcher: CoroutineDispatcher,
+ private val failureListener: DecodingFailureListener = DecodingFailureListener { _, _ -> },
+) {
+ /** A copy that gives up after [seconds], for probes that must not wait out the default timeouts. */
+ fun withCallTimeout(seconds: Long): RequestExecutor = RequestExecutor(
+ http.newBuilder().callTimeout(seconds, java.util.concurrent.TimeUnit.SECONDS).build(),
+ rateLimiter,
+ ioDispatcher,
+ failureListener,
+ )
+
+ /**
+ * [authorization] is the `Authorization` header value, empty for none. A read answered 429 with a
+ * short `Retry-After` is retried once, after the rate limiter has waited it out; a write never is.
+ */
+ suspend fun execute(request: ApiRequest, url: HttpUrl, authorization: String): ApiResult> {
+ if (request.endpoint.hasDotSegment()) return ApiResult.Failure(ApiError.UnsafePath(request.endpoint.path))
+ val first = send(request, url, authorization)
+ val limited = (first as? ApiResult.Failure)?.error as? ApiError.RateLimited
+ val retry = limited != null && request.endpoint.method == HttpMethod.GET &&
+ (limited.retryAfter ?: MAX_RETRY_WAIT) <= MAX_RETRY_WAIT
+ return if (retry) send(request, url, authorization) else first
+ }
+
+ private suspend fun send(request: ApiRequest, url: HttpUrl, authorization: String): ApiResult> {
+ rateLimiter.acquire(url.host)
+ val call = Request.Builder().url(url).applyEndpoint(request.endpoint, authorization).build()
+ return when (val sent = http.send(call)) {
+ is HttpOutcome.Failed -> ApiResult.Failure(sent.error)
+ is HttpOutcome.Answered -> sent.response.use { response -> answer(request, response, url) }
+ }
+ }
+
+ private suspend fun answer(request: ApiRequest, response: Response, url: HttpUrl): ApiResult> {
+ val body = withContext(ioDispatcher) { response.body.string() }
+ if (response.isSuccessful) return decode(request, body, LinkHeader.parse(response.header("Link")))
+ val error =
+ ApiError.from(response.code, errorMessage(body), body, response::header)
+ ?: ApiError.Server(response.code, body)
+ if (error is ApiError.RateLimited) rateLimiter.noteRateLimited(url.host, error.retryAfter)
+ return ApiResult.Failure(error)
+ }
+
+ private suspend fun decode(request: ApiRequest, body: String, link: LinkHeader): ApiResult> = try {
+ val decoded = withContext(ioDispatcher) { request.decoder.decode(AlohaJson, body.ifBlank { "{}" }) }
+ if (decoded.failures.isNotEmpty()) failureListener.onDropped(request.endpoint.path, decoded.failures)
+ ApiResult.Success(Answer(decoded, link))
+ } catch (e: SerializationException) {
+ ApiResult.Failure(ApiError.Decoding(request.endpoint.path, e.message.orEmpty()))
+ } catch (e: IllegalArgumentException) {
+ ApiResult.Failure(ApiError.Decoding(request.endpoint.path, e.message.orEmpty()))
+ }
+
+ private companion object {
+ val MAX_RETRY_WAIT = 5.seconds
+ val DOT_SEGMENTS = setOf(".", "..", "%2e", "%2e.", ".%2e", "%2e%2e")
+
+ /**
+ * A path segment URL resolution would treat as `.` or `..`, in plain or percent-encoded form.
+ * Ids and names in paths come from servers; one such segment would move a request carrying the
+ * account's token to another route, so it is refused before anything is sent.
+ */
+ fun Endpoint.hasDotSegment(): Boolean = path.split('/').any { it.lowercase() in DOT_SEGMENTS }
+ }
+}
diff --git a/core/network/src/main/kotlin/social/aloha/core/network/capabilities/CapabilityDetector.kt b/core/network/src/main/kotlin/social/aloha/core/network/capabilities/CapabilityDetector.kt
new file mode 100644
index 00000000..f84c3550
--- /dev/null
+++ b/core/network/src/main/kotlin/social/aloha/core/network/capabilities/CapabilityDetector.kt
@@ -0,0 +1,226 @@
+// SPDX-FileCopyrightText: 2026 Aloha Social contributors
+// SPDX-License-Identifier: MIT
+
+package social.aloha.core.network.capabilities
+
+import java.time.Instant
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.async
+import kotlinx.coroutines.coroutineScope
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+import okhttp3.HttpUrl
+import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
+import okhttp3.OkHttpClient
+import social.aloha.core.model.AccessToken
+import social.aloha.core.model.InstanceDescription
+import social.aloha.core.model.NextcloudTheme
+import social.aloha.core.model.NodeInfo
+import social.aloha.core.model.ServerCapabilities
+import social.aloha.core.network.ApiError
+import social.aloha.core.network.ApiRequest
+import social.aloha.core.network.ApiResult
+import social.aloha.core.network.Authentication
+import social.aloha.core.network.Endpoint
+import social.aloha.core.network.QueryItem
+import social.aloha.core.network.RateLimiter
+import social.aloha.core.network.RequestExecutor
+import social.aloha.core.network.decoding.OptionalBoolSerializer
+import social.aloha.core.network.dto.OcsCapabilitiesDto
+import social.aloha.core.network.dto.toDomain
+import social.aloha.core.network.endpoints.InstanceEndpoints
+import social.aloha.core.network.request
+import social.aloha.core.network.resolve
+import social.aloha.core.network.unitRequest
+
+/**
+ * Works out what one server can do, at sign-in and every 24 hours after. A capability that cannot be
+ * determined is absent: every probe tolerates failure, and failure means false.
+ *
+ * A route counts as present when it answers 2xx, 401 or 403 (it exists, the token lacks a scope) and
+ * as absent on 404. `only_video` and `only_news` are Nextcloud Social's own and are trusted only
+ * when NodeInfo names Nextcloud Social: Mastodon ignores unknown parameters, so their acceptance
+ * proves nothing there. Pixelfed's `/api/v2/config` `features`, which Nextcloud Social serves
+ * publicly, is taken at its word where it speaks. `hls_url`, reactions and quotes have nothing
+ * announcing them and are latched later, on first sighting in an entity.
+ */
+public class CapabilityDetector internal constructor(executor: RequestExecutor, private val now: () -> Instant) {
+ public constructor(
+ http: OkHttpClient,
+ rateLimiter: RateLimiter,
+ ioDispatcher: CoroutineDispatcher,
+ now: () -> Instant,
+ ) :
+ this(RequestExecutor(http, rateLimiter, ioDispatcher), now)
+
+ private val executor = executor.withCallTimeout(PROBE_SECONDS)
+
+ public suspend fun detect(
+ apiBase: HttpUrl,
+ token: AccessToken?,
+ instance: InstanceDescription,
+ nodeInfo: NodeInfo?,
+ ): ServerCapabilities {
+ val nextcloud = nodeInfo?.isNextcloudSocial == true
+ val found = findings(apiBase, token, nextcloud)
+ val announcesFourThree = (instance.mastodonApiVersion ?: 0) >= MASTODON_4_3_API ||
+ listOf("4.3", "4.4", "4.5").any { instance.version.contains(it) }
+ return ServerCapabilities(
+ apiBase = apiBase.toString(),
+ softwareName = nodeInfo?.softwareName?.lowercase().orEmpty(),
+ softwareVersion = nodeInfo?.softwareVersion ?: instance.version,
+ mastodonApiVersion = instance.mastodonApiVersion,
+ streamingUrl = instance.streamingUrl,
+ webPushVapidKey = instance.vapidKey.takeIf { instance.hasWebPush },
+ groupedNotifications = announcesFourThree && found.grouped,
+ notificationPolicy = found.policy,
+ filtersV2 = found.filters,
+ editHistory = announcesFourThree,
+ translation = instance.translationEnabled,
+ translationLanguages = found.languages,
+ onlyMediaFilter = found.onlyMedia,
+ onlyVideoFilter = found.onlyVideo,
+ onlyNewsFilter = found.onlyNews,
+ watchPositions = found.watch,
+ stories = found.stories,
+ collections = found.collections,
+ mediaFromNextcloudFiles = found.fromFile,
+ preferencesWrite = nextcloud,
+ limits = instance.limits,
+ theme = found.theme,
+ detectedAt = now(),
+ )
+ }
+
+ private data class Findings(
+ val grouped: Boolean,
+ val policy: Boolean,
+ val filters: Boolean,
+ val watch: Boolean,
+ val stories: Boolean,
+ val collections: Boolean,
+ val fromFile: Boolean,
+ val onlyMedia: Boolean,
+ val onlyVideo: Boolean,
+ val onlyNews: Boolean,
+ val languages: Map>,
+ val theme: NextcloudTheme?,
+ )
+
+ /** Every probe at once; the server's Pixelfed `features` answer wins over a probe where it speaks. */
+ private suspend fun findings(apiBase: HttpUrl, token: AccessToken?, nextcloud: Boolean): Findings = coroutineScope {
+ val probes = Probes(executor, apiBase, token?.let { "Bearer ${it.value}" }.orEmpty())
+ val grouped = async { probes.exists("api/v2/notifications", LIMIT_ONE) }
+ val policy = async { probes.exists("api/v2/notifications/policy") }
+ val filters = async { probes.exists("api/v2/filters") }
+ val watch = async { probes.exists("api/v1/videos/continue", LIMIT_ONE) }
+ val stories = async { probes.exists("api/v1/stories/carousel") }
+ val collections = async { probes.exists("api/v1/collections") }
+ val fromFile = async { probes.exists("api/v1/media/from-file") }
+ val features = async { probes.pixelfedFeatures() }
+ val languages = async { probes.translationLanguages() }
+ val theme = async { theme(ServerCapabilities.minimal(apiBase.toString()).nextcloudRoot) }
+ val video = async { nextcloud && probes.acceptsNarrowing("only_video") }
+ val news = async { nextcloud && probes.acceptsNarrowing("only_news") }
+ val media = async { nextcloud || probes.acceptsNarrowing("only_media") }
+ Findings(
+ grouped = grouped.await(),
+ policy = policy.await(),
+ filters = filters.await(),
+ watch = watch.await(),
+ stories = features.await()?.stories ?: stories.await(),
+ collections = features.await()?.collections ?: collections.await(),
+ fromFile = fromFile.await(),
+ onlyMedia = media.await(),
+ onlyVideo = video.await(),
+ onlyNews = news.await(),
+ languages = languages.await(),
+ theme = theme.await(),
+ )
+ }
+
+ /**
+ * The colour a Nextcloud wears, from its public `theming` capability at the Nextcloud root (not
+ * the API base). Null on Mastodon and on a Nextcloud with the Theming app disabled.
+ */
+ public suspend fun theme(nextcloudRoot: String): NextcloudTheme? {
+ val root = nextcloudRoot.toHttpUrlOrNull() ?: return null
+ val url = root.newBuilder().addPathSegments(
+ "ocs/v2.php/cloud/capabilities",
+ ).addQueryParameter("format", "json").build()
+ val request =
+ request(
+ Endpoint("ocs/v2.php/cloud/capabilities", authentication = Authentication.NextcloudPublic),
+ OcsCapabilitiesDto.serializer(),
+ ) {
+ it.ocs?.data?.capabilities?.theming?.toDomain()
+ }
+ return (executor.execute(request, url, authorization = "") as? ApiResult.Success)?.value?.decoded?.value
+ ?.takeIf { it.hasColour }
+ }
+
+ private companion object {
+ const val PROBE_SECONDS = 8L
+ const val MASTODON_4_3_API = 3
+ val LIMIT_ONE = listOf(QueryItem("limit", "1"))
+ }
+}
+
+/** The individual probes, each answering true or false and never failing. */
+private class Probes(private val executor: RequestExecutor, private val apiBase: HttpUrl, private val bearer: String) {
+ suspend fun exists(path: String, query: List = emptyList()): Boolean {
+ val request = unitRequest(Endpoint(path, query = query))
+ return when (val result = executor.execute(request, request.endpoint.resolve(apiBase), bearer)) {
+ is ApiResult.Success -> true
+ is ApiResult.Failure -> result.error.provesRouteExists()
+ }
+ }
+
+ /** Nextcloud Social answers 422 to a narrowing it does not accept; 2xx means it was taken. */
+ suspend fun acceptsNarrowing(parameter: String): Boolean {
+ val request = unitRequest(
+ Endpoint("api/v1/timelines/public", query = listOf(QueryItem("limit", "1"), QueryItem(parameter, "true"))),
+ )
+ return executor.execute(request, request.endpoint.resolve(apiBase), bearer) is ApiResult.Success
+ }
+
+ suspend fun translationLanguages(): Map> =
+ fetch(InstanceEndpoints.translationLanguages()).orEmpty()
+
+ suspend fun pixelfedFeatures(): PixelfedFeaturesDto.Features? = fetch(
+ request(Endpoint("api/v2/config", authentication = Authentication.None), PixelfedFeaturesDto.serializer()) {
+ it.features
+ },
+ )
+
+ private suspend fun fetch(request: ApiRequest): T? = (
+ executor.execute(
+ request,
+ request.endpoint.resolve(apiBase),
+ bearer,
+ ) as? ApiResult.Success
+ )?.value?.decoded?.value
+}
+
+/**
+ * 401 and 403 prove the route exists; the token only lacked a scope. A 405 on a POST-only route
+ * proves it too. Everything else, 404 included, counts as absent.
+ */
+private fun ApiError.provesRouteExists(): Boolean = when (this) {
+ is ApiError.Unauthorised, is ApiError.Forbidden -> true
+ is ApiError.Server -> status == METHOD_NOT_ALLOWED
+ else -> false
+}
+
+private const val METHOD_NOT_ALLOWED = 405
+
+@Serializable
+internal data class PixelfedFeaturesDto(val features: Features? = null) {
+ @Serializable
+ data class Features(
+ @Serializable(with = OptionalBoolSerializer::class) val stories: Boolean? = null,
+ @Serializable(with = OptionalBoolSerializer::class) val collections: Boolean? = null,
+ @SerialName("stories_reactions") @Serializable(with = OptionalBoolSerializer::class)
+ val storiesReactions: Boolean? = null,
+ )
+}
diff --git a/core/network/src/main/kotlin/social/aloha/core/network/decoding/DecodeOnly.kt b/core/network/src/main/kotlin/social/aloha/core/network/decoding/DecodeOnly.kt
new file mode 100644
index 00000000..e4286acb
--- /dev/null
+++ b/core/network/src/main/kotlin/social/aloha/core/network/decoding/DecodeOnly.kt
@@ -0,0 +1,24 @@
+// SPDX-FileCopyrightText: 2026 Aloha Social contributors
+// SPDX-License-Identifier: MIT
+
+package social.aloha.core.network.decoding
+
+import kotlinx.serialization.SerializationException
+import kotlinx.serialization.encoding.Decoder
+import kotlinx.serialization.json.JsonDecoder
+import kotlinx.serialization.json.JsonElement
+import kotlinx.serialization.json.JsonNull
+import kotlinx.serialization.json.JsonPrimitive
+
+/*
+ * The shared pieces of the lenient serializers: they read the raw JSON element and decide, and they
+ * never encode, because wire DTOs only travel from the server to the app.
+ */
+
+internal fun Decoder.jsonDecoder(): JsonDecoder = this as? JsonDecoder ?: throw SerializationException("JSON only")
+
+internal fun Decoder.jsonElement(): JsonElement = jsonDecoder().decodeJsonElement()
+
+internal fun JsonElement.primitiveOrNull(): JsonPrimitive? = (this as? JsonPrimitive)?.takeUnless { it is JsonNull }
+
+internal fun decodeOnly(): Nothing = throw SerializationException("wire DTOs are decode-only")
diff --git a/core/network/src/main/kotlin/social/aloha/core/network/decoding/Envelopes.kt b/core/network/src/main/kotlin/social/aloha/core/network/decoding/Envelopes.kt
new file mode 100644
index 00000000..10f441af
--- /dev/null
+++ b/core/network/src/main/kotlin/social/aloha/core/network/decoding/Envelopes.kt
@@ -0,0 +1,82 @@
+// SPDX-FileCopyrightText: 2026 Aloha Social contributors
+// SPDX-License-Identifier: MIT
+
+package social.aloha.core.network.decoding
+
+import kotlinx.serialization.KSerializer
+import kotlinx.serialization.SerializationException
+import kotlinx.serialization.builtins.ListSerializer
+import kotlinx.serialization.builtins.MapSerializer
+import kotlinx.serialization.builtins.serializer
+import kotlinx.serialization.descriptors.SerialDescriptor
+import kotlinx.serialization.encoding.Decoder
+import kotlinx.serialization.encoding.Encoder
+import kotlinx.serialization.json.Json
+import kotlinx.serialization.json.JsonArray
+import kotlinx.serialization.json.JsonDecoder
+import kotlinx.serialization.json.JsonElement
+import kotlinx.serialization.json.JsonNull
+import kotlinx.serialization.json.JsonObject
+import kotlinx.serialization.json.JsonPrimitive
+import kotlinx.serialization.json.booleanOrNull
+import kotlinx.serialization.json.doubleOrNull
+
+/** Every element of [array] that decodes; the ones that fail are recorded, as a lossy list does. */
+internal fun Json.lossy(element: KSerializer, array: JsonElement?): List =
+ (array as? JsonArray)?.mapIndexedNotNull { index, item -> decodeOrRecord(element, item, index) }.orEmpty()
+
+/**
+ * A list sent either bare or wrapped in an object under [member] (`{"reactions": […]}`,
+ * `{"channels": […]}`). Nextcloud Social's own routes wrap; Pixelfed's and some forks' do not.
+ * Anything else is empty.
+ */
+internal class ListOrMemberSerializer(private val element: KSerializer, private val member: String) :
+ KSerializer> {
+ override val descriptor: SerialDescriptor = ListSerializer(element).descriptor
+
+ override fun deserialize(decoder: Decoder): List {
+ val json = decoder.jsonDecoder()
+ return when (val root = json.decodeJsonElement()) {
+ is JsonArray -> json.json.lossy(element, root)
+ is JsonObject -> json.json.lossy(element, root[member])
+ else -> emptyList()
+ }
+ }
+
+ override fun serialize(encoder: Encoder, value: List): Unit = decodeOnly()
+}
+
+/** A list of strings sent as an array or as one space-separated string, as OAuth scopes are. */
+internal object StringListSerializer : KSerializer> {
+ override val descriptor: SerialDescriptor = ListSerializer(String.serializer()).descriptor
+
+ override fun deserialize(decoder: Decoder): List =
+ when (val element = decoder.jsonDecoder().decodeJsonElement()) {
+ is JsonArray -> element.mapNotNull { it.primitiveOrNull()?.content }
+ is JsonPrimitive -> element.primitiveOrNull()?.content?.split(' ')?.filter { it.isNotEmpty() }.orEmpty()
+ else -> emptyList()
+ }
+
+ override fun serialize(encoder: Encoder, value: List): Unit = decodeOnly()
+}
+
+/**
+ * `{"2026-01": 12, "2026-02": "7", "public": true}`: a map of numbers whose values may arrive as
+ * strings or booleans. A value that is none of them is left out.
+ */
+internal object NumberMapSerializer : KSerializer