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> { + override val descriptor: SerialDescriptor = MapSerializer(String.serializer(), Double.serializer()).descriptor + + override fun deserialize(decoder: Decoder): Map { + val root = decoder.jsonDecoder().decodeJsonElement() as? JsonObject ?: return emptyMap() + return root.mapNotNull { (key, value) -> value.asNumber()?.let { key to it } }.toMap() + } + + private fun JsonElement.asNumber(): Double? { + val primitive = (this as? JsonPrimitive)?.takeUnless { it is JsonNull } ?: return null + if (primitive.isString) return primitive.content.toDoubleOrNull() + return primitive.doubleOrNull ?: primitive.booleanOrNull?.let { if (it) 1.0 else 0.0 } + } + + override fun serialize(encoder: Encoder, value: Map): Unit = decodeOnly() +} diff --git a/core/network/src/main/kotlin/social/aloha/core/network/decoding/Lenient.kt b/core/network/src/main/kotlin/social/aloha/core/network/decoding/Lenient.kt new file mode 100644 index 00000000..0f140f5f --- /dev/null +++ b/core/network/src/main/kotlin/social/aloha/core/network/decoding/Lenient.kt @@ -0,0 +1,181 @@ +// SPDX-FileCopyrightText: 2026 Aloha Social contributors +// SPDX-License-Identifier: MIT + +package social.aloha.core.network.decoding + +import java.time.Instant +import java.time.LocalDate +import java.time.OffsetDateTime +import java.time.ZoneOffset +import java.time.format.DateTimeParseException +import kotlinx.serialization.KSerializer +import kotlinx.serialization.SerializationException +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.descriptors.nullable +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.JsonDecoder +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.booleanOrNull +import kotlinx.serialization.json.doubleOrNull +import kotlinx.serialization.json.longOrNull + +/* + * Lenient decoders for the shapes the fediverse actually sends. Every wire DTO field that a real + * server has been seen to send in more than one shape uses one of these, so one odd field never + * fails an entity. They decode only; DTOs are never encoded. + */ + +/** + * A server id, always a string: Nextcloud Social sends numeric `nid`s where Mastodon sends snowflake + * strings, and ids beyond 2^53 must never pass through a number type. + */ +internal object FlexibleIdSerializer : KSerializer { + override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("FlexibleId", PrimitiveKind.STRING) + + override fun deserialize(decoder: Decoder): String { + val primitive = decoder.jsonElement().primitiveOrNull() ?: throw SerializationException("id was null") + return if (primitive.isString) primitive.content else primitive.longOrNull?.toString() ?: primitive.content + } + + override fun serialize(encoder: Encoder, value: String): Unit = decodeOnly() +} + +/** An optional id: a number becomes its decimal string, `""` and `null` become absent. */ +internal object OptionalIdSerializer : KSerializer { + override val descriptor: SerialDescriptor = + PrimitiveSerialDescriptor("OptionalId", PrimitiveKind.STRING).nullable + + override fun deserialize(decoder: Decoder): String? { + val primitive = decoder.jsonElement().primitiveOrNull() ?: return null + val text = if (primitive.isString) primitive.content else primitive.longOrNull?.toString() + return text?.takeIf { it.isNotEmpty() } + } + + override fun serialize(encoder: Encoder, value: String?): Unit = decodeOnly() +} + +/** + * A URL that may be `null`, absent, `""` or whitespace, all meaning "none": Nextcloud Social sends + * `"avatar": ""` for a local account and `null` from `verify_credentials`. + */ +internal object LenientUrlSerializer : KSerializer { + override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("LenientUrl", PrimitiveKind.STRING).nullable + + override fun deserialize(decoder: Decoder): String? { + val primitive = decoder.jsonElement().primitiveOrNull()?.takeIf { it.isString } ?: return null + return primitive.content.trim().takeIf { it.isNotEmpty() } + } + + override fun serialize(encoder: Encoder, value: String?): Unit = decodeOnly() +} + +/** A boolean that also accepts `0`/`1` and `"true"`/`"false"`/`"yes"`; anything else is false. */ +internal object LenientBoolSerializer : KSerializer { + override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("LenientBool", PrimitiveKind.BOOLEAN) + + override fun deserialize(decoder: Decoder): Boolean = + decoder.jsonElement().primitiveOrNull()?.toLenientBoolean() ?: false + + override fun serialize(encoder: Encoder, value: Boolean): Unit = decodeOnly() +} + +/** An optional boolean with the same leniency; `null` and absent stay absent. */ +internal object OptionalBoolSerializer : KSerializer { + override val descriptor: SerialDescriptor = + PrimitiveSerialDescriptor("OptionalBool", PrimitiveKind.BOOLEAN).nullable + + override fun deserialize(decoder: Decoder): Boolean? = decoder.jsonElement().primitiveOrNull()?.toLenientBoolean() + + override fun serialize(encoder: Encoder, value: Boolean?): Unit = decodeOnly() +} + +/** An integer that also accepts a numeric string or a float; anything else is 0. */ +internal object LenientIntSerializer : KSerializer { + override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("LenientInt", PrimitiveKind.INT) + + override fun deserialize(decoder: Decoder): Int = decoder.jsonElement().primitiveOrNull()?.toIntOrNull() ?: 0 + + override fun serialize(encoder: Encoder, value: Int): Unit = decodeOnly() +} + +/** An optional integer with the same leniency. */ +internal object OptionalIntSerializer : KSerializer { + override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("OptionalInt", PrimitiveKind.INT).nullable + + override fun deserialize(decoder: Decoder): Int? = decoder.jsonElement().primitiveOrNull()?.toIntOrNull() + + override fun serialize(encoder: Encoder, value: Int?): Unit = decodeOnly() +} + +/** An optional double that also accepts a numeric string. */ +internal object OptionalDoubleSerializer : KSerializer { + override val descriptor: SerialDescriptor = + PrimitiveSerialDescriptor("OptionalDouble", PrimitiveKind.DOUBLE).nullable + + override fun deserialize(decoder: Decoder): Double? { + val primitive = decoder.jsonElement().primitiveOrNull() ?: return null + return primitive.doubleOrNull ?: primitive.content.toDoubleOrNull() + } + + override fun serialize(encoder: Encoder, value: Double?): Unit = decodeOnly() +} + +private val truthyText = setOf("true", "1", "yes") + +/** A boolean from `true`/`false`, a number (non-zero is true) or text; null for a number-like value that is neither. */ +private fun JsonPrimitive.toLenientBoolean(): Boolean? = + if (isString) content.lowercase() in truthyText else booleanOrNull ?: longOrNull?.let { it != 0L } + +private fun JsonPrimitive.toIntOrNull(): Int? = + longOrNull?.toInt() ?: doubleOrNull?.toInt() ?: content.toLongOrNull()?.toInt() + +/** + * A timestamp in any form a server sends: ISO 8601 with or without fractional seconds or offset, a + * bare `yyyy-MM-dd` (filter expiry, announcement dates), or Unix seconds as a number or a string + * (`/api/v1/instance/activity`). Unreadable values are absent rather than failing the entity. + */ +internal object LenientInstantSerializer : KSerializer { + override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor( + "LenientInstant", + PrimitiveKind.STRING, + ).nullable + + override fun deserialize(decoder: Decoder): Instant? { + val primitive = decoder.jsonElement().primitiveOrNull() ?: return null + return if (primitive.isString) { + parseInstant( + primitive.content, + ) + } else { + primitive.longOrNull?.let(Instant::ofEpochSecond) + } + } + + override fun serialize(encoder: Encoder, value: Instant?): Unit = decodeOnly() +} + +internal fun parseInstant(raw: String): Instant? { + val text = raw.trim() + val seconds = text.toLongOrNull() + return when { + text.isEmpty() -> null + + seconds != null -> Instant.ofEpochSecond(seconds) + + else -> tryParse { OffsetDateTime.parse(text).toInstant() } + ?: tryParse { Instant.parse(text) } + ?: tryParse { java.time.LocalDateTime.parse(text).toInstant(ZoneOffset.UTC) } + ?: tryParse { LocalDate.parse(text).atStartOfDay(ZoneOffset.UTC).toInstant() } + } +} + +private inline fun tryParse(block: () -> Instant): Instant? = try { + block() +} catch (_: DateTimeParseException) { + null +} diff --git a/core/network/src/main/kotlin/social/aloha/core/network/decoding/Lossy.kt b/core/network/src/main/kotlin/social/aloha/core/network/decoding/Lossy.kt new file mode 100644 index 00000000..6f9098bf --- /dev/null +++ b/core/network/src/main/kotlin/social/aloha/core/network/decoding/Lossy.kt @@ -0,0 +1,88 @@ +// 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.descriptors.SerialDescriptor +import kotlinx.serialization.descriptors.nullable +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 social.aloha.core.network.DecodingFailure + +/** + * Collects what lossy decoding dropped while one response body is decoded. Decoding is synchronous + * and runs on one thread, so a thread-local collector sees exactly the failures of that body. + */ +internal object DecodingFailures { + private val current = ThreadLocal?>() + + fun collect(block: () -> T): Pair> { + val previous = current.get() + val failures = mutableListOf() + current.set(failures) + try { + return block() to failures.toList() + } finally { + current.set(previous) + } + } + + fun record(failure: DecodingFailure) { + current.get()?.add(failure) + } +} + +/** + * Decodes one element of a lossy array, or records why it could not and returns null. A malformed + * entity never fails its page: a timeline page with one bad status shows the other nineteen. + */ +internal fun Json.decodeOrRecord(serializer: KSerializer, element: JsonElement, index: Int): T? = try { + decodeFromJsonElement(serializer, element) +} catch (e: SerializationException) { + DecodingFailures.record(DecodingFailure(index, serializer.descriptor.serialName, e.message.orEmpty())) + null +} catch (e: IllegalArgumentException) { + DecodingFailures.record(DecodingFailure(index, serializer.descriptor.serialName, e.message.orEmpty())) + null +} + +/** A list that decodes element by element and drops, and records, the elements that fail. */ +internal class LossyListSerializer(private val element: KSerializer) : KSerializer> { + override val descriptor: SerialDescriptor = ListSerializer(element).descriptor + + override fun deserialize(decoder: Decoder): List { + val json = decoder as? JsonDecoder ?: throw SerializationException("JSON only") + val array = json.decodeJsonElement() as? JsonArray ?: return emptyList() + return array.mapIndexedNotNull { index, item -> json.json.decodeOrRecord(element, item, index) } + } + + override fun serialize(encoder: Encoder, value: List): Unit = + throw SerializationException("wire DTOs are decode-only") +} + +/** + * A nested object that is absent when it has the wrong shape, e.g. a fork that sends `[]` for an + * empty `meta` object. Declare one per DTO: `object CardOrNull : KSerializer by + * OrNullSerializer(CardDto.serializer())`. + */ +internal class OrNullSerializer(private val inner: KSerializer) : KSerializer { + override val descriptor: SerialDescriptor = inner.descriptor.nullable + + override fun deserialize(decoder: Decoder): T? { + val json = decoder as? JsonDecoder ?: throw SerializationException("JSON only") + val element = json.decodeJsonElement() + if (element is JsonNull) return null + return json.json.decodeOrRecord(inner, element, index = 0) + } + + override fun serialize(encoder: Encoder, value: T?): Unit = + throw SerializationException("wire DTOs are decode-only") +} diff --git a/core/network/src/main/kotlin/social/aloha/core/network/decoding/Shapes.kt b/core/network/src/main/kotlin/social/aloha/core/network/decoding/Shapes.kt new file mode 100644 index 00000000..6369671b --- /dev/null +++ b/core/network/src/main/kotlin/social/aloha/core/network/decoding/Shapes.kt @@ -0,0 +1,81 @@ +// 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.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.descriptors.nullable +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +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.doubleOrNull +import social.aloha.core.model.VideoChapters + +/** Optional text that also accepts a number; any other shape, and `""`, is absent. */ +internal object LenientTextSerializer : KSerializer { + override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("LenientText", PrimitiveKind.STRING).nullable + + override fun deserialize(decoder: Decoder): String? = + (decoder.jsonElement() as? JsonPrimitive)?.takeUnless { it is JsonNull }?.content?.takeIf { it.isNotEmpty() } + + override fun serialize(encoder: Encoder, value: String?): Unit = decodeOnly() +} + +/** + * PeerTube's `{id, label}` for a video's category, language and licence, which Nextcloud Social may pass + * on as that object or as the bare label. Both read as the label. + */ +internal object LabelledTextSerializer : KSerializer { + override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("LabelledText", PrimitiveKind.STRING).nullable + + override fun deserialize(decoder: Decoder): String? { + val element = decoder.jsonElement() + val text = when (element) { + is JsonObject -> (element["label"] as? JsonPrimitive)?.takeUnless { it is JsonNull }?.content + is JsonPrimitive -> element.takeUnless { it is JsonNull }?.content + else -> null + } + return text?.takeIf { it.isNotEmpty() } + } + + override fun serialize(encoder: Encoder, value: String?): Unit = decodeOnly() +} + +/** A chapter start in seconds, sent as a number or as a clock (`1:02`); anything else is absent. */ +internal object ChapterStartSerializer : KSerializer { + override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("ChapterStart", PrimitiveKind.DOUBLE).nullable + + override fun deserialize(decoder: Decoder): Double? { + val primitive = (decoder.jsonElement() as? JsonPrimitive)?.takeUnless { it is JsonNull } ?: return null + return if (primitive.isString) VideoChapters.seconds(primitive.content) else primitive.doubleOrNull + } + + override fun serialize(encoder: Encoder, value: Double?): Unit = decodeOnly() +} + +/** + * An object that a server sends as `[]` when it has nothing to say: Nextcloud Social without ffmpeg + * sends `meta.original: []` for every video. `null` and an empty array are quietly absent; any other + * wrong shape is absent and recorded. + */ +internal class EmptyArrayAsNullSerializer(private val inner: KSerializer) : KSerializer { + override val descriptor: SerialDescriptor = inner.descriptor.nullable + + override fun deserialize(decoder: Decoder): T? { + val json = decoder as? JsonDecoder ?: throw SerializationException("JSON only") + val element = json.decodeJsonElement() + if (element is JsonNull || (element is JsonArray && element.isEmpty())) return null + return json.json.decodeOrRecord(inner, element, index = 0) + } + + override fun serialize(encoder: Encoder, value: T?): Unit = decodeOnly() +} diff --git a/core/network/src/main/kotlin/social/aloha/core/network/di/NetworkModule.kt b/core/network/src/main/kotlin/social/aloha/core/network/di/NetworkModule.kt new file mode 100644 index 00000000..9fdb0665 --- /dev/null +++ b/core/network/src/main/kotlin/social/aloha/core/network/di/NetworkModule.kt @@ -0,0 +1,102 @@ +// SPDX-FileCopyrightText: 2026 Aloha Social contributors +// SPDX-License-Identifier: MIT + +package social.aloha.core.network.di + +import android.content.Context +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.time.Clock +import javax.inject.Qualifier +import javax.inject.Singleton +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import okhttp3.OkHttpClient +import social.aloha.core.network.AlohaHttp +import social.aloha.core.network.RateLimiter +import social.aloha.core.network.capabilities.CapabilityDetector +import social.aloha.core.network.oauth.OAuthClient +import social.aloha.core.network.probe.ReprobeGate +import social.aloha.core.network.probe.ServerProbe +import social.aloha.core.network.tls.AlohaTrustManager +import social.aloha.core.network.tls.ClientCertificateAliases +import social.aloha.core.network.tls.ClientCertificateKeyManager +import social.aloha.core.network.tls.UserTrustStore +import social.aloha.core.network.tls.systemTrustManager + +/** The dispatcher for blocking I/O. */ +@Qualifier +@Retention(AnnotationRetention.BINARY) +public annotation class IoDispatcher + +/** The `User-Agent` every request carries; bound by the app, which knows its version. */ +@Qualifier +@Retention(AnnotationRetention.BINARY) +public annotation class UserAgent + +@Module +@InstallIn(SingletonComponent::class) +internal object NetworkModule { + @Provides + @IoDispatcher + fun ioDispatcher(): CoroutineDispatcher = Dispatchers.IO + + // Both TLS stores live in no-backup storage: a trust decision belongs to this device. + @Provides + @Singleton + fun userTrustStore(@ApplicationContext context: Context): UserTrustStore = + UserTrustStore(File(context.noBackupFilesDir, "tls/trusted-certificates.p12")) + + @Provides + @Singleton + fun clientCertificateAliases(@ApplicationContext context: Context): ClientCertificateAliases = + ClientCertificateAliases(File(context.noBackupFilesDir, "tls/client-certificates.properties")) + + @Provides + @Singleton + fun okHttpClient( + @ApplicationContext context: Context, + @UserAgent userAgent: String, + trustStore: UserTrustStore, + aliases: ClientCertificateAliases, + ): OkHttpClient = AlohaHttp.client( + cacheDirectory = File(context.cacheDir, "http"), + userAgent = userAgent, + trustManager = AlohaTrustManager(systemTrustManager(), trustStore), + keyManager = ClientCertificateKeyManager(context, aliases), + ) + + @Provides + fun clock(): Clock = Clock.systemUTC() + + @Provides + @Singleton + fun rateLimiter(clock: Clock): RateLimiter = RateLimiter(nowMillis = clock::millis) + + @Provides + @Singleton + fun serverProbe(http: OkHttpClient, rateLimiter: RateLimiter, @IoDispatcher io: CoroutineDispatcher): ServerProbe = + ServerProbe(http, rateLimiter, io) + + @Provides + @Singleton + fun oauthClient(http: OkHttpClient, rateLimiter: RateLimiter, @IoDispatcher io: CoroutineDispatcher): OAuthClient = + OAuthClient(http, rateLimiter, io) + + @Provides + @Singleton + fun capabilityDetector( + http: OkHttpClient, + rateLimiter: RateLimiter, + @IoDispatcher io: CoroutineDispatcher, + clock: Clock, + ): CapabilityDetector = CapabilityDetector(http, rateLimiter, io, clock::instant) + + @Provides + @Singleton + fun reprobeGate(clock: Clock): ReprobeGate = ReprobeGate(nowMillis = clock::millis) +} diff --git a/core/network/src/main/kotlin/social/aloha/core/network/dto/AccountDto.kt b/core/network/src/main/kotlin/social/aloha/core/network/dto/AccountDto.kt new file mode 100644 index 00000000..80fe919b --- /dev/null +++ b/core/network/src/main/kotlin/social/aloha/core/network/dto/AccountDto.kt @@ -0,0 +1,114 @@ +// SPDX-FileCopyrightText: 2026 Aloha Social contributors +// SPDX-License-Identifier: MIT + +package social.aloha.core.network.dto + +import java.time.Instant +import kotlinx.serialization.KSerializer +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import social.aloha.core.model.Account +import social.aloha.core.model.AccountField +import social.aloha.core.model.AccountSource +import social.aloha.core.model.Visibility +import social.aloha.core.network.decoding.FlexibleIdSerializer +import social.aloha.core.network.decoding.LenientBoolSerializer +import social.aloha.core.network.decoding.LenientInstantSerializer +import social.aloha.core.network.decoding.LenientIntSerializer +import social.aloha.core.network.decoding.LenientUrlSerializer +import social.aloha.core.network.decoding.LossyListSerializer +import social.aloha.core.network.decoding.OrNullSerializer + +@Serializable +internal data class AccountDto( + @Serializable(with = FlexibleIdSerializer::class) val id: String, + val username: String? = null, + val acct: String? = null, + @SerialName("display_name") val displayName: String? = null, + val note: String? = null, + @Serializable(with = LenientUrlSerializer::class) val url: String? = null, + @Serializable(with = LenientUrlSerializer::class) val uri: String? = null, + @Serializable(with = LenientUrlSerializer::class) val avatar: String? = null, + @SerialName("avatar_static") @Serializable(with = LenientUrlSerializer::class) val avatarStatic: String? = null, + @Serializable(with = LenientUrlSerializer::class) val header: String? = null, + @SerialName("header_static") @Serializable(with = LenientUrlSerializer::class) val headerStatic: String? = null, + @Serializable(with = LenientBoolSerializer::class) val locked: Boolean = false, + @Serializable(with = LenientBoolSerializer::class) val bot: Boolean = false, + @Serializable(with = LenientBoolSerializer::class) val discoverable: Boolean = false, + @Serializable(with = LenientBoolSerializer::class) val suspended: Boolean = false, + @Serializable(with = LenientBoolSerializer::class) val limited: Boolean = false, + @SerialName("created_at") @Serializable(with = LenientInstantSerializer::class) val createdAt: Instant? = null, + @SerialName("followers_count") @Serializable(with = LenientIntSerializer::class) val followersCount: Int = 0, + @SerialName("following_count") @Serializable(with = LenientIntSerializer::class) val followingCount: Int = 0, + @SerialName("statuses_count") @Serializable(with = LenientIntSerializer::class) val statusesCount: Int = 0, + @SerialName("last_status_at") @Serializable(with = LenientInstantSerializer::class) + val lastStatusAt: Instant? = null, + @Serializable(with = LossyListSerializer::class) val fields: List = emptyList(), + @Serializable(with = LossyListSerializer::class) val emojis: List = emptyList(), + @Serializable(with = AccountSourceOrNull::class) val source: AccountSourceDto? = null, + @Serializable(with = AccountOrNull::class) val moved: AccountDto? = null, +) + +@Serializable +internal data class AccountFieldDto( + val name: String, + val value: String = "", + @SerialName("verified_at") @Serializable(with = LenientInstantSerializer::class) val verifiedAt: Instant? = null, +) + +@Serializable +internal data class AccountSourceDto( + val note: String? = null, + @Serializable(with = LossyListSerializer::class) val fields: List? = null, + val privacy: String? = null, + @Serializable(with = LenientBoolSerializer::class) val sensitive: Boolean = false, + val language: String? = null, + @SerialName("follow_requests_count") @Serializable(with = LenientIntSerializer::class) + val followRequestsCount: Int = 0, +) + +internal object AccountOrNull : KSerializer by OrNullSerializer(AccountDto.serializer()) + +internal object AccountSourceOrNull : KSerializer by OrNullSerializer(AccountSourceDto.serializer()) + +internal fun AccountDto.toDomain(): Account { + val name = username.orEmpty() + return Account( + id = id, + username = name, + acct = acct ?: name, + displayName = displayName.orEmpty(), + note = note.orEmpty(), + url = url, + uri = uri, + avatar = avatar, + avatarStatic = avatarStatic, + header = header, + headerStatic = headerStatic, + locked = locked, + bot = bot, + discoverable = discoverable, + suspended = suspended, + limited = limited, + createdAt = createdAt, + followersCount = followersCount, + followingCount = followingCount, + statusesCount = statusesCount, + lastStatusAt = lastStatusAt, + fields = fields.map { it.toDomain() }, + emojis = emojis.map { it.toDomain() }, + source = source?.toDomain(), + moved = moved?.toDomain(), + ) +} + +internal fun AccountFieldDto.toDomain(): AccountField = AccountField(name, value, verifiedAt) + +internal fun AccountSourceDto.toDomain(): AccountSource = AccountSource( + note = note, + fields = fields?.map { it.toDomain() }, + privacy = privacy?.let(Visibility::fromWire), + sensitive = sensitive, + language = language, + followRequestsCount = followRequestsCount, +) diff --git a/core/network/src/main/kotlin/social/aloha/core/network/dto/AccountExtrasDto.kt b/core/network/src/main/kotlin/social/aloha/core/network/dto/AccountExtrasDto.kt new file mode 100644 index 00000000..9afe81a9 --- /dev/null +++ b/core/network/src/main/kotlin/social/aloha/core/network/dto/AccountExtrasDto.kt @@ -0,0 +1,136 @@ +// SPDX-FileCopyrightText: 2026 Aloha Social contributors +// SPDX-License-Identifier: MIT + +package social.aloha.core.network.dto + +import java.time.Instant +import kotlinx.serialization.KSerializer +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import social.aloha.core.model.AuthorizedApp +import social.aloha.core.model.PortfolioLayout +import social.aloha.core.model.PortfolioPage +import social.aloha.core.model.PortfolioPlace +import social.aloha.core.model.PortfolioPost +import social.aloha.core.model.PortfolioSettings +import social.aloha.core.model.PortfolioSource +import social.aloha.core.model.VideoChannel +import social.aloha.core.network.decoding.FlexibleIdSerializer +import social.aloha.core.network.decoding.LenientBoolSerializer +import social.aloha.core.network.decoding.LenientInstantSerializer +import social.aloha.core.network.decoding.LenientIntSerializer +import social.aloha.core.network.decoding.LenientTextSerializer +import social.aloha.core.network.decoding.LenientUrlSerializer +import social.aloha.core.network.decoding.LossyListSerializer +import social.aloha.core.network.decoding.OptionalBoolSerializer +import social.aloha.core.network.decoding.OptionalIdSerializer +import social.aloha.core.network.decoding.OrNullSerializer +import social.aloha.core.network.decoding.StringListSerializer + +/** Scopes arrive as an array from the app and as one space-separated string from the OAuth layer. */ +@Serializable +internal data class AuthorizedAppDto( + @Serializable(with = FlexibleIdSerializer::class) val id: String, + @Serializable(with = LenientTextSerializer::class) val name: String? = null, + @Serializable(with = LenientUrlSerializer::class) val website: String? = null, + @SerialName("created_at") @Serializable(with = LenientInstantSerializer::class) val createdAt: Instant? = null, + @SerialName("signed_in") @Serializable(with = LenientInstantSerializer::class) val signedIn: Instant? = null, + @SerialName("last_used_at") @Serializable(with = LenientInstantSerializer::class) val lastUsedAt: Instant? = null, + @Serializable(with = StringListSerializer::class) val scopes: List = emptyList(), +) + +internal fun AuthorizedAppDto.toDomain(): AuthorizedApp = + AuthorizedApp(id, name.orEmpty(), website, createdAt, signedIn, lastUsedAt, scopes) + +@Serializable +internal data class VideoChannelDto( + @Serializable(with = FlexibleIdSerializer::class) val id: String, + @Serializable(with = LenientTextSerializer::class) val handle: String? = null, + @Serializable(with = LenientTextSerializer::class) val name: String? = null, + @Serializable(with = LenientTextSerializer::class) val description: String? = null, + @Serializable(with = LenientUrlSerializer::class) val url: String? = null, + @SerialName("videos_count") @Serializable(with = LenientIntSerializer::class) val videosCount: Int = 0, + @SerialName("created_at") @Serializable(with = LenientInstantSerializer::class) val createdAt: Instant? = null, +) + +internal fun VideoChannelDto.toDomain(): VideoChannel = + VideoChannel(id, handle.orEmpty(), name.orEmpty(), description.orEmpty(), url, videosCount, createdAt) + +@Serializable +internal data class PortfolioPlaceDto( + @Serializable(with = LenientTextSerializer::class) val name: String? = null, + @Serializable(with = LenientTextSerializer::class) val country: String? = null, +) + +internal object PortfolioPlaceOrNull : KSerializer by OrNullSerializer( + PortfolioPlaceDto.serializer(), +) + +@Serializable +internal data class PortfolioPostDto( + @Serializable(with = FlexibleIdSerializer::class) val id: String, + @Serializable(with = LenientTextSerializer::class) val content: String? = null, + @SerialName("created_at") @Serializable(with = LenientInstantSerializer::class) val createdAt: Instant? = null, + @Serializable(with = LenientUrlSerializer::class) val url: String? = null, + @SerialName("media_attachments") @Serializable(with = LossyListSerializer::class) + val mediaAttachments: List = emptyList(), + @Serializable(with = PortfolioPlaceOrNull::class) val place: PortfolioPlaceDto? = null, +) + +internal fun PortfolioPostDto.toDomain(): PortfolioPost = PortfolioPost( + id = id, + content = content, + createdAt = createdAt, + url = url, + mediaAttachments = mediaAttachments.map { it.toDomain() }, + place = place?.let { PortfolioPlace(it.name, it.country) }, +) + +/** The four display switches, shared by the settings and the published page. */ +@Serializable +internal data class PortfolioDto( + @Serializable(with = LenientBoolSerializer::class) val active: Boolean = false, + @Serializable(with = LenientTextSerializer::class) val title: String? = null, + @Serializable(with = LenientTextSerializer::class) val intro: String? = null, + @Serializable(with = LenientTextSerializer::class) val handle: String? = null, + @Serializable(with = LenientUrlSerializer::class) val avatar: String? = null, + @Serializable(with = LenientTextSerializer::class) val layout: String? = null, + @Serializable(with = LenientTextSerializer::class) val source: String? = null, + @SerialName("collection_id") @Serializable(with = OptionalIdSerializer::class) val collectionId: String? = null, + @SerialName("show_captions") @Serializable(with = OptionalBoolSerializer::class) val showCaptions: Boolean? = null, + @SerialName("show_places") @Serializable(with = OptionalBoolSerializer::class) val showPlaces: Boolean? = null, + @SerialName("show_dates") @Serializable(with = OptionalBoolSerializer::class) val showDates: Boolean? = null, + @SerialName("show_avatar") @Serializable(with = OptionalBoolSerializer::class) val showAvatar: Boolean? = null, + @Serializable(with = LenientUrlSerializer::class) val url: String? = null, + @Serializable(with = LossyListSerializer::class) val posts: List = emptyList(), +) + +/** The owner's settings: a switch the server leaves out is off. */ +internal fun PortfolioDto.toSettings(): PortfolioSettings = PortfolioSettings( + active = active, + title = title.orEmpty(), + intro = intro.orEmpty(), + layout = PortfolioLayout.fromWire(layout), + source = PortfolioSource.fromWire(source), + collectionId = collectionId, + showCaptions = showCaptions ?: false, + showPlaces = showPlaces ?: false, + showDates = showDates ?: false, + showAvatar = showAvatar ?: false, + url = url, + posts = posts.map { it.toDomain() }, +) + +/** The published page: a switch the server leaves out is on, since the page defaults are the generous ones. */ +internal fun PortfolioDto.toPage(): PortfolioPage = PortfolioPage( + title = title.orEmpty(), + intro = intro, + handle = handle, + avatar = avatar, + layout = PortfolioLayout.fromWire(layout), + showCaptions = showCaptions ?: true, + showPlaces = showPlaces ?: true, + showDates = showDates ?: true, + showAvatar = showAvatar ?: true, + posts = posts.map { it.toDomain() }, +) diff --git a/core/network/src/main/kotlin/social/aloha/core/network/dto/AnnualReportDto.kt b/core/network/src/main/kotlin/social/aloha/core/network/dto/AnnualReportDto.kt new file mode 100644 index 00000000..e49c9889 --- /dev/null +++ b/core/network/src/main/kotlin/social/aloha/core/network/dto/AnnualReportDto.kt @@ -0,0 +1,109 @@ +// SPDX-FileCopyrightText: 2026 Aloha Social contributors +// SPDX-License-Identifier: MIT + +package social.aloha.core.network.dto + +import kotlinx.serialization.KSerializer +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import social.aloha.core.model.AnnualArchetype +import social.aloha.core.model.AnnualHashtag +import social.aloha.core.model.AnnualMonth +import social.aloha.core.model.AnnualReport +import social.aloha.core.model.AnnualReportData +import social.aloha.core.model.AnnualReportState +import social.aloha.core.model.AnnualTopStatuses +import social.aloha.core.model.WrappedAnnualReports +import social.aloha.core.network.decoding.LenientIntSerializer +import social.aloha.core.network.decoding.LenientTextSerializer +import social.aloha.core.network.decoding.LenientUrlSerializer +import social.aloha.core.network.decoding.LossyListSerializer +import social.aloha.core.network.decoding.OptionalIdSerializer +import social.aloha.core.network.decoding.OptionalIntSerializer +import social.aloha.core.network.decoding.OrNullSerializer + +@Serializable +internal data class AnnualMonthDto( + @Serializable(with = LenientIntSerializer::class) val month: Int = 0, + @Serializable(with = LenientIntSerializer::class) val statuses: Int = 0, + @Serializable(with = LenientIntSerializer::class) val followers: Int = 0, +) + +@Serializable +internal data class AnnualHashtagDto( + @Serializable(with = LenientTextSerializer::class) val name: String? = null, + @Serializable(with = LenientIntSerializer::class) val count: Int = 0, +) + +@Serializable +internal data class AnnualTopStatusesDto( + @SerialName("by_reblogs") @Serializable(with = OptionalIdSerializer::class) val byReblogs: String? = null, + @SerialName("by_replies") @Serializable(with = OptionalIdSerializer::class) val byReplies: String? = null, + @SerialName("by_favourites") @Serializable(with = OptionalIdSerializer::class) val byFavourites: String? = null, +) + +@Serializable +internal data class AnnualReportDataDto( + @Serializable(with = LenientTextSerializer::class) val archetype: String? = null, + @SerialName("time_series") @Serializable(with = LossyListSerializer::class) + val timeSeries: List = emptyList(), + @SerialName("top_hashtags") @Serializable(with = LossyListSerializer::class) + val topHashtags: List = emptyList(), + @SerialName( + "top_statuses", + ) @Serializable(with = TopStatusesOrNull::class) val topStatuses: AnnualTopStatusesDto? = null, +) + +@Serializable +internal data class AnnualReportDto( + @Serializable(with = LenientIntSerializer::class) val year: Int = 0, + @Serializable(with = AnnualDataOrNull::class) val data: AnnualReportDataDto? = null, + @SerialName("schema_version") @Serializable(with = OptionalIntSerializer::class) val schemaVersion: Int? = null, + @SerialName("share_url") @Serializable(with = LenientUrlSerializer::class) val shareUrl: String? = null, + @SerialName("account_id") @Serializable(with = OptionalIdSerializer::class) val accountId: String? = null, +) + +@Serializable +internal data class WrappedAnnualReportsDto( + @SerialName("annual_reports") @Serializable(with = LossyListSerializer::class) + val annualReports: List = emptyList(), + @Serializable(with = LossyListSerializer::class) val accounts: List = emptyList(), + @Serializable(with = LossyListSerializer::class) val statuses: List = emptyList(), +) + +@Serializable +internal data class AnnualReportStateDto(@Serializable(with = LenientTextSerializer::class) val state: String? = null) + +internal object TopStatusesOrNull : KSerializer by OrNullSerializer( + AnnualTopStatusesDto.serializer(), +) + +internal object AnnualDataOrNull : KSerializer by OrNullSerializer( + AnnualReportDataDto.serializer(), +) + +internal fun AnnualReportDto.toDomain(): AnnualReport = AnnualReport( + year = year, + data = data?.toDomain() ?: AnnualReportData(), + schemaVersion = schemaVersion ?: 1, + shareUrl = shareUrl, + accountId = accountId.orEmpty(), +) + +internal fun AnnualReportDataDto.toDomain(): AnnualReportData = AnnualReportData( + archetype = AnnualArchetype.fromWire(archetype), + timeSeries = timeSeries.map { AnnualMonth(it.month, it.statuses, it.followers) }, + topHashtags = topHashtags.map { AnnualHashtag(it.name.orEmpty(), it.count) }, + topStatuses = topStatuses?.let { AnnualTopStatuses(it.byReblogs, it.byReplies, it.byFavourites) } + ?: AnnualTopStatuses(), +) + +internal fun WrappedAnnualReportsDto.toDomain(): WrappedAnnualReports = WrappedAnnualReports( + annualReports = annualReports.map { it.toDomain() }, + accounts = accounts.map { it.toDomain() }, + statuses = statuses.map { it.toDomain() }, +) + +/** A state the server leaves out is `ineligible`: there is nothing to show. */ +internal fun AnnualReportStateDto.toDomain(): AnnualReportState = + state?.let(AnnualReportState::fromWire) ?: AnnualReportState.Ineligible diff --git a/core/network/src/main/kotlin/social/aloha/core/network/dto/CardPollDto.kt b/core/network/src/main/kotlin/social/aloha/core/network/dto/CardPollDto.kt new file mode 100644 index 00000000..646ad0f5 --- /dev/null +++ b/core/network/src/main/kotlin/social/aloha/core/network/dto/CardPollDto.kt @@ -0,0 +1,124 @@ +// SPDX-FileCopyrightText: 2026 Aloha Social contributors +// SPDX-License-Identifier: MIT + +package social.aloha.core.network.dto + +import java.time.Instant +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import social.aloha.core.model.Card +import social.aloha.core.model.Poll +import social.aloha.core.model.PollOption +import social.aloha.core.model.VideoChapter +import social.aloha.core.model.VideoDetails +import social.aloha.core.network.decoding.ChapterStartSerializer +import social.aloha.core.network.decoding.FlexibleIdSerializer +import social.aloha.core.network.decoding.LabelledTextSerializer +import social.aloha.core.network.decoding.LenientBoolSerializer +import social.aloha.core.network.decoding.LenientInstantSerializer +import social.aloha.core.network.decoding.LenientIntSerializer +import social.aloha.core.network.decoding.LenientTextSerializer +import social.aloha.core.network.decoding.LenientUrlSerializer +import social.aloha.core.network.decoding.LossyListSerializer +import social.aloha.core.network.decoding.OptionalBoolSerializer +import social.aloha.core.network.decoding.OptionalIntSerializer + +@Serializable +internal data class CardDto( + @Serializable(with = LenientUrlSerializer::class) val url: String? = null, + val title: String? = null, + val description: String? = null, + val type: String? = null, + @SerialName("author_name") val authorName: String? = null, + @SerialName("author_url") @Serializable(with = LenientUrlSerializer::class) val authorUrl: String? = null, + @SerialName("provider_name") val providerName: String? = null, + @SerialName("provider_url") @Serializable(with = LenientUrlSerializer::class) val providerUrl: String? = null, + @Serializable(with = LenientUrlSerializer::class) val image: String? = null, + val blurhash: String? = null, + @Serializable(with = OptionalIntSerializer::class) val width: Int? = null, + @Serializable(with = OptionalIntSerializer::class) val height: Int? = null, + val html: String? = null, +) + +@Serializable +internal data class PollDto( + @Serializable(with = FlexibleIdSerializer::class) val id: String, + @SerialName("expires_at") @Serializable(with = LenientInstantSerializer::class) val expiresAt: Instant? = null, + @Serializable(with = LenientBoolSerializer::class) val expired: Boolean = false, + @Serializable(with = LenientBoolSerializer::class) val multiple: Boolean = false, + @SerialName("votes_count") @Serializable(with = LenientIntSerializer::class) val votesCount: Int = 0, + @SerialName("voters_count") @Serializable(with = OptionalIntSerializer::class) val votersCount: Int? = null, + @Serializable(with = LenientBoolSerializer::class) val voted: Boolean = false, + @SerialName("own_votes") @Serializable(with = LossyListSerializer::class) val ownVotes: List = emptyList(), + @Serializable(with = LossyListSerializer::class) val options: List = emptyList(), + @Serializable(with = LossyListSerializer::class) val emojis: List = emptyList(), +) + +@Serializable +internal data class PollOptionDto( + val title: String, + @SerialName("votes_count") @Serializable(with = OptionalIntSerializer::class) val votesCount: Int? = null, +) + +@Serializable +internal data class VideoDetailsDto( + @Serializable(with = LenientIntSerializer::class) val views: Int = 0, + @Serializable(with = LenientIntSerializer::class) val likes: Int = 0, + @Serializable(with = LenientIntSerializer::class) val dislikes: Int = 0, + @Serializable(with = LabelledTextSerializer::class) val category: String? = null, + @Serializable(with = LabelledTextSerializer::class) val language: String? = null, + @Serializable(with = LabelledTextSerializer::class) val licence: String? = null, + @Serializable(with = LenientBoolSerializer::class) val live: Boolean = false, + @Serializable(with = LenientTextSerializer::class) val support: String? = null, + @Serializable(with = OptionalBoolSerializer::class) val download: Boolean? = null, + @Serializable(with = LossyListSerializer::class) val chapters: List = emptyList(), +) + +@Serializable +internal data class VideoChapterDto( + @Serializable(with = ChapterStartSerializer::class) val start: Double? = null, + @Serializable(with = LenientTextSerializer::class) val title: String? = null, +) + +internal fun CardDto.toDomain(): Card = Card( + url = url, + title = title.orEmpty(), + description = description.orEmpty(), + type = type ?: "link", + authorName = authorName, + authorUrl = authorUrl, + providerName = providerName, + providerUrl = providerUrl, + image = image, + blurhash = blurhash, + width = width, + height = height, + html = html, +) + +internal fun PollDto.toDomain(): Poll = Poll( + id = id, + expiresAt = expiresAt, + expired = expired, + multiple = multiple, + votesCount = votesCount, + votersCount = votersCount, + voted = voted, + ownVotes = ownVotes, + options = options.map { PollOption(it.title, it.votesCount) }, + emojis = emojis.map { it.toDomain() }, +) + +/** A chapter whose start is unreadable is dropped. */ +internal fun VideoDetailsDto.toDomain(): VideoDetails = VideoDetails( + views = views, + likes = likes, + dislikes = dislikes, + category = category, + language = language, + licence = licence, + live = live, + support = support, + download = download, + chapters = chapters.mapNotNull { chapter -> chapter.start?.let { VideoChapter(it, chapter.title.orEmpty()) } }, +) diff --git a/core/network/src/main/kotlin/social/aloha/core/network/dto/CollectionDto.kt b/core/network/src/main/kotlin/social/aloha/core/network/dto/CollectionDto.kt new file mode 100644 index 00000000..7fa66ae0 --- /dev/null +++ b/core/network/src/main/kotlin/social/aloha/core/network/dto/CollectionDto.kt @@ -0,0 +1,73 @@ +// SPDX-FileCopyrightText: 2026 Aloha Social contributors +// SPDX-License-Identifier: MIT + +package social.aloha.core.network.dto + +import java.time.Instant +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import social.aloha.core.model.ContinueWatchingItem +import social.aloha.core.model.MediaCollection +import social.aloha.core.network.decoding.FlexibleIdSerializer +import social.aloha.core.network.decoding.LenientInstantSerializer +import social.aloha.core.network.decoding.LenientTextSerializer +import social.aloha.core.network.decoding.LenientUrlSerializer +import social.aloha.core.network.decoding.LossyListSerializer +import social.aloha.core.network.decoding.OptionalDoubleSerializer +import social.aloha.core.network.decoding.OptionalIdSerializer +import social.aloha.core.network.decoding.OptionalIntSerializer + +/** A Pixelfed album. Nextcloud Social counts its posts as `size`, Pixelfed as `post_count`. */ +@Serializable +internal data class CollectionDto( + @Serializable(with = FlexibleIdSerializer::class) val id: String, + @Serializable(with = LenientTextSerializer::class) val title: String? = null, + @Serializable(with = LenientTextSerializer::class) val description: String? = null, + @Serializable(with = LenientUrlSerializer::class) val url: String? = null, + @SerialName("post_count") @Serializable(with = OptionalIntSerializer::class) val postCount: Int? = null, + @Serializable(with = OptionalIntSerializer::class) val size: Int? = null, + @Serializable(with = LenientUrlSerializer::class) val thumbnail: String? = null, + @Serializable(with = LenientTextSerializer::class) val visibility: String? = null, + @SerialName("updated_at") @Serializable(with = LenientInstantSerializer::class) val updatedAt: Instant? = null, +) + +internal fun CollectionDto.toDomain(): MediaCollection = MediaCollection( + id = id, + title = title.orEmpty(), + description = description, + url = url, + postCount = postCount ?: size ?: 0, + thumbnail = thumbnail, + visibility = visibility, + updatedAt = updatedAt, +) + +/** + * A continue-watching entry: the documented `{status_id, position, duration, status}`, or, as + * Nextcloud Social answers, the post itself without its `account`. The post is kept only when it + * decodes as a status. + */ +@Serializable +internal data class ContinueWatchingItemDto( + @SerialName("status_id") @Serializable(with = OptionalIdSerializer::class) val statusId: String? = null, + @Serializable(with = OptionalIdSerializer::class) val id: String? = null, + @Serializable(with = OptionalDoubleSerializer::class) val position: Double? = null, + @Serializable(with = OptionalDoubleSerializer::class) val duration: Double? = null, + @SerialName("updated_at") @Serializable(with = LenientInstantSerializer::class) val updatedAt: Instant? = null, + @Serializable(with = StatusOrNull::class) val status: StatusDto? = null, + @SerialName("media_attachments") @Serializable(with = LossyListSerializer::class) + val mediaAttachments: List = emptyList(), +) + +/** Null when the entry names no post at all. */ +internal fun ContinueWatchingItemDto.toDomain(): ContinueWatchingItem? { + val statusId = statusId ?: status?.id ?: id ?: return null + val attachmentDuration = mediaAttachments.firstNotNullOfOrNull { it.toDomain().duration } + return ContinueWatchingItem( + statusId = statusId, + position = position ?: 0.0, + duration = duration ?: attachmentDuration ?: 0.0, + updatedAt = updatedAt, + status = status?.toDomain(), + ) +} diff --git a/core/network/src/main/kotlin/social/aloha/core/network/dto/ConversationsDto.kt b/core/network/src/main/kotlin/social/aloha/core/network/dto/ConversationsDto.kt new file mode 100644 index 00000000..088114c4 --- /dev/null +++ b/core/network/src/main/kotlin/social/aloha/core/network/dto/ConversationsDto.kt @@ -0,0 +1,138 @@ +// SPDX-FileCopyrightText: 2026 Aloha Social contributors +// SPDX-License-Identifier: MIT + +package social.aloha.core.network.dto + +import java.time.Instant +import kotlinx.serialization.KSerializer +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import social.aloha.core.model.AccountList +import social.aloha.core.model.Announcement +import social.aloha.core.model.AnnouncementReaction +import social.aloha.core.model.Conversation +import social.aloha.core.model.Marker +import social.aloha.core.model.MarkerSet +import social.aloha.core.model.SearchResults +import social.aloha.core.model.StatusContext +import social.aloha.core.model.Suggestion +import social.aloha.core.model.UnreadCount +import social.aloha.core.network.decoding.FlexibleIdSerializer +import social.aloha.core.network.decoding.LenientBoolSerializer +import social.aloha.core.network.decoding.LenientInstantSerializer +import social.aloha.core.network.decoding.LenientIntSerializer +import social.aloha.core.network.decoding.LenientUrlSerializer +import social.aloha.core.network.decoding.LossyListSerializer +import social.aloha.core.network.decoding.OrNullSerializer + +@Serializable +internal data class StatusContextDto( + @Serializable(with = LossyListSerializer::class) val ancestors: List = emptyList(), + @Serializable(with = LossyListSerializer::class) val descendants: List = emptyList(), +) + +@Serializable +internal data class ConversationDto( + @Serializable(with = FlexibleIdSerializer::class) val id: String, + @Serializable(with = LossyListSerializer::class) val accounts: List = emptyList(), + @Serializable(with = LenientBoolSerializer::class) val unread: Boolean = false, + @SerialName("last_status") @Serializable(with = StatusOrNull::class) val lastStatus: StatusDto? = null, +) + +@Serializable +internal data class MarkerDto( + @SerialName("last_read_id") @Serializable(with = FlexibleIdSerializer::class) val lastReadId: String, + @Serializable(with = LenientIntSerializer::class) val version: Int = 0, + @SerialName("updated_at") @Serializable(with = LenientInstantSerializer::class) val updatedAt: Instant? = null, +) + +/** The markers, keyed by timeline; `{}` for an account with none yet. */ +@Serializable +internal data class MarkerSetDto( + @Serializable(with = MarkerOrNull::class) val home: MarkerDto? = null, + @Serializable(with = MarkerOrNull::class) val notifications: MarkerDto? = null, +) + +@Serializable +internal data class UnreadCountDto(@Serializable(with = LenientIntSerializer::class) val count: Int = 0) + +@Serializable +internal data class AccountListDto( + @Serializable(with = FlexibleIdSerializer::class) val id: String, + val title: String = "", + @SerialName("replies_policy") val repliesPolicy: String? = null, + @Serializable(with = LenientBoolSerializer::class) val exclusive: Boolean = false, +) + +@Serializable +internal data class SearchResultsDto( + @Serializable(with = LossyListSerializer::class) val accounts: List = emptyList(), + @Serializable(with = LossyListSerializer::class) val statuses: List = emptyList(), + @Serializable(with = LossyListSerializer::class) val hashtags: List = emptyList(), +) + +@Serializable +internal data class SuggestionDto( + val account: AccountDto, + val source: String? = null, + @Serializable(with = LossyListSerializer::class) val sources: List? = null, +) + +@Serializable +internal data class AnnouncementDto( + @Serializable(with = FlexibleIdSerializer::class) val id: String, + val content: String = "", + @Serializable(with = LenientBoolSerializer::class) val published: Boolean = false, + @Serializable(with = LenientBoolSerializer::class) val read: Boolean = false, + @SerialName("published_at") @Serializable(with = LenientInstantSerializer::class) val publishedAt: Instant? = null, + @SerialName("updated_at") @Serializable(with = LenientInstantSerializer::class) val updatedAt: Instant? = null, + @SerialName("all_day") @Serializable(with = LenientBoolSerializer::class) val allDay: Boolean = false, + @SerialName("ends_at") @Serializable(with = LenientInstantSerializer::class) val endsAt: Instant? = null, + @Serializable(with = LossyListSerializer::class) val reactions: List = emptyList(), +) + +@Serializable +internal data class AnnouncementReactionDto( + val name: String = "", + @Serializable(with = LenientIntSerializer::class) val count: Int = 0, + @Serializable(with = LenientBoolSerializer::class) val me: Boolean = false, + @Serializable(with = LenientUrlSerializer::class) val url: String? = null, +) + +internal object MarkerOrNull : KSerializer by OrNullSerializer(MarkerDto.serializer()) + +internal fun StatusContextDto.toDomain(): StatusContext = + StatusContext(ancestors.map { it.toDomain() }, descendants.map { it.toDomain() }) + +internal fun ConversationDto.toDomain(): Conversation = + Conversation(id, accounts.map { it.toDomain() }, unread, lastStatus?.toDomain()) + +internal fun MarkerDto.toDomain(): Marker = Marker(lastReadId, version, updatedAt) + +internal fun MarkerSetDto.toDomain(): MarkerSet = MarkerSet(home?.toDomain(), notifications?.toDomain()) + +internal fun UnreadCountDto.toDomain(): UnreadCount = UnreadCount(count) + +internal fun AccountListDto.toDomain(): AccountList = AccountList(id, title, repliesPolicy, exclusive) + +internal fun SearchResultsDto.toDomain(): SearchResults = SearchResults( + accounts.map { it.toDomain() }, + statuses.map { it.toDomain() }, + hashtags.map { it.toDomain() }, +) + +internal fun SuggestionDto.toDomain(): Suggestion = Suggestion(account.toDomain(), source, sources) + +internal fun AnnouncementDto.toDomain(): Announcement = Announcement( + id = id, + content = content, + published = published, + read = read, + publishedAt = publishedAt, + updatedAt = updatedAt, + allDay = allDay, + endsAt = endsAt, + reactions = reactions.filter { + it.name.isNotEmpty() + }.map { AnnouncementReaction(it.name, it.count, it.me, it.url) }, +) diff --git a/core/network/src/main/kotlin/social/aloha/core/network/dto/CustomEmojiDto.kt b/core/network/src/main/kotlin/social/aloha/core/network/dto/CustomEmojiDto.kt new file mode 100644 index 00000000..77f331ff --- /dev/null +++ b/core/network/src/main/kotlin/social/aloha/core/network/dto/CustomEmojiDto.kt @@ -0,0 +1,28 @@ +// SPDX-FileCopyrightText: 2026 Aloha Social contributors +// SPDX-License-Identifier: MIT + +package social.aloha.core.network.dto + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import social.aloha.core.model.CustomEmoji +import social.aloha.core.network.decoding.LenientUrlSerializer +import social.aloha.core.network.decoding.OptionalBoolSerializer + +@Serializable +internal data class CustomEmojiDto( + val shortcode: String, + @Serializable(with = LenientUrlSerializer::class) val url: String? = null, + @SerialName("static_url") @Serializable(with = LenientUrlSerializer::class) val staticUrl: String? = null, + @SerialName("visible_in_picker") @Serializable(with = OptionalBoolSerializer::class) + val visibleInPicker: Boolean? = null, + val category: String? = null, +) + +internal fun CustomEmojiDto.toDomain(): CustomEmoji = CustomEmoji( + shortcode = shortcode, + url = url, + staticUrl = staticUrl, + visibleInPicker = visibleInPicker ?: true, + category = category, +) diff --git a/core/network/src/main/kotlin/social/aloha/core/network/dto/DirectoriesDto.kt b/core/network/src/main/kotlin/social/aloha/core/network/dto/DirectoriesDto.kt new file mode 100644 index 00000000..75be5afc --- /dev/null +++ b/core/network/src/main/kotlin/social/aloha/core/network/dto/DirectoriesDto.kt @@ -0,0 +1,207 @@ +// SPDX-FileCopyrightText: 2026 Aloha Social contributors +// SPDX-License-Identifier: MIT + +package social.aloha.core.network.dto + +import kotlinx.serialization.KSerializer +import kotlinx.serialization.Serializable +import kotlinx.serialization.SerializationException +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonDecoder +import kotlinx.serialization.json.JsonObject +import social.aloha.core.model.DirectoryHashtagResults +import social.aloha.core.model.DirectorySearchResults +import social.aloha.core.model.DirectorySource +import social.aloha.core.model.DirectorySourceReport +import social.aloha.core.model.DiscoverCategory +import social.aloha.core.model.FollowGraph +import social.aloha.core.model.FollowGraphStatus +import social.aloha.core.model.FollowGraphSuggestion +import social.aloha.core.model.StarterPack +import social.aloha.core.model.Tag +import social.aloha.core.network.decoding.LenientIntSerializer +import social.aloha.core.network.decoding.LenientTextSerializer +import social.aloha.core.network.decoding.LossyListSerializer +import social.aloha.core.network.decoding.OptionalBoolSerializer +import social.aloha.core.network.decoding.OptionalIdSerializer +import social.aloha.core.network.decoding.OptionalIntSerializer +import social.aloha.core.network.decoding.decodeOrRecord +import social.aloha.core.network.decoding.lossy + +/** Nextcloud Social names a pack by `id`; the documented shape by `slug`. */ +@Serializable +internal data class StarterPackDto( + @Serializable(with = LenientTextSerializer::class) val slug: String? = null, + @Serializable(with = OptionalIdSerializer::class) val id: String? = null, + @Serializable(with = LenientTextSerializer::class) val name: String? = null, + @Serializable(with = LenientTextSerializer::class) val description: String? = null, + @Serializable(with = LossyListSerializer::class) val handles: List = emptyList(), + @Serializable(with = OptionalIntSerializer::class) val size: Int? = null, + @Serializable(with = LossyListSerializer::class) val accounts: List = emptyList(), +) + +/** Null when the pack has no name to address it by. */ +internal fun StarterPackDto.toDomain(): StarterPack? { + val slug = slug ?: id ?: return null + return StarterPack( + slug = slug, + name = name ?: slug, + description = description.orEmpty(), + handles = handles, + size = size?.takeIf { it > 0 } ?: maxOf(handles.size, accounts.size), + accounts = accounts.map { it.toDomain() }, + ) +} + +@Serializable +internal data class DirectorySourceDto( + @Serializable(with = LenientTextSerializer::class) val host: String? = null, + @Serializable(with = LenientTextSerializer::class) val kind: String? = null, + @Serializable(with = LenientTextSerializer::class) val label: String? = null, +) + +internal fun DirectorySourceDto.toDomain(): DirectorySource = + DirectorySource(host.orEmpty(), kind.orEmpty(), label ?: host.orEmpty()) + +/** A source's count arrives as `count`, `accounts` or `hashtags`, depending on what was asked. */ +@Serializable +internal data class DirectorySourceReportDto( + @Serializable(with = LenientTextSerializer::class) val host: String? = null, + @Serializable(with = LenientTextSerializer::class) val status: String? = null, + @Serializable(with = LenientTextSerializer::class) val error: String? = null, + @Serializable(with = OptionalIntSerializer::class) val count: Int? = null, + @Serializable(with = OptionalIntSerializer::class) val accounts: Int? = null, + @Serializable(with = OptionalIntSerializer::class) val hashtags: Int? = null, +) + +internal fun DirectorySourceReportDto.toDomain(): DirectorySourceReport = + DirectorySourceReport(host.orEmpty(), status, error, count ?: accounts ?: hashtags ?: 0) + +internal data class DirectoryResultsDto(val results: List, val sources: List) + +/** + * The documented `{: […], sources: […]}` or a bare array of results. [members] are tried + * in order; hashtags arrive as `hashtags` or `tags`. + */ +internal class DirectoryResultsSerializer(private val element: KSerializer, private vararg val members: String) : + KSerializer> { + override val descriptor: SerialDescriptor = DirectorySourceReportDto.serializer().descriptor + + override fun deserialize(decoder: Decoder): DirectoryResultsDto { + val json = decoder as? JsonDecoder ?: throw SerializationException("JSON only") + return when (val root = json.decodeJsonElement()) { + is JsonArray -> DirectoryResultsDto(json.json.lossy(element, root), emptyList()) + + is JsonObject -> DirectoryResultsDto( + results = json.json.lossy(element, members.firstNotNullOfOrNull { root[it] as? JsonArray }), + sources = json.json.lossy(DirectorySourceReportDto.serializer(), root["sources"]), + ) + + else -> DirectoryResultsDto(emptyList(), emptyList()) + } + } + + override fun serialize(encoder: Encoder, value: DirectoryResultsDto): Unit = + throw SerializationException("wire DTOs are decode-only") +} + +internal fun DirectoryResultsDto.toSearchResults(): DirectorySearchResults = + DirectorySearchResults(results.map { it.toDomain() }, sources.map { it.toDomain() }) + +internal fun DirectoryResultsDto.toHashtagResults(): DirectoryHashtagResults = DirectoryHashtagResults( + results.map(TagDto::toDomain).filter { + it.name.isNotEmpty() + }, + sources.map { it.toDomain() }, +) + +/** `{account, count, via}` (with `followers` and `through` as older names), or a bare account. */ +internal object FollowGraphSuggestionSerializer : KSerializer { + override val descriptor: SerialDescriptor = AccountDto.serializer().descriptor + + override fun deserialize(decoder: Decoder): FollowGraphSuggestion { + val json = decoder as? JsonDecoder ?: throw SerializationException("JSON only") + val root = + json.decodeJsonElement() as? JsonObject ?: throw SerializationException("suggestion is not an object") + val nested = root["account"] as? JsonObject + ?: return FollowGraphSuggestion(json.json.decodeFromJsonElement(AccountDto.serializer(), root).toDomain()) + val count = json.json.decodeFromJsonElement(SuggestionCountsDto.serializer(), root) + val via = json.json.lossy(AccountDto.serializer(), root["via"] ?: root["through"]) + return FollowGraphSuggestion( + account = json.json.decodeFromJsonElement(AccountDto.serializer(), nested).toDomain(), + count = count.count ?: count.followers ?: 0, + via = via.map { it.toDomain() }, + ) + } + + override fun serialize(encoder: Encoder, value: FollowGraphSuggestion): Unit = + throw SerializationException("wire DTOs are decode-only") +} + +@Serializable +internal data class SuggestionCountsDto( + @Serializable(with = OptionalIntSerializer::class) val count: Int? = null, + @Serializable(with = OptionalIntSerializer::class) val followers: Int? = null, +) + +/** Reads `suggestions` lossily: one malformed suggestion never empties the graph. */ +internal object FollowGraphSerializer : KSerializer { + override val descriptor: SerialDescriptor = FollowGraphCountsDto.serializer().descriptor + + override fun deserialize(decoder: Decoder): FollowGraph { + val json = decoder as? JsonDecoder ?: throw SerializationException("JSON only") + val root = json.decodeJsonElement() as? JsonObject ?: return FollowGraph() + val counts = json.json.decodeFromJsonElement(FollowGraphCountsDto.serializer(), root) + val suggestions = (root["suggestions"] as? JsonArray).orEmpty().mapIndexedNotNull { index, item -> + json.json.decodeOrRecord(FollowGraphSuggestionSerializer, item, index) + } + return FollowGraph(suggestions, counts.asked, counts.needs) + } + + override fun serialize(encoder: Encoder, value: FollowGraph): Unit = + throw SerializationException("wire DTOs are decode-only") +} + +@Serializable +internal data class FollowGraphCountsDto( + @Serializable(with = LenientIntSerializer::class) val asked: Int = 0, + @Serializable(with = LenientIntSerializer::class) val needs: Int = 0, +) + +/** The flag arrives as `worthwhile`, `eligible` or `ready`. */ +@Serializable +internal data class FollowGraphStatusDto( + @Serializable(with = OptionalBoolSerializer::class) val worthwhile: Boolean? = null, + @Serializable(with = OptionalBoolSerializer::class) val eligible: Boolean? = null, + @Serializable(with = OptionalBoolSerializer::class) val ready: Boolean? = null, + @Serializable(with = LenientIntSerializer::class) val following: Int = 0, + @Serializable(with = LenientIntSerializer::class) val needs: Int = 0, +) + +/** With no flag at all, enough follows is the answer. */ +internal fun FollowGraphStatusDto.toDomain(): FollowGraphStatus = FollowGraphStatus( + isWorthwhile = worthwhile ?: eligible ?: ready ?: (needs == 0 || following >= needs), + following = following, + needs = needs, +) + +/** A category's tags arrive as `hashtags` or `tags`; an unnamed one is keyed by its name. */ +@Serializable +internal data class DiscoverCategoryDto( + @Serializable(with = OptionalIdSerializer::class) val id: String? = null, + @Serializable(with = LenientTextSerializer::class) val name: String? = null, + @Serializable(with = LossyListSerializer::class) val hashtags: List? = null, + @Serializable(with = LossyListSerializer::class) val tags: List? = null, +) + +internal fun DiscoverCategoryDto.toDomain(): DiscoverCategory { + val name = name.orEmpty() + return DiscoverCategory( + id = id ?: name.lowercase(), + name = name, + hashtags = (hashtags ?: tags).orEmpty().mapNotNull(Tag::normalise), + ) +} diff --git a/core/network/src/main/kotlin/social/aloha/core/network/dto/DiscoveryDto.kt b/core/network/src/main/kotlin/social/aloha/core/network/dto/DiscoveryDto.kt new file mode 100644 index 00000000..40cd43c4 --- /dev/null +++ b/core/network/src/main/kotlin/social/aloha/core/network/dto/DiscoveryDto.kt @@ -0,0 +1,79 @@ +// SPDX-FileCopyrightText: 2026 Aloha Social contributors +// SPDX-License-Identifier: MIT + +package social.aloha.core.network.dto + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import social.aloha.core.model.AuthorizationServerMetadata +import social.aloha.core.model.NodeInfo +import social.aloha.core.network.decoding.LenientBoolSerializer +import social.aloha.core.network.decoding.LenientUrlSerializer +import social.aloha.core.network.decoding.LossyListSerializer + +@Serializable +internal data class NodeInfoDto( + val version: String = "", + val software: SoftwareDto = SoftwareDto(), + @Serializable(with = LossyListSerializer::class) val protocols: List = emptyList(), + @Serializable(with = LenientBoolSerializer::class) val openRegistrations: Boolean = false, +) { + @Serializable + data class SoftwareDto( + val name: String = "", + val version: String = "", + @Serializable(with = LenientUrlSerializer::class) val repository: String? = null, + @Serializable(with = LenientUrlSerializer::class) val homepage: String? = null, + ) +} + +internal fun NodeInfoDto.toDomain(): NodeInfo = NodeInfo( + version = version, + softwareName = software.name, + softwareVersion = software.version, + repository = software.repository, + homepage = software.homepage, + protocols = protocols, + openRegistrations = openRegistrations, +) + +/** `/.well-known/nodeinfo`: where the NodeInfo documents are. */ +@Serializable +internal data class NodeInfoDirectoryDto( + @Serializable(with = LossyListSerializer::class) val links: List = emptyList(), +) { + @Serializable + data class LinkDto(val rel: String = "", @Serializable(with = LenientUrlSerializer::class) val href: String? = null) + + /** The href of the newest schema version listed, since 2.1 carries the repository and homepage. */ + fun newestHref(): String? = links.filter { it.href != null && it.rel.contains("nodeinfo") } + .maxByOrNull { it.rel.substringAfterLast('/') } + ?.href +} + +@Serializable +internal data class AuthorizationServerMetadataDto( + @Serializable(with = LenientUrlSerializer::class) val issuer: String? = null, + @SerialName("authorization_endpoint") @Serializable(with = LenientUrlSerializer::class) + val authorizationEndpoint: String? = null, + @SerialName("token_endpoint") @Serializable(with = LenientUrlSerializer::class) val tokenEndpoint: String? = null, + @SerialName("revocation_endpoint") @Serializable(with = LenientUrlSerializer::class) + val revocationEndpoint: String? = null, + @SerialName("userinfo_endpoint") @Serializable(with = LenientUrlSerializer::class) + val userinfoEndpoint: String? = null, + @SerialName("code_challenge_methods_supported") val codeChallengeMethods: List? = null, + @SerialName("scopes_supported") val scopes: List? = null, +) + +/** Null when the document names no issuer, which makes it useless for discovery. */ +internal fun AuthorizationServerMetadataDto.toDomain(): AuthorizationServerMetadata? = issuer?.let { + AuthorizationServerMetadata( + issuer = it, + authorizationEndpoint = authorizationEndpoint, + tokenEndpoint = tokenEndpoint, + revocationEndpoint = revocationEndpoint, + userinfoEndpoint = userinfoEndpoint, + codeChallengeMethods = codeChallengeMethods, + scopes = scopes, + ) +} diff --git a/core/network/src/main/kotlin/social/aloha/core/network/dto/InstanceConfigurationDto.kt b/core/network/src/main/kotlin/social/aloha/core/network/dto/InstanceConfigurationDto.kt new file mode 100644 index 00000000..3fc79b25 --- /dev/null +++ b/core/network/src/main/kotlin/social/aloha/core/network/dto/InstanceConfigurationDto.kt @@ -0,0 +1,133 @@ +// SPDX-FileCopyrightText: 2026 Aloha Social contributors +// SPDX-License-Identifier: MIT + +package social.aloha.core.network.dto + +import kotlinx.serialization.KSerializer +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import social.aloha.core.model.ServerLimits +import social.aloha.core.network.decoding.LenientBoolSerializer +import social.aloha.core.network.decoding.LenientUrlSerializer +import social.aloha.core.network.decoding.LossyListSerializer +import social.aloha.core.network.decoding.OptionalIntSerializer +import social.aloha.core.network.decoding.OrNullSerializer + +/** + * An instance's `configuration`. Each block is decoded on its own, so one odd block leaves only its own + * limits at Mastodon's defaults. + */ +@Serializable +internal data class InstanceConfigurationDto( + @Serializable(with = UrlsOrNull::class) val urls: InstanceUrlsDto? = null, + @Serializable(with = StatusesConfigOrNull::class) val statuses: StatusesConfigDto? = null, + @SerialName("media_attachments") @Serializable(with = MediaConfigOrNull::class) + val mediaAttachments: MediaConfigDto? = null, + @Serializable(with = PollsConfigOrNull::class) val polls: PollsConfigDto? = null, + @Serializable(with = AccountsConfigOrNull::class) val accounts: AccountsConfigDto? = null, + @Serializable(with = TranslationConfigOrNull::class) val translation: TranslationConfigDto? = null, + @Serializable(with = VapidOrNull::class) val vapid: VapidDto? = null, +) + +/** + * `urls`, an empty object on Nextcloud Social, which is the signal that there is no streaming. v1 spells + * the key `streaming_api` and Mastodon 4.x's v2 `configuration.urls` spells it `streaming`; both are read. + */ +@Serializable +internal data class InstanceUrlsDto( + @SerialName("streaming_api") @Serializable(with = LenientUrlSerializer::class) val streamingApi: String? = null, + @Serializable(with = LenientUrlSerializer::class) val streaming: String? = null, +) { + val resolved: String? get() = streaming ?: streamingApi +} + +@Serializable +internal data class StatusesConfigDto( + @SerialName("max_characters") @Serializable(with = OptionalIntSerializer::class) val maxCharacters: Int? = null, + @SerialName("max_media_attachments") @Serializable(with = OptionalIntSerializer::class) + val maxMediaAttachments: Int? = null, + @SerialName("characters_reserved_per_url") @Serializable(with = OptionalIntSerializer::class) + val charactersReservedPerUrl: Int? = null, +) + +@Serializable +internal data class MediaConfigDto( + @SerialName("supported_mime_types") @Serializable(with = LossyListSerializer::class) + val supportedMimeTypes: List? = null, + @SerialName("image_size_limit") val imageSizeLimit: Long? = null, + @SerialName("video_size_limit") val videoSizeLimit: Long? = null, +) + +@Serializable +internal data class PollsConfigDto( + @SerialName("max_options") @Serializable(with = OptionalIntSerializer::class) val maxOptions: Int? = null, + @SerialName("max_characters_per_option") @Serializable(with = OptionalIntSerializer::class) + val maxCharactersPerOption: Int? = null, + @SerialName("min_expiration") val minExpiration: Long? = null, + @SerialName("max_expiration") val maxExpiration: Long? = null, +) + +@Serializable +internal data class AccountsConfigDto( + @SerialName("max_featured_tags") @Serializable(with = OptionalIntSerializer::class) + val maxFeaturedTags: Int? = null, +) + +@Serializable +internal data class TranslationConfigDto( + @Serializable(with = LenientBoolSerializer::class) val enabled: Boolean = false, +) + +@Serializable +internal data class VapidDto(@SerialName("public_key") val publicKey: String? = null) + +internal object UrlsOrNull : KSerializer by OrNullSerializer(InstanceUrlsDto.serializer()) + +internal object ConfigurationOrNull : KSerializer by +OrNullSerializer(InstanceConfigurationDto.serializer()) + +internal object StatusesConfigOrNull : KSerializer by OrNullSerializer( + StatusesConfigDto.serializer(), +) + +internal object MediaConfigOrNull : KSerializer by OrNullSerializer(MediaConfigDto.serializer()) + +internal object PollsConfigOrNull : KSerializer by OrNullSerializer(PollsConfigDto.serializer()) + +internal object AccountsConfigOrNull : KSerializer by OrNullSerializer( + AccountsConfigDto.serializer(), +) + +internal object TranslationConfigOrNull : KSerializer by +OrNullSerializer(TranslationConfigDto.serializer()) + +internal object VapidOrNull : KSerializer by OrNullSerializer(VapidDto.serializer()) + +/** The limits a configuration states, each one falling back to Mastodon's default on its own. */ +internal fun InstanceConfigurationDto?.toLimits(): ServerLimits = ServerLimits.MastodonDefaults + .with(this?.statuses) + .with(this?.mediaAttachments) + .with(this?.polls) + .with(this?.accounts) + +private fun ServerLimits.with(statuses: StatusesConfigDto?): ServerLimits = copy( + maxStatusCharacters = statuses?.maxCharacters ?: maxStatusCharacters, + maxMediaAttachments = statuses?.maxMediaAttachments ?: maxMediaAttachments, + charactersReservedPerUrl = statuses?.charactersReservedPerUrl ?: charactersReservedPerUrl, +) + +private fun ServerLimits.with(media: MediaConfigDto?): ServerLimits = copy( + imageSizeLimit = media?.imageSizeLimit ?: imageSizeLimit, + videoSizeLimit = media?.videoSizeLimit ?: videoSizeLimit, + supportedMimeTypes = media?.supportedMimeTypes ?: supportedMimeTypes, +) + +private fun ServerLimits.with(polls: PollsConfigDto?): ServerLimits = copy( + maxPollOptions = polls?.maxOptions ?: maxPollOptions, + maxPollOptionCharacters = polls?.maxCharactersPerOption ?: maxPollOptionCharacters, + minPollExpiration = polls?.minExpiration ?: minPollExpiration, + maxPollExpiration = polls?.maxExpiration ?: maxPollExpiration, +) + +private fun ServerLimits.with(accounts: AccountsConfigDto?): ServerLimits = + copy(maxFeaturedTags = accounts?.maxFeaturedTags ?: maxFeaturedTags) diff --git a/core/network/src/main/kotlin/social/aloha/core/network/dto/InstanceDto.kt b/core/network/src/main/kotlin/social/aloha/core/network/dto/InstanceDto.kt new file mode 100644 index 00000000..455901e4 --- /dev/null +++ b/core/network/src/main/kotlin/social/aloha/core/network/dto/InstanceDto.kt @@ -0,0 +1,172 @@ +// SPDX-FileCopyrightText: 2026 Aloha Social contributors +// SPDX-License-Identifier: MIT + +package social.aloha.core.network.dto + +import java.time.Instant +import kotlinx.serialization.KSerializer +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.builtins.MapSerializer +import kotlinx.serialization.builtins.serializer +import social.aloha.core.model.InstanceDescription +import social.aloha.core.model.InstanceDocument +import social.aloha.core.model.InstanceRule +import social.aloha.core.network.decoding.FlexibleIdSerializer +import social.aloha.core.network.decoding.LenientBoolSerializer +import social.aloha.core.network.decoding.LenientInstantSerializer +import social.aloha.core.network.decoding.LenientUrlSerializer +import social.aloha.core.network.decoding.LossyListSerializer +import social.aloha.core.network.decoding.OptionalIntSerializer +import social.aloha.core.network.decoding.OrNullSerializer + +/** + * `/api/v2/instance`. v2 renames v1's `uri` to `domain`, makes `thumbnail` an object, moves contact and + * registration fields under `contact` and `registrations`, and folds `urls` and `translation` into + * `configuration`. + */ +@Serializable +internal data class InstanceV2Dto( + val domain: String? = null, + val title: String? = null, + val version: String? = null, + @SerialName("source_url") val sourceUrl: String? = null, + val description: String? = null, + @Serializable(with = ThumbnailOrNull::class) val thumbnail: ThumbnailDto? = null, + @Serializable(with = LossyListSerializer::class) val languages: List? = null, + @Serializable(with = LossyListSerializer::class) val rules: List? = null, + @Serializable(with = ContactOrNull::class) val contact: ContactDto? = null, + @Serializable(with = RegistrationsOrNull::class) val registrations: RegistrationsDto? = null, + @Serializable(with = UsageOrNull::class) val usage: UsageDto? = null, + @SerialName("api_versions") @Serializable(with = ApiVersionsOrNull::class) + val apiVersions: Map? = null, + @Serializable(with = ConfigurationOrNull::class) val configuration: InstanceConfigurationDto? = null, +) + +/** `/api/v1/instance/`. */ +@Serializable +internal data class InstanceV1Dto( + val uri: String? = null, + val title: String? = null, + val version: String? = null, + @SerialName("short_description") val shortDescription: String? = null, + val description: String? = null, + @Serializable(with = LenientUrlSerializer::class) val thumbnail: String? = null, + @Serializable(with = LossyListSerializer::class) val languages: List? = null, + @Serializable(with = LossyListSerializer::class) val rules: List? = null, + val email: String? = null, + @SerialName("contact_account") @Serializable(with = AccountOrNull::class) val contactAccount: AccountDto? = null, + @Serializable(with = LenientBoolSerializer::class) val registrations: Boolean = false, + @SerialName("approval_required") @Serializable(with = LenientBoolSerializer::class) + val approvalRequired: Boolean = false, + @Serializable(with = StatsOrNull::class) val stats: StatsDto? = null, + @Serializable(with = UrlsOrNull::class) val urls: InstanceUrlsDto? = null, + @Serializable(with = ConfigurationOrNull::class) val configuration: InstanceConfigurationDto? = null, +) + +@Serializable +internal data class InstanceRuleDto( + @Serializable(with = FlexibleIdSerializer::class) val id: String, + val text: String = "", + val hint: String? = null, +) + +@Serializable +internal data class InstanceDocumentDto( + val content: String = "", + @SerialName("updated_at") @Serializable(with = LenientInstantSerializer::class) val updatedAt: Instant? = null, +) + +@Serializable +internal data class ThumbnailDto(@Serializable(with = LenientUrlSerializer::class) val url: String? = null) + +@Serializable +internal data class ContactDto( + val email: String? = null, + @Serializable(with = AccountOrNull::class) val account: AccountDto? = null, +) + +@Serializable +internal data class RegistrationsDto( + @Serializable(with = LenientBoolSerializer::class) val enabled: Boolean = false, + @SerialName("approval_required") @Serializable(with = LenientBoolSerializer::class) + val approvalRequired: Boolean = false, +) + +@Serializable +internal data class UsageDto(@Serializable(with = UsageUsersOrNull::class) val users: UsageUsersDto? = null) + +@Serializable +internal data class UsageUsersDto( + @SerialName("active_month") @Serializable(with = OptionalIntSerializer::class) val activeMonth: Int? = null, +) + +@Serializable +internal data class StatsDto( + @SerialName("user_count") @Serializable(with = OptionalIntSerializer::class) val userCount: Int? = null, + @SerialName("status_count") @Serializable(with = OptionalIntSerializer::class) val statusCount: Int? = null, + @SerialName("domain_count") @Serializable(with = OptionalIntSerializer::class) val domainCount: Int? = null, +) + +internal object ThumbnailOrNull : KSerializer by OrNullSerializer(ThumbnailDto.serializer()) + +internal object ContactOrNull : KSerializer by OrNullSerializer(ContactDto.serializer()) + +internal object RegistrationsOrNull : KSerializer by OrNullSerializer(RegistrationsDto.serializer()) + +internal object UsageOrNull : KSerializer by OrNullSerializer(UsageDto.serializer()) + +internal object UsageUsersOrNull : KSerializer by OrNullSerializer(UsageUsersDto.serializer()) + +internal object StatsOrNull : KSerializer by OrNullSerializer(StatsDto.serializer()) + +internal object ApiVersionsOrNull : KSerializer?> by +OrNullSerializer(MapSerializer(String.serializer(), Int.serializer())) + +internal fun InstanceV2Dto.toDomain(): InstanceDescription = InstanceDescription( + domain = domain.orEmpty(), + title = title.orEmpty(), + version = version.orEmpty(), + sourceUrl = sourceUrl, + shortDescription = description.orEmpty(), + description = description.orEmpty(), + thumbnail = thumbnail?.url, + languages = languages.orEmpty(), + rules = rules.orEmpty().map { it.toDomain() }, + contactAccount = contact?.account?.toDomain(), + contactEmail = contact?.email, + registrationsEnabled = registrations?.enabled ?: false, + approvalRequired = registrations?.approvalRequired ?: false, + userCount = usage?.users?.activeMonth, + streamingUrl = configuration?.urls?.resolved, + vapidKey = configuration?.vapid?.publicKey, + translationEnabled = configuration?.translation?.enabled ?: false, + apiVersions = apiVersions.orEmpty(), + limits = configuration.toLimits(), +) + +internal fun InstanceV1Dto.toDomain(): InstanceDescription = InstanceDescription( + domain = uri.orEmpty(), + title = title.orEmpty(), + version = version.orEmpty(), + shortDescription = shortDescription.orEmpty(), + description = description.orEmpty(), + thumbnail = thumbnail, + languages = languages.orEmpty(), + rules = rules.orEmpty().map { it.toDomain() }, + contactAccount = contactAccount?.toDomain(), + contactEmail = email, + registrationsEnabled = registrations, + approvalRequired = approvalRequired, + userCount = stats?.userCount, + statusCount = stats?.statusCount, + domainCount = stats?.domainCount, + streamingUrl = urls?.resolved ?: configuration?.urls?.resolved, + vapidKey = configuration?.vapid?.publicKey, + translationEnabled = configuration?.translation?.enabled ?: false, + limits = configuration.toLimits(), +) + +internal fun InstanceRuleDto.toDomain(): InstanceRule = InstanceRule(id, text, hint) + +internal fun InstanceDocumentDto.toDomain(): InstanceDocument = InstanceDocument(content, updatedAt) diff --git a/core/network/src/main/kotlin/social/aloha/core/network/dto/InterestsDto.kt b/core/network/src/main/kotlin/social/aloha/core/network/dto/InterestsDto.kt new file mode 100644 index 00000000..1dcfc889 --- /dev/null +++ b/core/network/src/main/kotlin/social/aloha/core/network/dto/InterestsDto.kt @@ -0,0 +1,151 @@ +// SPDX-FileCopyrightText: 2026 Aloha Social contributors +// SPDX-License-Identifier: MIT + +package social.aloha.core.network.dto + +import java.time.Instant +import kotlinx.serialization.KSerializer +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import social.aloha.core.model.HeldPost +import social.aloha.core.model.HeldPostsPage +import social.aloha.core.model.InterestSettings +import social.aloha.core.model.InterestTag +import social.aloha.core.model.InterestsState +import social.aloha.core.model.SubscriptionEntry +import social.aloha.core.model.SubscriptionFeed +import social.aloha.core.model.WeeklyRecap +import social.aloha.core.network.decoding.FlexibleIdSerializer +import social.aloha.core.network.decoding.LenientBoolSerializer +import social.aloha.core.network.decoding.LenientInstantSerializer +import social.aloha.core.network.decoding.LenientIntSerializer +import social.aloha.core.network.decoding.LenientTextSerializer +import social.aloha.core.network.decoding.LenientUrlSerializer +import social.aloha.core.network.decoding.LossyListSerializer +import social.aloha.core.network.decoding.OptionalBoolSerializer +import social.aloha.core.network.decoding.OptionalDoubleSerializer +import social.aloha.core.network.decoding.OptionalIntSerializer +import social.aloha.core.network.decoding.OrNullSerializer + +/** The interests shape has grown on the server by accretion; every field tolerates absence. */ +@Serializable +internal data class InterestsStateDto( + @Serializable(with = InterestSettingsOrNull::class) val settings: InterestSettingsDto? = null, + @Serializable(with = LossyListSerializer::class) val interests: List = emptyList(), + @Serializable(with = LossyListSerializer::class) val candidates: List = emptyList(), + @Serializable(with = LenientBoolSerializer::class) val thin: Boolean = false, +) + +/** Learning is on unless the server says otherwise. */ +@Serializable +internal data class InterestSettingsDto( + @Serializable(with = OptionalBoolSerializer::class) val learning: Boolean? = null, + @Serializable(with = LenientBoolSerializer::class) val paused: Boolean = false, + @Serializable(with = LossyListSerializer::class) val languages: List = emptyList(), +) + +internal object InterestSettingsOrNull : KSerializer by OrNullSerializer( + InterestSettingsDto.serializer(), +) + +/** A ranked tag, named `tag` or `name`, whose score may be a string. */ +@Serializable +internal data class InterestTagDto( + @Serializable(with = LenientTextSerializer::class) val tag: String? = null, + @Serializable(with = LenientTextSerializer::class) val name: String? = null, + @Serializable(with = OptionalDoubleSerializer::class) val score: Double? = null, + @Serializable(with = LenientBoolSerializer::class) val pinned: Boolean = false, +) + +internal fun InterestsStateDto.toDomain(): InterestsState = InterestsState( + settings = settings?.let { InterestSettings(it.learning ?: true, it.paused, it.languages) } ?: InterestSettings(), + interests = interests.map { it.toDomain() }, + candidates = candidates.map { it.toDomain() }, + thin = thin, +) + +internal fun InterestTagDto.toDomain(): InterestTag = InterestTag(tag ?: name.orEmpty(), score ?: 0.0, pinned) + +/** A feed's home page arrives as `site_url` or `link`, its count as `entry_count` or `entries`. */ +@Serializable +internal data class SubscriptionFeedDto( + @Serializable(with = FlexibleIdSerializer::class) val id: String, + @Serializable(with = LenientUrlSerializer::class) val url: String? = null, + @Serializable(with = LenientTextSerializer::class) val title: String? = null, + @SerialName("site_url") @Serializable(with = LenientUrlSerializer::class) val siteUrl: String? = null, + @Serializable(with = LenientUrlSerializer::class) val link: String? = null, + @SerialName("entry_count") @Serializable(with = OptionalIntSerializer::class) val entryCount: Int? = null, + @Serializable(with = OptionalIntSerializer::class) val entries: Int? = null, + @Serializable(with = LenientTextSerializer::class) val error: String? = null, + @SerialName("last_read_at") @Serializable(with = LenientInstantSerializer::class) val lastReadAt: Instant? = null, +) + +internal fun SubscriptionFeedDto.toDomain(): SubscriptionFeed = SubscriptionFeed( + id = id, + url = url, + title = title.orEmpty(), + siteUrl = siteUrl ?: link, + entryCount = entryCount ?: entries ?: 0, + error = error, + lastReadAt = lastReadAt, +) + +@Serializable +internal data class SubscriptionEntryDto( + @Serializable(with = FlexibleIdSerializer::class) val id: String, + @Serializable(with = LenientTextSerializer::class) val title: String? = null, + @Serializable(with = LenientUrlSerializer::class) val url: String? = null, + @Serializable(with = LenientUrlSerializer::class) val link: String? = null, + @Serializable(with = LenientTextSerializer::class) val summary: String? = null, + @Serializable(with = LenientInstantSerializer::class) val published: Instant? = null, + @SerialName("published_at") @Serializable(with = LenientInstantSerializer::class) val publishedAt: Instant? = null, + @SerialName("feed_title") @Serializable(with = LenientTextSerializer::class) val feedTitle: String? = null, + @Serializable(with = LenientTextSerializer::class) val feed: String? = null, +) + +internal fun SubscriptionEntryDto.toDomain(): SubscriptionEntry = + SubscriptionEntry(id, title.orEmpty(), url ?: link, summary, published ?: publishedAt, feedTitle ?: feed) + +/** A Google Takeout import answers with how many channels it subscribed to. */ +@Serializable +internal data class TakeoutResultDto(@Serializable(with = LenientIntSerializer::class) val subscribed: Int = 0) + +@Serializable +internal data class WeeklyRecapDto( + @Serializable(with = LenientBoolSerializer::class) val enabled: Boolean = false, + @SerialName("this_week") @Serializable(with = LenientIntSerializer::class) val thisWeek: Int = 0, + @SerialName("last_week") @Serializable(with = LenientIntSerializer::class) val lastWeek: Int = 0, +) + +internal fun WeeklyRecapDto.toDomain(): WeeklyRecap = WeeklyRecap(enabled, thisWeek, lastWeek) + +@Serializable +internal data class HeldPostDto( + @Serializable(with = FlexibleIdSerializer::class) val id: String, + @SerialName("created_at") @Serializable(with = LenientInstantSerializer::class) val createdAt: Instant? = null, + @SerialName("spoiler_text") @Serializable(with = LenientTextSerializer::class) val spoilerText: String? = null, + @Serializable(with = LenientTextSerializer::class) val text: String? = null, + @Serializable(with = LenientTextSerializer::class) val content: String? = null, + @SerialName("media_count") @Serializable(with = LenientIntSerializer::class) val mediaCount: Int = 0, + @Serializable(with = LenientTextSerializer::class) val reason: String? = null, +) + +@Serializable +internal data class HeldPostsPageDto( + @Serializable(with = LossyListSerializer::class) val held: List = emptyList(), + @Serializable(with = LossyListSerializer::class) val reasons: List = emptyList(), +) + +internal fun HeldPostsPageDto.toDomain(): HeldPostsPage = HeldPostsPage( + held = held.map { + HeldPost( + it.id, + it.createdAt, + it.spoilerText.orEmpty(), + it.text ?: it.content.orEmpty(), + it.mediaCount, + it.reason, + ) + }, + reasons = reasons, +) diff --git a/core/network/src/main/kotlin/social/aloha/core/network/dto/MediaAttachmentDto.kt b/core/network/src/main/kotlin/social/aloha/core/network/dto/MediaAttachmentDto.kt new file mode 100644 index 00000000..29b7e3f4 --- /dev/null +++ b/core/network/src/main/kotlin/social/aloha/core/network/dto/MediaAttachmentDto.kt @@ -0,0 +1,88 @@ +// SPDX-FileCopyrightText: 2026 Aloha Social contributors +// SPDX-License-Identifier: MIT + +package social.aloha.core.network.dto + +import kotlinx.serialization.KSerializer +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import social.aloha.core.model.AttachmentKind +import social.aloha.core.model.MediaAttachment +import social.aloha.core.model.MediaDimensions +import social.aloha.core.model.MediaFocus +import social.aloha.core.model.MediaMeta +import social.aloha.core.network.decoding.EmptyArrayAsNullSerializer +import social.aloha.core.network.decoding.FlexibleIdSerializer +import social.aloha.core.network.decoding.LenientUrlSerializer +import social.aloha.core.network.decoding.OptionalDoubleSerializer +import social.aloha.core.network.decoding.OptionalIntSerializer +import social.aloha.core.network.decoding.OrNullSerializer + +/** + * An attachment on the wire. Without ffmpeg Nextcloud Social sends `meta.original: []` and a preview + * equal to the video URL; a fork may send `meta: []`. Either shape leaves the dimensions absent. + */ +@Serializable +internal data class MediaAttachmentDto( + @Serializable(with = FlexibleIdSerializer::class) val id: String, + val type: String? = null, + @Serializable(with = LenientUrlSerializer::class) val url: String? = null, + @SerialName("preview_url") @Serializable(with = LenientUrlSerializer::class) val previewUrl: String? = null, + @SerialName("remote_url") @Serializable(with = LenientUrlSerializer::class) val remoteUrl: String? = null, + val description: String? = null, + val blurhash: String? = null, + @Serializable(with = MediaMetaOrNull::class) val meta: MediaMetaDto? = null, + @SerialName("hls_url") @Serializable(with = LenientUrlSerializer::class) val hlsUrl: String? = null, +) + +@Serializable +internal data class MediaMetaDto( + @Serializable(with = MediaDimensionsOrNull::class) val original: MediaDimensionsDto? = null, + @Serializable(with = MediaDimensionsOrNull::class) val small: MediaDimensionsDto? = null, + @Serializable(with = MediaFocusOrNull::class) val focus: MediaFocusDto? = null, +) + +@Serializable +internal data class MediaDimensionsDto( + @Serializable(with = OptionalIntSerializer::class) val width: Int? = null, + @Serializable(with = OptionalIntSerializer::class) val height: Int? = null, + val size: String? = null, + @Serializable(with = OptionalDoubleSerializer::class) val aspect: Double? = null, + @Serializable(with = OptionalDoubleSerializer::class) val duration: Double? = null, + @SerialName("frame_rate") val frameRate: String? = null, + @Serializable(with = OptionalIntSerializer::class) val bitrate: Int? = null, +) + +@Serializable +internal data class MediaFocusDto( + @Serializable(with = OptionalDoubleSerializer::class) val x: Double? = null, + @Serializable(with = OptionalDoubleSerializer::class) val y: Double? = null, +) + +internal object MediaMetaOrNull : KSerializer by EmptyArrayAsNullSerializer(MediaMetaDto.serializer()) + +internal object MediaDimensionsOrNull : KSerializer by +EmptyArrayAsNullSerializer(MediaDimensionsDto.serializer()) + +internal object MediaFocusOrNull : KSerializer by OrNullSerializer(MediaFocusDto.serializer()) + +internal fun MediaAttachmentDto.toDomain(): MediaAttachment = MediaAttachment( + id = id, + type = AttachmentKind.fromWire(type), + url = url, + previewUrl = previewUrl, + remoteUrl = remoteUrl, + description = description, + blurhash = blurhash, + meta = meta?.toDomain(), + hlsUrl = hlsUrl, +) + +internal fun MediaMetaDto.toDomain(): MediaMeta = MediaMeta( + original = original?.toDomain(), + small = small?.toDomain(), + focus = focus?.let { if (it.x != null && it.y != null) MediaFocus(it.x, it.y) else null }, +) + +internal fun MediaDimensionsDto.toDomain(): MediaDimensions = + MediaDimensions(width, height, size, aspect, duration, frameRate, bitrate) diff --git a/core/network/src/main/kotlin/social/aloha/core/network/dto/MigrationDto.kt b/core/network/src/main/kotlin/social/aloha/core/network/dto/MigrationDto.kt new file mode 100644 index 00000000..e7c6eaba --- /dev/null +++ b/core/network/src/main/kotlin/social/aloha/core/network/dto/MigrationDto.kt @@ -0,0 +1,127 @@ +// SPDX-FileCopyrightText: 2026 Aloha Social contributors +// SPDX-License-Identifier: MIT + +package social.aloha.core.network.dto + +import kotlinx.serialization.KSerializer +import kotlinx.serialization.Serializable +import kotlinx.serialization.SerializationException +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +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.longOrNull +import social.aloha.core.model.MigrationAnnouncement +import social.aloha.core.model.MigrationEntry +import social.aloha.core.model.MigrationLine +import social.aloha.core.model.MigrationLookup +import social.aloha.core.model.MigrationReport +import social.aloha.core.network.decoding.LenientTextSerializer +import social.aloha.core.network.decoding.OptionalBoolSerializer +import social.aloha.core.network.decoding.decodeOrRecord +import social.aloha.core.network.decoding.lossy + +private fun decodeOnly(): Nothing = throw SerializationException("wire DTOs are decode-only") + +@Serializable +internal data class MigrationAnnouncementDto( + @Serializable(with = LenientTextSerializer::class) val handle: String? = null, + @Serializable(with = LenientTextSerializer::class) val address: String? = null, +) + +internal fun MigrationAnnouncementDto.toDomain(): MigrationAnnouncement = + MigrationAnnouncement(handle.orEmpty(), address.orEmpty()) + +@Serializable +internal data class MigrationRawEntryDto( + @Serializable(with = LenientTextSerializer::class) val handle: String? = null, + @Serializable(with = LenientTextSerializer::class) val acct: String? = null, + @Serializable(with = OptionalBoolSerializer::class) val found: Boolean? = null, + @Serializable(with = AccountOrNull::class) val account: AccountDto? = null, +) + +/** + * Which handles exist somewhere. The server has sent this in two shapes: `{results: [...]}` with one + * entry per handle, and `{found, accounts, missing}` where `found` holds accounts or bare handles. + * Both are read; a handle is listed once, first mention wins. + */ +internal object MigrationLookupSerializer : KSerializer { + override val descriptor: SerialDescriptor = MigrationRawEntryDto.serializer().descriptor + + override fun deserialize(decoder: Decoder): MigrationLookup { + val json = decoder as? JsonDecoder ?: throw SerializationException("JSON only") + val root = json.decodeJsonElement() as? JsonObject ?: return MigrationLookup() + val results = json.json.lossy(MigrationRawEntryDto.serializer(), root["results"]).mapNotNull { raw -> + val account = raw.account?.toDomain() + val handle = raw.handle ?: raw.acct ?: account?.acct + handle?.takeIf { it.isNotEmpty() }?.let { MigrationEntry(it, raw.found ?: (account != null), account) } + } + val found = (root["found"] as? JsonArray).orEmpty().mapIndexedNotNull { index, item -> + foundEntry(json, item, index) + } + val accounts = json.json.lossy(AccountDto.serializer(), root["accounts"]).map { it.toDomain() } + .map { MigrationEntry(it.acct, found = true, account = it) } + val missing = (root["missing"] as? JsonArray).orEmpty().mapNotNull { + it.text() + }.map { MigrationEntry(it, found = false) } + return MigrationLookup((results + found + accounts + missing).distinctBy { it.handle }) + } + + private fun foundEntry(json: JsonDecoder, item: JsonElement, index: Int): MigrationEntry? = when (item) { + is JsonPrimitive -> item.text()?.let { MigrationEntry(it, found = true) } + + is JsonObject -> json.json.decodeOrRecord(AccountDto.serializer(), item, index)?.toDomain() + ?.let { MigrationEntry(it.acct, found = true, account = it) } + + else -> null + } + + override fun serialize(encoder: Encoder, value: MigrationLookup): Unit = decodeOnly() +} + +private fun JsonElement.text(): String? = + (this as? JsonPrimitive)?.takeUnless { it is JsonNull }?.content?.takeIf { it.isNotEmpty() } + +/** + * An import report: every key kept as it came, numbers and yes/no as text, a list as its length, + * and `log` as the lines of the log. Sorted by key. + */ +internal object MigrationReportSerializer : KSerializer { + override val descriptor: SerialDescriptor = MigrationRawEntryDto.serializer().descriptor + + override fun deserialize(decoder: Decoder): MigrationReport { + val root = (decoder as? JsonDecoder)?.decodeJsonElement() as? JsonObject ?: return MigrationReport() + val log = (root["log"] as? JsonArray).orEmpty().mapNotNull { it.text() } + val lines = root.filterKeys { it != "log" }.toSortedMap().mapNotNull { (key, value) -> + value.reportValue()?.let { MigrationLine(key, it) } + } + return MigrationReport(lines, log) + } + + private fun JsonElement.reportValue(): String? = when (this) { + is JsonArray -> size.toString() + is JsonNull -> null + is JsonPrimitive -> primitiveValue() + else -> null + } + + private fun JsonPrimitive.primitiveValue(): String { + val number = longOrNull + val flag = booleanOrNull + return when { + isString -> content + number != null -> number.toString() + flag == true -> "yes" + flag == false -> "no" + else -> content + } + } + + override fun serialize(encoder: Encoder, value: MigrationReport): Unit = decodeOnly() +} diff --git a/core/network/src/main/kotlin/social/aloha/core/network/dto/ModerationDto.kt b/core/network/src/main/kotlin/social/aloha/core/network/dto/ModerationDto.kt new file mode 100644 index 00000000..f07627df --- /dev/null +++ b/core/network/src/main/kotlin/social/aloha/core/network/dto/ModerationDto.kt @@ -0,0 +1,113 @@ +// SPDX-FileCopyrightText: 2026 Aloha Social contributors +// SPDX-License-Identifier: MIT + +package social.aloha.core.network.dto + +import java.time.Instant +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import social.aloha.core.model.AdminAccount +import social.aloha.core.model.AdminReport +import social.aloha.core.model.InstanceActivityWeek +import social.aloha.core.model.PublicDomainBlock +import social.aloha.core.network.decoding.FlexibleIdSerializer +import social.aloha.core.network.decoding.LenientBoolSerializer +import social.aloha.core.network.decoding.LenientInstantSerializer +import social.aloha.core.network.decoding.LenientIntSerializer +import social.aloha.core.network.decoding.LenientTextSerializer +import social.aloha.core.network.decoding.LossyListSerializer + +@Serializable +internal data class AdminAccountDto( + @Serializable(with = FlexibleIdSerializer::class) val id: String, + @Serializable(with = LenientTextSerializer::class) val username: String? = null, + @Serializable(with = LenientTextSerializer::class) val domain: String? = null, + @SerialName("created_at") @Serializable(with = LenientInstantSerializer::class) val createdAt: Instant? = null, + @Serializable(with = LenientBoolSerializer::class) val confirmed: Boolean = false, + @Serializable(with = LenientBoolSerializer::class) val approved: Boolean = false, + @Serializable(with = LenientBoolSerializer::class) val disabled: Boolean = false, + @Serializable(with = LenientBoolSerializer::class) val silenced: Boolean = false, + @Serializable(with = LenientBoolSerializer::class) val suspended: Boolean = false, + @Serializable(with = LenientBoolSerializer::class) val sensitized: Boolean = false, + @Serializable(with = AccountOrNull::class) val account: AccountDto? = null, +) + +internal fun AdminAccountDto.toDomain(): AdminAccount = AdminAccount( + id = id, + username = username.orEmpty(), + domain = domain?.takeIf { it.isNotEmpty() }, + createdAt = createdAt, + confirmed = confirmed, + approved = approved, + disabled = disabled, + silenced = silenced, + suspended = suspended, + sensitized = sensitized, + account = account?.toDomain(), +) + +@Serializable +internal data class AdminReportDto( + @Serializable(with = FlexibleIdSerializer::class) val id: String, + @SerialName("action_taken") @Serializable(with = LenientBoolSerializer::class) val actionTaken: Boolean = false, + @SerialName("action_taken_at") @Serializable(with = LenientInstantSerializer::class) + val actionTakenAt: Instant? = null, + @Serializable(with = LenientTextSerializer::class) val category: String? = null, + @Serializable(with = LenientTextSerializer::class) val comment: String? = null, + @Serializable(with = LenientBoolSerializer::class) val forwarded: Boolean = false, + @SerialName("created_at") @Serializable(with = LenientInstantSerializer::class) val createdAt: Instant? = null, + @SerialName("updated_at") @Serializable(with = LenientInstantSerializer::class) val updatedAt: Instant? = null, + @Serializable(with = AccountOrNull::class) val account: AccountDto? = null, + @SerialName("target_account") @Serializable(with = AccountOrNull::class) val targetAccount: AccountDto? = null, + @SerialName("assigned_account") @Serializable(with = AccountOrNull::class) val assignedAccount: AccountDto? = null, + @SerialName("action_taken_by_account") @Serializable(with = AccountOrNull::class) + val actionTakenByAccount: AccountDto? = null, + @Serializable(with = LossyListSerializer::class) val statuses: List = emptyList(), +) + +internal fun AdminReportDto.toDomain(): AdminReport = AdminReport( + id = id, + actionTaken = actionTaken, + actionTakenAt = actionTakenAt, + category = category.orEmpty(), + comment = comment.orEmpty(), + forwarded = forwarded, + createdAt = createdAt, + updatedAt = updatedAt, + account = account?.toDomain(), + targetAccount = targetAccount?.toDomain(), + assignedAccount = assignedAccount?.toDomain(), + actionTakenByAccount = actionTakenByAccount?.toDomain(), + statuses = statuses.map { it.toDomain() }, +) + +/** Every value of `/api/v1/instance/activity` arrives as a string. */ +@Serializable +internal data class InstanceActivityWeekDto( + @Serializable(with = LenientTextSerializer::class) val week: String? = null, + @Serializable(with = LenientIntSerializer::class) val statuses: Int = 0, + @Serializable(with = LenientIntSerializer::class) val logins: Int = 0, + @Serializable(with = LenientIntSerializer::class) val registrations: Int = 0, +) + +internal fun InstanceActivityWeekDto.toDomain(): InstanceActivityWeek = InstanceActivityWeek( + week = week?.toDoubleOrNull()?.toLong() ?: 0, + statuses = statuses, + logins = logins, + registrations = registrations, +) + +@Serializable +internal data class PublicDomainBlockDto( + @Serializable(with = LenientTextSerializer::class) val domain: String? = null, + @Serializable(with = LenientTextSerializer::class) val digest: String? = null, + @Serializable(with = LenientTextSerializer::class) val severity: String? = null, + @Serializable(with = LenientTextSerializer::class) val comment: String? = null, +) + +internal fun PublicDomainBlockDto.toDomain(): PublicDomainBlock = PublicDomainBlock( + domain = domain.orEmpty(), + digest = digest.orEmpty(), + severity = severity ?: "suspend", + comment = comment.orEmpty(), +) diff --git a/core/network/src/main/kotlin/social/aloha/core/network/dto/NextcloudThemeDto.kt b/core/network/src/main/kotlin/social/aloha/core/network/dto/NextcloudThemeDto.kt new file mode 100644 index 00000000..85782a70 --- /dev/null +++ b/core/network/src/main/kotlin/social/aloha/core/network/dto/NextcloudThemeDto.kt @@ -0,0 +1,100 @@ +// SPDX-FileCopyrightText: 2026 Aloha Social contributors +// SPDX-License-Identifier: MIT + +package social.aloha.core.network.dto + +import java.time.Instant +import kotlinx.serialization.KSerializer +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import social.aloha.core.model.DeliveryInstance +import social.aloha.core.model.DeliveryReport +import social.aloha.core.model.DeliveryState +import social.aloha.core.model.NextcloudTheme +import social.aloha.core.network.decoding.LenientInstantSerializer +import social.aloha.core.network.decoding.LenientIntSerializer +import social.aloha.core.network.decoding.LenientTextSerializer +import social.aloha.core.network.decoding.LossyListSerializer +import social.aloha.core.network.decoding.OrNullSerializer + +/** + * Nextcloud's OCS capabilities envelope, unwrapped only as far as `theming`; the rest of `capabilities` + * belongs to other apps. + */ +@Serializable +internal data class OcsCapabilitiesDto(@Serializable(with = OcsOrNull::class) val ocs: OcsDto? = null) { + val theming: NextcloudThemeDto? get() = ocs?.data?.capabilities?.theming +} + +@Serializable +internal data class OcsDto(@Serializable(with = OcsDataOrNull::class) val data: OcsDataDto? = null) + +@Serializable +internal data class OcsDataDto( + @Serializable(with = CapabilitiesOrNull::class) val capabilities: CapabilitiesDto? = null, +) + +@Serializable +internal data class CapabilitiesDto(@Serializable(with = ThemingOrNull::class) val theming: NextcloudThemeDto? = null) + +@Serializable +internal data class NextcloudThemeDto( + @Serializable(with = LenientTextSerializer::class) val name: String? = null, + @Serializable(with = LenientTextSerializer::class) val slogan: String? = null, + @Serializable(with = LenientTextSerializer::class) val color: String? = null, + @SerialName("color-element-bright") @Serializable(with = LenientTextSerializer::class) + val colorElementBright: String? = null, + @SerialName("color-element-dark") @Serializable(with = LenientTextSerializer::class) + val colorElementDark: String? = null, + @SerialName("color-text") @Serializable(with = LenientTextSerializer::class) val colorText: String? = null, +) + +/** Where one post got to. Author only. */ +@Serializable +internal data class DeliveryReportDto( + @Serializable(with = LenientIntSerializer::class) val total: Int = 0, + @Serializable(with = LenientIntSerializer::class) val delivered: Int = 0, + @Serializable(with = LenientIntSerializer::class) val sending: Int = 0, + @Serializable(with = LenientIntSerializer::class) val waiting: Int = 0, + @Serializable(with = LenientIntSerializer::class) val failing: Int = 0, + @Serializable(with = LenientIntSerializer::class) val abandoned: Int = 0, + @Serializable(with = LenientIntSerializer::class) val retention: Int = DeliveryReport.DEFAULT_RETENTION, + @Serializable(with = LossyListSerializer::class) val instances: List = emptyList(), +) + +/** One server a post was sent to; `last` is seen both as Unix seconds and as an ISO date. */ +@Serializable +internal data class DeliveryInstanceDto( + val host: String = "", + val state: String? = null, + @Serializable(with = LenientIntSerializer::class) val tries: Int = 0, + @Serializable(with = LenientInstantSerializer::class) val last: Instant? = null, +) + +internal object OcsOrNull : KSerializer by OrNullSerializer(OcsDto.serializer()) + +internal object OcsDataOrNull : KSerializer by OrNullSerializer(OcsDataDto.serializer()) + +internal object CapabilitiesOrNull : KSerializer by OrNullSerializer(CapabilitiesDto.serializer()) + +internal object ThemingOrNull : KSerializer by OrNullSerializer(NextcloudThemeDto.serializer()) + +internal fun NextcloudThemeDto.toDomain(): NextcloudTheme = NextcloudTheme( + name = name.orEmpty(), + slogan = slogan.orEmpty(), + colourHex = NextcloudTheme.normalise(color), + elementBrightHex = NextcloudTheme.normalise(colorElementBright), + elementDarkHex = NextcloudTheme.normalise(colorElementDark), + textHex = NextcloudTheme.normalise(colorText), +) + +internal fun DeliveryReportDto.toDomain(): DeliveryReport = DeliveryReport( + total = total, + delivered = delivered, + sending = sending, + waiting = waiting, + failing = failing, + abandoned = abandoned, + retention = retention, + instances = instances.map { DeliveryInstance(it.host, DeliveryState.fromWire(it.state), it.tries, it.last) }, +) diff --git a/core/network/src/main/kotlin/social/aloha/core/network/dto/NotificationDto.kt b/core/network/src/main/kotlin/social/aloha/core/network/dto/NotificationDto.kt new file mode 100644 index 00000000..11f0ae35 --- /dev/null +++ b/core/network/src/main/kotlin/social/aloha/core/network/dto/NotificationDto.kt @@ -0,0 +1,81 @@ +// SPDX-FileCopyrightText: 2026 Aloha Social contributors +// SPDX-License-Identifier: MIT + +package social.aloha.core.network.dto + +import java.time.Instant +import kotlinx.serialization.KSerializer +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import social.aloha.core.model.GroupedNotifications +import social.aloha.core.model.Notification +import social.aloha.core.model.NotificationGroup +import social.aloha.core.model.NotificationKind +import social.aloha.core.network.decoding.FlexibleIdSerializer +import social.aloha.core.network.decoding.LenientInstantSerializer +import social.aloha.core.network.decoding.LenientIntSerializer +import social.aloha.core.network.decoding.LossyListSerializer +import social.aloha.core.network.decoding.OptionalIdSerializer + +@Serializable +internal data class NotificationDto( + @Serializable(with = FlexibleIdSerializer::class) val id: String, + val type: String? = null, + @SerialName("created_at") @Serializable(with = LenientInstantSerializer::class) val createdAt: Instant? = null, + val account: AccountDto, + @Serializable(with = StatusOrNull::class) val status: StatusDto? = null, +) + +@Serializable +internal data class GroupedNotificationsDto( + @Serializable(with = LossyListSerializer::class) val accounts: List = emptyList(), + @Serializable(with = LossyListSerializer::class) val statuses: List = emptyList(), + @SerialName("notification_groups") @Serializable(with = LossyListSerializer::class) + val notificationGroups: List = emptyList(), +) + +@Serializable +internal data class NotificationGroupDto( + @SerialName("group_key") val groupKey: String? = null, + @SerialName("notifications_count") @Serializable(with = LenientIntSerializer::class) + val notificationsCount: Int = 0, + val type: String? = null, + @SerialName("most_recent_notification_id") @Serializable(with = OptionalIdSerializer::class) + val mostRecentNotificationId: String? = null, + @SerialName("page_min_id") @Serializable(with = OptionalIdSerializer::class) val pageMinId: String? = null, + @SerialName("page_max_id") @Serializable(with = OptionalIdSerializer::class) val pageMaxId: String? = null, + @SerialName("latest_page_notification_at") @Serializable(with = LenientInstantSerializer::class) + val latestPageNotificationAt: Instant? = null, + @SerialName("sample_account_ids") @Serializable(with = FlexibleIdListSerializer::class) + val sampleAccountIds: List = emptyList(), + @SerialName("status_id") @Serializable(with = OptionalIdSerializer::class) val statusId: String? = null, +) + +/** A list of ids that may arrive as numbers or strings; a malformed one is dropped. */ +internal object FlexibleIdListSerializer : KSerializer> by LossyListSerializer(FlexibleIdSerializer) + +internal fun NotificationDto.toDomain(): Notification = Notification( + id = id, + type = NotificationKind.fromWire(type), + account = account.toDomain(), + createdAt = createdAt ?: Instant.EPOCH, + status = status?.toDomain(), +) + +internal fun GroupedNotificationsDto.toDomain(): GroupedNotifications = GroupedNotifications( + accounts = accounts.map { it.toDomain() }, + statuses = statuses.map { it.toDomain() }, + notificationGroups = notificationGroups.map { it.toDomain() }, +) + +internal fun NotificationGroupDto.toDomain(): NotificationGroup = NotificationGroup( + groupKey = groupKey.orEmpty(), + notificationsCount = notificationsCount, + type = NotificationKind.fromWire(type), + mostRecentNotificationId = mostRecentNotificationId.orEmpty(), + pageMinId = pageMinId, + pageMaxId = pageMaxId, + latestPageNotificationAt = latestPageNotificationAt, + sampleAccountIds = sampleAccountIds, + statusId = statusId, +) diff --git a/core/network/src/main/kotlin/social/aloha/core/network/dto/PreferencesDto.kt b/core/network/src/main/kotlin/social/aloha/core/network/dto/PreferencesDto.kt new file mode 100644 index 00000000..a2f2bea9 --- /dev/null +++ b/core/network/src/main/kotlin/social/aloha/core/network/dto/PreferencesDto.kt @@ -0,0 +1,84 @@ +// SPDX-FileCopyrightText: 2026 Aloha Social contributors +// SPDX-License-Identifier: MIT + +package social.aloha.core.network.dto + +import java.time.Instant +import kotlinx.serialization.KSerializer +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import social.aloha.core.model.NotificationPolicy +import social.aloha.core.model.NotificationPolicySummary +import social.aloha.core.model.NotificationRequest +import social.aloha.core.model.PolicyDecision +import social.aloha.core.model.Preferences +import social.aloha.core.model.SensitiveMediaPolicy +import social.aloha.core.model.Visibility +import social.aloha.core.network.decoding.FlexibleIdSerializer +import social.aloha.core.network.decoding.LenientBoolSerializer +import social.aloha.core.network.decoding.LenientInstantSerializer +import social.aloha.core.network.decoding.LenientIntSerializer +import social.aloha.core.network.decoding.OrNullSerializer + +@Serializable +internal data class PreferencesDto( + @SerialName("posting:default:visibility") val defaultVisibility: String? = null, + @SerialName("posting:default:sensitive") @Serializable(with = LenientBoolSerializer::class) + val defaultSensitive: Boolean = false, + @SerialName("posting:default:language") val defaultLanguage: String? = null, + @SerialName("reading:expand:media") val expandMedia: String? = null, + @SerialName("reading:expand:spoilers") @Serializable(with = LenientBoolSerializer::class) + val expandSpoilers: Boolean = false, +) + +@Serializable +internal data class NotificationPolicyDto( + @SerialName("for_not_following") val forNotFollowing: String? = null, + @SerialName("for_not_followers") val forNotFollowers: String? = null, + @SerialName("for_new_accounts") val forNewAccounts: String? = null, + @SerialName("for_private_mentions") val forPrivateMentions: String? = null, + @SerialName("for_limited_accounts") val forLimitedAccounts: String? = null, + @Serializable(with = PolicySummaryOrNull::class) val summary: NotificationPolicySummaryDto? = null, +) + +@Serializable +internal data class NotificationPolicySummaryDto( + @SerialName("pending_requests_count") @Serializable(with = LenientIntSerializer::class) + val pendingRequestsCount: Int = 0, + @SerialName("pending_notifications_count") @Serializable(with = LenientIntSerializer::class) + val pendingNotificationsCount: Int = 0, +) + +@Serializable +internal data class NotificationRequestDto( + @Serializable(with = FlexibleIdSerializer::class) val id: String, + val account: AccountDto, + @SerialName("created_at") @Serializable(with = LenientInstantSerializer::class) val createdAt: Instant? = null, + @SerialName("updated_at") @Serializable(with = LenientInstantSerializer::class) val updatedAt: Instant? = null, + @SerialName("notifications_count") @Serializable(with = LenientIntSerializer::class) + val notificationsCount: Int = 0, + @SerialName("last_status") @Serializable(with = StatusOrNull::class) val lastStatus: StatusDto? = null, +) + +internal object PolicySummaryOrNull : KSerializer by +OrNullSerializer(NotificationPolicySummaryDto.serializer()) + +internal fun PreferencesDto.toDomain(): Preferences = Preferences( + defaultVisibility = defaultVisibility?.let(Visibility::fromWire), + defaultSensitive = defaultSensitive, + defaultLanguage = defaultLanguage, + expandMedia = expandMedia?.let(SensitiveMediaPolicy::fromWire), + expandSpoilers = expandSpoilers, +) + +internal fun NotificationPolicyDto.toDomain(): NotificationPolicy = NotificationPolicy( + forNotFollowing = PolicyDecision.fromWire(forNotFollowing), + forNotFollowers = PolicyDecision.fromWire(forNotFollowers), + forNewAccounts = PolicyDecision.fromWire(forNewAccounts), + forPrivateMentions = PolicyDecision.fromWire(forPrivateMentions), + forLimitedAccounts = PolicyDecision.fromWire(forLimitedAccounts), + summary = summary?.let { NotificationPolicySummary(it.pendingRequestsCount, it.pendingNotificationsCount) }, +) + +internal fun NotificationRequestDto.toDomain(): NotificationRequest = + NotificationRequest(id, account.toDomain(), createdAt, updatedAt, notificationsCount, lastStatus?.toDomain()) diff --git a/core/network/src/main/kotlin/social/aloha/core/network/dto/ProfileDto.kt b/core/network/src/main/kotlin/social/aloha/core/network/dto/ProfileDto.kt new file mode 100644 index 00000000..341f673f --- /dev/null +++ b/core/network/src/main/kotlin/social/aloha/core/network/dto/ProfileDto.kt @@ -0,0 +1,160 @@ +// SPDX-FileCopyrightText: 2026 Aloha Social contributors +// SPDX-License-Identifier: MIT + +package social.aloha.core.network.dto + +import java.time.Instant +import kotlinx.serialization.KSerializer +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.SerializationException +import kotlinx.serialization.builtins.ListSerializer +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonDecoder +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.longOrNull +import social.aloha.core.model.FamiliarFollowers +import social.aloha.core.model.GifEntry +import social.aloha.core.model.GifLibrary +import social.aloha.core.model.ProfileHighlights +import social.aloha.core.model.TaggedPerson +import social.aloha.core.model.TeamAccount +import social.aloha.core.network.decoding.FlexibleIdSerializer +import social.aloha.core.network.decoding.LenientBoolSerializer +import social.aloha.core.network.decoding.LenientIntSerializer +import social.aloha.core.network.decoding.LenientTextSerializer +import social.aloha.core.network.decoding.LenientUrlSerializer +import social.aloha.core.network.decoding.LossyListSerializer +import social.aloha.core.network.decoding.OptionalIdSerializer +import social.aloha.core.network.decoding.OptionalIntSerializer + +/** + * Highlights: `since` in Unix seconds as a number or a string, and `hashtags` as tag objects + * (`{name, count}`) or bare names. Both read as names. + */ +@Serializable +internal data class ProfileHighlightsDto( + @Serializable(with = LenientBoolSerializer::class) val available: Boolean = false, + @Serializable(with = UnixSecondsSerializer::class) val since: Instant? = null, + @Serializable(with = LossyListSerializer::class) val weeks: List< + @Serializable(with = LenientIntSerializer::class) + Int, + > = + emptyList(), + @Serializable(with = NamesSerializer::class) val hashtags: List = emptyList(), +) + +internal fun ProfileHighlightsDto.toDomain(): ProfileHighlights = ProfileHighlights(available, since, weeks, hashtags) + +/** Unix seconds as a number or a numeric string; zero and anything else are absent. */ +internal object UnixSecondsSerializer : KSerializer { + override val descriptor: SerialDescriptor = LenientIntSerializer.descriptor + + override fun deserialize(decoder: Decoder): Instant? { + val primitive = (decoder as? JsonDecoder)?.decodeJsonElement() as? JsonPrimitive ?: return null + if (primitive is JsonNull) return null + val seconds = primitive.longOrNull ?: primitive.content.toDoubleOrNull()?.toLong() ?: return null + return if (seconds > 0) Instant.ofEpochSecond(seconds) else null + } + + override fun serialize(encoder: Encoder, value: Instant?): Unit = throw SerializationException("decode-only") +} + +/** A list of names sent as strings or as objects with a `name`. */ +internal object NamesSerializer : KSerializer> { + override val descriptor: SerialDescriptor = ListSerializer(LenientTextSerializer).descriptor + + override fun deserialize(decoder: Decoder): List { + val array = (decoder as? JsonDecoder)?.decodeJsonElement() as? JsonArray ?: return emptyList() + return array.mapNotNull { item -> + when (item) { + is JsonObject -> (item["name"] as? JsonPrimitive)?.takeUnless { it is JsonNull }?.content + is JsonPrimitive -> item.takeUnless { it is JsonNull }?.content + else -> null + }?.takeIf { it.isNotEmpty() } + } + } + + override fun serialize(encoder: Encoder, value: List): Unit = throw SerializationException("decode-only") +} + +@Serializable +internal data class FamiliarFollowersDto( + @Serializable(with = FlexibleIdSerializer::class) val id: String, + @Serializable(with = LossyListSerializer::class) val accounts: List = emptyList(), +) + +internal fun FamiliarFollowersDto.toDomain(): FamiliarFollowers = FamiliarFollowers(id, accounts.map { it.toDomain() }) + +/** A tagged person: an account-shaped object, or the bare handle. */ +internal object TaggedPersonSerializer : KSerializer { + override val descriptor: SerialDescriptor = TaggedPersonDto.serializer().descriptor + + override fun deserialize(decoder: Decoder): TaggedPerson { + val json = decoder as? JsonDecoder ?: throw SerializationException("JSON only") + val element = json.decodeJsonElement() + if (element is JsonPrimitive && element.isString) return TaggedPerson(element.content, element.content) + val dto = json.json.decodeFromJsonElement(TaggedPersonDto.serializer(), element) + val handle = dto.acct ?: dto.username.orEmpty() + return TaggedPerson(dto.id ?: handle, handle, dto.displayName) + } + + override fun serialize(encoder: Encoder, value: TaggedPerson): Unit = throw SerializationException("decode-only") +} + +@Serializable +internal data class TaggedPersonDto( + @Serializable(with = OptionalIdSerializer::class) val id: String? = null, + @Serializable(with = LenientTextSerializer::class) val acct: String? = null, + @Serializable(with = LenientTextSerializer::class) val username: String? = null, + @SerialName("display_name") @Serializable(with = LenientTextSerializer::class) val displayName: String? = null, +) + +/** A library picture; its title may arrive as `name`, its preview as `preview`. */ +@Serializable +internal data class GifEntryDto( + @Serializable(with = LenientTextSerializer::class) val slug: String? = null, + @Serializable(with = LenientTextSerializer::class) val title: String? = null, + @Serializable(with = LenientTextSerializer::class) val name: String? = null, + @Serializable(with = LenientUrlSerializer::class) val url: String? = null, + @SerialName("preview_url") @Serializable(with = LenientUrlSerializer::class) val previewUrl: String? = null, + @Serializable(with = LenientUrlSerializer::class) val preview: String? = null, + @Serializable(with = OptionalIntSerializer::class) val width: Int? = null, + @Serializable(with = OptionalIntSerializer::class) val height: Int? = null, +) + +internal fun GifEntryDto.toDomain(): GifEntry { + val slug = slug.orEmpty() + return GifEntry(slug, title ?: name ?: slug, url, previewUrl ?: preview, width, height) +} + +/** The total defaults to the number of pictures on the page. */ +@Serializable +internal data class GifLibraryDto( + @Serializable(with = LossyListSerializer::class) val gifs: List = emptyList(), + @Serializable(with = OptionalIntSerializer::class) val total: Int? = null, + @Serializable(with = LenientTextSerializer::class) val attribution: String? = null, +) + +internal fun GifLibraryDto.toDomain(): GifLibrary = + GifLibrary(gifs.map { it.toDomain() }, total ?: gifs.size, attribution) + +/** A team account's handle may arrive as `acct`, its name as `display_name`. */ +@Serializable +internal data class TeamAccountDto( + @Serializable(with = LenientTextSerializer::class) val handle: String? = null, + @Serializable(with = LenientTextSerializer::class) val acct: String? = null, + @Serializable(with = LenientTextSerializer::class) val name: String? = null, + @SerialName("display_name") @Serializable(with = LenientTextSerializer::class) val displayName: String? = null, + @Serializable(with = LenientUrlSerializer::class) val avatar: String? = null, +) + +internal fun TeamAccountDto.toDomain(): TeamAccount { + val handle = handle ?: acct.orEmpty() + return TeamAccount(handle, name ?: displayName ?: handle, avatar) +} diff --git a/core/network/src/main/kotlin/social/aloha/core/network/dto/RelationshipFilterDto.kt b/core/network/src/main/kotlin/social/aloha/core/network/dto/RelationshipFilterDto.kt new file mode 100644 index 00000000..da686f95 --- /dev/null +++ b/core/network/src/main/kotlin/social/aloha/core/network/dto/RelationshipFilterDto.kt @@ -0,0 +1,97 @@ +// SPDX-FileCopyrightText: 2026 Aloha Social contributors +// SPDX-License-Identifier: MIT + +package social.aloha.core.network.dto + +import java.time.Instant +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import social.aloha.core.model.Filter +import social.aloha.core.model.FilterAction +import social.aloha.core.model.FilterContext +import social.aloha.core.model.FilterKeyword +import social.aloha.core.model.FilterStatus +import social.aloha.core.model.Relationship +import social.aloha.core.network.decoding.FlexibleIdSerializer +import social.aloha.core.network.decoding.LenientBoolSerializer +import social.aloha.core.network.decoding.LenientInstantSerializer +import social.aloha.core.network.decoding.LossyListSerializer +import social.aloha.core.network.decoding.OptionalBoolSerializer + +@Serializable +internal data class RelationshipDto( + @Serializable(with = FlexibleIdSerializer::class) val id: String, + @Serializable(with = LenientBoolSerializer::class) val following: Boolean = false, + @SerialName("followed_by") @Serializable(with = LenientBoolSerializer::class) val followedBy: Boolean = false, + @Serializable(with = LenientBoolSerializer::class) val blocking: Boolean = false, + @SerialName("blocked_by") @Serializable(with = LenientBoolSerializer::class) val blockedBy: Boolean = false, + @Serializable(with = LenientBoolSerializer::class) val muting: Boolean = false, + @SerialName("muting_notifications") @Serializable(with = LenientBoolSerializer::class) + val mutingNotifications: Boolean = false, + @Serializable(with = LenientBoolSerializer::class) val requested: Boolean = false, + @SerialName("requested_by") @Serializable(with = LenientBoolSerializer::class) val requestedBy: Boolean = false, + @SerialName("domain_blocking") @Serializable(with = LenientBoolSerializer::class) + val domainBlocking: Boolean = false, + @Serializable(with = LenientBoolSerializer::class) val endorsed: Boolean = false, + @Serializable(with = LenientBoolSerializer::class) val notifying: Boolean = false, + @SerialName("showing_reblogs") @Serializable(with = OptionalBoolSerializer::class) + val showingReblogs: Boolean? = null, + val note: String? = null, + @Serializable(with = LossyListSerializer::class) val languages: List? = null, + @SerialName("mute_expires_at") @Serializable(with = LenientInstantSerializer::class) + val muteExpiresAt: Instant? = null, +) + +@Serializable +internal data class FilterDto( + @Serializable(with = FlexibleIdSerializer::class) val id: String, + val title: String? = null, + @Serializable(with = LossyListSerializer::class) val context: List = emptyList(), + @SerialName("expires_at") @Serializable(with = LenientInstantSerializer::class) val expiresAt: Instant? = null, + @SerialName("filter_action") val filterAction: String? = null, + @Serializable(with = LossyListSerializer::class) val keywords: List = emptyList(), + @Serializable(with = LossyListSerializer::class) val statuses: List = emptyList(), +) + +@Serializable +internal data class FilterKeywordDto( + @Serializable(with = FlexibleIdSerializer::class) val id: String, + val keyword: String, + @SerialName("whole_word") @Serializable(with = LenientBoolSerializer::class) val wholeWord: Boolean = false, +) + +@Serializable +internal data class FilterStatusDto( + @Serializable(with = FlexibleIdSerializer::class) val id: String, + @SerialName("status_id") @Serializable(with = FlexibleIdSerializer::class) val statusId: String, +) + +/** A relationship the server does not qualify shows reblogs, as Mastodon's default follow does. */ +internal fun RelationshipDto.toDomain(): Relationship = Relationship( + id = id, + following = following, + followedBy = followedBy, + blocking = blocking, + blockedBy = blockedBy, + muting = muting, + mutingNotifications = mutingNotifications, + requested = requested, + requestedBy = requestedBy, + domainBlocking = domainBlocking, + endorsed = endorsed, + notifying = notifying, + showingReblogs = showingReblogs ?: true, + note = note, + languages = languages, + muteExpiresAt = muteExpiresAt, +) + +internal fun FilterDto.toDomain(): Filter = Filter( + id = id, + title = title.orEmpty(), + context = context.map(FilterContext::fromWire), + expiresAt = expiresAt, + filterAction = FilterAction.fromWire(filterAction), + keywords = keywords.map { FilterKeyword(it.id, it.keyword, it.wholeWord) }, + statuses = statuses.map { FilterStatus(it.id, it.statusId) }, +) diff --git a/core/network/src/main/kotlin/social/aloha/core/network/dto/StatisticsDto.kt b/core/network/src/main/kotlin/social/aloha/core/network/dto/StatisticsDto.kt new file mode 100644 index 00000000..f24f48fd --- /dev/null +++ b/core/network/src/main/kotlin/social/aloha/core/network/dto/StatisticsDto.kt @@ -0,0 +1,122 @@ +// SPDX-FileCopyrightText: 2026 Aloha Social contributors +// SPDX-License-Identifier: MIT + +package social.aloha.core.network.dto + +import kotlinx.serialization.KSerializer +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import social.aloha.core.model.AccountStatistics +import social.aloha.core.model.ConversationPartner +import social.aloha.core.model.NamedCount +import social.aloha.core.model.NumberMap +import social.aloha.core.model.StatisticsAccount +import social.aloha.core.model.StatisticsActivity +import social.aloha.core.model.StatisticsPartners +import social.aloha.core.model.StatisticsWindow +import social.aloha.core.network.decoding.LenientBoolSerializer +import social.aloha.core.network.decoding.LenientIntSerializer +import social.aloha.core.network.decoding.LossyListSerializer +import social.aloha.core.network.decoding.NumberMapSerializer +import social.aloha.core.network.decoding.OrNullSerializer + +@Serializable +internal data class StatisticsDto( + @Serializable(with = StatisticsAccountOrNull::class) val account: StatisticsAccountDto? = null, + @Serializable(with = StatisticsWindowOrNull::class) val window: StatisticsWindowDto? = null, + @Serializable(with = NumberMapSerializer::class) val posts: Map = emptyMap(), + @Serializable(with = NumberMapSerializer::class) val engagement: Map = emptyMap(), + @Serializable(with = NumberMapSerializer::class) val rates: Map = emptyMap(), + @Serializable(with = NumberMapSerializer::class) val visibility: Map = emptyMap(), + @Serializable(with = NumberMapSerializer::class) val consistency: Map = emptyMap(), + @Serializable(with = NumberMapSerializer::class) val media: Map = emptyMap(), + @SerialName("by_month") @Serializable(with = NumberMapSerializer::class) val byMonth: Map = + emptyMap(), + @SerialName("engagement_by_month") @Serializable(with = NumberMapSerializer::class) + val engagementByMonth: Map = emptyMap(), + @Serializable(with = StatisticsActivityOrNull::class) val activity: StatisticsActivityDto? = null, + @Serializable(with = StatisticsPartnersOrNull::class) val partners: StatisticsPartnersDto? = null, + @Serializable(with = LossyListSerializer::class) val languages: List = emptyList(), + @Serializable(with = LossyListSerializer::class) val domains: List = emptyList(), + @Serializable(with = LossyListSerializer::class) val hashtags: List = emptyList(), +) + +@Serializable +internal data class StatisticsAccountDto( + val acct: String = "", + @Serializable(with = LenientIntSerializer::class) val followers: Int = 0, + @Serializable(with = LenientIntSerializer::class) val following: Int = 0, +) + +@Serializable +internal data class StatisticsWindowDto( + @Serializable(with = LenientIntSerializer::class) val days: Int = 0, + @Serializable(with = LenientIntSerializer::class) val counted: Int = 0, + @Serializable(with = LenientBoolSerializer::class) val capped: Boolean = false, +) + +@Serializable +internal data class StatisticsActivityDto( + @Serializable(with = NumberMapSerializer::class) val originals: Map = emptyMap(), + @Serializable(with = NumberMapSerializer::class) val replies: Map = emptyMap(), + @Serializable(with = NumberMapSerializer::class) val boosts: Map = emptyMap(), +) + +@Serializable +internal data class StatisticsPartnersDto( + @Serializable(with = LossyListSerializer::class) val inbound: List = emptyList(), + @Serializable(with = LossyListSerializer::class) val outbound: List = emptyList(), +) + +@Serializable +internal data class NamedCountDto( + val name: String, + @Serializable(with = LenientIntSerializer::class) val count: Int = 0, +) + +@Serializable +internal data class ConversationPartnerDto( + val account: String, + @Serializable(with = LenientIntSerializer::class) val replies: Int = 0, +) + +internal object StatisticsAccountOrNull : KSerializer by OrNullSerializer( + StatisticsAccountDto.serializer(), +) + +internal object StatisticsWindowOrNull : KSerializer by OrNullSerializer( + StatisticsWindowDto.serializer(), +) + +internal object StatisticsActivityOrNull : KSerializer by OrNullSerializer( + StatisticsActivityDto.serializer(), +) + +internal object StatisticsPartnersOrNull : KSerializer by OrNullSerializer( + StatisticsPartnersDto.serializer(), +) + +internal fun StatisticsDto.toDomain(): AccountStatistics = AccountStatistics( + account = account?.let { StatisticsAccount(it.acct, it.followers, it.following) }, + window = window?.let { StatisticsWindow(it.days, it.counted, it.capped) }, + posts = NumberMap(posts), + engagement = NumberMap(engagement), + rates = NumberMap(rates), + visibility = NumberMap(visibility), + consistency = NumberMap(consistency), + media = NumberMap(media), + byMonth = NumberMap(byMonth), + engagementByMonth = NumberMap(engagementByMonth), + activity = activity?.let { + StatisticsActivity(NumberMap(it.originals), NumberMap(it.replies), NumberMap(it.boosts)) + }, + partners = partners?.let { p -> + StatisticsPartners( + p.inbound.map { ConversationPartner(it.account, it.replies) }, + p.outbound.map { ConversationPartner(it.account, it.replies) }, + ) + }, + languages = languages.map { NamedCount(it.name, it.count) }, + domains = domains.map { NamedCount(it.name, it.count) }, + hashtags = hashtags.map { NamedCount(it.name, it.count) }, +) diff --git a/core/network/src/main/kotlin/social/aloha/core/network/dto/StatusDto.kt b/core/network/src/main/kotlin/social/aloha/core/network/dto/StatusDto.kt new file mode 100644 index 00000000..7864e4fd --- /dev/null +++ b/core/network/src/main/kotlin/social/aloha/core/network/dto/StatusDto.kt @@ -0,0 +1,118 @@ +// SPDX-FileCopyrightText: 2026 Aloha Social contributors +// SPDX-License-Identifier: MIT + +package social.aloha.core.network.dto + +import java.time.Instant +import kotlinx.serialization.KSerializer +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import social.aloha.core.model.Status +import social.aloha.core.model.Visibility +import social.aloha.core.network.decoding.FlexibleIdSerializer +import social.aloha.core.network.decoding.LenientBoolSerializer +import social.aloha.core.network.decoding.LenientInstantSerializer +import social.aloha.core.network.decoding.LenientIntSerializer +import social.aloha.core.network.decoding.LenientTextSerializer +import social.aloha.core.network.decoding.LenientUrlSerializer +import social.aloha.core.network.decoding.LossyListSerializer +import social.aloha.core.network.decoding.OptionalBoolSerializer +import social.aloha.core.network.decoding.OptionalIdSerializer +import social.aloha.core.network.decoding.OrNullSerializer + +/** + * A status on the wire. Nextcloud Social always sends `reblog: null` (a boost is not visible as a boost), + * `reactions: []` whatever was reacted, and `place: null` whatever was sent. + */ +@Serializable +internal data class StatusDto( + @Serializable(with = FlexibleIdSerializer::class) val id: String, + val account: AccountDto, + val uri: String? = null, + @Serializable(with = LenientUrlSerializer::class) val url: String? = null, + @SerialName("created_at") @Serializable(with = LenientInstantSerializer::class) val createdAt: Instant? = null, + @SerialName("edited_at") @Serializable(with = LenientInstantSerializer::class) val editedAt: Instant? = null, + val content: String? = null, + @SerialName("spoiler_text") val spoilerText: String? = null, + val visibility: String? = null, + @Serializable(with = LenientBoolSerializer::class) val sensitive: Boolean = false, + val language: String? = null, + @SerialName("replies_count") @Serializable(with = LenientIntSerializer::class) val repliesCount: Int = 0, + @SerialName("reblogs_count") @Serializable(with = LenientIntSerializer::class) val reblogsCount: Int = 0, + @SerialName("favourites_count") @Serializable(with = LenientIntSerializer::class) val favouritesCount: Int = 0, + @Serializable(with = LenientBoolSerializer::class) val favourited: Boolean = false, + @Serializable(with = LenientBoolSerializer::class) val reblogged: Boolean = false, + @Serializable(with = LenientBoolSerializer::class) val bookmarked: Boolean = false, + @Serializable(with = LenientBoolSerializer::class) val pinned: Boolean = false, + @Serializable(with = LenientBoolSerializer::class) val muted: Boolean = false, + @SerialName("in_reply_to_id") @Serializable(with = OptionalIdSerializer::class) val inReplyToId: String? = null, + @SerialName("in_reply_to_account_id") @Serializable(with = OptionalIdSerializer::class) + val inReplyToAccountId: String? = null, + @Serializable(with = StatusOrNull::class) val reblog: StatusDto? = null, + @SerialName("media_attachments") @Serializable(with = LossyListSerializer::class) + val mediaAttachments: List = emptyList(), + @Serializable(with = LossyListSerializer::class) val mentions: List = emptyList(), + @Serializable(with = LossyListSerializer::class) val tags: List = emptyList(), + @Serializable(with = LossyListSerializer::class) val emojis: List = emptyList(), + @Serializable(with = PollOrNull::class) val poll: PollDto? = null, + @Serializable(with = CardOrNull::class) val card: CardDto? = null, + @Serializable(with = ApplicationOrNull::class) val application: ApplicationSummaryDto? = null, + val text: String? = null, + @Serializable(with = LossyListSerializer::class) val filtered: List? = null, + @Serializable(with = LossyListSerializer::class) val reactions: List? = null, + @SerialName("quote_id") @Serializable(with = OptionalIdSerializer::class) val quoteId: String? = null, + @Serializable(with = QuoteSerializer::class) val quote: QuotedStatusDto? = null, + @SerialName("quote_approval_policy") @Serializable(with = LenientTextSerializer::class) + val quoteApprovalPolicy: String? = null, + @SerialName("dislikes_count") @Serializable(with = LenientIntSerializer::class) val dislikesCount: Int = 0, + @Serializable(with = LenientBoolSerializer::class) val disliked: Boolean = false, + @Serializable(with = OptionalBoolSerializer::class) val archived: Boolean? = null, + @Serializable(with = StatusPlaceOrNull::class) val place: StatusPlaceDto? = null, + @Serializable(with = VideoDetailsOrNull::class) val video: VideoDetailsDto? = null, +) + +internal object StatusOrNull : KSerializer by OrNullSerializer(StatusDto.serializer()) + +@Suppress("LongMethod") // a one-to-one field mapping; splitting it hides nothing +internal fun StatusDto.toDomain(): Status = Status( + id = id, + account = account.toDomain(), + uri = uri.orEmpty(), + url = url, + createdAt = createdAt ?: Instant.EPOCH, + editedAt = editedAt, + content = content.orEmpty(), + spoilerText = spoilerText.orEmpty(), + visibility = Visibility.fromWire(visibility), + sensitive = sensitive, + language = language, + repliesCount = repliesCount, + reblogsCount = reblogsCount, + favouritesCount = favouritesCount, + favourited = favourited, + reblogged = reblogged, + bookmarked = bookmarked, + pinned = pinned, + muted = muted, + inReplyToId = inReplyToId, + inReplyToAccountId = inReplyToAccountId, + reblog = reblog?.toDomain(), + mediaAttachments = mediaAttachments.map { it.toDomain() }, + mentions = mentions.map { it.toDomain() }, + tags = tags.map { it.toDomain() }, + emojis = emojis.map { it.toDomain() }, + poll = poll?.toDomain(), + card = card?.toDomain(), + application = application?.toDomain(), + text = text, + filtered = filtered?.map { it.toDomain() }, + reactions = reactions?.map { it.toDomain() }, + quoteId = quoteId, + quote = quote?.toDomain(), + quoteApprovalPolicy = quoteApprovalPolicy, + dislikesCount = dislikesCount, + disliked = disliked, + archived = archived, + place = place?.toDomain(), + video = video?.toDomain(), +) diff --git a/core/network/src/main/kotlin/social/aloha/core/network/dto/StatusEditsDto.kt b/core/network/src/main/kotlin/social/aloha/core/network/dto/StatusEditsDto.kt new file mode 100644 index 00000000..147223f4 --- /dev/null +++ b/core/network/src/main/kotlin/social/aloha/core/network/dto/StatusEditsDto.kt @@ -0,0 +1,92 @@ +// SPDX-FileCopyrightText: 2026 Aloha Social contributors +// SPDX-License-Identifier: MIT + +package social.aloha.core.network.dto + +import java.time.Instant +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import social.aloha.core.model.ScheduledStatus +import social.aloha.core.model.ScheduledStatusParams +import social.aloha.core.model.StatusEdit +import social.aloha.core.model.StatusSource +import social.aloha.core.model.Translation +import social.aloha.core.model.Visibility +import social.aloha.core.network.decoding.FlexibleIdSerializer +import social.aloha.core.network.decoding.LenientBoolSerializer +import social.aloha.core.network.decoding.LenientInstantSerializer +import social.aloha.core.network.decoding.LossyListSerializer +import social.aloha.core.network.decoding.OptionalIdSerializer + +@Serializable +internal data class TranslationDto( + val content: String = "", + @SerialName("spoiler_text") val spoilerText: String? = null, + @SerialName("detected_source_language") val detectedSourceLanguage: String? = null, + val provider: String? = null, +) + +@Serializable +internal data class StatusEditDto( + val account: AccountDto, + val content: String? = null, + @SerialName("spoiler_text") val spoilerText: String? = null, + @Serializable(with = LenientBoolSerializer::class) val sensitive: Boolean = false, + @SerialName("created_at") @Serializable(with = LenientInstantSerializer::class) val createdAt: Instant? = null, + @SerialName("media_attachments") @Serializable(with = LossyListSerializer::class) + val mediaAttachments: List = emptyList(), +) + +@Serializable +internal data class StatusSourceDto( + @Serializable(with = FlexibleIdSerializer::class) val id: String, + val text: String = "", + @SerialName("spoiler_text") val spoilerText: String = "", +) + +@Serializable +internal data class ScheduledStatusDto( + @Serializable(with = FlexibleIdSerializer::class) val id: String, + val params: ScheduledStatusParamsDto, + @SerialName("scheduled_at") @Serializable(with = LenientInstantSerializer::class) val scheduledAt: Instant? = null, + @SerialName("media_attachments") @Serializable(with = LossyListSerializer::class) + val mediaAttachments: List = emptyList(), +) + +@Serializable +internal data class ScheduledStatusParamsDto( + val text: String? = null, + val visibility: String? = null, + @SerialName("spoiler_text") val spoilerText: String? = null, + @Serializable(with = LenientBoolSerializer::class) val sensitive: Boolean = false, + val language: String? = null, + @SerialName("in_reply_to_id") @Serializable(with = OptionalIdSerializer::class) val inReplyToId: String? = null, +) + +internal fun TranslationDto.toDomain(): Translation = + Translation(content, spoilerText, detectedSourceLanguage, provider) + +internal fun StatusEditDto.toDomain(): StatusEdit = StatusEdit( + account = account.toDomain(), + content = content.orEmpty(), + spoilerText = spoilerText.orEmpty(), + sensitive = sensitive, + createdAt = createdAt ?: Instant.EPOCH, + mediaAttachments = mediaAttachments.map { it.toDomain() }, +) + +internal fun StatusSourceDto.toDomain(): StatusSource = StatusSource(id, text, spoilerText) + +internal fun ScheduledStatusDto.toDomain(): ScheduledStatus = ScheduledStatus( + id = id, + params = ScheduledStatusParams( + text = params.text, + visibility = params.visibility?.let(Visibility::fromWire), + spoilerText = params.spoilerText, + sensitive = params.sensitive, + language = params.language, + inReplyToId = params.inReplyToId, + ), + scheduledAt = scheduledAt ?: Instant.EPOCH, + mediaAttachments = mediaAttachments.map { it.toDomain() }, +) diff --git a/core/network/src/main/kotlin/social/aloha/core/network/dto/StatusPartsDto.kt b/core/network/src/main/kotlin/social/aloha/core/network/dto/StatusPartsDto.kt new file mode 100644 index 00000000..f784f501 --- /dev/null +++ b/core/network/src/main/kotlin/social/aloha/core/network/dto/StatusPartsDto.kt @@ -0,0 +1,137 @@ +// SPDX-FileCopyrightText: 2026 Aloha Social contributors +// SPDX-License-Identifier: MIT + +package social.aloha.core.network.dto + +import kotlinx.serialization.KSerializer +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.SerializationException +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.descriptors.nullable +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.JsonDecoder +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import social.aloha.core.model.ApplicationSummary +import social.aloha.core.model.FilterResult +import social.aloha.core.model.Mention +import social.aloha.core.model.QuotedStatus +import social.aloha.core.model.Reaction +import social.aloha.core.model.StatusPlace +import social.aloha.core.model.StatusTag +import social.aloha.core.network.decoding.FlexibleIdSerializer +import social.aloha.core.network.decoding.LenientBoolSerializer +import social.aloha.core.network.decoding.LenientIntSerializer +import social.aloha.core.network.decoding.LenientUrlSerializer +import social.aloha.core.network.decoding.LossyListSerializer +import social.aloha.core.network.decoding.OptionalDoubleSerializer +import social.aloha.core.network.decoding.OrNullSerializer +import social.aloha.core.network.decoding.decodeOrRecord + +@Serializable +internal data class MentionDto( + @Serializable(with = FlexibleIdSerializer::class) val id: String, + val username: String = "", + val acct: String = "", + @Serializable(with = LenientUrlSerializer::class) val url: String? = null, +) + +@Serializable +internal data class StatusTagDto( + val name: String, + @Serializable(with = LenientUrlSerializer::class) val url: String? = null, +) + +@Serializable +internal data class ReactionDto( + val name: String, + @Serializable(with = LenientIntSerializer::class) val count: Int = 0, + @Serializable(with = LenientBoolSerializer::class) val me: Boolean = false, + @Serializable(with = LenientUrlSerializer::class) val url: String? = null, + @SerialName("static_url") @Serializable(with = LenientUrlSerializer::class) val staticUrl: String? = null, +) + +@Serializable +internal data class FilterResultDto( + val filter: FilterDto, + @SerialName("keyword_matches") @Serializable(with = LossyListSerializer::class) + val keywordMatches: List? = null, + @SerialName("status_matches") @Serializable(with = LossyListSerializer::class) + val statusMatches: List? = null, +) + +@Serializable +internal data class ApplicationSummaryDto( + val name: String, + @Serializable(with = LenientUrlSerializer::class) val website: String? = null, +) + +internal data class QuotedStatusDto(val state: String?, val quotedStatus: StatusDto?) + +@Serializable +internal data class StatusPlaceDto( + @Serializable(with = FlexibleIdSerializer::class) val id: String, + val name: String = "", + val country: String? = null, + @SerialName("lat") @Serializable(with = OptionalDoubleSerializer::class) val latitude: Double? = null, + @SerialName("lon") @Serializable(with = OptionalDoubleSerializer::class) val longitude: Double? = null, + @SerialName("long") @Serializable(with = OptionalDoubleSerializer::class) val long: Double? = null, +) + +internal object CardOrNull : KSerializer by OrNullSerializer(CardDto.serializer()) + +internal object PollOrNull : KSerializer by OrNullSerializer(PollDto.serializer()) + +internal object ApplicationOrNull : KSerializer by OrNullSerializer( + ApplicationSummaryDto.serializer(), +) + +internal object StatusPlaceOrNull : KSerializer by OrNullSerializer(StatusPlaceDto.serializer()) + +internal object VideoDetailsOrNull : KSerializer by OrNullSerializer(VideoDetailsDto.serializer()) + +/** + * A quote in either shape servers send: Mastodon 4.4's `{state, quoted_status}` wrapper, or the quoted + * status itself. A quote that fits neither is absent rather than failing the status. + */ +internal object QuoteSerializer : KSerializer { + override val descriptor: SerialDescriptor = StatusDto.serializer().descriptor.nullable + + override fun deserialize(decoder: Decoder): QuotedStatusDto? { + val json = decoder as? JsonDecoder ?: throw SerializationException("JSON only") + val element = json.decodeJsonElement() as? JsonObject ?: return null + val isWrapper = "quoted_status" in element || "state" in element + val quoted = (if (isWrapper) element["quoted_status"] else element) + ?.takeUnless { it is JsonNull } + ?.let { json.json.decodeOrRecord(StatusDto.serializer(), it, index = 0) } + val state = if (isWrapper) { + (element["state"] as? JsonPrimitive)?.takeUnless { + it is JsonNull + }?.content + } else { + null + } + return if (isWrapper || quoted != null) QuotedStatusDto(state, quoted) else null + } + + override fun serialize(encoder: Encoder, value: QuotedStatusDto?): Unit = + throw SerializationException("wire DTOs are decode-only") +} + +internal fun MentionDto.toDomain(): Mention = Mention(id, username, acct, url) + +internal fun StatusTagDto.toDomain(): StatusTag = StatusTag(name, url) + +internal fun ReactionDto.toDomain(): Reaction = Reaction(name, count, me, url, staticUrl) + +internal fun FilterResultDto.toDomain(): FilterResult = FilterResult(filter.toDomain(), keywordMatches, statusMatches) + +internal fun ApplicationSummaryDto.toDomain(): ApplicationSummary = ApplicationSummary(name, website) + +internal fun QuotedStatusDto.toDomain(): QuotedStatus = QuotedStatus(state, quotedStatus?.toDomain()) + +/** Nextcloud Social spells the longitude `long` in place search and `lon` on a status; both are read. */ +internal fun StatusPlaceDto.toDomain(): StatusPlace = StatusPlace(id, name, country, latitude, longitude ?: long) diff --git a/core/network/src/main/kotlin/social/aloha/core/network/dto/StoryDto.kt b/core/network/src/main/kotlin/social/aloha/core/network/dto/StoryDto.kt new file mode 100644 index 00000000..54b5b7a7 --- /dev/null +++ b/core/network/src/main/kotlin/social/aloha/core/network/dto/StoryDto.kt @@ -0,0 +1,170 @@ +// SPDX-FileCopyrightText: 2026 Aloha Social contributors +// SPDX-License-Identifier: MIT + +package social.aloha.core.network.dto + +import java.time.Instant +import kotlinx.serialization.KSerializer +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.SerializationException +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonDecoder +import kotlinx.serialization.json.JsonObject +import social.aloha.core.model.Account +import social.aloha.core.model.AttachmentKind +import social.aloha.core.model.Story +import social.aloha.core.model.StoryCarousel +import social.aloha.core.model.StoryReaction +import social.aloha.core.network.decoding.FlexibleIdSerializer +import social.aloha.core.network.decoding.LenientInstantSerializer +import social.aloha.core.network.decoding.LenientTextSerializer +import social.aloha.core.network.decoding.LenientUrlSerializer +import social.aloha.core.network.decoding.LossyListSerializer +import social.aloha.core.network.decoding.OptionalBoolSerializer +import social.aloha.core.network.decoding.OptionalDoubleSerializer +import social.aloha.core.network.decoding.OptionalIdSerializer +import social.aloha.core.network.decoding.OptionalIntSerializer +import social.aloha.core.network.decoding.OrNullSerializer +import social.aloha.core.network.decoding.lossy + +/** + * A story in every shape seen: Nextcloud Social's own (`account_id` and the file under `media`), + * the Mastodon-ish flat one (`account`, `url`, `type`), and a Pixelfed v1.2 carousel item (`src`, + * `type: "photo"`, `created_at`). + */ +@Serializable +internal data class StoryDto( + @Serializable(with = FlexibleIdSerializer::class) val id: String, + @Serializable(with = AccountOrNull::class) val account: AccountDto? = null, + @SerialName("account_id") @Serializable(with = LenientTextSerializer::class) val accountId: String? = null, + @Serializable(with = LenientUrlSerializer::class) val url: String? = null, + @Serializable(with = LenientUrlSerializer::class) val src: String? = null, + @SerialName("preview_url") @Serializable(with = LenientUrlSerializer::class) val previewUrl: String? = null, + @Serializable(with = LenientTextSerializer::class) val type: String? = null, + @Serializable(with = StoryMediaOrNull::class) val media: StoryMediaDto? = null, + @Serializable(with = LenientTextSerializer::class) val caption: String? = null, + @Serializable(with = OptionalDoubleSerializer::class) val duration: Double? = null, + @SerialName("published_at") @Serializable(with = LenientInstantSerializer::class) val publishedAt: Instant? = null, + @SerialName("created_at") @Serializable(with = LenientInstantSerializer::class) val createdAt: Instant? = null, + @SerialName("expires_at") @Serializable(with = LenientInstantSerializer::class) val expiresAt: Instant? = null, + @Serializable(with = OptionalBoolSerializer::class) val seen: Boolean? = null, + @SerialName("view_count") @Serializable(with = OptionalIntSerializer::class) val viewCount: Int? = null, +) + +/** Nextcloud Social nests the file under `media`, as a media attachment. */ +@Serializable +internal data class StoryMediaDto( + @Serializable(with = LenientUrlSerializer::class) val url: String? = null, + @SerialName("preview_url") @Serializable(with = LenientUrlSerializer::class) val previewUrl: String? = null, + @Serializable(with = LenientTextSerializer::class) val type: String? = null, +) + +internal object StoryMediaOrNull : KSerializer by OrNullSerializer(StoryMediaDto.serializer()) + +/** Pixelfed calls a picture a `photo`; every other name is an attachment kind. */ +private fun storyKind(raw: String?): AttachmentKind = + if (raw == "photo") AttachmentKind.Image else raw?.let(AttachmentKind::fromWire) ?: AttachmentKind.Image + +/** [poster] and [seen] come from a carousel node, where the account sits on the node above a run of stories. */ +internal fun StoryDto.toDomain(poster: Account? = null, seen: Boolean? = null): Story { + val published = publishedAt ?: createdAt + return Story( + id = id, + account = poster ?: account?.toDomain(), + accountUri = accountId, + url = fileUrl(), + previewUrl = previewUrl ?: media?.previewUrl, + type = storyKind(type ?: media?.type), + caption = caption, + duration = duration ?: Story.DEFAULT_DURATION, + publishedAt = published, + expiresAt = expiresAt ?: published?.plus(Story.LIFETIME), + seen = seen ?: this.seen ?: false, + viewCount = viewCount, + ) +} + +/** The file: flat `url`, Nextcloud Social's `media.url`, or a Pixelfed carousel item's `src`. */ +private fun StoryDto.fileUrl(): String? = url ?: media?.url ?: src + +/** The poster of a Pixelfed carousel node, which is not an account entity. */ +@Serializable +internal data class StoryUserDto( + @Serializable(with = FlexibleIdSerializer::class) val id: String, + val username: String = "", + @SerialName("username_acct") val usernameAcct: String? = null, + @Serializable(with = LenientUrlSerializer::class) val avatar: String? = null, +) + +internal fun StoryUserDto.toDomain(): Account = + Account(id = id, username = username, acct = usernameAcct ?: username, avatar = avatar, avatarStatic = avatar) + +@Serializable +internal data class StoryNodeDto( + @Serializable(with = AccountOrNull::class) val account: AccountDto? = null, + @Serializable(with = StoryUserOrNull::class) val user: StoryUserDto? = null, + @Serializable(with = LossyListSerializer::class) val stories: List? = null, + @Serializable(with = LossyListSerializer::class) val nodes: List? = null, + @Serializable(with = OptionalBoolSerializer::class) val seen: Boolean? = null, +) + +internal object StoryUserOrNull : KSerializer by OrNullSerializer(StoryUserDto.serializer()) + +internal data class StoryCarouselDto(val own: List, val nodes: List, val flat: List) + +/** Either a flat array of stories or `{self, nodes}`; `self` may be `null`. */ +internal object StoryCarouselSerializer : KSerializer { + override val descriptor: SerialDescriptor = StoryNodeDto.serializer().descriptor + + override fun deserialize(decoder: Decoder): StoryCarouselDto { + val json = decoder as? JsonDecoder ?: throw SerializationException("JSON only") + return when (val root = json.decodeJsonElement()) { + is JsonArray -> StoryCarouselDto(emptyList(), emptyList(), json.json.lossy(StoryDto.serializer(), root)) + + is JsonObject -> StoryCarouselDto( + own = json.json.lossy(StoryDto.serializer(), root["self"]), + nodes = json.json.lossy(StoryNodeDto.serializer(), root["nodes"]), + flat = emptyList(), + ) + + else -> StoryCarouselDto(emptyList(), emptyList(), emptyList()) + } + } + + override fun serialize(encoder: Encoder, value: StoryCarouselDto): Unit = + throw SerializationException("wire DTOs are decode-only") +} + +/** A node without a poster has no one to show the stories under, so it is left out. */ +internal fun StoryCarouselDto.toDomain(): StoryCarousel = StoryCarousel( + own = own.map { it.toDomain() }, + others = flat.map { it.toDomain() } + nodes.flatMap { node -> + val poster = node.account?.toDomain() ?: node.user?.toDomain() ?: return@flatMap emptyList() + (node.stories ?: node.nodes).orEmpty().map { it.toDomain(poster, node.seen) } + }, +) + +@Serializable +internal data class StoryReactionDto( + @Serializable(with = OptionalIdSerializer::class) val id: String? = null, + @Serializable(with = AccountOrNull::class) val account: AccountDto? = null, + @Serializable(with = AccountOrNull::class) val profile: AccountDto? = null, + @Serializable(with = LenientTextSerializer::class) val reaction: String? = null, + @Serializable(with = LenientTextSerializer::class) val emoji: String? = null, + @Serializable(with = LenientTextSerializer::class) val comment: String? = null, + @Serializable(with = LenientTextSerializer::class) val caption: String? = null, + @Serializable(with = LenientTextSerializer::class) val text: String? = null, + @SerialName("created_at") @Serializable(with = LenientInstantSerializer::class) val createdAt: Instant? = null, +) + +/** Null when the reaction names nobody, since a reaction is read as who reacted. */ +internal fun StoryReactionDto.toDomain(): StoryReaction? { + val who = (account ?: profile)?.toDomain() ?: return null + val reaction = reaction ?: emoji + val comment = comment ?: caption ?: text + return StoryReaction(id ?: "${who.id}-${reaction ?: comment.orEmpty()}", who, reaction, comment, createdAt) +} diff --git a/core/network/src/main/kotlin/social/aloha/core/network/dto/TagDto.kt b/core/network/src/main/kotlin/social/aloha/core/network/dto/TagDto.kt new file mode 100644 index 00000000..405039ad --- /dev/null +++ b/core/network/src/main/kotlin/social/aloha/core/network/dto/TagDto.kt @@ -0,0 +1,53 @@ +// SPDX-FileCopyrightText: 2026 Aloha Social contributors +// SPDX-License-Identifier: MIT + +package social.aloha.core.network.dto + +import java.time.Instant +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import social.aloha.core.model.FeaturedTag +import social.aloha.core.model.Tag +import social.aloha.core.model.TagHistory +import social.aloha.core.network.decoding.FlexibleIdSerializer +import social.aloha.core.network.decoding.LenientInstantSerializer +import social.aloha.core.network.decoding.LenientIntSerializer +import social.aloha.core.network.decoding.LenientTextSerializer +import social.aloha.core.network.decoding.LenientUrlSerializer +import social.aloha.core.network.decoding.LossyListSerializer +import social.aloha.core.network.decoding.OptionalBoolSerializer + +@Serializable +internal data class TagDto( + val name: String? = null, + @Serializable(with = LenientUrlSerializer::class) val url: String? = null, + @Serializable(with = LossyListSerializer::class) val history: List = emptyList(), + @Serializable(with = OptionalBoolSerializer::class) val following: Boolean? = null, +) + +/** One bucket of tag use; counts arrive as strings on Mastodon and as numbers on some forks. */ +@Serializable +internal data class TagHistoryDto( + @Serializable(with = LenientTextSerializer::class) val day: String? = null, + @Serializable(with = LenientTextSerializer::class) val uses: String? = null, + @Serializable(with = LenientTextSerializer::class) val accounts: String? = null, +) + +@Serializable +internal data class FeaturedTagDto( + @Serializable(with = FlexibleIdSerializer::class) val id: String, + val name: String = "", + @Serializable(with = LenientUrlSerializer::class) val url: String? = null, + @SerialName("statuses_count") @Serializable(with = LenientIntSerializer::class) val statusesCount: Int = 0, + @SerialName("last_status_at") @Serializable(with = LenientInstantSerializer::class) + val lastStatusAt: Instant? = null, +) + +internal fun TagDto.toDomain(): Tag = Tag( + name = name.orEmpty(), + url = url, + history = history.map { TagHistory(it.day.orEmpty(), it.uses ?: "0", it.accounts ?: "0") }, + following = following, +) + +internal fun FeaturedTagDto.toDomain(): FeaturedTag = FeaturedTag(id, name, url, statusesCount, lastStatusAt) diff --git a/core/network/src/main/kotlin/social/aloha/core/network/endpoints/AccountEndpoints.kt b/core/network/src/main/kotlin/social/aloha/core/network/endpoints/AccountEndpoints.kt new file mode 100644 index 00000000..18379ab1 --- /dev/null +++ b/core/network/src/main/kotlin/social/aloha/core/network/endpoints/AccountEndpoints.kt @@ -0,0 +1,134 @@ +// SPDX-FileCopyrightText: 2026 Aloha Social contributors +// SPDX-License-Identifier: MIT + +package social.aloha.core.network.endpoints + +import kotlinx.serialization.builtins.serializer +import social.aloha.core.model.Account +import social.aloha.core.model.Relationship +import social.aloha.core.network.ApiRequest +import social.aloha.core.network.Body +import social.aloha.core.network.Endpoint +import social.aloha.core.network.HttpMethod +import social.aloha.core.network.QueryItem +import social.aloha.core.network.dto.AccountDto +import social.aloha.core.network.dto.RelationshipDto +import social.aloha.core.network.dto.toDomain +import social.aloha.core.network.flagQuery +import social.aloha.core.network.listRequest +import social.aloha.core.network.queryOf +import social.aloha.core.network.repeatedQuery +import social.aloha.core.network.request +import social.aloha.core.network.unitRequest + +/** An account action that takes no parameters and answers with the new relationship. */ +public enum class AccountAction(public val wire: String) { + Unfollow("unfollow"), + Block("block"), + Unblock("unblock"), + Unmute("unmute"), + Endorse("pin"), + Unendorse("unpin"), + RemoveFromFollowers("remove_from_followers"), +} + +private fun accounts(endpoint: Endpoint): ApiRequest> = + listRequest(endpoint, AccountDto.serializer()) { it.toDomain() } + +private fun relationship(endpoint: Endpoint): ApiRequest = + request(endpoint, RelationshipDto.serializer()) { it.toDomain() } + +public object AccountEndpoints { + private const val SEARCH_LIMIT = 8 + + /** + * The signed-in account. On Nextcloud Social this answers 500 for an account whose avatar has not + * been cached yet; the caller falls back to `/oauth/userinfo` and retries later. + */ + public fun verifyCredentials(): ApiRequest = + request(Endpoint("api/v1/accounts/verify_credentials"), AccountDto.serializer()) { it.toDomain() } + + public fun account(id: String): ApiRequest = + request(Endpoint("api/v1/accounts/$id"), AccountDto.serializer()) { it.toDomain() } + + public fun lookup(acct: String): ApiRequest = request( + Endpoint("api/v1/accounts/lookup", query = listOf(QueryItem("acct", acct))), + AccountDto.serializer(), + ) { it.toDomain() } + + public fun relationships(ids: List): ApiRequest> = listRequest( + Endpoint("api/v1/accounts/relationships", query = repeatedQuery("id", ids)), + RelationshipDto.serializer(), + ) { it.toDomain() } + + /** What a composer calls to complete a `@handle`; Nextcloud Social serves it for exactly that. */ + public fun search(query: String, limit: Int = SEARCH_LIMIT, resolve: Boolean = false): ApiRequest> { + val items = listOf(QueryItem("q", query), Paging.limitItem(limit)) + flagQuery("resolve", resolve) + return accounts(Endpoint("api/v1/accounts/search", query = items)) + } + + public fun follow(id: String, reblogs: Boolean = true, notify: Boolean = false): ApiRequest { + val fields = listOf(QueryItem("reblogs", reblogs.toString()), QueryItem("notify", notify.toString())) + return relationship(Endpoint("api/v1/accounts/$id/follow", HttpMethod.POST, body = Body.Form(fields))) + } + + public fun action(id: String, action: AccountAction): ApiRequest = + relationship(Endpoint("api/v1/accounts/$id/${action.wire}", HttpMethod.POST)) + + /** Mutes an account, for [durationSeconds] or indefinitely. */ + public fun mute(id: String, notifications: Boolean, durationSeconds: Long? = null): ApiRequest { + val fields = listOf(QueryItem("notifications", notifications.toString())) + + queryOf("duration", durationSeconds?.toString()) + return relationship(Endpoint("api/v1/accounts/$id/mute", HttpMethod.POST, body = Body.Form(fields))) + } + + public fun followers( + id: String, + limit: Int = Paging.DEFAULT_LIMIT, + anchor: PageAnchor = PageAnchor.Cold, + ): ApiRequest> = + accounts(Endpoint("api/v1/accounts/$id/followers", query = Paging.pageItems(limit, anchor))) + + public fun following( + id: String, + limit: Int = Paging.DEFAULT_LIMIT, + anchor: PageAnchor = PageAnchor.Cold, + ): ApiRequest> = + accounts(Endpoint("api/v1/accounts/$id/following", query = Paging.pageItems(limit, anchor))) +} + +public object FollowRequestEndpoints { + public fun all(): ApiRequest> = accounts(Endpoint("api/v1/follow_requests")) + + public fun authorise(id: String): ApiRequest = + relationship(Endpoint("api/v1/follow_requests/$id/authorize", HttpMethod.POST)) + + public fun reject(id: String): ApiRequest = + relationship(Endpoint("api/v1/follow_requests/$id/reject", HttpMethod.POST)) +} + +/** Blocked and muted accounts and domains. None of these routes sends a `Link` header or takes a cursor. */ +public object BlockEndpoints { + private const val DEFAULT_LIMIT = 40 + private const val MAXIMUM_LIMIT = 80 + + /** Blocked accounts, paged by `limit` alone. */ + public fun blocks(limit: Int = DEFAULT_LIMIT): ApiRequest> = + accounts(Endpoint("api/v1/blocks", query = listOf(Paging.limitItem(limit, MAXIMUM_LIMIT)))) + + /** Muted accounts, paged by `limit` alone. */ + public fun mutes(limit: Int = DEFAULT_LIMIT): ApiRequest> = + accounts(Endpoint("api/v1/mutes", query = listOf(Paging.limitItem(limit, MAXIMUM_LIMIT)))) + + /** The blocked domains, as bare hostnames. */ + public fun domainBlocks(): ApiRequest> = + listRequest(Endpoint("api/v1/domain_blocks"), String.serializer()) { it } + + public fun blockDomain(domain: String): ApiRequest = unitRequest( + Endpoint("api/v1/domain_blocks", HttpMethod.POST, body = Body.Form(listOf(QueryItem("domain", domain)))), + ) + + public fun unblockDomain(domain: String): ApiRequest = unitRequest( + Endpoint("api/v1/domain_blocks", HttpMethod.DELETE, body = Body.Form(listOf(QueryItem("domain", domain)))), + ) +} diff --git a/core/network/src/main/kotlin/social/aloha/core/network/endpoints/AccountExtraEndpoints.kt b/core/network/src/main/kotlin/social/aloha/core/network/endpoints/AccountExtraEndpoints.kt new file mode 100644 index 00000000..a6f9571e --- /dev/null +++ b/core/network/src/main/kotlin/social/aloha/core/network/endpoints/AccountExtraEndpoints.kt @@ -0,0 +1,181 @@ +// SPDX-FileCopyrightText: 2026 Aloha Social contributors +// SPDX-License-Identifier: MIT + +package social.aloha.core.network.endpoints + +import java.io.File +import social.aloha.core.model.Account +import social.aloha.core.model.AccountField +import social.aloha.core.model.AuthorizedApp +import social.aloha.core.model.FeaturedTag +import social.aloha.core.model.MediaCollection +import social.aloha.core.model.PortfolioPage +import social.aloha.core.model.PortfolioSettings +import social.aloha.core.model.Visibility +import social.aloha.core.network.ApiRequest +import social.aloha.core.network.Authentication +import social.aloha.core.network.Body +import social.aloha.core.network.Endpoint +import social.aloha.core.network.HttpMethod +import social.aloha.core.network.Part +import social.aloha.core.network.QueryItem +import social.aloha.core.network.dto.AccountDto +import social.aloha.core.network.dto.AuthorizedAppDto +import social.aloha.core.network.dto.CollectionDto +import social.aloha.core.network.dto.FeaturedTagDto +import social.aloha.core.network.dto.NamesSerializer +import social.aloha.core.network.dto.PortfolioDto +import social.aloha.core.network.dto.toDomain +import social.aloha.core.network.dto.toPage +import social.aloha.core.network.dto.toSettings +import social.aloha.core.network.listRequest +import social.aloha.core.network.queryOf +import social.aloha.core.network.request +import social.aloha.core.network.unitRequest + +private fun Boolean.asFlag(): String = if (this) "1" else "0" + +/** A new avatar or header, streamed from disk. */ +public data class ProfilePicture(val file: File, val fileName: String, val mimeType: String) + +/** + * What `PATCH /api/v1/accounts/update_credentials` may change. Only the fields that are set are + * sent, so a save touches nothing it did not mean to. + */ +public data class CredentialsUpdate( + val displayName: String? = null, + val note: String? = null, + val avatar: ProfilePicture? = null, + val header: ProfilePicture? = null, + val fields: List? = null, + val locked: Boolean? = null, + val discoverable: Boolean? = null, + val indexable: Boolean? = null, + val bot: Boolean? = null, + val privacy: Visibility? = null, + val sensitive: Boolean? = null, + val language: String? = null, +) { + val isEmpty: Boolean get() = parts().isEmpty() + + internal fun parts(): List = buildList { + displayName?.let { add(Part.Field("display_name", it)) } + note?.let { add(Part.Field("note", it)) } + avatar?.let { add(Part.FileContent("avatar", it.file, it.fileName, it.mimeType)) } + header?.let { add(Part.FileContent("header", it.file, it.fileName, it.mimeType)) } + fields?.forEachIndexed { index, field -> + add(Part.Field("fields_attributes[$index][name]", field.name)) + add(Part.Field("fields_attributes[$index][value]", field.value)) + } + flags().forEach { (name, value) -> value?.let { add(Part.Field(name, it.toString())) } } + privacy?.let { add(Part.Field("source[privacy]", it.wire)) } + language?.let { add(Part.Field("source[language]", it)) } + } + + private fun flags(): List> = listOf( + "locked" to locked, + "discoverable" to discoverable, + "indexable" to indexable, + "bot" to bot, + "source[sensitive]" to sensitive, + ) +} + +public object CredentialEndpoints { + private fun account(endpoint: Endpoint): ApiRequest = + request(endpoint, AccountDto.serializer()) { it.toDomain() } + + /** Multipart even without a picture: one encoding for every save. */ + public fun update(changes: CredentialsUpdate): ApiRequest = account( + Endpoint("api/v1/accounts/update_credentials", HttpMethod.PATCH, body = Body.Multipart(changes.parts())), + ) + + /** Removes the picture; the server falls back to the initials it draws. */ + public fun deleteAvatar(): ApiRequest = account(Endpoint("api/v1/profile/avatar", HttpMethod.DELETE)) + + public fun deleteHeader(): ApiRequest = account(Endpoint("api/v1/profile/header", HttpMethod.DELETE)) +} + +/** The hashtags the reader features on their own profile. */ +public object FeaturedTagEndpoints { + public fun all(): ApiRequest> = + listRequest(Endpoint("api/v1/featured_tags"), FeaturedTagDto.serializer()) { it.toDomain() } + + /** Names of hashtags the reader posts with and has not featured. */ + public fun suggestions(): ApiRequest> = + request(Endpoint("api/v1/featured_tags/suggestions"), NamesSerializer) { it } + + public fun create(name: String): ApiRequest = request( + Endpoint("api/v1/featured_tags", HttpMethod.POST, body = Body.Form(listOf(QueryItem("name", name)))), + FeaturedTagDto.serializer(), + ) { it.toDomain() } + + public fun delete(id: String): ApiRequest = + unitRequest(Endpoint("api/v1/featured_tags/$id", HttpMethod.DELETE)) +} + +/** + * The account itself as Nextcloud holds it. These are Nextcloud-session routes: they go out with the + * app password and `OCS-APIRequest: true`, and are refused on the device without one. + */ +public object SocialAccountEndpoints { + /** + * Deletes the reader's Social account and keeps their Nextcloud one: the posts, the follows, and + * a `Delete` to every server that knew them. It cannot be undone. [handle] has to be the handle + * being deleted, deliberately not a password, since an account signed in through SSO has none. + */ + public fun delete(handle: String): ApiRequest = unitRequest( + Endpoint( + "api/v1/account/delete", + HttpMethod.POST, + body = Body.Form(listOf(QueryItem("confirm", handle))), + authentication = Authentication.NextcloudSession, + ), + ) +} + +/** Applications that hold a token for the reader's account; a Nextcloud-session route. */ +public object AuthorizedAppEndpoints { + public fun all(): ApiRequest> = listRequest( + Endpoint("api/v1/authorized_apps", authentication = Authentication.NextcloudSession), + AuthorizedAppDto.serializer(), + ) { it.toDomain() } + + public fun revoke(id: String): ApiRequest = unitRequest( + Endpoint("api/v1/authorized_apps/$id", HttpMethod.DELETE, authentication = Authentication.NextcloudSession), + ) +} + +/** Pixelfed's portfolio page, served by Nextcloud Social. */ +public object PortfolioEndpoints { + public fun own(): ApiRequest = + request(Endpoint("api/v1.1/portfolio"), PortfolioDto.serializer()) { it.toSettings() } + + public fun save(settings: PortfolioSettings): ApiRequest { + val fields = listOf( + QueryItem("active", settings.active.asFlag()), + QueryItem("title", settings.title), + QueryItem("intro", settings.intro), + QueryItem("layout", settings.layout.wire), + QueryItem("source", settings.source.wire), + QueryItem("show_captions", settings.showCaptions.asFlag()), + QueryItem("show_places", settings.showPlaces.asFlag()), + QueryItem("show_dates", settings.showDates.asFlag()), + QueryItem("show_avatar", settings.showAvatar.asFlag()), + ) + queryOf("collection_id", settings.collectionId) + return request( + Endpoint("api/v1.1/portfolio", HttpMethod.POST, body = Body.Form(fields)), + PortfolioDto.serializer(), + ) { + it.toSettings() + } + } + + /** The published page; anybody with the address may read it. */ + public fun page(handle: String): ApiRequest = + request(Endpoint("api/v1.1/portfolio/$handle"), PortfolioDto.serializer()) { it.toPage() } + + /** The reader's own albums, for the picture-source picker. */ + public fun ownCollections(): ApiRequest> = + listRequest(Endpoint("api/v1.1/collections/self"), CollectionDto.serializer()) { it.toDomain() } +} diff --git a/core/network/src/main/kotlin/social/aloha/core/network/endpoints/AdminAccountEndpoints.kt b/core/network/src/main/kotlin/social/aloha/core/network/endpoints/AdminAccountEndpoints.kt new file mode 100644 index 00000000..edc2e48d --- /dev/null +++ b/core/network/src/main/kotlin/social/aloha/core/network/endpoints/AdminAccountEndpoints.kt @@ -0,0 +1,97 @@ +// SPDX-FileCopyrightText: 2026 Aloha Social contributors +// SPDX-License-Identifier: MIT + +package social.aloha.core.network.endpoints + +import social.aloha.core.model.AdminAccount +import social.aloha.core.model.AdminAccountAction +import social.aloha.core.network.ApiRequest +import social.aloha.core.network.Body +import social.aloha.core.network.Endpoint +import social.aloha.core.network.HttpMethod +import social.aloha.core.network.QueryItem +import social.aloha.core.network.dto.AdminAccountDto +import social.aloha.core.network.dto.toDomain +import social.aloha.core.network.listRequest +import social.aloha.core.network.queryOf +import social.aloha.core.network.request +import social.aloha.core.network.unitRequest + +/** + * What a moderator may do to an account, under `/api/v1/admin/accounts`. Like [ModerationEndpoints] + * these take the bearer token and answer 403 to anybody who is not a Nextcloud administrator. + */ +public object AdminAccountEndpoints { + /** Where an account lives, for the accounts filter; [Any] sends no filter. */ + public enum class Origin(internal val parameter: String?) { Any(null), Local("local"), Remote("remote") } + + /** + * The standings this server has, for the accounts filter; [Any] sends no filter. Mastodon's + * `pending` and `disabled` answer with nothing here and are left out. + */ + public enum class Standing(internal val parameter: String?) { + Any(null), + Active("active"), + Silenced("silenced"), + Suspended("suspended"), + } + + /** + * A page of accounts, newest first. `email` and `ip` are accepted by the server and match nothing, since + * it holds neither for a fediverse account, so they are not offered. + */ + public fun accounts( + origin: Origin = Origin.Any, + standing: Standing = Standing.Any, + username: String = "", + byDomain: String = "", + limit: Int = Paging.DEFAULT_LIMIT, + anchor: PageAnchor = PageAnchor.Cold, + ): ApiRequest> = listRequest( + Endpoint( + "api/v1/admin/accounts", + query = Paging.pageItems(limit, anchor) + + queryOf("origin", origin.parameter) + + queryOf("status", standing.parameter) + + queryOf("username", username.ifEmpty { null }) + + queryOf("by_domain", byDomain.ifEmpty { null }), + ), + AdminAccountDto.serializer(), + ) { it.toDomain() } + + public fun account(id: String): ApiRequest = accountRequest(Endpoint("api/v1/admin/accounts/$id")) + + /** + * Silences or suspends an account, or records a decision that changes nothing. [reportId] resolves that + * report in the same step, so the decision and the report never disagree. + */ + public fun act( + id: String, + action: AdminAccountAction, + note: String = "", + reportId: String? = null, + ): ApiRequest = unitRequest( + Endpoint( + "api/v1/admin/accounts/$id/action", + HttpMethod.POST, + body = Body.Form( + listOf(QueryItem("type", action.wire)) + queryOf("text", note.ifEmpty { null }) + + queryOf("report_id", reportId), + ), + ), + ) + + /** Lifts a silence. */ + public fun unsilence(id: String): ApiRequest = accountAction(id, "unsilence") + + /** Lifts a suspension. */ + public fun unsuspend(id: String): ApiRequest = accountAction(id, "unsuspend") + + /** Stops forcing every attachment of the account behind a warning. */ + public fun unsensitive(id: String): ApiRequest = accountAction(id, "unsensitive") +} + +private fun accountRequest(endpoint: Endpoint) = request(endpoint, AdminAccountDto.serializer()) { it.toDomain() } + +private fun accountAction(id: String, action: String) = + accountRequest(Endpoint("api/v1/admin/accounts/$id/$action", HttpMethod.POST)) diff --git a/core/network/src/main/kotlin/social/aloha/core/network/endpoints/AnnualReportEndpoints.kt b/core/network/src/main/kotlin/social/aloha/core/network/endpoints/AnnualReportEndpoints.kt new file mode 100644 index 00000000..51082f6b --- /dev/null +++ b/core/network/src/main/kotlin/social/aloha/core/network/endpoints/AnnualReportEndpoints.kt @@ -0,0 +1,40 @@ +// SPDX-FileCopyrightText: 2026 Aloha Social contributors +// SPDX-License-Identifier: MIT + +package social.aloha.core.network.endpoints + +import social.aloha.core.model.AnnualReportState +import social.aloha.core.model.WrappedAnnualReports +import social.aloha.core.network.ApiRequest +import social.aloha.core.network.Endpoint +import social.aloha.core.network.HttpMethod +import social.aloha.core.network.dto.AnnualReportStateDto +import social.aloha.core.network.dto.WrappedAnnualReportsDto +import social.aloha.core.network.dto.toDomain +import social.aloha.core.network.request +import social.aloha.core.network.unitRequest + +/** "Your year": Mastodon's `#Wrapstodon`, which Nextcloud Social serves over the API only. */ +public object AnnualReportEndpoints { + /** Every year this account has a report for, newest first. */ + public fun all(): ApiRequest = wrapped(Endpoint("api/v1/annual_reports")) + + public fun year(year: Int): ApiRequest = wrapped(Endpoint("api/v1/annual_reports/$year")) + + /** Whether a year has a report. */ + public fun state(year: Int): ApiRequest = + request(Endpoint("api/v1/annual_reports/$year/state"), AnnualReportStateDto.serializer()) { it.toDomain() } + + /** + * Asks for a report to be generated. Nextcloud Social answers at once with nothing to wait for; the + * request is still sent before a read, because a server that needs it acts on it. + */ + public fun generate(year: Int): ApiRequest = + unitRequest(Endpoint("api/v1/annual_reports/$year/generate", HttpMethod.POST)) + + /** Marks a report read, so the app stops offering it at the top of Home. */ + public fun markRead(year: Int): ApiRequest = + unitRequest(Endpoint("api/v1/annual_reports/$year/read", HttpMethod.POST)) + + private fun wrapped(endpoint: Endpoint) = request(endpoint, WrappedAnnualReportsDto.serializer()) { it.toDomain() } +} diff --git a/core/network/src/main/kotlin/social/aloha/core/network/endpoints/ComposeEndpoints.kt b/core/network/src/main/kotlin/social/aloha/core/network/endpoints/ComposeEndpoints.kt new file mode 100644 index 00000000..c552222b --- /dev/null +++ b/core/network/src/main/kotlin/social/aloha/core/network/endpoints/ComposeEndpoints.kt @@ -0,0 +1,177 @@ +// SPDX-FileCopyrightText: 2026 Aloha Social contributors +// SPDX-License-Identifier: MIT + +package social.aloha.core.network.endpoints + +import java.io.File +import java.util.Locale +import social.aloha.core.model.Announcement +import social.aloha.core.model.ContinueWatchingItem +import social.aloha.core.model.MediaAttachment +import social.aloha.core.model.ScheduledStatus +import social.aloha.core.model.Status +import social.aloha.core.network.ApiRequest +import social.aloha.core.network.Body +import social.aloha.core.network.Endpoint +import social.aloha.core.network.HttpMethod +import social.aloha.core.network.Part +import social.aloha.core.network.QueryItem +import social.aloha.core.network.decoding.LossyListSerializer +import social.aloha.core.network.dto.AnnouncementDto +import social.aloha.core.network.dto.ContinueWatchingItemDto +import social.aloha.core.network.dto.MediaAttachmentDto +import social.aloha.core.network.dto.ScheduledStatusDto +import social.aloha.core.network.dto.StatusDto +import social.aloha.core.network.dto.toDomain +import social.aloha.core.network.listRequest +import social.aloha.core.network.queryOf +import social.aloha.core.network.repeatedQuery +import social.aloha.core.network.request +import social.aloha.core.network.unitRequest + +public object ComposeEndpoints { + /** Posts [draft], carrying its idempotency key, which makes a retry after a timeout safe. */ + public fun post(draft: StatusPost): ApiRequest = request( + Endpoint( + "api/v1/statuses", + HttpMethod.POST, + body = Body.Form(draft.formItems()), + idempotencyKey = draft.idempotencyKey, + ), + StatusDto.serializer(), + ) { it.toDomain() } + + /** Edits a known status; it cannot duplicate, so no idempotency key is sent. */ + public fun edit(id: String, draft: StatusPost): ApiRequest = request( + Endpoint("api/v1/statuses/$id", HttpMethod.PUT, body = Body.Form(draft.formItems())), + StatusDto.serializer(), + ) { + it.toDomain() + } + + /** Scheduled statuses; the route sends no `Link` header. */ + public fun scheduled(): ApiRequest> = + listRequest(Endpoint("api/v1/scheduled_statuses"), ScheduledStatusDto.serializer()) { it.toDomain() } + + public fun deleteScheduled(id: String): ApiRequest = + unitRequest(Endpoint("api/v1/scheduled_statuses/$id", HttpMethod.DELETE)) +} + +public object MediaEndpoints { + /** Uploads one file; v2 first, v1 only after a 404. The file is streamed from disk. */ + public fun upload( + file: File, + fileName: String, + mimeType: String, + description: String?, + v2: Boolean = true, + ): ApiRequest { + val parts = buildList { + add(Part.FileContent("file", file, fileName, mimeType)) + description?.takeIf { it.isNotEmpty() }?.let { add(Part.Field("description", it)) } + } + return attachment( + Endpoint(if (v2) "api/v2/media" else "api/v1/media", HttpMethod.POST, body = Body.Multipart(parts)), + ) + } + + public fun updateDescription(id: String, description: String): ApiRequest = attachment( + Endpoint( + "api/v1/media/$id", + HttpMethod.PUT, + body = Body.Form(listOf(QueryItem("description", description))), + ), + ) + + /** + * Attaches a file the viewer already has in Nextcloud, so it never travels to the phone and back. + * A traversal or a folder answers 422. + */ + public fun fromNextcloudFile(path: String, description: String?): ApiRequest = attachment( + Endpoint( + "api/v1/media/from-file", + HttpMethod.POST, + body = Body.Form(listOf(QueryItem("path", path)) + queryOf("description", description)), + ), + ) + + /** + * The focal point as Mastodon defines it: `x,y` in −1…1 with the origin in the centre, what a + * cropped preview keeps in frame. Formatted without the device locale, which could write `0,50`. + */ + public fun updateFocus(id: String, x: Double, y: Double, description: String? = null): ApiRequest { + val focus = String.format(Locale.ROOT, "%.2f,%.2f", x.coerceIn(-1.0, 1.0), y.coerceIn(-1.0, 1.0)) + val fields = listOf(QueryItem("focus", focus)) + queryOf("description", description) + return attachment(Endpoint("api/v1/media/$id", HttpMethod.PUT, body = Body.Form(fields))) + } + + /** Attaches a picture from the instance's own GIF library, the way an upload would have. */ + public fun fromGif(slug: String, description: String?): ApiRequest = attachment( + Endpoint( + "api/v1/media/from-gif", + HttpMethod.POST, + body = Body.Form(listOf(QueryItem("slug", slug)) + queryOf("description", description)), + ), + ) + + private fun attachment(endpoint: Endpoint): ApiRequest = + request(endpoint, MediaAttachmentDto.serializer()) { it.toDomain() } +} + +/** Nextcloud Social's watch positions: never federated, never shown to anybody else. */ +public object VideoEndpoints { + public fun reportWatched(statusId: String, positionSeconds: Double, durationSeconds: Double): ApiRequest = + unitRequest( + Endpoint( + "api/v1/statuses/$statusId/watched", + HttpMethod.POST, + body = Body.Form( + listOf( + QueryItem("position", positionSeconds.toInt().toString()), + QueryItem("duration", durationSeconds.toInt().toString()), + ), + ), + ), + ) + + public fun forgetWatched(statusId: String): ApiRequest = + unitRequest(Endpoint("api/v1/statuses/$statusId/watched", HttpMethod.DELETE)) + + /** Where the reader got to in the videos they have not finished; an entry that names no post is dropped. */ + public fun continueWatching(limit: Int = Paging.DEFAULT_LIMIT): ApiRequest> = request( + Endpoint("api/v1/videos/continue", query = listOf(Paging.limitItem(limit, CONTINUE_WATCHING_LIMIT))), + LossyListSerializer(ContinueWatchingItemDto.serializer()), + ) { items -> items.mapNotNull { it.toDomain() } } + + private const val CONTINUE_WATCHING_LIMIT = 40 +} + +public object SafetyEndpoints { + /** Reports an account, optionally with statuses and the rules they break. */ + public fun report(report: ReportDraft): ApiRequest = unitRequest( + Endpoint( + "api/v1/reports", + HttpMethod.POST, + body = Body.Form( + listOf( + QueryItem("account_id", report.accountId), + QueryItem("comment", report.comment), + QueryItem("forward", report.forward.toString()), + QueryItem("category", report.category), + ) + repeatedQuery("status_ids", report.statusIds) + repeatedQuery("rule_ids", report.ruleIds), + ), + ), + ) + + public fun announcements(): ApiRequest> = + listRequest(Endpoint("api/v1/announcements"), AnnouncementDto.serializer()) { it.toDomain() } +} + +public data class ReportDraft( + val accountId: String, + val comment: String, + val category: String, + val statusIds: List = emptyList(), + val ruleIds: List = emptyList(), + val forward: Boolean = false, +) diff --git a/core/network/src/main/kotlin/social/aloha/core/network/endpoints/DiscoveryEndpoints.kt b/core/network/src/main/kotlin/social/aloha/core/network/endpoints/DiscoveryEndpoints.kt new file mode 100644 index 00000000..98ffe7d9 --- /dev/null +++ b/core/network/src/main/kotlin/social/aloha/core/network/endpoints/DiscoveryEndpoints.kt @@ -0,0 +1,130 @@ +// SPDX-FileCopyrightText: 2026 Aloha Social contributors +// SPDX-License-Identifier: MIT + +package social.aloha.core.network.endpoints + +import social.aloha.core.model.Account +import social.aloha.core.model.DirectoryHashtagResults +import social.aloha.core.model.DirectorySearchResults +import social.aloha.core.model.DirectorySource +import social.aloha.core.model.DiscoverCategory +import social.aloha.core.model.FollowGraph +import social.aloha.core.model.FollowGraphStatus +import social.aloha.core.model.StarterPack +import social.aloha.core.model.Status +import social.aloha.core.network.ApiRequest +import social.aloha.core.network.Endpoint +import social.aloha.core.network.HttpMethod +import social.aloha.core.network.QueryItem +import social.aloha.core.network.decoding.ListOrMemberSerializer +import social.aloha.core.network.decoding.LossyListSerializer +import social.aloha.core.network.dto.AccountDto +import social.aloha.core.network.dto.DirectoryResultsSerializer +import social.aloha.core.network.dto.DirectorySourceDto +import social.aloha.core.network.dto.DiscoverCategoryDto +import social.aloha.core.network.dto.FollowGraphSerializer +import social.aloha.core.network.dto.FollowGraphStatusDto +import social.aloha.core.network.dto.StarterPackDto +import social.aloha.core.network.dto.StatusDto +import social.aloha.core.network.dto.TagDto +import social.aloha.core.network.dto.toDomain +import social.aloha.core.network.dto.toHashtagResults +import social.aloha.core.network.dto.toSearchResults +import social.aloha.core.network.listRequest +import social.aloha.core.network.queryOf +import social.aloha.core.network.request +import social.aloha.core.network.unitRequest + +/** How a discover grid narrows its trending posts. */ +public enum class DiscoverMedia(public val wire: String) { + Images("image"), + Videos("video"), +} + +/** + * Starter packs, other servers' directories, the follow graph and Pixelfed's discover surface, all + * Nextcloud Social's own. + */ +public object DiscoveryEndpoints { + private const val MAXIMUM_DIRECTORY = 40 + private const val MAXIMUM_DISCOVER = 60 + private const val DEFAULT_DISCOVER = 30 + + public fun starterPacks(): ApiRequest> = + request(Endpoint("api/v1/starter_packs"), LossyListSerializer(StarterPackDto.serializer())) { list -> + list.mapNotNull { it.toDomain() } + } + + public fun starterPack(slug: String): ApiRequest = + request(Endpoint("api/v1/starter_packs/$slug"), StarterPackDto.serializer()) { it.toDomain() } + + public fun followStarterPack(slug: String): ApiRequest = + unitRequest(Endpoint("api/v1/starter_packs/$slug/follow", HttpMethod.POST)) + + /** The directories the server asks when searching beyond itself. */ + public fun directories(): ApiRequest> = + listRequest(Endpoint("api/v1/directories"), DirectorySourceDto.serializer()) { it.toDomain() } + + public fun directorySearch( + query: String, + source: String? = null, + limit: Int = Paging.DEFAULT_LIMIT, + ): ApiRequest = request( + Endpoint( + "api/v1/directories/search", + query = + listOf(QueryItem("q", query), Paging.limitItem(limit, MAXIMUM_DIRECTORY)) + + queryOf("source", source), + ), + DirectoryResultsSerializer(AccountDto.serializer(), "accounts"), + ) { it.toSearchResults() } + + public fun directoryHashtags( + query: String? = null, + source: String? = null, + limit: Int = Paging.DEFAULT_LIMIT, + ): ApiRequest = request( + Endpoint( + "api/v1/directories/hashtags", + query = listOf(Paging.limitItem(limit, MAXIMUM_DIRECTORY)) + + queryOf("q", query?.takeIf { it.isNotEmpty() }) + queryOf("source", source), + ), + DirectoryResultsSerializer(TagDto.serializer(), "hashtags", "tags"), + ) { it.toHashtagResults() } + + /** Accounts the people the reader follows also follow. */ + public fun followGraph(): ApiRequest = + request(Endpoint("api/v1/follow_graph"), FollowGraphSerializer) { + it + } + + public fun followGraphStatus(): ApiRequest = + request(Endpoint("api/v1/follow_graph/status"), FollowGraphStatusDto.serializer()) { it.toDomain() } + + /** Trending posts narrowed to pictures or video (Pixelfed's discover). */ + public fun discoverPosts(media: DiscoverMedia, limit: Int = DEFAULT_DISCOVER): ApiRequest> = + listRequest( + Endpoint( + "api/v2/discover/posts", + query = listOf(QueryItem("media", media.wire), Paging.limitItem(limit, MAXIMUM_DISCOVER)), + ), + StatusDto.serializer(), + ) { it.toDomain() } + + /** Curated subjects, sent bare or as `{categories: [...]}`. */ + public fun categories(): ApiRequest> = request( + Endpoint("api/v1.1/discover/categories"), + ListOrMemberSerializer(DiscoverCategoryDto.serializer(), "categories"), + ) { list -> list.map { it.toDomain() } } + + public fun popularAccounts(limit: Int = Paging.DEFAULT_LIMIT): ApiRequest> = listRequest( + Endpoint("api/v1.1/discover/accounts/popular", query = listOf(Paging.limitItem(limit))), + AccountDto.serializer(), + ) { it.toDomain() } + + /** Mastodon's trending posts, the picture and video grids' fallback where Pixelfed's discover is absent. */ + public fun trendingStatuses(limit: Int = Paging.DEFAULT_LIMIT): ApiRequest> = listRequest( + Endpoint("api/v1/trends/statuses", query = listOf(Paging.limitItem(limit, MAXIMUM_DIRECTORY))), + StatusDto.serializer(), + ) { it.toDomain() } +} diff --git a/core/network/src/main/kotlin/social/aloha/core/network/endpoints/FilterEndpoints.kt b/core/network/src/main/kotlin/social/aloha/core/network/endpoints/FilterEndpoints.kt new file mode 100644 index 00000000..b4cf8920 --- /dev/null +++ b/core/network/src/main/kotlin/social/aloha/core/network/endpoints/FilterEndpoints.kt @@ -0,0 +1,86 @@ +// SPDX-FileCopyrightText: 2026 Aloha Social contributors +// SPDX-License-Identifier: MIT + +package social.aloha.core.network.endpoints + +import social.aloha.core.model.Filter +import social.aloha.core.model.FilterAction +import social.aloha.core.model.FilterContext +import social.aloha.core.network.ApiRequest +import social.aloha.core.network.Body +import social.aloha.core.network.Endpoint +import social.aloha.core.network.HttpMethod +import social.aloha.core.network.QueryItem +import social.aloha.core.network.dto.FilterDto +import social.aloha.core.network.dto.toDomain +import social.aloha.core.network.listRequest +import social.aloha.core.network.request +import social.aloha.core.network.unitRequest + +/** + * One word of a filter as the editor holds it: an existing keyword carries its [id], a new one does + * not, and one the editor removed is sent with [destroy] so the server drops it. + */ +public data class KeywordDraft( + val keyword: String, + val id: String? = null, + val wholeWord: Boolean = false, + val destroy: Boolean = false, +) + +/** What a filter editor sends when creating or changing a filter. */ +public data class FilterDraft( + val title: String, + val context: List, + val action: FilterAction, + val expiresInSeconds: Long?, + val keywords: List, +) + +public object FilterEndpoints { + public fun all(): ApiRequest> = listRequest(Endpoint("api/v2/filters"), FilterDto.serializer()) { + it.toDomain() + } + + public fun get(id: String): ApiRequest = request(Endpoint("api/v2/filters/$id"), FilterDto.serializer()) { + it.toDomain() + } + + public fun delete(id: String): ApiRequest = unitRequest(Endpoint("api/v2/filters/$id", HttpMethod.DELETE)) + + public fun create(draft: FilterDraft): ApiRequest = request( + Endpoint("api/v2/filters", HttpMethod.POST, body = Body.Form(formItems(draft))), + FilterDto.serializer(), + ) { + it.toDomain() + } + + /** Changes a filter; what is not named is left alone by the server. */ + public fun update(id: String, draft: FilterDraft): ApiRequest = request( + Endpoint("api/v2/filters/$id", HttpMethod.PUT, body = Body.Form(formItems(draft))), + FilterDto.serializer(), + ) { + it.toDomain() + } + + /** + * Rails' nested-attributes shape, which Mastodon's v2 API takes: a flat `keywords[]` is silently + * ignored. An absent expiry is sent as `""` rather than omitted, because the server leaves an + * unnamed field alone and an update would keep an expiry the editor just cleared. + */ + internal fun formItems(draft: FilterDraft): List = listOf(QueryItem("title", draft.title)) + + draft.context.filter { it != FilterContext.Unknown }.map { QueryItem("context[]", it.wire) } + + QueryItem("filter_action", draft.action.wire) + + QueryItem("expires_in", draft.expiresInSeconds?.toString().orEmpty()) + + draft.keywords.flatMapIndexed(::keywordItems) + + private fun keywordItems(index: Int, draft: KeywordDraft): List { + val prefix = "keywords_attributes[$index]" + return buildList { + add(QueryItem("$prefix[keyword]", draft.keyword)) + add(QueryItem("$prefix[whole_word]", if (draft.wholeWord) "1" else "0")) + draft.id?.let { add(QueryItem("$prefix[id]", it)) } + if (draft.destroy) add(QueryItem("$prefix[_destroy]", "1")) + } + } +} diff --git a/core/network/src/main/kotlin/social/aloha/core/network/endpoints/InstanceEndpoints.kt b/core/network/src/main/kotlin/social/aloha/core/network/endpoints/InstanceEndpoints.kt new file mode 100644 index 00000000..a669e7b3 --- /dev/null +++ b/core/network/src/main/kotlin/social/aloha/core/network/endpoints/InstanceEndpoints.kt @@ -0,0 +1,95 @@ +// SPDX-FileCopyrightText: 2026 Aloha Social contributors +// SPDX-License-Identifier: MIT + +package social.aloha.core.network.endpoints + +import kotlinx.serialization.builtins.ListSerializer +import kotlinx.serialization.builtins.MapSerializer +import kotlinx.serialization.builtins.serializer +import social.aloha.core.model.CustomEmoji +import social.aloha.core.model.InstanceActivityWeek +import social.aloha.core.model.InstanceDescription +import social.aloha.core.model.InstanceDocument +import social.aloha.core.model.InstanceRule +import social.aloha.core.model.Preferences +import social.aloha.core.model.PublicDomainBlock +import social.aloha.core.network.ApiRequest +import social.aloha.core.network.Authentication +import social.aloha.core.network.Body +import social.aloha.core.network.Endpoint +import social.aloha.core.network.HttpMethod +import social.aloha.core.network.QueryItem +import social.aloha.core.network.dto.CustomEmojiDto +import social.aloha.core.network.dto.InstanceActivityWeekDto +import social.aloha.core.network.dto.InstanceDocumentDto +import social.aloha.core.network.dto.InstanceRuleDto +import social.aloha.core.network.dto.InstanceV1Dto +import social.aloha.core.network.dto.InstanceV2Dto +import social.aloha.core.network.dto.PreferencesDto +import social.aloha.core.network.dto.PublicDomainBlockDto +import social.aloha.core.network.dto.toDomain +import social.aloha.core.network.listRequest +import social.aloha.core.network.request +import social.aloha.core.network.unitRequest + +/** + * The server's description of itself. Every route here is public: it is what the sign-in screen shows + * before anybody has signed in, so no credential is sent. + */ +public object InstanceEndpoints { + /** v2 first; v1 after a 404. */ + public fun v2(): ApiRequest = + request(public("api/v2/instance"), InstanceV2Dto.serializer()) { it.toDomain() } + + public fun v1(): ApiRequest = + request(public("api/v1/instance/"), InstanceV1Dto.serializer()) { it.toDomain() } + + public fun rules(): ApiRequest> = + listRequest(public("api/v1/instance/rules"), InstanceRuleDto.serializer()) { it.toDomain() } + + public fun extendedDescription(): ApiRequest = document("api/v1/instance/extended_description") + + /** 404 where the administrator has published none; the row is then hidden, not shown broken. */ + public fun privacyPolicy(): ApiRequest = document("api/v1/instance/privacy_policy") + + public fun termsOfService(): ApiRequest = document("api/v1/instance/terms_of_service") + + /** Which languages each source language can be translated into; `{}` without a provider. */ + public fun translationLanguages(): ApiRequest>> = request( + public("api/v1/instance/translation_languages"), + MapSerializer(String.serializer(), ListSerializer(String.serializer())), + ) { it } + + public fun customEmojis(): ApiRequest> = + listRequest(public("api/v1/custom_emojis"), CustomEmojiDto.serializer()) { it.toDomain() } + + /** Every instance this one has heard of, as bare hostnames. */ + public fun peers(): ApiRequest> = listRequest(public("api/v1/instance/peers"), String.serializer()) { + it + } + + /** The instance's weekly activity, newest week first. */ + public fun activity(): ApiRequest> = + listRequest(public("api/v1/instance/activity"), InstanceActivityWeekDto.serializer()) { it.toDomain() } + + /** The servers this one refuses, where the administrator publishes the list; empty otherwise, not an error. */ + public fun domainBlocks(): ApiRequest> = + listRequest(public("api/v1/instance/domain_blocks"), PublicDomainBlockDto.serializer()) { it.toDomain() } + + /** The account's preferences; this one needs the viewer. */ + public fun preferences(): ApiRequest = + request(Endpoint("api/v1/preferences"), PreferencesDto.serializer()) { it.toDomain() } + + /** + * Sets how sensitive media is shown (Nextcloud extension). [policy] is a + * [social.aloha.core.model.SensitiveMediaPolicy] wire value, or `""` to follow the instance again. + */ + public fun setExpandMedia(policy: String): ApiRequest = unitRequest( + Endpoint("api/v1/preferences", HttpMethod.PUT, body = Body.Form(listOf(QueryItem("expandMedia", policy)))), + ) +} + +private fun public(path: String): Endpoint = Endpoint(path, authentication = Authentication.None) + +private fun document(path: String): ApiRequest = + request(public(path), InstanceDocumentDto.serializer()) { it.toDomain() } diff --git a/core/network/src/main/kotlin/social/aloha/core/network/endpoints/InterestEndpoints.kt b/core/network/src/main/kotlin/social/aloha/core/network/endpoints/InterestEndpoints.kt new file mode 100644 index 00000000..e556f573 --- /dev/null +++ b/core/network/src/main/kotlin/social/aloha/core/network/endpoints/InterestEndpoints.kt @@ -0,0 +1,196 @@ +// SPDX-FileCopyrightText: 2026 Aloha Social contributors +// SPDX-License-Identifier: MIT + +package social.aloha.core.network.endpoints + +import java.io.File +import social.aloha.core.model.HeldPostsPage +import social.aloha.core.model.InterestsState +import social.aloha.core.model.Status +import social.aloha.core.model.SubscriptionEntry +import social.aloha.core.model.SubscriptionFeed +import social.aloha.core.model.WeeklyRecap +import social.aloha.core.network.ApiRequest +import social.aloha.core.network.Authentication +import social.aloha.core.network.Body +import social.aloha.core.network.Endpoint +import social.aloha.core.network.HttpMethod +import social.aloha.core.network.Part +import social.aloha.core.network.QueryItem +import social.aloha.core.network.decoding.ListOrMemberSerializer +import social.aloha.core.network.dto.HeldPostsPageDto +import social.aloha.core.network.dto.InterestsStateDto +import social.aloha.core.network.dto.StatusDto +import social.aloha.core.network.dto.SubscriptionEntryDto +import social.aloha.core.network.dto.SubscriptionFeedDto +import social.aloha.core.network.dto.TakeoutResultDto +import social.aloha.core.network.dto.WeeklyRecapDto +import social.aloha.core.network.dto.toDomain +import social.aloha.core.network.listRequest +import social.aloha.core.network.queryOf +import social.aloha.core.network.repeatedQuery +import social.aloha.core.network.request +import social.aloha.core.network.unitRequest + +private fun Boolean.asFlag(): String = if (this) "1" else "0" + +/** The reader's hashtag interests on Nextcloud Social. Every change answers with the new state. */ +public object InterestEndpoints { + private fun state(endpoint: Endpoint): ApiRequest = + request(endpoint, InterestsStateDto.serializer()) { it.toDomain() } + + public fun state(): ApiRequest = state(Endpoint("api/v1/interests")) + + public fun add(tag: String): ApiRequest = + state(Endpoint("api/v1/interests", HttpMethod.POST, body = Body.Form(listOf(QueryItem("tag", tag))))) + + public fun remove(tag: String): ApiRequest = + state(Endpoint("api/v1/interests/$tag", HttpMethod.DELETE)) + + public fun move(tag: String, position: Int): ApiRequest = state( + Endpoint( + "api/v1/interests/$tag/move", + HttpMethod.POST, + body = Body.Form(listOf(QueryItem("position", position.toString()))), + ), + ) + + public fun pin(tag: String): ApiRequest = + state(Endpoint("api/v1/interests/$tag/pin", HttpMethod.POST)) + + public fun unpin(tag: String): ApiRequest = + state(Endpoint("api/v1/interests/$tag/unpin", HttpMethod.POST)) + + public fun reset(): ApiRequest = state(Endpoint("api/v1/interests/reset", HttpMethod.POST)) + + /** + * Changes only the settings passed. An empty [languages] list still goes out as a field, since + * leaving it out reads as "leave the languages alone". + */ + public fun settings( + learning: Boolean? = null, + paused: Boolean? = null, + languages: List? = null, + noticeAcknowledged: Boolean? = null, + ): ApiRequest { + val fields = queryOf("learning", learning?.asFlag()) + queryOf("paused", paused?.asFlag()) + + languages?.let { + if (it.isEmpty()) listOf(QueryItem("languages", "")) else repeatedQuery("languages", it) + }.orEmpty() + + queryOf("noticeAcknowledged", noticeAcknowledged?.asFlag()) + return state(Endpoint("api/v1/interests/settings", HttpMethod.PUT, body = Body.Form(fields))) + } + + /** Reading signals, batched. The server answers 204 whether or not it is learning. */ + public fun signals(events: List): ApiRequest = unitRequest( + Endpoint("api/v1/interests/signals", HttpMethod.POST, body = Body.Form(repeatedQuery("events", events))), + ) + + /** "Show fewer like this". */ + public fun fewerLikeThis(statusId: String): ApiRequest = + unitRequest(Endpoint("api/v1/interests/less/$statusId", HttpMethod.POST)) + + public fun undoFewerLikeThis(statusId: String): ApiRequest = + unitRequest(Endpoint("api/v1/interests/less/$statusId", HttpMethod.DELETE)) + + /** The interests feed itself, paged by `max_id` with a `Link` header. */ + public fun timeline( + limit: Int = Paging.DEFAULT_LIMIT, + anchor: PageAnchor = PageAnchor.Cold, + ): ApiRequest> = listRequest( + Endpoint("api/v1/timelines/interests", query = Paging.pageItems(limit, anchor)), + StatusDto.serializer(), + ) { + it.toDomain() + } +} + +/** Feeds outside the fediverse that the server reads for the viewer; entries link out. */ +public object SubscriptionEndpoints { + private const val DEFAULT_LIMIT = 40 + + public fun feeds(): ApiRequest> = request( + Endpoint("api/v1/subscriptions"), + ListOrMemberSerializer(SubscriptionFeedDto.serializer(), "feeds"), + ) { list -> + list.map { it.toDomain() } + } + + /** A feed URL or a YouTube channel link; the server works out which. */ + public fun follow(url: String): ApiRequest = + unitRequest(Endpoint("api/v1/subscriptions", HttpMethod.POST, body = Body.Form(listOf(QueryItem("url", url))))) + + public fun unfollow(id: String): ApiRequest = + unitRequest(Endpoint("api/v1/subscriptions/$id", HttpMethod.DELETE)) + + /** Newest first. The route sends no `Link` header, so the caller pages by the last entry's id. */ + public fun timeline(limit: Int = DEFAULT_LIMIT, maxId: String? = null): ApiRequest> = + request( + Endpoint( + "api/v1/subscriptions/timeline", + query = listOf(Paging.limitItem(limit)) + queryOf("max_id", maxId), + ), + ListOrMemberSerializer(SubscriptionEntryDto.serializer(), "items"), + ) { list -> list.map { it.toDomain() } } + + /** Google Takeout's `subscriptions.csv`; answers with how many channels it subscribed to. */ + public fun importTakeout(csv: File, fileName: String = "subscriptions.csv"): ApiRequest = request( + Endpoint( + "api/v1/subscriptions/takeout", + HttpMethod.POST, + body = Body.Multipart(listOf(Part.FileContent("file", csv, fileName, "text/csv"))), + ), + TakeoutResultDto.serializer(), + ) { it.subscribed } +} + +/** + * The reader's own past posts. These routes take a Nextcloud session, not the Social token: a + * bearer token answers 401, so they go out with the app password. + */ +public object MemoryEndpoints { + /** Posts of the reader's from this calendar day in earlier years. */ + public fun onThisDay(): ApiRequest> = listRequest( + Endpoint("api/v1/memories/on_this_day", authentication = Authentication.NextcloudSession), + StatusDto.serializer(), + ) { it.toDomain() } + + public fun recap(): ApiRequest = request( + Endpoint("api/v1/memories/recap", authentication = Authentication.NextcloudSession), + WeeklyRecapDto.serializer(), + ) { it.toDomain() } + + public fun setRecap(enabled: Boolean): ApiRequest = unitRequest( + Endpoint( + "api/v1/memories/recap", + HttpMethod.POST, + body = Body.Form(listOf(QueryItem("enabled", enabled.asFlag()))), + authentication = Authentication.NextcloudSession, + ), + ) +} + +/** The reader's own posts waiting for a moderator; a Nextcloud-session route like the memories. */ +public object ReviewEndpoints { + public fun held(): ApiRequest = request( + Endpoint("api/v1/review", authentication = Authentication.NextcloudSession), + HeldPostsPageDto.serializer(), + ) { it.toDomain() } + + /** Withdraws the post: deleted, and no moderation record is made. */ + public fun withdraw(id: String): ApiRequest = + unitRequest(Endpoint("api/v1/review/$id", HttpMethod.DELETE, authentication = Authentication.NextcloudSession)) +} + +/** The reader's side of administrators' announcements; the list itself is `SafetyEndpoints.announcements`. */ +public object AnnouncementEndpoints { + public fun dismiss(id: String): ApiRequest = + unitRequest(Endpoint("api/v1/announcements/$id/dismiss", HttpMethod.POST)) + + /** The emoji goes in the path, percent-encoded on the way out. */ + public fun react(id: String, emoji: String): ApiRequest = + unitRequest(Endpoint("api/v1/announcements/$id/reactions/$emoji", HttpMethod.PUT)) + + public fun unreact(id: String, emoji: String): ApiRequest = + unitRequest(Endpoint("api/v1/announcements/$id/reactions/$emoji", HttpMethod.DELETE)) +} diff --git a/core/network/src/main/kotlin/social/aloha/core/network/endpoints/MigrationEndpoints.kt b/core/network/src/main/kotlin/social/aloha/core/network/endpoints/MigrationEndpoints.kt new file mode 100644 index 00000000..b019a051 --- /dev/null +++ b/core/network/src/main/kotlin/social/aloha/core/network/endpoints/MigrationEndpoints.kt @@ -0,0 +1,153 @@ +// SPDX-FileCopyrightText: 2026 Aloha Social contributors +// SPDX-License-Identifier: MIT + +package social.aloha.core.network.endpoints + +import java.io.File +import kotlinx.serialization.builtins.serializer +import social.aloha.core.model.AccountStatistics +import social.aloha.core.model.MigrationAnnouncement +import social.aloha.core.model.MigrationListKind +import social.aloha.core.model.MigrationLookup +import social.aloha.core.model.MigrationReport +import social.aloha.core.model.VideoChannel +import social.aloha.core.network.ApiRequest +import social.aloha.core.network.Authentication +import social.aloha.core.network.Body +import social.aloha.core.network.Endpoint +import social.aloha.core.network.HttpMethod +import social.aloha.core.network.Part +import social.aloha.core.network.QueryItem +import social.aloha.core.network.decoding.ListOrMemberSerializer +import social.aloha.core.network.dto.MigrationAnnouncementDto +import social.aloha.core.network.dto.MigrationLookupSerializer +import social.aloha.core.network.dto.MigrationReportSerializer +import social.aloha.core.network.dto.StatisticsDto +import social.aloha.core.network.dto.VideoChannelDto +import social.aloha.core.network.dto.toDomain +import social.aloha.core.network.flagQuery +import social.aloha.core.network.repeatedQuery +import social.aloha.core.network.request + +private fun Boolean.asFlag(): String = if (this) "1" else "0" + +private const val ZIP = "application/zip" +private const val CSV = "text/csv" + +/** + * Taking the account somewhere else, or bringing one here. The exports are downloads (a zip or a + * CSV) rather than JSON, so they are not built here. + */ +public object MigrationEndpoints { + private fun report(endpoint: Endpoint): ApiRequest = + request(endpoint, MigrationReportSerializer) { it } + + private fun aliases(endpoint: Endpoint): ApiRequest> = + request(endpoint, ListOrMemberSerializer(String.serializer(), "aliases")) { it } + + private fun upload( + path: String, + file: File, + fileName: String, + mimeType: String, + query: List = emptyList(), + ) = Endpoint( + path, + HttpMethod.POST, + query = query, + body = Body.Multipart(listOf(Part.FileContent("file", file, fileName, mimeType))), + ) + + /** The whole account from another server's archive: profile, posts, media, relationships. */ + public fun importArchive(archive: File, fileName: String): ApiRequest = + report(upload("api/v1/migration/import", archive, fileName, ZIP)) + + public fun importList(kind: MigrationListKind, csv: File, fileName: String): ApiRequest = + report(upload("api/v1/migration/${kind.importPath}", csv, fileName, CSV)) + + public fun importPosts(archive: File, fileName: String, fetchMedia: Boolean): ApiRequest = report( + upload("api/v1/migration/posts", archive, fileName, ZIP, listOf(QueryItem("fetch_media", fetchMedia.asFlag()))), + ) + + /** One video, by its address on another server. */ + public fun importVideo(url: String, fetchMedia: Boolean): ApiRequest = report( + Endpoint( + "api/v1/migration/video", + HttpMethod.POST, + body = Body.Form(listOf(QueryItem("url", url), QueryItem("fetch_media", fetchMedia.asFlag()))), + ), + ) + + /** Candidate handles pulled out of an Instagram archive. */ + public fun instagramPeople(archive: File, fileName: String): ApiRequest> = request( + upload("api/v1/migration/people", archive, fileName, ZIP), + ListOrMemberSerializer(String.serializer(), "handles"), + ) { it } + + /** Which of [handles] exist somewhere. */ + public fun findPeople(handles: List): ApiRequest = request( + Endpoint("api/v1/migration/people/find", HttpMethod.POST, body = Body.Form(repeatedQuery("handles", handles))), + MigrationLookupSerializer, + ) { it } + + /** What to paste into the old server's "move to" box. */ + public fun announcement(): ApiRequest = + request(Endpoint("api/v1/migration/announcement"), MigrationAnnouncementDto.serializer()) { it.toDomain() } + + public fun aliases(): ApiRequest> = aliases(Endpoint("api/v1/migration/aliases")) + + public fun addAlias(alias: String): ApiRequest> = aliases( + Endpoint("api/v1/migration/aliases", HttpMethod.POST, body = Body.Form(listOf(QueryItem("alias", alias)))), + ) + + /** The alias rides in the query: a DELETE with a body is dropped by some of the proxies a Nextcloud sits behind. */ + public fun removeAlias(alias: String): ApiRequest> = + aliases(Endpoint("api/v1/migration/aliases", HttpMethod.DELETE, query = listOf(QueryItem("alias", alias)))) +} + +/** + * The reader's own numbers. A Nextcloud-session route: a bearer token answers 401. The CSV export is + * a download and is not built here. + */ +public object StatisticsEndpoints { + /** [days] 0 means everything; [fresh] bypasses the server's cache. */ + public fun overview(days: Int, fresh: Boolean = false): ApiRequest = request( + Endpoint( + "api/v1/statistics", + query = listOf(QueryItem("days", days.toString())) + flagQuery("fresh", fresh), + authentication = Authentication.NextcloudSession, + ), + StatisticsDto.serializer(), + ) { it.toDomain() } +} + +/** The reader's video channels, PeerTube's `Group`. A Nextcloud-session route; every answer is `{channels: [...]}`. */ +public object ChannelEndpoints { + private fun channels(endpoint: Endpoint): ApiRequest> = + request(endpoint, ListOrMemberSerializer(VideoChannelDto.serializer(), "channels")) { list -> + list.map { it.toDomain() } + } + + public fun all(): ApiRequest> = + channels(Endpoint("api/v1/channels", authentication = Authentication.NextcloudSession)) + + public fun create(handle: String, name: String, description: String): ApiRequest> = channels( + Endpoint( + "api/v1/channels", + HttpMethod.POST, + body = Body.Form( + listOf(QueryItem("handle", handle), QueryItem("name", name), QueryItem("description", description)), + ), + authentication = Authentication.NextcloudSession, + ), + ) + + public fun update(id: String, name: String, description: String): ApiRequest> = channels( + Endpoint( + "api/v1/channels/$id", + HttpMethod.PUT, + body = Body.Form(listOf(QueryItem("name", name), QueryItem("description", description))), + authentication = Authentication.NextcloudSession, + ), + ) +} diff --git a/core/network/src/main/kotlin/social/aloha/core/network/endpoints/ModerationEndpoints.kt b/core/network/src/main/kotlin/social/aloha/core/network/endpoints/ModerationEndpoints.kt new file mode 100644 index 00000000..63c31d7e --- /dev/null +++ b/core/network/src/main/kotlin/social/aloha/core/network/endpoints/ModerationEndpoints.kt @@ -0,0 +1,86 @@ +// SPDX-FileCopyrightText: 2026 Aloha Social contributors +// SPDX-License-Identifier: MIT + +package social.aloha.core.network.endpoints + +import social.aloha.core.model.AdminReport +import social.aloha.core.model.Card +import social.aloha.core.model.Status +import social.aloha.core.model.Tag +import social.aloha.core.network.ApiRequest +import social.aloha.core.network.Endpoint +import social.aloha.core.network.HttpMethod +import social.aloha.core.network.QueryItem +import social.aloha.core.network.dto.AdminReportDto +import social.aloha.core.network.dto.CardDto +import social.aloha.core.network.dto.StatusDto +import social.aloha.core.network.dto.TagDto +import social.aloha.core.network.dto.toDomain +import social.aloha.core.network.listRequest +import social.aloha.core.network.request +import social.aloha.core.network.unitRequest + +/** + * The reports queue and trend moderation of Mastodon's admin API under `/api/v1/admin/`, which Nextcloud + * Social serves from its admin controller. Every route takes the bearer token, and the server checks on + * each one that the Nextcloud user behind it is an administrator; a 403 is the honest answer for anybody + * else, and the app hides the section then rather than showing rows it cannot load. Instance + * configuration stays in the web administration. Accounts are in [AdminAccountEndpoints]. + */ +public object ModerationEndpoints { + /** What a trend decision is about. */ + public enum class TrendKind(internal val path: String) { Tags("tags"), Statuses("statuses"), Links("links") } + + /** The queue a moderator works through; unresolved only unless [resolved]. */ + public fun reports( + resolved: Boolean = false, + limit: Int = Paging.DEFAULT_LIMIT, + anchor: PageAnchor = PageAnchor.Cold, + ): ApiRequest> = listRequest( + Endpoint( + "api/v1/admin/reports", + query = Paging.pageItems(limit, anchor) + QueryItem("resolved", resolved.toString()), + ), + AdminReportDto.serializer(), + ) { it.toDomain() } + + public fun report(id: String): ApiRequest = reportRequest(Endpoint("api/v1/admin/reports/$id")) + + public fun resolveReport(id: String): ApiRequest = reportAction(id, "resolve") + + public fun reopenReport(id: String): ApiRequest = reportAction(id, "reopen") + + public fun assignReportToSelf(id: String): ApiRequest = reportAction(id, "assign_to_self") + + public fun unassignReport(id: String): ApiRequest = reportAction(id, "unassign") + + /** What may trend, from the moderator's side of the same readers the public routes use. */ + public fun trendingTags(limit: Int = Paging.DEFAULT_LIMIT): ApiRequest> = + listRequest(trends("tags", limit), TagDto.serializer()) { it.toDomain() } + + public fun trendingStatuses(limit: Int = Paging.DEFAULT_LIMIT): ApiRequest> = + listRequest(trends("statuses", limit), StatusDto.serializer()) { it.toDomain() } + + public fun trendingLinks(limit: Int = Paging.DEFAULT_LIMIT): ApiRequest> = + listRequest(trends("links", limit), CardDto.serializer()) { it.toDomain() } + + /** + * Records that a moderator looked. Everything nobody objected to trends already, so approving grants + * nothing; only [rejectTrend] changes what readers see. + */ + public fun approveTrend(kind: TrendKind, id: String): ApiRequest = trendDecision(kind, id, "approve") + + /** Hides the tag, status or link from the trending routes. */ + public fun rejectTrend(kind: TrendKind, id: String): ApiRequest = trendDecision(kind, id, "reject") +} + +private fun reportRequest(endpoint: Endpoint) = request(endpoint, AdminReportDto.serializer()) { it.toDomain() } + +private fun reportAction(id: String, action: String) = + reportRequest(Endpoint("api/v1/admin/reports/$id/$action", HttpMethod.POST)) + +private fun trends(kind: String, limit: Int) = + Endpoint("api/v1/admin/trends/$kind", query = listOf(Paging.limitItem(limit))) + +private fun trendDecision(kind: ModerationEndpoints.TrendKind, id: String, decision: String) = + unitRequest(Endpoint("api/v1/admin/trends/${kind.path}/$id/$decision", HttpMethod.POST)) diff --git a/core/network/src/main/kotlin/social/aloha/core/network/endpoints/NotificationEndpoints.kt b/core/network/src/main/kotlin/social/aloha/core/network/endpoints/NotificationEndpoints.kt new file mode 100644 index 00000000..2f98444f --- /dev/null +++ b/core/network/src/main/kotlin/social/aloha/core/network/endpoints/NotificationEndpoints.kt @@ -0,0 +1,115 @@ +// SPDX-FileCopyrightText: 2026 Aloha Social contributors +// SPDX-License-Identifier: MIT + +package social.aloha.core.network.endpoints + +import social.aloha.core.model.Account +import social.aloha.core.model.GroupedNotifications +import social.aloha.core.model.MarkerSet +import social.aloha.core.model.Notification +import social.aloha.core.model.NotificationPolicy +import social.aloha.core.model.NotificationRequest +import social.aloha.core.model.UnreadCount +import social.aloha.core.network.ApiRequest +import social.aloha.core.network.Body +import social.aloha.core.network.Endpoint +import social.aloha.core.network.HttpMethod +import social.aloha.core.network.dto.AccountDto +import social.aloha.core.network.dto.GroupedNotificationsDto +import social.aloha.core.network.dto.MarkerSetDto +import social.aloha.core.network.dto.NotificationDto +import social.aloha.core.network.dto.NotificationPolicyDto +import social.aloha.core.network.dto.NotificationRequestDto +import social.aloha.core.network.dto.UnreadCountDto +import social.aloha.core.network.dto.toDomain +import social.aloha.core.network.listRequest +import social.aloha.core.network.pagedObjectRequest +import social.aloha.core.network.queryOf +import social.aloha.core.network.repeatedQuery +import social.aloha.core.network.request +import social.aloha.core.network.unitRequest + +public object NotificationEndpoints { + private const val GROUPED_LIMIT = 40 + private const val REQUESTS_LIMIT = 40 + + /** Grouped notifications (Mastodon 4.3); the page is `notification_groups`. */ + public fun grouped( + limit: Int = GROUPED_LIMIT, + anchor: PageAnchor = PageAnchor.Cold, + types: List = emptyList(), + excludeTypes: List = emptyList(), + ): ApiRequest = pagedObjectRequest( + Endpoint("api/v2/notifications", query = filtered(limit, anchor, types, excludeTypes)), + GroupedNotificationsDto.serializer(), + pageMember = "notification_groups", + ) { it.toDomain() } + + public fun flat( + limit: Int = Paging.DEFAULT_LIMIT, + anchor: PageAnchor = PageAnchor.Cold, + types: List = emptyList(), + excludeTypes: List = emptyList(), + ): ApiRequest> = listRequest( + Endpoint("api/v1/notifications", query = filtered(limit, anchor, types, excludeTypes)), + NotificationDto.serializer(), + ) { it.toDomain() } + + private fun filtered(limit: Int, anchor: PageAnchor, types: List, excludeTypes: List) = + Paging.pageItems(limit, anchor) + repeatedQuery("types", types) + repeatedQuery("exclude_types", excludeTypes) + + public fun unreadCount(grouped: Boolean): ApiRequest = request( + Endpoint(if (grouped) "api/v2/notifications/unread_count" else "api/v1/notifications/unread_count"), + UnreadCountDto.serializer(), + ) { it.toDomain() } + + public fun groupAccounts(groupKey: String): ApiRequest> = + listRequest(Endpoint("api/v2/notifications/$groupKey/accounts"), AccountDto.serializer()) { it.toDomain() } + + public fun dismissGroup(groupKey: String): ApiRequest = + unitRequest(Endpoint("api/v2/notifications/$groupKey/dismiss", HttpMethod.POST)) + + public fun dismiss(id: String): ApiRequest = + unitRequest(Endpoint("api/v1/notifications/$id/dismiss", HttpMethod.POST)) + + public fun clear(): ApiRequest = unitRequest(Endpoint("api/v1/notifications/clear", HttpMethod.POST)) + + /** The v2 route is where a 4.3 client looks; the v1 spelling is for servers written against 4.2. */ + public fun policy(v2: Boolean): ApiRequest = request( + Endpoint(if (v2) "api/v2/notifications/policy" else "api/v1/notifications/policy"), + NotificationPolicyDto.serializer(), + ) { it.toDomain() } + + public fun requests(): ApiRequest> = listRequest( + Endpoint("api/v1/notifications/requests", query = listOf(Paging.limitItem(REQUESTS_LIMIT, REQUESTS_LIMIT))), + NotificationRequestDto.serializer(), + ) { it.toDomain() } + + public fun acceptRequest(id: String): ApiRequest = + unitRequest(Endpoint("api/v1/notifications/requests/$id/accept", HttpMethod.POST)) + + public fun dismissRequest(id: String): ApiRequest = + unitRequest(Endpoint("api/v1/notifications/requests/$id/dismiss", HttpMethod.POST)) +} + +public object MarkerEndpoints { + public fun read(): ApiRequest = request( + Endpoint("api/v1/markers", query = repeatedQuery("timeline", listOf("home", "notifications"))), + MarkerSetDto.serializer(), + ) { it.toDomain() } + + /** + * Moves the read markers. A marker never moves backwards: the server enforces it and so does the + * caller, so a device that is behind cannot un-read what another has read. + */ + public fun write(home: String?, notifications: String?): ApiRequest = request( + Endpoint( + "api/v1/markers", + HttpMethod.POST, + body = Body.Form( + queryOf("home[last_read_id]", home) + queryOf("notifications[last_read_id]", notifications), + ), + ), + MarkerSetDto.serializer(), + ) { it.toDomain() } +} diff --git a/core/network/src/main/kotlin/social/aloha/core/network/endpoints/Paging.kt b/core/network/src/main/kotlin/social/aloha/core/network/endpoints/Paging.kt new file mode 100644 index 00000000..27f802f8 --- /dev/null +++ b/core/network/src/main/kotlin/social/aloha/core/network/endpoints/Paging.kt @@ -0,0 +1,40 @@ +// SPDX-FileCopyrightText: 2026 Aloha Social contributors +// SPDX-License-Identifier: MIT + +package social.aloha.core.network.endpoints + +import social.aloha.core.network.QueryItem + +/** + * Where a page request starts from. A fetch is always anchored: `min_id` from the newest cached entry, + * `max_id` from the oldest, or a cold load. Following a `Link` cursor replays its URL verbatim instead. + */ +public sealed interface PageAnchor { + public data object Cold : PageAnchor + + public data class OlderThan(val id: String) : PageAnchor + + public data class NewerThan(val id: String) : PageAnchor + + public data class ImmediatelyAfter(val id: String) : PageAnchor +} + +internal fun PageAnchor.queryItems(): List = when (this) { + PageAnchor.Cold -> emptyList() + is PageAnchor.OlderThan -> listOf(QueryItem("max_id", id)) + is PageAnchor.NewerThan -> listOf(QueryItem("min_id", id)) + is PageAnchor.ImmediatelyAfter -> listOf(QueryItem("since_id", id)) +} + +/** Page sizes. The server caps most routes at 50 whatever is asked, and answers `limit=0` with a 400. */ +public object Paging { + public const val DEFAULT_LIMIT: Int = 20 + public const val MAXIMUM_LIMIT: Int = 50 + + /** A `limit` between 1 and [maximum], so `limit=0` is never sent. */ + internal fun limitItem(limit: Int, maximum: Int = MAXIMUM_LIMIT): QueryItem = + QueryItem("limit", limit.coerceIn(1, maximum).toString()) + + internal fun pageItems(limit: Int, anchor: PageAnchor): List = + listOf(limitItem(limit)) + anchor.queryItems() +} diff --git a/core/network/src/main/kotlin/social/aloha/core/network/endpoints/ProfileEndpoints.kt b/core/network/src/main/kotlin/social/aloha/core/network/endpoints/ProfileEndpoints.kt new file mode 100644 index 00000000..2cc01924 --- /dev/null +++ b/core/network/src/main/kotlin/social/aloha/core/network/endpoints/ProfileEndpoints.kt @@ -0,0 +1,105 @@ +// SPDX-FileCopyrightText: 2026 Aloha Social contributors +// SPDX-License-Identifier: MIT + +package social.aloha.core.network.endpoints + +import social.aloha.core.model.AccountList +import social.aloha.core.model.FamiliarFollowers +import social.aloha.core.model.FeaturedTag +import social.aloha.core.model.ProfileHighlights +import social.aloha.core.model.Relationship +import social.aloha.core.model.Status +import social.aloha.core.model.TaggedPerson +import social.aloha.core.network.ApiRequest +import social.aloha.core.network.Body +import social.aloha.core.network.Endpoint +import social.aloha.core.network.HttpMethod +import social.aloha.core.network.QueryItem +import social.aloha.core.network.decoding.ListOrMemberSerializer +import social.aloha.core.network.dto.AccountListDto +import social.aloha.core.network.dto.FamiliarFollowersDto +import social.aloha.core.network.dto.FeaturedTagDto +import social.aloha.core.network.dto.ProfileHighlightsDto +import social.aloha.core.network.dto.RelationshipDto +import social.aloha.core.network.dto.StatusDto +import social.aloha.core.network.dto.TaggedPersonSerializer +import social.aloha.core.network.dto.toDomain +import social.aloha.core.network.listRequest +import social.aloha.core.network.repeatedQuery +import social.aloha.core.network.request +import social.aloha.core.network.unitRequest + +/** + * What a profile shows beyond the account entity: Nextcloud Social's highlights, featured tags, + * pinned posts, mutual followers, private notes and Pixelfed's tagged photos. Endorsing and removing + * a follower are `AccountEndpoints.action`. + */ +public object ProfileEndpoints { + private const val PINNED_LIMIT = 5 + private const val TAGGED_LIMIT = 40 + + /** Twelve weeks of posting, for a local account. */ + public fun highlights(id: String): ApiRequest = + request(Endpoint("api/v1/accounts/$id/highlights"), ProfileHighlightsDto.serializer()) { it.toDomain() } + + /** Up to ten hashtags an account features at the top of its profile. */ + public fun featuredTags(id: String): ApiRequest> = + listRequest(Endpoint("api/v1/accounts/$id/featured_tags"), FeaturedTagDto.serializer()) { it.toDomain() } + + /** Up to five pinned posts. */ + public fun pinnedStatuses(id: String): ApiRequest> = listRequest( + Endpoint( + "api/v1/accounts/$id/statuses", + query = listOf(QueryItem("pinned", "true"), Paging.limitItem(PINNED_LIMIT)), + ), + StatusDto.serializer(), + ) { it.toDomain() } + + /** For each account asked about, the people the reader follows who also follow it. */ + public fun familiarFollowers(ids: List): ApiRequest> = listRequest( + Endpoint("api/v1/accounts/familiar_followers", query = repeatedQuery("id", ids)), + FamiliarFollowersDto.serializer(), + ) { it.toDomain() } + + /** A private note about an account, seen by nobody else. */ + public fun setNote(id: String, comment: String): ApiRequest = request( + Endpoint("api/v1/accounts/$id/note", HttpMethod.POST, body = Body.Form(listOf(QueryItem("comment", comment)))), + RelationshipDto.serializer(), + ) { it.toDomain() } + + /** The reader's lists that contain an account. */ + public fun listsContaining(id: String): ApiRequest> = + listRequest(Endpoint("api/v1/accounts/$id/lists"), AccountListDto.serializer()) { it.toDomain() } + + /** Photos an account is tagged in (Pixelfed). */ + public fun tagged( + id: String, + limit: Int = Paging.DEFAULT_LIMIT, + anchor: PageAnchor = PageAnchor.Cold, + ): ApiRequest> = listRequest( + Endpoint( + "api/v1.1/accounts/$id/tagged", + query = Paging.pageItems(limit.coerceAtMost(TAGGED_LIMIT), anchor), + ), + StatusDto.serializer(), + ) { it.toDomain() } + + /** Tags people in the reader's own photo; handles go without the `@`. Answers with the people now tagged. */ + public fun tagPeople(statusId: String, handles: List): ApiRequest> = request( + Endpoint( + "api/v1.1/compose/tag", + HttpMethod.POST, + body = Body.Form(listOf(QueryItem("status_id", statusId)) + repeatedQuery("accounts", handles)), + ), + ListOrMemberSerializer(TaggedPersonSerializer, "tagged_people"), + ) { it } + + /** Takes the reader's own tag off somebody's photo. */ + public fun untagMe(statusId: String): ApiRequest = unitRequest( + Endpoint( + "api/v1.1/compose/tag/untagme", + HttpMethod.POST, + body = Body.Form(listOf(QueryItem("status_id", statusId))), + ), + ) +} diff --git a/core/network/src/main/kotlin/social/aloha/core/network/endpoints/SearchEndpoints.kt b/core/network/src/main/kotlin/social/aloha/core/network/endpoints/SearchEndpoints.kt new file mode 100644 index 00000000..8d65cff1 --- /dev/null +++ b/core/network/src/main/kotlin/social/aloha/core/network/endpoints/SearchEndpoints.kt @@ -0,0 +1,193 @@ +// SPDX-FileCopyrightText: 2026 Aloha Social contributors +// SPDX-License-Identifier: MIT + +package social.aloha.core.network.endpoints + +import social.aloha.core.model.Account +import social.aloha.core.model.AccountList +import social.aloha.core.model.Card +import social.aloha.core.model.ListRepliesPolicy +import social.aloha.core.model.SearchResults +import social.aloha.core.model.Suggestion +import social.aloha.core.model.Tag +import social.aloha.core.network.ApiRequest +import social.aloha.core.network.Authentication +import social.aloha.core.network.Body +import social.aloha.core.network.Endpoint +import social.aloha.core.network.HttpMethod +import social.aloha.core.network.QueryItem +import social.aloha.core.network.dto.AccountDto +import social.aloha.core.network.dto.AccountListDto +import social.aloha.core.network.dto.CardDto +import social.aloha.core.network.dto.SearchResultsDto +import social.aloha.core.network.dto.SuggestionDto +import social.aloha.core.network.dto.TagDto +import social.aloha.core.network.dto.toDomain +import social.aloha.core.network.flagQuery +import social.aloha.core.network.listRequest +import social.aloha.core.network.queryOf +import social.aloha.core.network.repeatedQuery +import social.aloha.core.network.request +import social.aloha.core.network.unitRequest + +/** The order of the profile directory. */ +public enum class DirectoryOrder(public val wire: String) { + /** Most recently posted. */ + Active("active"), + + /** Most recently joined. */ + New("new"), +} + +public object SearchEndpoints { + private const val DIRECTORY_LIMIT = 40 + private const val TRENDS_LIMIT = 10 + private const val SUGGESTIONS_LIMIT = 20 + + public fun search( + query: String, + type: String? = null, + resolve: Boolean = false, + limit: Int = Paging.DEFAULT_LIMIT, + ): ApiRequest = request( + Endpoint( + "api/v2/search", + query = + listOf(QueryItem("q", query), Paging.limitItem(limit)) + queryOf("type", type) + + flagQuery("resolve", resolve), + ), + SearchResultsDto.serializer(), + ) { it.toDomain() } + + /** + * The instance's profile directory: the accounts here that chose to be discoverable. `local` is + * accepted by the server and ignored, so it is not sent. + */ + public fun directory( + order: DirectoryOrder = DirectoryOrder.Active, + limit: Int = DIRECTORY_LIMIT, + offset: Int = 0, + ): ApiRequest> = listRequest( + Endpoint( + "api/v1/directory", + query = listOf( + QueryItem("order", order.wire), + Paging.limitItem(limit), + QueryItem("offset", offset.coerceAtLeast(0).toString()), + ), + authentication = Authentication.None, + ), + AccountDto.serializer(), + ) { it.toDomain() } + + public fun trendingTags(limit: Int = TRENDS_LIMIT, period: String = "1d"): ApiRequest> = listRequest( + Endpoint( + "api/v1/trends/tags", + query = listOf(Paging.limitItem(limit), QueryItem("period", period)), + authentication = Authentication.None, + ), + TagDto.serializer(), + ) { it.toDomain() } + + public fun trendingLinks(limit: Int = TRENDS_LIMIT): ApiRequest> = listRequest( + Endpoint("api/v1/trends/links", query = listOf(Paging.limitItem(limit)), authentication = Authentication.None), + CardDto.serializer(), + ) { it.toDomain() } + + public fun suggestions(): ApiRequest> = listRequest( + Endpoint("api/v2/suggestions", query = listOf(Paging.limitItem(SUGGESTIONS_LIMIT))), + SuggestionDto.serializer(), + ) { it.toDomain() } + + public fun dismissSuggestion(accountId: String): ApiRequest = + unitRequest(Endpoint("api/v1/suggestions/$accountId", HttpMethod.DELETE)) +} + +/** Hashtag routes; every name is normalised the way the server stores it. */ +public object TagEndpoints { + public fun tag(name: String): ApiRequest = tagRequest(Endpoint("api/v1/tags/${normalised(name)}")) + + public fun follow(name: String): ApiRequest = + tagRequest(Endpoint("api/v1/tags/${normalised(name)}/follow", HttpMethod.POST)) + + public fun unfollow(name: String): ApiRequest = + tagRequest(Endpoint("api/v1/tags/${normalised(name)}/unfollow", HttpMethod.POST)) + + /** + * Followed tags. Pages on the followed-tag row id, not a status id: a tag can be unfollowed and + * followed again, so its name cannot page. + */ + public fun followed( + limit: Int = Paging.DEFAULT_LIMIT, + anchor: PageAnchor = PageAnchor.Cold, + ): ApiRequest> = + listRequest(Endpoint("api/v1/followed_tags", query = Paging.pageItems(limit, anchor)), TagDto.serializer()) { + it.toDomain() + } + + /** Tags that travel with this one on public posts (Nextcloud Social). */ + public fun related(name: String, limit: Int = Paging.DEFAULT_LIMIT): ApiRequest> = listRequest( + Endpoint("api/v1/tags/${normalised(name)}/related", query = listOf(Paging.limitItem(limit, RELATED_LIMIT))), + TagDto.serializer(), + ) { it.toDomain() } + + private const val RELATED_LIMIT = 40 + + private fun normalised(name: String): String = Tag.normalise(name) ?: name + + private fun tagRequest(endpoint: Endpoint): ApiRequest = request(endpoint, TagDto.serializer()) { + it.toDomain() + } +} + +public object ListEndpoints { + public fun all(): ApiRequest> = + listRequest(Endpoint("api/v1/lists"), AccountListDto.serializer()) { it.toDomain() } + + public fun create(title: String): ApiRequest = request( + Endpoint("api/v1/lists", HttpMethod.POST, body = Body.Form(listOf(QueryItem("title", title)))), + AccountListDto.serializer(), + ) { it.toDomain() } + + public fun delete(id: String): ApiRequest = unitRequest(Endpoint("api/v1/lists/$id", HttpMethod.DELETE)) + + /** + * A list's members, paged by the `Link` header. No `limit` is sent: Mastodon reads `limit=0` as + * "all", and Nextcloud Social answers it with a 400. + */ + public fun accounts(id: String): ApiRequest> = + listRequest(Endpoint("api/v1/lists/$id/accounts"), AccountDto.serializer()) { it.toDomain() } + + /** Renames a list and sets whose replies it shows and whether its members leave the home timeline. */ + public fun update( + id: String, + title: String, + repliesPolicy: ListRepliesPolicy?, + exclusive: Boolean, + ): ApiRequest { + val fields = listOf(QueryItem("title", title), QueryItem("exclusive", exclusive.toString())) + + queryOf("replies_policy", repliesPolicy?.wire) + return request( + Endpoint("api/v1/lists/$id", HttpMethod.PUT, body = Body.Form(fields)), + AccountListDto.serializer(), + ) { + it.toDomain() + } + } + + public fun addAccounts(id: String, accountIds: List): ApiRequest = unitRequest( + Endpoint( + "api/v1/lists/$id/accounts", + HttpMethod.POST, + body = Body.Form(repeatedQuery("account_ids", accountIds)), + ), + ) + + public fun removeAccounts(id: String, accountIds: List): ApiRequest = unitRequest( + Endpoint( + "api/v1/lists/$id/accounts", + HttpMethod.DELETE, + body = Body.Form(repeatedQuery("account_ids", accountIds)), + ), + ) +} diff --git a/core/network/src/main/kotlin/social/aloha/core/network/endpoints/StatusEndpoints.kt b/core/network/src/main/kotlin/social/aloha/core/network/endpoints/StatusEndpoints.kt new file mode 100644 index 00000000..6561f149 --- /dev/null +++ b/core/network/src/main/kotlin/social/aloha/core/network/endpoints/StatusEndpoints.kt @@ -0,0 +1,120 @@ +// SPDX-FileCopyrightText: 2026 Aloha Social contributors +// SPDX-License-Identifier: MIT + +package social.aloha.core.network.endpoints + +import social.aloha.core.model.Account +import social.aloha.core.model.Card +import social.aloha.core.model.Poll +import social.aloha.core.model.Status +import social.aloha.core.model.StatusContext +import social.aloha.core.model.StatusEdit +import social.aloha.core.model.StatusSource +import social.aloha.core.model.Translation +import social.aloha.core.network.ApiRequest +import social.aloha.core.network.Body +import social.aloha.core.network.Endpoint +import social.aloha.core.network.HttpMethod +import social.aloha.core.network.QueryItem +import social.aloha.core.network.dto.AccountDto +import social.aloha.core.network.dto.CardDto +import social.aloha.core.network.dto.PollDto +import social.aloha.core.network.dto.StatusContextDto +import social.aloha.core.network.dto.StatusDto +import social.aloha.core.network.dto.StatusEditDto +import social.aloha.core.network.dto.StatusSourceDto +import social.aloha.core.network.dto.TranslationDto +import social.aloha.core.network.dto.toDomain +import social.aloha.core.network.listRequest +import social.aloha.core.network.queryOf +import social.aloha.core.network.repeatedQuery +import social.aloha.core.network.request +import social.aloha.core.network.unitRequest + +/** A one-tap action on a status; the wire value is the last path segment. */ +public enum class StatusAction(public val wire: String) { + Favourite("favourite"), + Unfavourite("unfavourite"), + Reblog("reblog"), + Unreblog("unreblog"), + Bookmark("bookmark"), + Unbookmark("unbookmark"), + Pin("pin"), + Unpin("unpin"), + Mute("mute"), + Unmute("unmute"), +} + +public object StatusEndpoints { + public fun status(id: String): ApiRequest = + request(Endpoint("api/v1/statuses/$id"), StatusDto.serializer()) { it.toDomain() } + + /** + * The link preview of one status. The card is inlined in the status entity, which every list reads; + * this route builds and caches it on the server, so it is a detail-screen request, never a per-row + * one. A status with no link answers `{}`, which is null here. + */ + public fun card(id: String): ApiRequest = + request(Endpoint("api/v1/statuses/$id/card"), CardDto.serializer()) { dto -> + dto.takeUnless { it.url == null && it.title.isNullOrEmpty() }?.toDomain() + } + + public fun context(id: String): ApiRequest = + request(Endpoint("api/v1/statuses/$id/context"), StatusContextDto.serializer()) { it.toDomain() } + + public fun history(id: String): ApiRequest> = + listRequest(Endpoint("api/v1/statuses/$id/history"), StatusEditDto.serializer()) { it.toDomain() } + + /** The original plain text, which an edit composer loads instead of the rendered HTML. */ + public fun source(id: String): ApiRequest = + request(Endpoint("api/v1/statuses/$id/source"), StatusSourceDto.serializer()) { it.toDomain() } + + public fun favouritedBy(id: String, limit: Int = Paging.DEFAULT_LIMIT): ApiRequest> = + accounts("api/v1/statuses/$id/favourited_by", limit) + + public fun rebloggedBy(id: String, limit: Int = Paging.DEFAULT_LIMIT): ApiRequest> = + accounts("api/v1/statuses/$id/reblogged_by", limit) + + private fun accounts(path: String, limit: Int): ApiRequest> = + listRequest(Endpoint(path, query = Paging.pageItems(limit, PageAnchor.Cold)), AccountDto.serializer()) { + it.toDomain() + } + + public fun action(id: String, action: StatusAction): ApiRequest = + request(Endpoint("api/v1/statuses/$id/${action.wire}", HttpMethod.POST), StatusDto.serializer()) { + it.toDomain() + } + + /** Deletes a status; the answer carries its source text, for "delete and redraft". */ + public fun delete(id: String): ApiRequest = + request(Endpoint("api/v1/statuses/$id", HttpMethod.DELETE), StatusDto.serializer()) { it.toDomain() } + + /** Server translation. A server without a provider answers 503 with a message to show verbatim. */ + public fun translate(id: String, language: String?): ApiRequest = request( + Endpoint("api/v1/statuses/$id/translate", HttpMethod.POST, body = Body.Form(queryOf("lang", language))), + TranslationDto.serializer(), + ) { it.toDomain() } + + /** An emoji reaction. Nextcloud Social takes Unicode emoji only. */ + public fun react(id: String, emoji: String): ApiRequest = unitRequest( + Endpoint("api/v1/statuses/$id/react", HttpMethod.POST, body = Body.Form(listOf(QueryItem("name", emoji)))), + ) + + public fun unreact(id: String, emoji: String): ApiRequest = unitRequest( + Endpoint("api/v1/statuses/$id/unreact", HttpMethod.POST, body = Body.Form(listOf(QueryItem("name", emoji)))), + ) + + public fun vote(pollId: String, choices: List): ApiRequest = request( + Endpoint( + "api/v1/polls/$pollId/votes", + HttpMethod.POST, + body = Body.Form(repeatedQuery("choices", choices.map(Int::toString))), + ), + PollDto.serializer(), + ) { it.toDomain() } +} + +public object PollEndpoints { + public fun poll(id: String): ApiRequest = + request(Endpoint("api/v1/polls/$id"), PollDto.serializer()) { it.toDomain() } +} diff --git a/core/network/src/main/kotlin/social/aloha/core/network/endpoints/StatusExtraEndpoints.kt b/core/network/src/main/kotlin/social/aloha/core/network/endpoints/StatusExtraEndpoints.kt new file mode 100644 index 00000000..3bd3891b --- /dev/null +++ b/core/network/src/main/kotlin/social/aloha/core/network/endpoints/StatusExtraEndpoints.kt @@ -0,0 +1,138 @@ +// SPDX-FileCopyrightText: 2026 Aloha Social contributors +// SPDX-License-Identifier: MIT + +package social.aloha.core.network.endpoints + +import social.aloha.core.model.DeliveryReport +import social.aloha.core.model.GifLibrary +import social.aloha.core.model.QuoteApprovalPolicy +import social.aloha.core.model.Reaction +import social.aloha.core.model.Status +import social.aloha.core.model.StatusPlace +import social.aloha.core.model.TeamAccount +import social.aloha.core.network.ApiRequest +import social.aloha.core.network.Body +import social.aloha.core.network.Endpoint +import social.aloha.core.network.HttpMethod +import social.aloha.core.network.QueryItem +import social.aloha.core.network.decoding.ListOrMemberSerializer +import social.aloha.core.network.dto.DeliveryReportDto +import social.aloha.core.network.dto.GifLibraryDto +import social.aloha.core.network.dto.ReactionDto +import social.aloha.core.network.dto.StatusDto +import social.aloha.core.network.dto.StatusPlaceDto +import social.aloha.core.network.dto.TeamAccountDto +import social.aloha.core.network.dto.toDomain +import social.aloha.core.network.listRequest +import social.aloha.core.network.queryOf +import social.aloha.core.network.request +import social.aloha.core.network.unitRequest + +private fun statusList(endpoint: Endpoint): ApiRequest> = + listRequest(endpoint, StatusDto.serializer()) { it.toDomain() } + +/** + * Nextcloud Social's per-status extras: archive, delivery, quotes and dislikes. A stock Mastodon + * answers 404 to all of them, so each sits behind a capability. + */ +public object StatusExtraEndpoints { + /** Off the profile, not deleted, not federated (Pixelfed's archive). */ + public fun archive(id: String): ApiRequest = + unitRequest(Endpoint("api/pixelfed/v1/archive/add/$id", HttpMethod.POST)) + + public fun unarchive(id: String): ApiRequest = + unitRequest(Endpoint("api/pixelfed/v1/archive/remove/$id", HttpMethod.POST)) + + public fun archived( + limit: Int = Paging.DEFAULT_LIMIT, + anchor: PageAnchor = PageAnchor.Cold, + ): ApiRequest> = + statusList(Endpoint("api/pixelfed/v1/archive/list", query = Paging.pageItems(limit, anchor))) + + /** + * The reactions to a status. Nextcloud Social stores and returns them here only; the status + * entity always carries an empty `reactions` list, for every viewer. + */ + public fun reactions(id: String): ApiRequest> = + listRequest(Endpoint("api/v1/statuses/$id/reactions"), ReactionDto.serializer()) { it.toDomain() } + + /** Author only: where the post got to, server by server. */ + public fun delivery(id: String): ApiRequest = + request(Endpoint("api/v1/statuses/$id/delivery"), DeliveryReportDto.serializer()) { it.toDomain() } + + public fun quotes( + id: String, + limit: Int = Paging.DEFAULT_LIMIT, + anchor: PageAnchor = PageAnchor.Cold, + ): ApiRequest> = + statusList(Endpoint("api/v1/statuses/$id/quotes", query = Paging.pageItems(limit, anchor))) + + public fun setQuotePolicy(id: String, policy: QuoteApprovalPolicy): ApiRequest = request( + Endpoint( + "api/v1/statuses/$id/interaction_policy", + HttpMethod.PUT, + body = Body.Form(listOf(QueryItem("quote_approval_policy", policy.wire))), + ), + StatusDto.serializer(), + ) { it.toDomain() } + + /** Withdraws a quote already made; the quoting post stays, shown as withdrawn on its own server. */ + public fun revokeQuote(id: String, quotingId: String): ApiRequest = + unitRequest(Endpoint("api/v1/statuses/$id/quotes/$quotingId/revoke", HttpMethod.POST)) + + /** + * PeerTube's thumbs-down. The server publishes `dislikes_count` and `disliked` on a video but + * wires no route to them yet, so this answers 404 until it does. + */ + public fun dislike(id: String): ApiRequest = + unitRequest(Endpoint("api/v1/statuses/$id/dislike", HttpMethod.POST)) + + public fun undislike(id: String): ApiRequest = + unitRequest(Endpoint("api/v1/statuses/$id/undislike", HttpMethod.POST)) +} + +/** Places a post can be tagged with: names only, no map service on either side. */ +public object PlaceEndpoints { + private const val MAXIMUM_SEARCH = 40 + + public fun search(query: String, limit: Int = 10): ApiRequest> = listRequest( + Endpoint( + "api/v1/places/search", + query = listOf(QueryItem("q", query), Paging.limitItem(limit, MAXIMUM_SEARCH)), + ), + StatusPlaceDto.serializer(), + ) { it.toDomain() } + + public fun place(id: String): ApiRequest = + request(Endpoint("api/v1/places/$id"), StatusPlaceDto.serializer()) { it.toDomain() } + + public fun statuses( + id: String, + limit: Int = Paging.DEFAULT_LIMIT, + anchor: PageAnchor = PageAnchor.Cold, + ): ApiRequest> = + statusList(Endpoint("api/v1/places/$id/statuses", query = Paging.pageItems(limit, anchor))) +} + +/** The instance's own library of animated pictures; nothing about a search leaves the server. */ +public object GifEndpoints { + private const val MAXIMUM_LIMIT = 200 + private const val DEFAULT_LIMIT = 40 + + public fun library(query: String?, limit: Int = DEFAULT_LIMIT, offset: Int = 0): ApiRequest = request( + Endpoint( + "api/v1/gifs", + query = queryOf("q", query?.takeIf { it.isNotEmpty() }) + + listOf(Paging.limitItem(limit, MAXIMUM_LIMIT), QueryItem("offset", offset.coerceAtLeast(0).toString())), + ), + GifLibraryDto.serializer(), + ) { it.toDomain() } +} + +/** Group accounts the viewer may post as, sent as `{teams: [...]}`. */ +public object TeamEndpoints { + public fun all(): ApiRequest> = + request(Endpoint("api/v1.1/teams"), ListOrMemberSerializer(TeamAccountDto.serializer(), "teams")) { list -> + list.map { it.toDomain() }.filter { it.handle.isNotEmpty() } + } +} diff --git a/core/network/src/main/kotlin/social/aloha/core/network/endpoints/StatusPost.kt b/core/network/src/main/kotlin/social/aloha/core/network/endpoints/StatusPost.kt new file mode 100644 index 00000000..4646887d --- /dev/null +++ b/core/network/src/main/kotlin/social/aloha/core/network/endpoints/StatusPost.kt @@ -0,0 +1,93 @@ +// SPDX-FileCopyrightText: 2026 Aloha Social contributors +// SPDX-License-Identifier: MIT + +package social.aloha.core.network.endpoints + +import java.time.Instant +import java.util.UUID +import social.aloha.core.model.Visibility +import social.aloha.core.network.QueryItem +import social.aloha.core.network.queryOf +import social.aloha.core.network.repeatedQuery + +/** + * What the composer sends. The Nextcloud Social extras are optional and a stock server ignores them. + * + * @property idempotencyKey generated when the composer opened and regenerated only when the content + * changes; what makes a retry after a timeout safe. + * @property pollExpiresInSeconds a day when not set. + * @property quotedId the post being quoted. + * @property quotePolicy who may quote the new post: `public`, `followers` or `nobody`. + * @property placeId a place the server already knows; otherwise [placeName] and [placeCountry] make one. + * @property postAs a team account handle to post as; null posts as yourself. + */ +public data class StatusPost( + val text: String, + val visibility: Visibility = Visibility.Public, + val spoilerText: String? = null, + val sensitive: Boolean = false, + val language: String? = null, + val inReplyToId: String? = null, + val mediaIds: List = emptyList(), + val pollOptions: List = emptyList(), + val pollExpiresInSeconds: Long? = null, + val pollMultiple: Boolean = false, + val pollHideTotals: Boolean = false, + val scheduledAt: Instant? = null, + val idempotencyKey: String = UUID.randomUUID().toString(), + val quotedId: String? = null, + val quotePolicy: String? = null, + val placeId: String? = null, + val placeName: String? = null, + val placeCountry: String? = null, + val placeLatitude: Double? = null, + val placeLongitude: Double? = null, + val postAs: String? = null, + val videoTitle: String? = null, + val videoCategory: String? = null, + val videoLicence: String? = null, + val contentType: String? = null, +) { + internal fun formItems(): List = listOf( + QueryItem("status", text), + QueryItem("visibility", visibility.wire), + QueryItem("sensitive", sensitive.toString()), + ) + queryOf("spoiler_text", spoilerText) + queryOf("language", language) + + queryOf("in_reply_to_id", inReplyToId) + + repeatedQuery("media_ids", mediaIds) + pollItems() + queryOf("scheduled_at", scheduledAt?.toString()) + + extras() + + /** A poll and media are mutually exclusive, as Mastodon requires; with media, the poll is dropped. */ + private fun pollItems(): List { + if (pollOptions.isEmpty() || mediaIds.isNotEmpty()) return emptyList() + return repeatedQuery("poll[options]", pollOptions) + listOf( + QueryItem("poll[expires_in]", (pollExpiresInSeconds ?: DEFAULT_POLL_SECONDS).toString()), + QueryItem("poll[multiple]", pollMultiple.toString()), + QueryItem("poll[hide_totals]", pollHideTotals.toString()), + ) + } + + /** + * Nextcloud Social's extras. The web composer names the quote `quote_id` and the controller reads + * `quoted_id`, so both go; a server that knows neither ignores both. + */ + private fun extras(): List = + queryOf("quoted_id", quotedId) + queryOf("quote_id", quotedId) + queryOf("quote_policy", quotePolicy) + + placeItems() + + queryOf("post_as", postAs) + queryOf("video_title", videoTitle) + queryOf("video_category", videoCategory) + + queryOf("video_licence", videoLicence) + queryOf("content_type", contentType) + + private fun placeItems(): List = when { + placeId != null -> listOf(QueryItem("place_id", placeId)) + + !placeName.isNullOrEmpty() -> + listOf(QueryItem("place_name", placeName)) + queryOf("place_country", placeCountry) + + queryOf("place_lat", placeLatitude?.toString()) + queryOf("place_lon", placeLongitude?.toString()) + + else -> emptyList() + } + + private companion object { + const val DEFAULT_POLL_SECONDS = 86_400L + } +} diff --git a/core/network/src/main/kotlin/social/aloha/core/network/endpoints/StoryEndpoints.kt b/core/network/src/main/kotlin/social/aloha/core/network/endpoints/StoryEndpoints.kt new file mode 100644 index 00000000..6698cc72 --- /dev/null +++ b/core/network/src/main/kotlin/social/aloha/core/network/endpoints/StoryEndpoints.kt @@ -0,0 +1,157 @@ +// SPDX-FileCopyrightText: 2026 Aloha Social contributors +// SPDX-License-Identifier: MIT + +package social.aloha.core.network.endpoints + +import social.aloha.core.model.Account +import social.aloha.core.model.CharacterCount +import social.aloha.core.model.CollectionDraft +import social.aloha.core.model.MediaCollection +import social.aloha.core.model.Status +import social.aloha.core.model.Story +import social.aloha.core.model.StoryCarousel +import social.aloha.core.model.StoryReaction +import social.aloha.core.network.ApiRequest +import social.aloha.core.network.Body +import social.aloha.core.network.Endpoint +import social.aloha.core.network.HttpMethod +import social.aloha.core.network.QueryItem +import social.aloha.core.network.decoding.ListOrMemberSerializer +import social.aloha.core.network.dto.AccountDto +import social.aloha.core.network.dto.CollectionDto +import social.aloha.core.network.dto.StatusDto +import social.aloha.core.network.dto.StoryCarouselSerializer +import social.aloha.core.network.dto.StoryDto +import social.aloha.core.network.dto.StoryReactionDto +import social.aloha.core.network.dto.toDomain +import social.aloha.core.network.listRequest +import social.aloha.core.network.queryOf +import social.aloha.core.network.request +import social.aloha.core.network.unitRequest + +private fun stories(endpoint: Endpoint): ApiRequest> = + listRequest(endpoint, StoryDto.serializer()) { it.toDomain() } + +private fun carousel(endpoint: Endpoint): ApiRequest = + request(endpoint, StoryCarouselSerializer) { it.toDomain() } + +/** + * Stories, Pixelfed's feature as Nextcloud Social serves it. Past the Mastodon-ish `api/v1/stories` + * routes, Pixelfed names a story in a `sid` parameter rather than in the path: its v1.1 and v1.2 + * routes are used verbatim, since a path-style `/api/v1/stories/{id}/…` route does not exist and 404s. + */ +public object StoryEndpoints { + private const val MAXIMUM_REACTION = 20 + private const val MAXIMUM_CAPTION = 500 + private const val MINIMUM_DURATION = 3 + private const val MAXIMUM_DURATION = 30 + + /** The rail, as a flat array on Nextcloud Social. */ + public fun carousel(): ApiRequest = carousel(Endpoint("api/v1/stories/carousel")) + + /** Pixelfed's v1.2 rail: `{self, nodes}`, one node per account. */ + public fun carouselV2(): ApiRequest = carousel(Endpoint("api/v1.2/stories/carousel")) + + public fun own(): ApiRequest> = stories(Endpoint("api/v1/stories/self")) + + public fun forAccount(id: String): ApiRequest> = stories(Endpoint("api/v1/accounts/$id/stories")) + + /** Posts an uploaded attachment as a story. The server clamps the duration to 3–30 s; so does this. */ + public fun post(mediaId: String, caption: String?, durationSeconds: Int): ApiRequest { + val fields = listOf( + QueryItem("media_id", mediaId), + QueryItem("duration", durationSeconds.coerceIn(MINIMUM_DURATION, MAXIMUM_DURATION).toString()), + ) + queryOf("caption", caption?.let { CharacterCount.prefix(it, MAXIMUM_CAPTION) }) + return unitRequest(Endpoint("api/v1/stories", HttpMethod.POST, body = Body.Form(fields))) + } + + public fun markSeen(id: String): ApiRequest = + unitRequest(Endpoint("api/v1/stories/$id/seen", HttpMethod.POST)) + + public fun delete(id: String): ApiRequest = unitRequest(Endpoint("api/v1/stories/$id", HttpMethod.DELETE)) + + /** Who watched one of the viewer's own stories. */ + public fun viewers(id: String): ApiRequest> = listRequest( + Endpoint("api/v1.2/stories/viewers", query = listOf(QueryItem("sid", id))), + AccountDto.serializer(), + ) { it.toDomain() } + + /** An emoji, at most 20 characters, sent to the poster and nobody else. */ + public fun react(id: String, reaction: String): ApiRequest = storyForm( + "api/v1.2/stories/react", + QueryItem("sid", id), + QueryItem("reaction", CharacterCount.prefix(reaction, MAXIMUM_REACTION)), + ) + + /** A reply, delivered to the poster as a direct message rather than federated as a post. */ + public fun comment(id: String, caption: String): ApiRequest = storyForm( + "api/v1.2/stories/comment", + QueryItem("sid", id), + QueryItem("caption", CharacterCount.prefix(caption, MAXIMUM_CAPTION)), + ) + + /** Reactions and replies to one of the viewer's own stories, sent bare or as `{reactions: [...]}`. */ + public fun reactions(id: String): ApiRequest> = request( + Endpoint("api/v1.2/stories/reactions", query = listOf(QueryItem("sid", id))), + ListOrMemberSerializer(StoryReactionDto.serializer(), "reactions"), + ) { list -> list.mapNotNull { it.toDomain() } } + + /** Ends one of the viewer's own stories before its day is up; the route Pixelfed's own app calls. */ + public fun selfExpire(id: String): ApiRequest = + unitRequest(Endpoint("api/v1.1/stories/self-expire/$id", HttpMethod.POST)) + + /** People the writer may name in a story's caption. */ + public fun mentionAutocomplete(query: String): ApiRequest> = listRequest( + Endpoint("api/v1.2/stories/mention-autocomplete", query = listOf(QueryItem("q", query))), + AccountDto.serializer(), + ) { it.toDomain() } + + private fun storyForm(path: String, vararg fields: QueryItem): ApiRequest = + unitRequest(Endpoint(path, HttpMethod.POST, body = Body.Form(fields.toList()))) +} + +/** Pixelfed albums, read and written where the server supports them. */ +public object CollectionEndpoints { + private fun collections(endpoint: Endpoint): ApiRequest> = + listRequest(endpoint, CollectionDto.serializer()) { it.toDomain() } + + private fun collection(endpoint: Endpoint): ApiRequest = + request(endpoint, CollectionDto.serializer()) { it.toDomain() } + + public fun all(): ApiRequest> = collections(Endpoint("api/v1/collections")) + + public fun forAccount(id: String): ApiRequest> = + collections(Endpoint("api/v1/accounts/$id/collections")) + + public fun collection(id: String): ApiRequest = collection(Endpoint("api/v1/collections/$id")) + + public fun items(id: String): ApiRequest> = + listRequest(Endpoint("api/v1/collections/$id/items"), StatusDto.serializer()) { it.toDomain() } + + public fun create(draft: CollectionDraft): ApiRequest = + collection(Endpoint("api/v1/collections", HttpMethod.POST, body = draft.form())) + + public fun update(id: String, draft: CollectionDraft): ApiRequest = + collection(Endpoint("api/v1/collections/$id", HttpMethod.PUT, body = draft.form())) + + public fun delete(id: String): ApiRequest = unitRequest(Endpoint("api/v1/collections/$id", HttpMethod.DELETE)) + + public fun addItem(id: String, statusId: String): ApiRequest = unitRequest( + Endpoint( + "api/v1/collections/$id/items", + HttpMethod.POST, + body = Body.Form(listOf(QueryItem("status_id", statusId))), + ), + ) + + /** + * The status goes in the path: the server's route is `/items/{status_id}`, so a DELETE to + * `/items` carrying `status_id` in a body matches nothing and 404s. + */ + public fun removeItem(id: String, statusId: String): ApiRequest = + unitRequest(Endpoint("api/v1/collections/$id/items/$statusId", HttpMethod.DELETE)) + + private fun CollectionDraft.form(): Body = Body.Form( + listOf(QueryItem("title", title), QueryItem("description", description), QueryItem("visibility", visibility)), + ) +} diff --git a/core/network/src/main/kotlin/social/aloha/core/network/endpoints/TimelineEndpoints.kt b/core/network/src/main/kotlin/social/aloha/core/network/endpoints/TimelineEndpoints.kt new file mode 100644 index 00000000..e7fa43e5 --- /dev/null +++ b/core/network/src/main/kotlin/social/aloha/core/network/endpoints/TimelineEndpoints.kt @@ -0,0 +1,101 @@ +// SPDX-FileCopyrightText: 2026 Aloha Social contributors +// SPDX-License-Identifier: MIT + +package social.aloha.core.network.endpoints + +import social.aloha.core.model.Account +import social.aloha.core.model.Conversation +import social.aloha.core.model.Status +import social.aloha.core.model.Tag +import social.aloha.core.model.TimelineFilters +import social.aloha.core.model.TimelineSource +import social.aloha.core.model.UnreadCount +import social.aloha.core.network.ApiRequest +import social.aloha.core.network.Endpoint +import social.aloha.core.network.HttpMethod +import social.aloha.core.network.QueryItem +import social.aloha.core.network.dto.AccountDto +import social.aloha.core.network.dto.ConversationDto +import social.aloha.core.network.dto.StatusDto +import social.aloha.core.network.dto.UnreadCountDto +import social.aloha.core.network.dto.toDomain +import social.aloha.core.network.flagQuery +import social.aloha.core.network.listRequest +import social.aloha.core.network.request +import social.aloha.core.network.unitRequest + +public object TimelineEndpoints { + /** + * A page of [source]. The [filters] are Nextcloud Social's narrowings; `only_video` wins over + * `only_media` when both are set, since every video is media. + */ + public fun timeline( + source: TimelineSource, + filters: TimelineFilters = TimelineFilters.None, + limit: Int = Paging.DEFAULT_LIMIT, + anchor: PageAnchor = PageAnchor.Cold, + ): ApiRequest> { + val page = Paging.pageItems(limit, anchor) + val items = if (source is TimelineSource.Account) { + page + flagQuery("exclude_replies", !source.includeReplies) + flagQuery("only_media", source.onlyMedia) + } else { + page + flagQuery("local", source == TimelineSource.Local) + filterItems(filters) + } + return listRequest(Endpoint(path(source), query = items), StatusDto.serializer()) { it.toDomain() } + } + + /** Sources with a route of their own rather than a `{timeline}` segment. */ + private val ownRoutes: Map = mapOf( + TimelineSource.Bookmarks to "api/v1/bookmarks", + TimelineSource.Favourites to "api/v1/favourites/", + TimelineSource.Trending to "api/v1/trends/statuses", + ) + + private fun path(source: TimelineSource): String = when (source) { + is TimelineSource.List -> "api/v1/timelines/list/${source.id}" + is TimelineSource.Hashtag -> "api/v1/timelines/tag/${Tag.normalise(source.name) ?: source.name}" + is TimelineSource.Account -> "api/v1/accounts/${source.id}/statuses" + else -> ownRoutes[source] ?: "api/v1/timelines/${source.pathSegment}/" + } + + private fun filterItems(filters: TimelineFilters): List = if (filters.onlyVideo) { + flagQuery("only_video", true) + } else { + flagQuery("only_media", filters.onlyMedia) + flagQuery("only_news", filters.onlyNews) + } + + public fun conversations( + limit: Int = Paging.DEFAULT_LIMIT, + anchor: PageAnchor = PageAnchor.Cold, + ): ApiRequest> = listRequest( + Endpoint("api/v1/conversations", query = Paging.pageItems(limit, anchor)), + ConversationDto.serializer(), + ) { + it.toDomain() + } + + public fun markConversationRead(id: String): ApiRequest = + request(Endpoint("api/v1/conversations/$id/read", HttpMethod.POST), ConversationDto.serializer()) { + it.toDomain() + } + + /** Marks every conversation read. */ + public fun markAllConversationsRead(): ApiRequest = + unitRequest(Endpoint("api/v1/conversations/read_all", HttpMethod.POST)) + + /** Takes a conversation off the list; its messages are not deleted, and a later message brings it back. */ + public fun deleteConversation(id: String): ApiRequest = + unitRequest(Endpoint("api/v1/conversations/$id", HttpMethod.DELETE)) + + /** How many conversations have something unread. */ + public fun conversationsUnreadCount(): ApiRequest = + request(Endpoint("api/v1/conversations/unread_count"), UnreadCountDto.serializer()) { it.toDomain() } + + /** + * The people a direct message can be started with: mutual follows, which is who Pixelfed's composer + * offers. The rest of Pixelfed's `direct/thread` family is deliberately not used: it is a second + * representation of the same direct statuses the Mastodon routes read and write. + */ + public fun directMessageMutuals(): ApiRequest> = + listRequest(Endpoint("api/v1.1/direct/compose/mutuals"), AccountDto.serializer()) { it.toDomain() } +} diff --git a/core/network/src/main/kotlin/social/aloha/core/network/oauth/OAuthClient.kt b/core/network/src/main/kotlin/social/aloha/core/network/oauth/OAuthClient.kt new file mode 100644 index 00000000..798030b6 --- /dev/null +++ b/core/network/src/main/kotlin/social/aloha/core/network/oauth/OAuthClient.kt @@ -0,0 +1,171 @@ +// SPDX-FileCopyrightText: 2026 Aloha Social contributors +// SPDX-License-Identifier: MIT + +package social.aloha.core.network.oauth + +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import okhttp3.HttpUrl +import okhttp3.OkHttpClient +import social.aloha.core.model.AccessToken +import social.aloha.core.model.AuthorizationServerMetadata +import social.aloha.core.model.ClientRegistration +import social.aloha.core.model.OAuthUserInfo +import social.aloha.core.network.ApiResult +import social.aloha.core.network.Authentication +import social.aloha.core.network.Body +import social.aloha.core.network.Endpoint +import social.aloha.core.network.HttpMethod +import social.aloha.core.network.QueryItem +import social.aloha.core.network.RateLimiter +import social.aloha.core.network.RequestExecutor +import social.aloha.core.network.decoding.LenientUrlSerializer +import social.aloha.core.network.decoding.StringListSerializer +import social.aloha.core.network.dto.AuthorizationServerMetadataDto +import social.aloha.core.network.dto.toDomain +import social.aloha.core.network.map +import social.aloha.core.network.request +import social.aloha.core.network.unitRequest + +/** + * Registration, token exchange and revocation. Writes here are never retried: an authorisation code + * is single-use, and a second exchange answers 401 `unknown client_id`. + */ +public class OAuthClient internal constructor(private val executor: RequestExecutor) { + // A redirect is never followed: it would carry the code, the secret or the verifier to wherever it points. + public constructor(http: OkHttpClient, rateLimiter: RateLimiter, ioDispatcher: CoroutineDispatcher) : + this(RequestExecutor(http.newBuilder().followRedirects(false).build(), rateLimiter, ioDispatcher)) + + /** RFC 8414 metadata published under [apiBase], or null where the server has none. */ + public suspend fun metadata(apiBase: HttpUrl): AuthorizationServerMetadata? { + val url = apiBase.newBuilder().addPathSegments(".well-known/oauth-authorization-server").build() + val request = + request( + Endpoint(url.encodedPath.trimStart('/'), authentication = Authentication.None), + AuthorizationServerMetadataDto.serializer(), + ) { + it.toDomain() + } + return (executor.execute(request, url, authorization = "") as? ApiResult.Success)?.value?.decoded?.value + } + + /** `POST /api/v1/apps` with the app's identity and both redirect URIs. */ + public suspend fun register(apiBase: HttpUrl): ApiResult { + val endpoint = Endpoint( + path = "api/v1/apps", + method = HttpMethod.POST, + body = Body.Form( + listOf( + QueryItem("client_name", OAuthIdentity.CLIENT_NAME), + QueryItem("redirect_uris", OAuthIdentity.REGISTERED_REDIRECTS), + QueryItem("scopes", OAuthIdentity.SCOPES), + QueryItem("website", OAuthIdentity.WEBSITE), + ), + ), + authentication = Authentication.None, + ) + val url = apiBase.newBuilder().addPathSegments(endpoint.path).build() + return executor.execute(request(endpoint, ApplicationDto.serializer()) { it.toDomain() }, url, "") + .map { it.decoded.value } + } + + /** + * Exchanges [code] for a token. No `scope` is sent: the code names the authorisation, and + * Nextcloud Social ignores one anyway. + */ + public suspend fun exchange( + tokenEndpoint: HttpUrl, + registration: ClientRegistration, + pending: PendingAuthorization, + code: String, + ): ApiResult { + val fields = listOf( + QueryItem("grant_type", "authorization_code"), + QueryItem("code", code), + QueryItem("client_id", registration.clientId), + QueryItem("client_secret", registration.clientSecret), + QueryItem("redirect_uri", pending.redirectUri), + QueryItem("code_verifier", pending.verifier), + ) + val endpoint = + Endpoint( + tokenEndpoint.pathOf(), + HttpMethod.POST, + body = Body.Form(fields), + authentication = Authentication.None, + ) + return executor.execute(request(endpoint, TokenDto.serializer()) { it.toDomain() }, tokenEndpoint, "") + .map { it.decoded.value } + } + + /** Revokes one token; the server answers 200 whether or not it knew it. Other devices stay signed in. */ + public suspend fun revoke( + endpoints: OAuthEndpoints, + registration: ClientRegistration, + token: AccessToken, + ): ApiResult { + val fields = listOf( + QueryItem("client_id", registration.clientId), + QueryItem("client_secret", registration.clientSecret), + QueryItem("token", token.value), + ) + val endpoint = + Endpoint( + endpoints.revocation.pathOf(), + HttpMethod.POST, + body = Body.Form(fields), + authentication = Authentication.None, + ) + return executor.execute(unitRequest(endpoint), endpoints.revocation, "").map { } + } + + /** Who [token] belongs to; answers for a new account whose `verify_credentials` still fails. */ + public suspend fun userInfo(userinfoEndpoint: HttpUrl, token: AccessToken): ApiResult { + val endpoint = Endpoint(userinfoEndpoint.pathOf(), authentication = Authentication.Bearer) + return executor.execute( + request(endpoint, UserInfoDto.serializer()) { + it.toDomain() + }, + userinfoEndpoint, + "Bearer ${token.value}", + ) + .map { it.decoded.value } + } + + private fun HttpUrl.pathOf(): String = encodedPath.trimStart('/') +} + +@Serializable +internal data class ApplicationDto( + @SerialName("client_id") val clientId: String, + @SerialName("client_secret") val clientSecret: String, + @Serializable(with = StringListSerializer::class) val scopes: List = emptyList(), + @Serializable(with = LenientUrlSerializer::class) val website: String? = null, +) + +internal fun ApplicationDto.toDomain() = ClientRegistration( + clientId, + clientSecret, + scopes.joinToString(" ").ifEmpty { + OAuthIdentity.SCOPES + }, +) + +@Serializable +internal data class TokenDto( + @SerialName("access_token") val accessToken: String, + @SerialName("token_type") val tokenType: String = "Bearer", + val scope: String = "", +) + +internal fun TokenDto.toDomain() = AccessToken(accessToken, scope) + +@Serializable +internal data class UserInfoDto( + val sub: String, + @SerialName("preferred_username") val preferredUsername: String? = null, + val name: String? = null, +) + +internal fun UserInfoDto.toDomain() = OAuthUserInfo(sub, preferredUsername, name) diff --git a/core/network/src/main/kotlin/social/aloha/core/network/oauth/OAuthFlow.kt b/core/network/src/main/kotlin/social/aloha/core/network/oauth/OAuthFlow.kt new file mode 100644 index 00000000..892beabe --- /dev/null +++ b/core/network/src/main/kotlin/social/aloha/core/network/oauth/OAuthFlow.kt @@ -0,0 +1,158 @@ +// SPDX-FileCopyrightText: 2026 Aloha Social contributors +// SPDX-License-Identifier: MIT + +package social.aloha.core.network.oauth + +import kotlinx.serialization.Serializable +import okhttp3.HttpUrl +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull +import social.aloha.core.model.AuthorizationServerMetadata +import social.aloha.core.model.NodeInfo +import social.aloha.core.network.sameOrigin + +/** + * The identity the app registers with; the same on every platform, so a person's list of authorised + * apps stays tidy. + */ +public object OAuthIdentity { + public const val CLIENT_NAME: String = "Aloha Social" + public const val WEBSITE: String = "https://aloha.social" + + /** Coarse grants covering every granular scope Nextcloud Social checks; `push` is needed where Web Push exists. */ + public const val SCOPES: String = "read write follow push" + + /** The verified App Link: no other app can receive a code sent here. */ + public const val APP_LINK_REDIRECT: String = "https://aloha.social/oauth/callback" + + /** The fallback when link verification failed on this device; PKCE makes a hijacked code useless. */ + public const val SCHEME_REDIRECT: String = "alohasocial://oauth-callback" + + /** Both are registered, one per line; Nextcloud Social splits `redirect_uris` on newlines. */ + public const val REGISTERED_REDIRECTS: String = "$APP_LINK_REDIRECT\n$SCHEME_REDIRECT" +} + +/** Where a server's OAuth endpoints are. */ +public data class OAuthEndpoints( + val authorization: HttpUrl, + val token: HttpUrl, + val revocation: HttpUrl, + val userinfo: HttpUrl, +) { + public companion object { + /** The conventional paths under the API base, used where the server publishes no metadata. */ + public fun conventional(apiBase: HttpUrl): OAuthEndpoints = OAuthEndpoints( + authorization = apiBase.child("oauth/authorize"), + token = apiBase.child("oauth/token"), + revocation = apiBase.child("oauth/revoke"), + userinfo = apiBase.child("oauth/userinfo"), + ) + + /** + * The endpoints the metadata names, each accepted only on the API base's own origin, so a + * hostile document cannot send the code or the client secret elsewhere. + */ + public fun from(metadata: AuthorizationServerMetadata?, apiBase: HttpUrl): OAuthEndpoints { + val fallback = conventional(apiBase) + fun pick(raw: String?, default: HttpUrl): HttpUrl = + raw?.toHttpUrlOrNull()?.takeIf { it.sameOrigin(apiBase) } ?: default + return OAuthEndpoints( + authorization = pick(metadata?.authorizationEndpoint, fallback.authorization), + token = pick(metadata?.tokenEndpoint, fallback.token), + revocation = pick(metadata?.revocationEndpoint, fallback.revocation), + userinfo = pick(metadata?.userinfoEndpoint, fallback.userinfo), + ) + } + + private fun HttpUrl.child(path: String): HttpUrl = newBuilder().addPathSegments(path).build() + } +} + +/** + * One sign-in attempt in flight: what has to survive while the browser tab is open, a process death + * included, so the callback can be matched and the code exchanged. + * + * @property authorizationUrl the page opened in the browser, so it can be opened again. + * @property tokenEndpoint where the code is exchanged, fixed when the attempt began. + * @property nodeInfo what the probe learned about the software, which capability detection needs. + */ +@Serializable +public data class PendingAuthorization( + val apiBase: String, + val state: String, + val verifier: String, + val redirectUri: String, + val authorizationUrl: String, + val tokenEndpoint: String, + val userinfoEndpoint: String, + val nodeInfo: NodeInfo? = null, +) { + override fun toString(): String = "PendingAuthorization(apiBase=$apiBase)" +} + +private const val STATE_BYTES = 32 + +/** Builds the authorisation URL and the [PendingAuthorization] that belongs to it. */ +public fun authorizationUrl( + endpoints: OAuthEndpoints, + apiBase: HttpUrl, + clientId: String, + redirectUri: String, + pkce: Pkce = Pkce.generate(), + state: String = Pkce.randomToken(STATE_BYTES), +): Pair { + val url = endpoints.authorization.newBuilder() + .addQueryParameter("response_type", "code") + .addQueryParameter("client_id", clientId) + .addQueryParameter("redirect_uri", redirectUri) + .addQueryParameter("scope", OAuthIdentity.SCOPES) + .addQueryParameter("state", state) + .addQueryParameter("code_challenge", pkce.challenge) + .addQueryParameter("code_challenge_method", pkce.method) + .build() + val pending = PendingAuthorization( + apiBase.toString(), + state, + pkce.verifier, + redirectUri, + authorizationUrl = url.toString(), + tokenEndpoint = endpoints.token.toString(), + userinfoEndpoint = endpoints.userinfo.toString(), + ) + return url to pending +} + +/** What an OAuth callback said. */ +public sealed interface OAuthCallback { + public data class Code(val code: String) : OAuthCallback { + override fun toString(): String = "Code(redacted)" + } + + /** The person or the server refused; [reason] is the server's own wording. */ + public data class Denied(val reason: String?) : OAuthCallback + + /** The state did not match the attempt in flight: the code is dropped, never exchanged. */ + public data object StateMismatch : OAuthCallback + + public data object MissingCode : OAuthCallback + + public companion object { + /** + * Reads a callback URI. The server appends its parameters to whatever the registered URI + * had, and adds a `/` to the scheme redirect, so that one arrives as + * `alohasocial://oauth-callback/?code=…&state=…`. + */ + public fun parse(uri: String, expectedState: String): OAuthCallback { + // A custom-scheme URI is not an HttpUrl; its query is, so it is parsed on a placeholder origin. + val query = uri.substringAfter('?', missingDelimiterValue = "").substringBefore('#') + val parameters = "https://callback.invalid/?$query".toHttpUrlOrNull() ?: return MissingCode + val error = parameters.queryParameter("error") + val code = parameters.queryParameter("code")?.takeIf { it.isNotEmpty() } + return when { + parameters.queryParameter("state") != expectedState -> StateMismatch + error != null -> Denied(parameters.queryParameter("error_description") ?: error) + code != null -> Code(code) + else -> MissingCode + } + } + } +} diff --git a/core/network/src/main/kotlin/social/aloha/core/network/oauth/Pkce.kt b/core/network/src/main/kotlin/social/aloha/core/network/oauth/Pkce.kt new file mode 100644 index 00000000..408e8474 --- /dev/null +++ b/core/network/src/main/kotlin/social/aloha/core/network/oauth/Pkce.kt @@ -0,0 +1,28 @@ +// SPDX-FileCopyrightText: 2026 Aloha Social contributors +// SPDX-License-Identifier: MIT + +package social.aloha.core.network.oauth + +import java.security.MessageDigest +import java.security.SecureRandom +import java.util.Base64 + +/** An RFC 7636 proof key; S256 is the only method sent, Nextcloud Social refuses `plain` with a 400. */ +public class Pkce(public val verifier: String) { + public val challenge: String = base64Url(MessageDigest.getInstance("SHA-256").digest(verifier.toByteArray())) + public val method: String = "S256" + + override fun toString(): String = "Pkce(method=$method)" + + public companion object { + private const val VERIFIER_BYTES = 64 + private val random = SecureRandom() + + /** 64 random bytes as base64url: 86 characters, inside the RFC's 43 to 128. */ + public fun generate(): Pkce = Pkce(randomToken(VERIFIER_BYTES)) + + public fun randomToken(bytes: Int): String = base64Url(ByteArray(bytes).also(random::nextBytes)) + + private fun base64Url(bytes: ByteArray): String = Base64.getUrlEncoder().withoutPadding().encodeToString(bytes) + } +} diff --git a/core/network/src/main/kotlin/social/aloha/core/network/probe/RankRace.kt b/core/network/src/main/kotlin/social/aloha/core/network/probe/RankRace.kt new file mode 100644 index 00000000..bdc4d73b --- /dev/null +++ b/core/network/src/main/kotlin/social/aloha/core/network/probe/RankRace.kt @@ -0,0 +1,38 @@ +// SPDX-FileCopyrightText: 2026 Aloha Social contributors +// SPDX-License-Identifier: MIT + +package social.aloha.core.network.probe + +import kotlin.time.Duration +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeoutOrNull + +/** + * Runs [attempt] for every candidate concurrently and returns the lowest-ranked success. A success + * is accepted only once every better-ranked candidate has failed, so a fast answer never beats a + * better one that is still on its way; once the winner is known the others are cancelled. Returns + * null when nothing succeeded within [budget]. + */ +internal suspend fun lowestRankFirst( + candidates: List, + budget: Duration, + attempt: suspend (ProbeCandidate) -> T?, +): Pair? = withTimeoutOrNull(budget) { + coroutineScope { + val answers = Channel>(Channel.UNLIMITED) + val jobs = candidates.map { candidate -> launch { answers.send(candidate to attempt(candidate)) } } + val pending = candidates.map { it.rank }.toSortedSet() + var best: Pair? = null + while (pending.isNotEmpty()) { + val (candidate, value) = answers.receive() + pending.remove(candidate.rank) + if (value != null && (best == null || candidate.rank < best.first.rank)) best = candidate to value + val leader = best + if (leader != null && pending.none { it < leader.first.rank }) break + } + jobs.forEach { it.cancel() } + best + } +} diff --git a/core/network/src/main/kotlin/social/aloha/core/network/probe/ReprobeGate.kt b/core/network/src/main/kotlin/social/aloha/core/network/probe/ReprobeGate.kt new file mode 100644 index 00000000..c4eb5c14 --- /dev/null +++ b/core/network/src/main/kotlin/social/aloha/core/network/probe/ReprobeGate.kt @@ -0,0 +1,32 @@ +// SPDX-FileCopyrightText: 2026 Aloha Social contributors +// SPDX-License-Identifier: MIT + +package social.aloha.core.network.probe + +import java.util.concurrent.ConcurrentHashMap +import kotlin.time.Duration +import kotlin.time.Duration.Companion.hours + +/** + * Decides when an account's API base that stopped answering is probed for anew: an administrator + * may have added or removed the rewrite rules. At most once per [interval] per host, + * so a genuinely missing route does not probe on every request. + */ +public class ReprobeGate(private val nowMillis: () -> Long, private val interval: Duration = 1.hours) { + private val lastProbe = ConcurrentHashMap() + + /** True, and remembered, when [host] may be probed again now. */ + public fun tryAcquire(host: String): Boolean { + val now = nowMillis() + var granted = false + lastProbe.compute(host) { _, last -> + if (last == null || now - last >= interval.inWholeMilliseconds) { + granted = true + now + } else { + last + } + } + return granted + } +} diff --git a/core/network/src/main/kotlin/social/aloha/core/network/probe/ServerAddress.kt b/core/network/src/main/kotlin/social/aloha/core/network/probe/ServerAddress.kt new file mode 100644 index 00000000..d7710acb --- /dev/null +++ b/core/network/src/main/kotlin/social/aloha/core/network/probe/ServerAddress.kt @@ -0,0 +1,99 @@ +// SPDX-FileCopyrightText: 2026 Aloha Social contributors +// SPDX-License-Identifier: MIT + +package social.aloha.core.network.probe + +import java.util.Locale +import okhttp3.HttpUrl +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull + +/** + * What a person typed as their server, reduced to an origin and an optional path hint. Accepts + * `cloud.example`, `https://cloud.example/`, `cloud.example/nextcloud`, `@alice@cloud.example` and + * `alice@cloud.example`. HTTPS is assumed when no scheme is typed. + */ +public data class ServerAddress(val origin: HttpUrl, val pathHint: String?) { + val host: String get() = origin.host + + public companion object { + /** + * @param allowCleartext whether a typed `http://` address is accepted; true only in debug + * builds, whose network security configuration permits the local dev instance. + */ + public fun parse(typed: String, allowCleartext: Boolean = false): ServerAddress? { + val text = stripHandle(typed.trim())?.let { if (it.contains("://")) it else "https://$it" } + val url = text?.toHttpUrlOrNull()?.takeIf { (it.isHttps || allowCleartext) && it.host.isPlausibleHost() } + ?: return null + val origin = HttpUrl.Builder().scheme( + url.scheme, + ).host(url.host.lowercase(Locale.ROOT)).port(url.port).build() + return ServerAddress(origin, pathHint = url.encodedPath.trim('/').takeIf { it.isNotEmpty() }) + } + + private fun String.isPlausibleHost() = contains('.') || equals("localhost", ignoreCase = true) + + /** `@alice@host` and `alice@host` name a host; anything else is returned as it is. */ + private fun stripHandle(text: String): String? = when { + text.isEmpty() -> null + + text.startsWith("@") -> text.drop(1).split("@", limit = 2).takeIf { parts -> + parts.size == 2 && parts.all { it.isNotEmpty() } + }?.get(1) + + !text.contains("://") && text.contains('@') && !text.contains('/') -> + text.substringAfter('@').takeIf { it.isNotEmpty() } + + else -> text + } + } +} + +/** Why a candidate API base was tried; the sign-in screen words each kind for a person. */ +public enum class CandidateKind { + /** The `issuer` of the server's RFC 8414 authorization server metadata. */ + AuthorizationServerIssuer, + + /** `https://host/`: Mastodon, or Nextcloud Social with the root rewrite rules. */ + DomainRoot, + + /** `…/index.php/apps/social/`: Nextcloud Social without the rewrite rules. */ + AppPath, + + /** `…/apps/social/`: Nextcloud Social with pretty URLs, without the rewrite rules. */ + PrettyAppPath, + + /** The path the person typed. */ + TypedPath, + + /** Nextcloud Social in the subdirectory the person typed. */ + TypedAppPath, + + /** An API address the person entered by hand. */ + Manual, +} + +/** One API base the probe tries; the lowest [rank] that answers wins. */ +public data class ProbeCandidate(val rank: Int, val base: HttpUrl, val kind: CandidateKind) + +/** The fixed candidates for [address], in rank order, starting at rank 1 (rank 0 is the RFC 8414 issuer). */ +public fun candidatesFor(address: ServerAddress): List { + val seen = mutableSetOf() + val result = mutableListOf() + fun add(suffix: String, kind: CandidateKind) { + val base = address.origin.newBuilder().apply { if (suffix.isNotEmpty()) addPathSegments(suffix) }.build() + .asApiBase() + if (seen.add(base)) result += ProbeCandidate(result.size + 1, base, kind) + } + add("", CandidateKind.DomainRoot) + add("index.php/apps/social", CandidateKind.AppPath) + add("apps/social", CandidateKind.PrettyAppPath) + address.pathHint?.let { hint -> + add(hint, CandidateKind.TypedPath) + add("$hint/index.php/apps/social", CandidateKind.TypedAppPath) + } + return result +} + +/** An API base always ends in `/`, so relative endpoint paths compose onto it. */ +public fun HttpUrl.asApiBase(): HttpUrl = + if (encodedPath.endsWith("/")) this else newBuilder().addPathSegment("").build() diff --git a/core/network/src/main/kotlin/social/aloha/core/network/probe/ServerProbe.kt b/core/network/src/main/kotlin/social/aloha/core/network/probe/ServerProbe.kt new file mode 100644 index 00000000..eb3f6f21 --- /dev/null +++ b/core/network/src/main/kotlin/social/aloha/core/network/probe/ServerProbe.kt @@ -0,0 +1,205 @@ +// SPDX-FileCopyrightText: 2026 Aloha Social contributors +// SPDX-License-Identifier: MIT + +package social.aloha.core.network.probe + +import java.util.concurrent.CopyOnWriteArrayList +import kotlin.time.Duration.Companion.seconds +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.async +import kotlinx.coroutines.withContext +import okhttp3.HttpUrl +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull +import okhttp3.OkHttpClient +import social.aloha.core.model.AuthorizationServerMetadata +import social.aloha.core.model.InstanceDescription +import social.aloha.core.model.NodeInfo +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.RateLimiter +import social.aloha.core.network.RequestExecutor +import social.aloha.core.network.dto.AuthorizationServerMetadataDto +import social.aloha.core.network.dto.NodeInfoDirectoryDto +import social.aloha.core.network.dto.NodeInfoDto +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.sameOrigin + +/** Where a server's Mastodon API was found, and what it said about itself. */ +public data class ProbeOutcome( + val apiBase: HttpUrl, + val instance: InstanceDescription, + val nodeInfo: NodeInfo?, + val authorizationServer: AuthorizationServerMetadata?, + val winner: ProbeCandidate, + val attempted: List, +) + +public sealed interface ProbeResult { + public data class Found(val outcome: ProbeOutcome) : ProbeResult + + /** Something answered, but nothing like a Mastodon API; [attempted] is what was tried, in rank order. */ + public data class NothingAnswered(val attempted: List) : ProbeResult + + /** No candidate got any HTTP answer: the name did not resolve, the connection failed or timed out. */ + public data class Unreachable(val attempted: List) : ProbeResult + + /** The server's certificate is trusted by nobody; the person may choose to trust it and probe again. */ + public data class UntrustedCertificate(val error: ApiError.UntrustedCertificate) : ProbeResult +} + +/** + * Finds where a server's Mastodon API lives. Mastodon apps build `https://host/api/v1/…`; Nextcloud + * Social serves its routes under the app path, and the root is served only where the administrator + * installed the rewrite rules. A native client can target the app path directly. + * + * Candidates, all probed at once, lowest rank winning: the `issuer` of the RFC 8414 document at the + * domain root (rank 0, accepted only on the typed host), then the domain root, the app path, the + * pretty app path and, when a path was typed, that path and the app path beneath it. A candidate + * qualifies when `api/v2/instance` or `api/v1/instance` answers with a non-empty domain, so a + * Nextcloud login page answering 200 does not. NodeInfo at the domain root is read in parallel as + * the cross-check of what software this is. Five seconds per request, ten in total. + */ +public class ServerProbe internal constructor( + executor: RequestExecutor, + private val ioDispatcher: CoroutineDispatcher, +) { + public constructor(http: OkHttpClient, rateLimiter: RateLimiter, ioDispatcher: CoroutineDispatcher) : + this(RequestExecutor(http, rateLimiter, ioDispatcher), ioDispatcher) + + private val executor = executor.withCallTimeout(PER_REQUEST_SECONDS) + + public suspend fun discover(address: ServerAddress): ProbeResult { + val candidates = listOf(ProbeCandidate(0, address.origin, CandidateKind.AuthorizationServerIssuer)) + + candidatesFor(address) + return race(address.origin, candidates) + } + + /** Probes one API base a person typed by hand. */ + public suspend fun discover(manualBase: HttpUrl): ProbeResult { + // only scheme, host, port and path: credentials, a query or a fragment typed into the address + // would be shown back and sent with every request + val base = HttpUrl.Builder().scheme(manualBase.scheme).host(manualBase.host).port(manualBase.port) + .encodedPath(manualBase.encodedPath).build().asApiBase() + return race(base.newBuilder().encodedPath("/").build(), listOf(ProbeCandidate(1, base, CandidateKind.Manual))) + } + + // On the I/O dispatcher so the budget is always wall-clock time, whatever dispatcher the caller is on. + private suspend fun race(origin: HttpUrl, candidates: List): ProbeResult = + withContext(ioDispatcher) { + val tally = Tally() + val nodeInfo = async { nodeInfo(origin) } + val winner = + lowestRankFirst(candidates, TOTAL_BUDGET) { candidate -> attempt(candidate, origin, tally) } + when { + winner != null -> { + val (candidate, found) = winner + ProbeResult.Found( + ProbeOutcome( + found.base, + found.instance, + nodeInfo.await(), + found.metadata, + candidate, + candidates, + ), + ) + } + + tally.untrusted.isNotEmpty() -> + ProbeResult.UntrustedCertificate(tally.untrusted.first()).also { nodeInfo.cancel() } + + tally.answered -> ProbeResult.NothingAnswered(candidates).also { nodeInfo.cancel() } + + else -> ProbeResult.Unreachable(candidates).also { nodeInfo.cancel() } + } + } + + /** What the candidates of one probe ran into, gathered across the concurrent attempts. */ + private class Tally { + val untrusted = CopyOnWriteArrayList() + + /** Whether any request got an HTTP answer at all, whatever it was. */ + @Volatile var answered = false + } + + private class Found( + val base: HttpUrl, + val instance: InstanceDescription, + val metadata: AuthorizationServerMetadata?, + ) + + private suspend fun attempt(candidate: ProbeCandidate, origin: HttpUrl, tally: Tally): Found? { + if (candidate.kind != CandidateKind.AuthorizationServerIssuer) { + return instanceAt(candidate.base, tally)?.let { Found(candidate.base, it, metadata = null) } + } + val metadata = + fetch(authorizationServerRequest(), origin.newBuilder().encodedPath(WELL_KNOWN_OAUTH).build(), tally) + ?: return null + val issuer = metadata.issuer.toHttpUrlOrNull()?.takeIf { it.sameOrigin(origin) } + ?.asApiBase() ?: return null + return instanceAt(issuer, tally)?.let { Found(issuer, it, metadata) } + } + + /** The instance at [base], v2 first; null unless it names a domain. */ + public suspend fun instanceAt(base: HttpUrl): InstanceDescription? = instanceAt(base, Tally()) + + private suspend fun instanceAt(base: HttpUrl, tally: Tally): InstanceDescription? = + listOf(InstanceEndpoints.v2(), InstanceEndpoints.v1()).firstNotNullOfOrNull { request -> + fetch(request, request.endpoint.resolve(base), tally)?.takeIf { it.domain.isNotEmpty() } + } + + /** NodeInfo from the root directory of [origin], falling back to the conventional paths. */ + public suspend fun nodeInfo(origin: HttpUrl): NodeInfo? { + val none = Tally() + val directory = + fetch(nodeInfoDirectoryRequest(), origin.newBuilder().encodedPath(WELL_KNOWN_NODEINFO).build(), none) + val candidates = listOfNotNull(directory?.newestHref()?.toHttpUrlOrNull()) + + listOf("$WELL_KNOWN_NODEINFO/2.1", "$WELL_KNOWN_NODEINFO/2.0").map { + origin.newBuilder().encodedPath(it).build() + } + return candidates.firstNotNullOfOrNull { fetch(nodeInfoRequest(), it, none) } + } + + private suspend fun fetch(request: ApiRequest, url: HttpUrl, tally: Tally): T? = + when (val result = executor.execute(request, url, authorization = "")) { + is ApiResult.Success -> result.value.decoded.value.also { tally.answered = true } + + is ApiResult.Failure -> null.also { + when (val error = result.error) { + is ApiError.UntrustedCertificate -> tally.untrusted.add(error) + is ApiError.Transport -> Unit + else -> tally.answered = true + } + } + } + + private companion object { + const val PER_REQUEST_SECONDS = 5L + val TOTAL_BUDGET = 10.seconds + const val WELL_KNOWN_OAUTH = "/.well-known/oauth-authorization-server" + const val WELL_KNOWN_NODEINFO = "/.well-known/nodeinfo" + + fun authorizationServerRequest() = request( + Endpoint(WELL_KNOWN_OAUTH.drop(1), authentication = Authentication.None), + AuthorizationServerMetadataDto.serializer(), + ) { it.toDomain() ?: throw IllegalArgumentException("no issuer") } + + fun nodeInfoDirectoryRequest() = request( + Endpoint(WELL_KNOWN_NODEINFO.drop(1), authentication = Authentication.None), + NodeInfoDirectoryDto.serializer(), + ) { + it + } + + fun nodeInfoRequest() = + request(Endpoint("nodeinfo", authentication = Authentication.None), NodeInfoDto.serializer()) { + it.toDomain() + } + } +} diff --git a/core/network/src/main/kotlin/social/aloha/core/network/tls/AlohaTrustManager.kt b/core/network/src/main/kotlin/social/aloha/core/network/tls/AlohaTrustManager.kt new file mode 100644 index 00000000..dd38c665 --- /dev/null +++ b/core/network/src/main/kotlin/social/aloha/core/network/tls/AlohaTrustManager.kt @@ -0,0 +1,82 @@ +// SPDX-FileCopyrightText: 2026 Aloha Social contributors +// SPDX-License-Identifier: MIT + +package social.aloha.core.network.tls + +import android.annotation.SuppressLint +import java.net.Socket +import java.security.cert.CertificateException +import java.security.cert.X509Certificate +import javax.net.ssl.SSLEngine +import javax.net.ssl.SSLSocket +import javax.net.ssl.X509ExtendedTrustManager +import javax.net.ssl.X509TrustManager + +/** + * Thrown when a server presents a chain that neither the system nor the person trusts. Carries the + * host and chain so the sign-in screen can show the certificate and offer to trust it. + */ +public class UntrustedServerCertificateException( + public val host: String, + public val chain: List, + cause: CertificateException, +) : CertificateException("Untrusted certificate for $host", cause) + +/** + * The system's trust decision first; a chain it rejects is accepted only when the person trusted its + * leaf for exactly this host in [userStore]. The host comes from the TLS session, so an accepted + * certificate never vouches for a different server. + */ +// Deliberately custom: every decision starts with the platform's own trust manager, and the only +// addition is a certificate the person accepted for exactly this host. +@SuppressLint("CustomX509TrustManager") +public class AlohaTrustManager(private val system: X509TrustManager, private val userStore: UserTrustStore) : + X509ExtendedTrustManager() { + override fun checkServerTrusted(chain: Array, authType: String, socket: Socket?) { + val host = (socket as? SSLSocket)?.handshakeSession?.peerHost + verify(chain, host) { + val extended = system as? X509ExtendedTrustManager + if (extended != null) { + extended.checkServerTrusted(chain, authType, socket) + } else { + system.checkServerTrusted(chain, authType) + } + } + } + + override fun checkServerTrusted(chain: Array, authType: String, engine: SSLEngine?) { + verify(chain, engine?.peerHost) { + val extended = system as? X509ExtendedTrustManager + if (extended != null) { + extended.checkServerTrusted(chain, authType, engine) + } else { + system.checkServerTrusted(chain, authType) + } + } + } + + override fun checkServerTrusted(chain: Array, authType: String) { + // Without a session there is no host to match an accepted certificate against. + system.checkServerTrusted(chain, authType) + } + + private inline fun verify(chain: Array, host: String?, systemCheck: () -> Unit) { + try { + systemCheck() + } catch (e: CertificateException) { + if (host != null && userStore.isTrusted(host, chain)) return + throw UntrustedServerCertificateException(host.orEmpty(), chain.toList(), e) + } + } + + override fun checkClientTrusted(chain: Array, authType: String, socket: Socket?): Unit = + system.checkClientTrusted(chain, authType) + + override fun checkClientTrusted(chain: Array, authType: String, engine: SSLEngine?): Unit = + system.checkClientTrusted(chain, authType) + + override fun checkClientTrusted(chain: Array, authType: String): Unit = + system.checkClientTrusted(chain, authType) + + override fun getAcceptedIssuers(): Array = system.acceptedIssuers +} diff --git a/core/network/src/main/kotlin/social/aloha/core/network/tls/ClientCertificates.kt b/core/network/src/main/kotlin/social/aloha/core/network/tls/ClientCertificates.kt new file mode 100644 index 00000000..1449bd49 --- /dev/null +++ b/core/network/src/main/kotlin/social/aloha/core/network/tls/ClientCertificates.kt @@ -0,0 +1,80 @@ +// SPDX-FileCopyrightText: 2026 Aloha Social contributors +// SPDX-License-Identifier: MIT + +package social.aloha.core.network.tls + +import android.content.Context +import android.security.KeyChain +import android.security.KeyChainException +import java.io.File +import java.net.Socket +import java.security.Principal +import java.security.PrivateKey +import java.security.cert.X509Certificate +import java.util.Locale +import java.util.Properties +import javax.net.ssl.SSLEngine +import javax.net.ssl.SSLSocket +import javax.net.ssl.X509ExtendedKeyManager + +/** + * Which system KeyChain alias to present as a client certificate for which host. The person picks + * the alias with `KeyChain.choosePrivateKeyAlias`; the key itself never leaves the KeyChain. + */ +public class ClientCertificateAliases(private val file: File) { + private val lock = Any() + private var cached: Properties? = null + + public fun aliasFor(host: String): String? = synchronized(lock) { load().getProperty(key(host)) } + + public fun set(host: String, alias: String?) { + synchronized(lock) { + val properties = load() + if (alias == null) properties.remove(key(host)) else properties.setProperty(key(host), alias) + file.parentFile?.mkdirs() + file.outputStream().use { properties.store(it, null) } + } + } + + // read once: the aliases are consulted in every TLS handshake + private fun load(): Properties = cached ?: Properties().also { properties -> + if (file.exists()) file.inputStream().use(properties::load) + cached = properties + } + + private fun key(host: String) = host.lowercase(Locale.ROOT) +} + +/** Presents the KeyChain certificate chosen for the handshake's host, and nothing for any other host. */ +public class ClientCertificateKeyManager(private val context: Context, private val aliases: ClientCertificateAliases) : + X509ExtendedKeyManager() { + override fun chooseClientAlias( + keyType: Array?, + issuers: Array?, + socket: Socket?, + ): String? = (socket as? SSLSocket)?.handshakeSession?.peerHost?.let(aliases::aliasFor) + + override fun chooseEngineClientAlias( + keyType: Array?, + issuers: Array?, + engine: SSLEngine?, + ): String? = engine?.peerHost?.let(aliases::aliasFor) + + override fun getCertificateChain(alias: String): Array? = try { + KeyChain.getCertificateChain(context, alias) + } catch (_: KeyChainException) { + null + } + + override fun getPrivateKey(alias: String): PrivateKey? = try { + KeyChain.getPrivateKey(context, alias) + } catch (_: KeyChainException) { + null + } + + override fun getClientAliases(keyType: String?, issuers: Array?): Array? = null + + override fun getServerAliases(keyType: String?, issuers: Array?): Array? = null + + override fun chooseServerAlias(keyType: String?, issuers: Array?, socket: Socket?): String? = null +} diff --git a/core/network/src/main/kotlin/social/aloha/core/network/tls/UserTrustStore.kt b/core/network/src/main/kotlin/social/aloha/core/network/tls/UserTrustStore.kt new file mode 100644 index 00000000..56048812 --- /dev/null +++ b/core/network/src/main/kotlin/social/aloha/core/network/tls/UserTrustStore.kt @@ -0,0 +1,75 @@ +// SPDX-FileCopyrightText: 2026 Aloha Social contributors +// SPDX-License-Identifier: MIT + +package social.aloha.core.network.tls + +import java.io.File +import java.security.KeyStore +import java.security.MessageDigest +import java.security.cert.CertificateException +import java.security.cert.X509Certificate +import java.util.Locale +import javax.net.ssl.TrustManagerFactory +import javax.net.ssl.X509TrustManager + +/** + * Certificates the person accepted for servers the system does not trust, e.g. a self-hosted + * Nextcloud with a self-signed certificate. Stored as PKCS#12 in app-private storage. A certificate + * is trusted for the host it was accepted for only, and hostname verification still applies. + */ +public class UserTrustStore(private val file: File) { + private val lock = Any() + private var cached: KeyStore? = null + + /** Trusts [certificate] for [host] from now on. */ + public fun trust(host: String, certificate: X509Certificate) { + synchronized(lock) { + val store = load() + store.setCertificateEntry(alias(host, certificate), certificate) + file.parentFile?.mkdirs() + file.outputStream().use { store.store(it, PASSWORD) } + cached = store + } + } + + /** Whether [chain]'s leaf was accepted for [host]. */ + public fun isTrusted(host: String, chain: Array): Boolean { + val leaf = chain.firstOrNull() ?: return false + val entry = synchronized(lock) { load() }.getCertificate(alias(host, leaf)) as? X509Certificate + return entry == leaf && leaf.isValidNow() + } + + private fun X509Certificate.isValidNow(): Boolean = try { + checkValidity() + true + } catch (_: CertificateException) { + false + } + + private fun load(): KeyStore = cached ?: KeyStore.getInstance(TYPE).also { store -> + if (file.exists()) file.inputStream().use { store.load(it, PASSWORD) } else store.load(null, PASSWORD) + cached = store + } + + private fun prefix(host: String) = host.lowercase(Locale.ROOT) + "|" + + private fun alias(host: String, certificate: X509Certificate) = prefix(host) + certificate.sha256Fingerprint() + + private companion object { + const val TYPE = "PKCS12" + + // The file lives in app-private storage; the password only satisfies the PKCS#12 format. + val PASSWORD = "aloha".toCharArray() + } +} + +/** The SHA-256 fingerprint as colon-separated upper-case hex, the form a person compares. */ +public fun X509Certificate.sha256Fingerprint(): String = + MessageDigest.getInstance("SHA-256").digest(encoded).joinToString(":") { "%02X".format(it) } + +/** The platform's default trust manager: system CAs only in release builds. */ +public fun systemTrustManager(): X509TrustManager { + val factory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()) + factory.init(null as KeyStore?) + return factory.trustManagers.filterIsInstance().first() +} diff --git a/core/network/src/test/kotlin/social/aloha/core/network/ApiClientTest.kt b/core/network/src/test/kotlin/social/aloha/core/network/ApiClientTest.kt new file mode 100644 index 00000000..e9fc8f9b --- /dev/null +++ b/core/network/src/test/kotlin/social/aloha/core/network/ApiClientTest.kt @@ -0,0 +1,163 @@ +// SPDX-FileCopyrightText: 2026 Aloha Social contributors +// SPDX-License-Identifier: MIT + +package social.aloha.core.network + +import java.util.concurrent.TimeUnit +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.test.runTest +import mockwebserver3.MockResponse +import mockwebserver3.MockWebServer +import okhttp3.HttpUrl +import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.OkHttpClient +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertInstanceOf +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import social.aloha.core.model.CustomEmoji +import social.aloha.core.network.dto.CustomEmojiDto +import social.aloha.core.network.dto.toDomain + +class ApiClientTest { + private val server = MockWebServer() + private lateinit var base: HttpUrl + private val rateLimiter = RateLimiter(nowMillis = { 0L }) + private val dropped = mutableListOf() + + @BeforeEach + fun start() { + server.start() + base = server.url("/index.php/apps/social/") + } + + @AfterEach + fun stop() { + server.close() + } + + private fun client(credentials: Credentials = Credentials("secret-token", "Basic YWxpY2U6cHc=")) = ApiClient( + apiBase = base, + credentials = { credentials }, + http = OkHttpClient(), + rateLimiter = rateLimiter, + ioDispatcher = Dispatchers.IO, + failureListener = { _, failures -> dropped += failures }, + ) + + private fun emojis(authentication: Authentication = Authentication.Bearer, idempotencyKey: String? = null) = + listRequest( + Endpoint("api/v1/custom_emojis", authentication = authentication, idempotencyKey = idempotencyKey), + CustomEmojiDto.serializer(), + ) { it.toDomain() } + + private fun respond(body: String, code: Int = 200, headers: Map = emptyMap()) { + server.enqueue( + MockResponse.Builder().code(code).body(body).apply { + headers.forEach { (k, v) -> addHeader(k, v) } + }.build(), + ) + } + + @Test + fun `a bearer route sends the token and composes onto a non-root API base`() = runTest { + respond("""[{"shortcode":"a"}]""") + val result = client().execute(emojis()) + assertEquals(ApiResult.Success(listOf(CustomEmoji("a"))), result) + val recorded = server.takeRequest(1, TimeUnit.SECONDS) + assertEquals("/index.php/apps/social/api/v1/custom_emojis", recorded?.url?.encodedPath) + assertEquals("Bearer secret-token", recorded?.headers?.get("Authorization")) + assertEquals("application/json", recorded?.headers?.get("Accept")) + assertNull(recorded?.headers?.get("OCS-APIRequest")) + } + + @Test + fun `a Nextcloud session route sends the app password with OCS-APIRequest`() = runTest { + respond("[]") + client().execute(emojis(Authentication.NextcloudSession)) + val recorded = server.takeRequest(1, TimeUnit.SECONDS) + assertEquals("Basic YWxpY2U6cHc=", recorded?.headers?.get("Authorization")) + assertEquals("true", recorded?.headers?.get("OCS-APIRequest")) + } + + @Test + fun `a public route sends no credential`() = runTest { + respond("[]") + client().execute(emojis(Authentication.None)) + assertNull(server.takeRequest(1, TimeUnit.SECONDS)?.headers?.get("Authorization")) + } + + @Test + fun `a route whose credential is missing is refused before it leaves the device`() = runTest { + val result = client(Credentials(bearerToken = null)).execute(emojis()) + assertEquals(ApiResult.Failure(ApiError.Unauthorised(null)), result) + assertEquals(0, server.requestCount) + } + + @Test + fun `an idempotency key travels as a header`() = runTest { + respond("[]") + client().execute(emojis(idempotencyKey = "draft-7")) + assertEquals("draft-7", server.takeRequest(1, TimeUnit.SECONDS)?.headers?.get("Idempotency-Key")) + } + + @Test + fun `a page counts what the server sent, not what survived decoding`() = runTest { + respond( + """[{"shortcode":"a"},{"url":"broken"},{"shortcode":"b"}]""", + headers = mapOf("Link" to "<${base}api/v1/custom_emojis?max_id=2>; rel=\"next\""), + ) + val page = client().page(emojis(), limit = 3).valueOrNull() + assertEquals(listOf("a", "b"), page?.items?.map { it.shortcode }) + assertEquals(3, page?.rawCount) + assertEquals("2", page?.link?.next?.queryParameter("max_id")) + assertEquals(listOf(1), dropped.map { it.index }) + } + + @Test + fun `a cursor on another host is never followed`() = runTest { + val result = client().page("https://attacker.example/steal".toHttpUrl(), emojis(), limit = 20) + assertEquals(ApiResult.Failure(ApiError.ForeignCursor("attacker.example")), result) + assertEquals(0, server.requestCount) + } + + @Test + fun `a cursor on the same origin is followed verbatim`() = runTest { + respond("[]") + client().page( + base.resolve("api/v1/custom_emojis?only_video=true&max_id=9") ?: error("bad url"), + emojis(), + limit = 20, + ) + val recorded = server.takeRequest(1, TimeUnit.SECONDS) + assertEquals("only_video=true&max_id=9", recorded?.url?.query) + assertEquals("Bearer secret-token", recorded?.headers?.get("Authorization")) + } + + @Test + fun `a 429 blocks the host for Retry-After`() = runTest { + respond("", code = 429, headers = mapOf("Retry-After" to "30")) + val result = client().execute(emojis()) + assertInstanceOf(ApiResult.Failure::class.java, result) + assertTrue(rateLimiter.isBlocked(base.host)) + } + + @Test + fun `a body of the wrong shape is a decoding error, not a crash`() = runTest { + respond("""{"not":"a list"}""") + val single = request(Endpoint("api/v1/x"), CustomEmojiDto.serializer()) { it.toDomain() } + assertInstanceOf(ApiError.Decoding::class.java, client().execute(single).errorOrNull()) + } + + @Test + fun `a server-supplied id that would climb the path is refused before it is sent`() = runTest { + listOf("..", "%2e%2E", ".", "x/../../accounts").forEach { id -> + val request = unitRequest(Endpoint("api/v1/statuses/$id/favourite", HttpMethod.POST)) + assertInstanceOf(ApiError.UnsafePath::class.java, client().execute(request).errorOrNull(), id) + } + assertEquals(0, server.requestCount) + } +} diff --git a/core/network/src/test/kotlin/social/aloha/core/network/ApiErrorTest.kt b/core/network/src/test/kotlin/social/aloha/core/network/ApiErrorTest.kt new file mode 100644 index 00000000..acfdfbcc --- /dev/null +++ b/core/network/src/test/kotlin/social/aloha/core/network/ApiErrorTest.kt @@ -0,0 +1,70 @@ +// SPDX-FileCopyrightText: 2026 Aloha Social contributors +// SPDX-License-Identifier: MIT + +package social.aloha.core.network + +import kotlin.time.Duration.Companion.seconds +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 ApiErrorTest { + private fun from(status: Int, body: String = "", headers: Map = emptyMap()) = + ApiError.from(status, errorMessage(body), body, headers::get) + + @Test + fun `a revoked token is a re-authentication state, not a transient error`() { + val error = from(401, """{"error":"the access_token was revoked"}""") + assertEquals(ApiError.Unauthorised("the access_token was revoked"), error) + assertTrue(error?.requiresReauthentication == true) + assertFalse(error?.isTransient == true) + } + + @Test + fun `a 422's message is preserved for showing verbatim`() { + assertEquals( + ApiError.Unprocessable("Validation failed: Text is too long"), + from(422, """{"error":"Validation failed: Text is too long"}"""), + ) + } + + @Test + fun `error_description wins over error`() { + assertEquals( + ApiError.Forbidden("nope", insufficientScope = false), + from(403, """{"error":"x","error_description":"nope"}"""), + ) + } + + @Test + fun `an insufficient scope is recognised from WWW-Authenticate`() { + val error = from(403, headers = mapOf("WWW-Authenticate" to "Bearer error=\"insufficient_scope\"")) + assertEquals(ApiError.Forbidden(null, insufficientScope = true), error) + } + + @Test + fun `Retry-After is honoured where the server sent one`() { + assertEquals(ApiError.RateLimited(30.seconds), from(429, headers = mapOf("Retry-After" to "30"))) + assertEquals(ApiError.RateLimited(null), from(429)) + } + + @Test + fun `a non-JSON body from a PHP limit becomes the server error body`() { + assertEquals(ApiError.Server(413, "too large"), from(413, "too large")) + } + + @Test + fun `a 2xx is not an error`() { + assertNull(from(200)) + assertNull(from(204)) + } + + @Test + fun `5xx and transport failures are transient, 4xx are not`() { + assertTrue(ApiError.Server(503, null).isTransient) + assertFalse(ApiError.Server(400, null).isTransient) + assertFalse(ApiError.NotFound.isTransient) + } +} diff --git a/core/network/src/test/kotlin/social/aloha/core/network/LinkHeaderTest.kt b/core/network/src/test/kotlin/social/aloha/core/network/LinkHeaderTest.kt new file mode 100644 index 00000000..a5eaebf6 --- /dev/null +++ b/core/network/src/test/kotlin/social/aloha/core/network/LinkHeaderTest.kt @@ -0,0 +1,68 @@ +// SPDX-FileCopyrightText: 2026 Aloha Social contributors +// SPDX-License-Identifier: MIT + +package social.aloha.core.network + +import okhttp3.HttpUrl.Companion.toHttpUrl +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class LinkHeaderTest { + @Test + fun `both relations parse from Mastodon's own form`() { + val link = LinkHeader.parse( + "; rel=\"next\", " + + "; rel=\"prev\"", + ) + assertEquals("limit=20&max_id=41", link.next?.query) + assertEquals("limit=20&min_id=60", link.previous?.query) + } + + @Test + fun `a non-root API base survives in the cursor`() { + val link = LinkHeader.parse( + "; rel=\"next\"", + ) + assertEquals("/index.php/apps/social/api/v1/timelines/home", link.next?.encodedPath) + } + + @Test + fun `filters the caller sent survive into the cursor`() { + val link = LinkHeader.parse( + "; rel=\"next\"", + ) + assertEquals("true", link.next?.queryParameter("only_video")) + } + + @Test + fun `a comma inside a query value does not split the header`() { + val link = LinkHeader.parse("; rel=\"next\"") + assertNotNull(link.next) + assertNull(link.previous) + } + + @Test + fun `absent, empty and malformed headers are empty rather than fatal`() { + assertTrue(LinkHeader.parse(null).isEmpty) + assertTrue(LinkHeader.parse("").isEmpty) + assertTrue(LinkHeader.parse("garbage").isEmpty) + assertTrue(LinkHeader.parse("; rel=next").next) + assertNotNull(LinkHeader.parse("; rel='prev'").previous) + } + + @Test + fun `the header decides whether more exists, not the row count`() { + assertTrue(Paginated(listOf(1), LinkHeader(next = "https://x.test/2".toHttpUrl()), 1, 20).mayHaveMore) + assertTrue(Paginated(listOf(1), LinkHeader(), rawCount = 20, requestedLimit = 20).mayHaveMore) + assertFalse(Paginated(listOf(1), LinkHeader(), rawCount = 3, requestedLimit = 20).mayHaveMore) + } +} diff --git a/core/network/src/test/kotlin/social/aloha/core/network/RateLimiterTest.kt b/core/network/src/test/kotlin/social/aloha/core/network/RateLimiterTest.kt new file mode 100644 index 00000000..a14316a5 --- /dev/null +++ b/core/network/src/test/kotlin/social/aloha/core/network/RateLimiterTest.kt @@ -0,0 +1,70 @@ +// SPDX-FileCopyrightText: 2026 Aloha Social contributors +// SPDX-License-Identifier: MIT + +package social.aloha.core.network + +import kotlin.random.Random +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.minutes +import kotlin.time.Duration.Companion.seconds +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.currentTime +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.assertTrue +import org.junit.jupiter.api.Test + +// virtual time is how a rate limiter is tested without real waits +@OptIn(ExperimentalCoroutinesApi::class) +class RateLimiterTest { + @Test + fun `a full bucket lets a burst through without waiting`() = runTest { + val limiter = RateLimiter(capacity = 5.0, refillPerSecond = 1.0, nowMillis = { testScheduler.currentTime }) + repeat(5) { limiter.acquire("cloud.example") } + assertEquals(0L, currentTime) + } + + @Test + fun `an empty bucket waits for the refill`() = runTest { + val limiter = RateLimiter(capacity = 2.0, refillPerSecond = 2.0, nowMillis = { testScheduler.currentTime }) + repeat(3) { limiter.acquire("cloud.example") } + assertEquals(500L, currentTime) + } + + @Test + fun `hosts do not share a bucket`() = runTest { + val limiter = RateLimiter(capacity = 1.0, refillPerSecond = 1.0, nowMillis = { testScheduler.currentTime }) + limiter.acquire("a.example") + limiter.acquire("b.example") + assertEquals(0L, currentTime) + } + + @Test + fun `a 429 blocks the host until Retry-After has passed`() = runTest { + val limiter = RateLimiter(nowMillis = { testScheduler.currentTime }) + limiter.noteRateLimited("cloud.example", 30.seconds) + assertTrue(limiter.isBlocked("cloud.example")) + limiter.acquire("cloud.example") + assertEquals(30_000L, currentTime) + assertFalse(limiter.isBlocked("cloud.example")) + } + + @Test + fun `a 429 without Retry-After blocks for 20 to 60 seconds`() = runTest { + val limiter = RateLimiter(random = Random(7), nowMillis = { testScheduler.currentTime }) + limiter.noteRateLimited("cloud.example", retryAfter = null) + limiter.acquire("cloud.example") + assertTrue(currentTime in 20_000L..60_000L) + } + + @Test + fun `backoff grows exponentially, is jittered and never exceeds the cap`() { + val random = Random(1) + repeat(100) { attempt -> + val delay = Backoff.delay(attempt, base = 1.seconds, cap = 15.minutes, random = random) + assertTrue(delay >= 0.milliseconds && delay <= 15.minutes) + } + assertTrue(Backoff.delay(0, base = 1.seconds, random = random) <= 1.seconds) + } +} diff --git a/core/network/src/test/kotlin/social/aloha/core/network/SecretRedactionTest.kt b/core/network/src/test/kotlin/social/aloha/core/network/SecretRedactionTest.kt new file mode 100644 index 00000000..a755449a --- /dev/null +++ b/core/network/src/test/kotlin/social/aloha/core/network/SecretRedactionTest.kt @@ -0,0 +1,37 @@ +// SPDX-FileCopyrightText: 2026 Aloha Social contributors +// SPDX-License-Identifier: MIT + +package social.aloha.core.network + +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Test +import social.aloha.core.model.AccessToken +import social.aloha.core.model.ClientRegistration +import social.aloha.core.network.oauth.OAuthCallback +import social.aloha.core.network.oauth.PendingAuthorization +import social.aloha.core.network.oauth.Pkce + +/** A secret that reaches a string, in a log line or a crash report, must not carry its value. */ +class SecretRedactionTest { + private val secret = "s3cr3t-value-that-must-not-leak" + + @Test + fun `no value type that holds a secret prints it`() { + listOf( + AccessToken(secret, "read"), + ClientRegistration("client", secret, "read"), + Credentials(bearerToken = secret, nextcloudBasic = "Basic $secret"), + PendingAuthorization( + "https://cloud.example/", + state = secret, + verifier = secret, + redirectUri = "x", + "", + "", + "", + ), + OAuthCallback.Code(secret), + Pkce(secret), + ).forEach { value -> assertFalse(value.toString().contains(secret), value::class.simpleName) } + } +} diff --git a/core/network/src/test/kotlin/social/aloha/core/network/decoding/LenientDecodingTest.kt b/core/network/src/test/kotlin/social/aloha/core/network/decoding/LenientDecodingTest.kt new file mode 100644 index 00000000..f09f342a --- /dev/null +++ b/core/network/src/test/kotlin/social/aloha/core/network/decoding/LenientDecodingTest.kt @@ -0,0 +1,84 @@ +// SPDX-FileCopyrightText: 2026 Aloha Social contributors +// SPDX-License-Identifier: MIT + +package social.aloha.core.network.decoding + +import java.time.Instant +import kotlinx.serialization.Serializable +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 +import social.aloha.core.network.AlohaJson +import social.aloha.core.network.dto.CustomEmojiDto +import social.aloha.core.network.dto.toDomain + +class LenientDecodingTest { + @Serializable + private data class Probe( + @Serializable(with = FlexibleIdSerializer::class) val id: String, + @Serializable(with = OptionalIdSerializer::class) val parent: String? = null, + @Serializable(with = LenientUrlSerializer::class) val avatar: String? = null, + @Serializable(with = LenientBoolSerializer::class) val locked: Boolean = false, + @Serializable(with = LenientIntSerializer::class) val count: Int = 0, + @Serializable(with = LenientInstantSerializer::class) val at: Instant? = null, + @Serializable(with = LossyListSerializer::class) val emojis: List = emptyList(), + ) + + private fun decode(json: String): Probe = AlohaJson.decodeFromString(Probe.serializer(), json) + + @Test + fun `a numeric id beyond 2^53 keeps every digit`() { + assertEquals("1790000000000000123", decode("""{"id": 1790000000000000123}""").id) + } + + @Test + fun `an empty or null optional id is absent`() { + assertNull(decode("""{"id": "1", "parent": ""}""").parent) + assertNull(decode("""{"id": "1", "parent": null}""").parent) + assertEquals("42", decode("""{"id": "1", "parent": 42}""").parent) + } + + @Test + fun `an empty avatar is no avatar`() { + assertNull(decode("""{"id": "1", "avatar": ""}""").avatar) + assertNull(decode("""{"id": "1", "avatar": null}""").avatar) + assertNull(decode("""{"id": "1", "avatar": " "}""").avatar) + } + + @Test + fun `booleans and counts accept the shapes forks send`() { + assertTrue(decode("""{"id": "1", "locked": 1}""").locked) + assertTrue(decode("""{"id": "1", "locked": "true"}""").locked) + assertFalse(decode("""{"id": "1", "locked": "nope"}""").locked) + assertEquals(12, decode("""{"id": "1", "count": "12"}""").count) + assertEquals(0, decode("""{"id": "1", "count": null}""").count) + } + + @Test + fun `dates in every form a server sends`() { + val expected = Instant.parse("2026-09-28T10:00:00Z") + assertEquals(expected, decode("""{"id": "1", "at": "2026-09-28T10:00:00.000Z"}""").at) + assertEquals(expected, decode("""{"id": "1", "at": "2026-09-28T10:00:00Z"}""").at) + assertEquals(expected, decode("""{"id": "1", "at": "2026-09-28T12:00:00+02:00"}""").at) + assertEquals(Instant.parse("2026-09-28T00:00:00Z"), decode("""{"id": "1", "at": "2026-09-28"}""").at) + assertEquals(Instant.ofEpochSecond(1790000000), decode("""{"id": "1", "at": "1790000000"}""").at) + assertNull(decode("""{"id": "1", "at": "yesterday"}""").at) + } + + @Test + fun `a malformed element is dropped and recorded, the rest survive`() { + val (probe, failures) = DecodingFailures.collect { + decode("""{"id": "1", "emojis": [{"shortcode": "a"}, {"url": "x"}, {"shortcode": "b"}]}""") + } + assertEquals(listOf("a", "b"), probe.emojis.map { it.toDomain().shortcode }) + assertEquals(listOf(1), failures.map { it.index }) + } + + @Test + fun `an emoji without visible_in_picker is visible`() { + val emoji = AlohaJson.decodeFromString(CustomEmojiDto.serializer(), """{"shortcode": "x"}""").toDomain() + assertTrue(emoji.visibleInPicker) + } +} diff --git a/core/network/src/test/kotlin/social/aloha/core/network/dto/DtoDecodingTest.kt b/core/network/src/test/kotlin/social/aloha/core/network/dto/DtoDecodingTest.kt new file mode 100644 index 00000000..b235989e --- /dev/null +++ b/core/network/src/test/kotlin/social/aloha/core/network/dto/DtoDecodingTest.kt @@ -0,0 +1,217 @@ +// SPDX-FileCopyrightText: 2026 Aloha Social contributors +// SPDX-License-Identifier: MIT + +package social.aloha.core.network.dto + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import social.aloha.core.model.AttachmentKind +import social.aloha.core.model.NotificationKind +import social.aloha.core.model.Status +import social.aloha.core.model.TimelineSource +import social.aloha.core.model.Visibility +import social.aloha.core.network.AlohaJson +import social.aloha.core.network.ApiRequest +import social.aloha.core.network.Decoded +import social.aloha.core.network.endpoints.InstanceEndpoints +import social.aloha.core.network.endpoints.NotificationEndpoints +import social.aloha.core.network.endpoints.StatusEndpoints +import social.aloha.core.network.endpoints.TimelineEndpoints + +private fun ApiRequest.decode(body: String): Decoded = decoder.decode(AlohaJson, body) + +private const val ACCOUNT = """{"id": 1790000000000000001, "username": "alice", "acct": "alice", "avatar": ""}""" + +private fun status(extra: String = "", id: String = "1"): String = + """{"id": "$id", "created_at": "2026-09-28T10:00:00.000Z", "account": $ACCOUNT, "visibility": "public" $extra}""" + +private fun media(json: String) = status(""", "media_attachments": [$json]""") + +class DtoDecodingTest { + private fun decodeStatus(json: String): Status = StatusEndpoints.status("1").decode(json).value + + @Test + fun `an account with an empty avatar has none, and a numeric id keeps every digit`() { + val account = decodeStatus(status()).account + assertNull(account.avatar) + assertEquals("1790000000000000001", account.id) + } + + @Test + fun `an account without acct falls back to its username`() { + val s = decodeStatus("""{"id": "1", "account": {"id": "2", "username": "bob", "avatar": null}}""") + assertEquals("bob", s.account.acct) + assertNull(s.account.avatar) + } + + @Test + fun `nextcloud social's null reblog, empty reactions and null place decode as absent`() { + val s = decodeStatus(status(""", "reblog": null, "reactions": [], "place": null""")) + assertNull(s.reblog) + assertEquals(emptyList(), s.reactions) + assertNull(s.place) + } + + @Test + fun `media meta from a fork as an empty array leaves the dimensions absent`() { + val s = decodeStatus(media("""{"id": 5, "type": "video", "url": "https://x/v.mp4", "meta": []}""")) + val attachment = s.mediaAttachments.single() + assertEquals(AttachmentKind.Video, attachment.type) + assertNull(attachment.meta) + assertNull(attachment.aspectRatio) + } + + @Test + fun `an empty original from a server without ffmpeg defers classification and is not a failure`() { + val json = media("""{"id": "5", "type": "video", "meta": {"original": [], "focus": {"x": 0.1, "y": -0.2}}}""") + val decoded = StatusEndpoints.status("1").decode(json) + assertTrue(decoded.failures.isEmpty()) + val meta = decoded.value.mediaAttachments.single().meta + assertNotNull(meta) + assertNull(meta?.original) + assertEquals(0.1, meta?.focus?.x) + } + + @Test + fun `unknown enum values decode rather than fail and visibility fails closed`() { + val json = media("""{"id": "5", "type": "hologram"}""").replace("\"public\"", "\"local_only\"") + val s = decodeStatus(json) + assertEquals(AttachmentKind.Unknown, s.mediaAttachments.single().type) + assertEquals(Visibility.Unknown, s.visibility) + assertEquals(Visibility.Unknown, decodeStatus("""{"id": "1", "account": $ACCOUNT}""").visibility) + } + + @Test + fun `a quote decodes from the wrapper and from the bare status`() { + val wrapper = """{"state": "accepted", "quoted_status": ${status(id = "9")}}""" + val wrapped = decodeStatus(status(""", "quote": $wrapper""")) + assertEquals("accepted", wrapped.quote?.state) + assertEquals("9", wrapped.quotedStatus?.id) + val bare = decodeStatus(status(""", "quote": ${status(id = "8")}""")) + assertNull(bare.quote?.state) + assertEquals("8", bare.quotedStatus?.id) + assertNull(decodeStatus(status(""", "quote": "nonsense"""")).quote) + } + + @Test + fun `a malformed status drops from its page but still counts toward the raw count`() { + val page = "[${status()}, {\"id\": \"2\"}, ${status(id = "3")}]" + val decoded = TimelineEndpoints.timeline(TimelineSource.Home).decode(page) + assertEquals(listOf("1", "3"), decoded.value.map { it.id }) + assertEquals(3, decoded.rawCount) + assertEquals(listOf(1), decoded.failures.map { it.index }) + } + + @Test + fun `a malformed nested attachment is dropped and recorded without failing the status`() { + val json = status(""", "media_attachments": [{"type": "image"}, {"id": "6", "type": "image"}]""") + val decoded = StatusEndpoints.status("1").decode(json) + assertEquals(listOf("6"), decoded.value.mediaAttachments.map { it.id }) + assertEquals(1, decoded.failures.size) + } + + @Test + fun `a video status carries PeerTube's labelled facts and chapters in either form`() { + val video = """, "video": {"views": "12", "category": {"id": 3, "label": "Travels"}, "licence": "CC-BY", + "download": 0, "chapters": [{"start": 0, "title": "Intro"}, {"start": "1:02", "title": "Middle"}, + {"start": "soon", "title": "x"}]}""" + val details = decodeStatus(status(video)).video + assertEquals(12, details?.views) + assertEquals("Travels", details?.category) + assertEquals("CC-BY", details?.licence) + assertEquals(false, details?.download) + assertEquals(listOf(0.0, 62.0), details?.chapters?.map { it.start }) + } + + @Test + fun `a card of an empty object is no card`() { + assertNull(StatusEndpoints.card("1").decode("{}").value) + val card = StatusEndpoints.card("1").decode("""{"url": "https://a.test", "title": "Title"}""").value + assertEquals("Title", card?.title) + } + + @Test + fun `both spellings of the streaming url are read`() { + val modern = """{"domain":"m.test","configuration":{"urls":{"streaming":"wss://m.test/s"}}}""" + val legacy = """{"domain":"m.test","configuration":{"urls":{"streaming_api":"wss://m.test/s"}}}""" + assertTrue(InstanceEndpoints.v2().decode(modern).value.hasStreaming) + assertTrue(InstanceEndpoints.v2().decode(legacy).value.hasStreaming) + } + + @Test + fun `an empty urls object and an empty vapid key mean no streaming and no push`() { + val body = """{"domain":"cloud.test","configuration":{"urls":{},"vapid":{"public_key":""}}}""" + val instance = InstanceEndpoints.v2().decode(body).value + assertFalse(instance.hasStreaming) + assertFalse(instance.hasWebPush) + } + + @Test + fun `v1 and v2 produce the same limits, including a video ceiling beyond 2 GiB`() { + val configuration = """"configuration":{"statuses":{"max_characters":5000,"max_media_attachments":4}, + "media_attachments":{"image_size_limit":10485760,"video_size_limit":2147483648}}""" + val v1 = InstanceEndpoints.v1().decode("""{"uri":"cloud.test",$configuration}""").value + val v2 = InstanceEndpoints.v2().decode("""{"domain":"cloud.test",$configuration}""").value + assertEquals(v1.limits, v2.limits) + assertEquals(10L * 1024 * 1024, v2.limits.imageSizeLimit) + assertEquals(2048L * 1024 * 1024, v2.limits.sizeLimit("video/mp4")) + assertEquals(5000, v2.limits.maxStatusCharacters) + } + + @Test + fun `one odd configuration block leaves only its own limits at the defaults`() { + val body = """{"domain":"x","configuration":{"statuses":[],"polls":{"max_options":8}}}""" + val instance = InstanceEndpoints.v2().decode(body).value + assertEquals(500, instance.limits.maxStatusCharacters) + assertEquals(8, instance.limits.maxPollOptions) + } + + @Test + fun `grouped notifications count their groups as the page`() { + val first = """{"group_key": "favourite-1", "notifications_count": 2, "type": "favourite", + "most_recent_notification_id": 7, "sample_account_ids": [1790000000000000001]}""" + val second = """{"group_key": "x", "type": "story:react", "most_recent_notification_id": "8"}""" + val body = """{"accounts": [$ACCOUNT], "statuses": [], "notification_groups": [$first, $second]}""" + val decoded = NotificationEndpoints.grouped().decode(body) + val groups = decoded.value.notificationGroups + assertEquals(2, decoded.rawCount) + assertEquals("7", groups[0].mostRecentNotificationId) + assertEquals(listOf("1790000000000000001"), groups[0].sampleAccountIds) + assertEquals(NotificationKind.Unknown, groups[1].type) + assertNotNull(decoded.value.account("1790000000000000001")) + } + + @Test + fun `a relationship that does not mention reblogs shows them`() { + val json = """{"id": 3, "following": "true"}""" + val relationship = AlohaJson.decodeFromString(RelationshipDto.serializer(), json).toDomain() + assertTrue(relationship.showingReblogs) + assertTrue(relationship.following) + } + + @Test + fun `the nextcloud theme is read from the OCS envelope and normalised`() { + val theming = """{"name": "Cloud", "color": "#0082C9", "color-element-dark": "abc", "color-text": 12}""" + val body = """{"ocs": {"meta": {}, "data": {"capabilities": {"core": {}, "theming": $theming}}}}""" + val theme = AlohaJson.decodeFromString(OcsCapabilitiesDto.serializer(), body).theming?.toDomain() + assertEquals("Cloud", theme?.name) + assertEquals("#0082c9", theme?.colourHex) + assertEquals("#aabbcc", theme?.elementDarkHex) + assertNull(theme?.textHex) + val broken = AlohaJson.decodeFromString(OcsCapabilitiesDto.serializer(), """{"ocs": {"data": []}}""") + assertNull(broken.theming) + } + + @Test + fun `markers are an object keyed by timeline and empty for a new account`() { + val json = """{"home": {"last_read_id": 1790637085797595891, "version": 2}}""" + val set = AlohaJson.decodeFromString(MarkerSetDto.serializer(), json).toDomain() + assertEquals("1790637085797595891", set.home?.lastReadId) + assertNull(set.notifications) + assertNull(AlohaJson.decodeFromString(MarkerSetDto.serializer(), "{}").toDomain().home) + } +} diff --git a/core/network/src/test/kotlin/social/aloha/core/network/dto/ExtensionDecodingTest.kt b/core/network/src/test/kotlin/social/aloha/core/network/dto/ExtensionDecodingTest.kt new file mode 100644 index 00000000..40cb6a2f --- /dev/null +++ b/core/network/src/test/kotlin/social/aloha/core/network/dto/ExtensionDecodingTest.kt @@ -0,0 +1,154 @@ +// SPDX-FileCopyrightText: 2026 Aloha Social contributors +// SPDX-License-Identifier: MIT + +package social.aloha.core.network.dto + +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 +import social.aloha.core.model.AttachmentKind +import social.aloha.core.model.PortfolioLayout +import social.aloha.core.network.AlohaJson +import social.aloha.core.network.ApiRequest +import social.aloha.core.network.endpoints.CollectionEndpoints +import social.aloha.core.network.endpoints.DiscoveryEndpoints +import social.aloha.core.network.endpoints.InterestEndpoints +import social.aloha.core.network.endpoints.PlaceEndpoints +import social.aloha.core.network.endpoints.PortfolioEndpoints +import social.aloha.core.network.endpoints.ProfileEndpoints +import social.aloha.core.network.endpoints.StoryEndpoints +import social.aloha.core.network.endpoints.VideoEndpoints + +private fun ApiRequest.value(body: String): T = decoder.decode(AlohaJson, body).value + +private const val ACCOUNT = """{"id": "6", "username": "alice", "acct": "alice", "avatar": ""}""" + +private const val NEXTCLOUD_STORY = + """{"id": "1", "account_id": "http://nextcloud.local/index.php/apps/social/@alice", "caption": "Morning colours", + "duration": 8, "created_at": "2026-09-28T23:11:54+00:00", "expires_at": "2026-09-29T23:11:54+00:00", + "seen": false, "view_count": 0, "media": {"id": "16", "type": "image", + "url": "http://nextcloud.local/media/a.jpeg", "preview_url": "http://nextcloud.local/media/b.jpeg"}}""" + +class ExtensionDecodingTest { + @Test + fun `a Nextcloud story names its poster by uri and nests the file under media`() { + val story = StoryEndpoints.own().value("[$NEXTCLOUD_STORY]").single() + assertNull(story.account) + assertEquals("http://nextcloud.local/index.php/apps/social/@alice", story.accountUri) + assertEquals("http://nextcloud.local/media/a.jpeg", story.url) + assertEquals("http://nextcloud.local/media/b.jpeg", story.previewUrl) + assertEquals(8.0, story.duration) + assertEquals(Instant.parse("2026-09-28T23:11:54Z"), story.publishedAt) + assertEquals(0, story.viewCount) + } + + @Test + fun `a Pixelfed v1_2 carousel puts the poster on the node and calls a picture a photo`() { + val body = """{"self": null, "nodes": [{"id": "6", + "user": {"id": "6", "username": "alice", "username_acct": "alice", + "avatar": ""}, "nodes": [{"id": "1", "pid": "6", "type": "photo", "src": "http://nextcloud.local/m.jpeg", + "duration": 8, "seen": false, "created_at": "2026-09-28T23:11:54+00:00"}], "seen": true}]}""" + val carousel = StoryEndpoints.carouselV2().value(body) + val story = carousel.others.single() + assertEquals("alice", story.account?.acct) + assertEquals(AttachmentKind.Image, story.type) + assertEquals("http://nextcloud.local/m.jpeg", story.url) + assertTrue(story.seen) + assertEquals(Instant.parse("2026-09-29T23:11:54Z"), story.expiresAt) + assertTrue(carousel.own.isEmpty()) + } + + @Test + fun `a flat carousel is everybody else's stories`() { + assertEquals(1, StoryEndpoints.carousel().value("[$NEXTCLOUD_STORY]").others.size) + } + + @Test + fun `story reactions arrive bare or wrapped, and one without anybody is dropped`() { + val reaction = """{"id": "3", "account": $ACCOUNT, "emoji": "🎉"}""" + assertEquals("🎉", StoryEndpoints.reactions("1").value("[$reaction]").single().reaction) + val wrapped = """{"reactions": [$reaction, {"id": "4", "caption": "nobody"}]}""" + assertEquals(1, StoryEndpoints.reactions("1").value(wrapped).size) + } + + @Test + fun `continue watching reads a bare post without its account by id`() { + val bare = """[{"id": "179", "content": "

Test video

", "visibility": "public", + "media_attachments": [{"id": "m", "type": "video", "meta": {"original": {"duration": 30.5}}}]}]""" + val item = VideoEndpoints.continueWatching().value(bare).single() + assertEquals("179", item.statusId) + assertEquals(30.5, item.duration) + assertNull(item.status) + } + + @Test + fun `continue watching reads the documented shape with the post`() { + val documented = """[{"status_id": 179, "position": 12, "duration": 60, + "status": {"id": "179", "account": $ACCOUNT, "visibility": "public"}}, {"position": 3}]""" + val item = VideoEndpoints.continueWatching().value(documented).single() + assertEquals(12.0, item.position) + assertEquals("179", item.status?.id) + } + + @Test + fun `a collection's size is its post count`() { + val body = """[{"id": "1", "title": "Test album", "visibility": "public", "size": 1}]""" + assertEquals(1, CollectionEndpoints.all().value(body).single().postCount) + } + + @Test + fun `a starter pack is named by id on Nextcloud Social`() { + val body = """[{"id": "photography", "name": "Photography", "size": 2, "accounts": []}, {"name": "nameless"}]""" + val pack = DiscoveryEndpoints.starterPacks().value(body).single() + assertEquals("photography", pack.slug) + assertEquals(2, pack.size) + } + + @Test + fun `place search spells the longitude long and sends empty coordinates`() { + val place = PlaceEndpoints.search( + "Ber", + ).value("""[{"id": "1", "name": "Berlin", "country": "DE", "lat": "", "long": ""}]""") + .single() + assertEquals("Berlin, DE", place.label) + assertNull(place.latitude) + assertEquals( + 13.4, + PlaceEndpoints.place("1").value("""{"id": 1, "name": "Berlin", "lat": 52.5, "long": "13.4"}""").longitude, + ) + } + + @Test + fun `highlights read since as Unix seconds and hashtags as objects`() { + val body = """{"available": true, "since": 1790636762, "weeks": [0, "2", 13], + "hashtags": [{"name": "aloha", "count": 1}, "android"]}""" + val highlights = ProfileEndpoints.highlights("6").value(body) + assertEquals(Instant.ofEpochSecond(1790636762), highlights.since) + assertEquals(listOf(0, 2, 13), highlights.weeks) + assertEquals(listOf("aloha", "android"), highlights.hashtagNames) + assertFalse(ProfileEndpoints.highlights("7").value("""{"available": false}""").available) + } + + @Test + fun `interests tolerate absent settings and a string score`() { + val state = InterestEndpoints.state().value( + """{"interests": [{"name": "Photography", "score": "0.5"}], "thin": 1}""", + ) + assertTrue(state.settings.learning) + assertEquals("photography", state.interests.single().id) + assertEquals(0.5, state.interests.single().score) + assertTrue(state.thin) + } + + @Test + fun `the portfolio settings leave out switches as off and the page as on`() { + val body = """{"active": 1, "title": "Work", "layout": "rows"}""" + val settings = PortfolioEndpoints.own().value(body) + assertEquals(PortfolioLayout.Rows, settings.layout) + assertFalse(settings.showCaptions) + assertTrue(PortfolioEndpoints.page("alice").value(body).showCaptions) + } +} diff --git a/core/network/src/test/kotlin/social/aloha/core/network/dto/ModerationDecodingTest.kt b/core/network/src/test/kotlin/social/aloha/core/network/dto/ModerationDecodingTest.kt new file mode 100644 index 00000000..79bbbfdd --- /dev/null +++ b/core/network/src/test/kotlin/social/aloha/core/network/dto/ModerationDecodingTest.kt @@ -0,0 +1,141 @@ +// SPDX-FileCopyrightText: 2026 Aloha Social contributors +// SPDX-License-Identifier: MIT + +package social.aloha.core.network.dto + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import social.aloha.core.model.AdminStanding +import social.aloha.core.model.AnnualArchetype +import social.aloha.core.model.AnnualReportState +import social.aloha.core.network.AlohaJson +import social.aloha.core.network.ApiRequest +import social.aloha.core.network.endpoints.AdminAccountEndpoints +import social.aloha.core.network.endpoints.AnnualReportEndpoints +import social.aloha.core.network.endpoints.InstanceEndpoints +import social.aloha.core.network.endpoints.ModerationEndpoints + +private fun ApiRequest.decodeValue(body: String): T = decoder.decode(AlohaJson, body).value + +class ModerationDecodingTest { + private val wrapped = """ + { + "annual_reports": [ + { + "year": 2025, + "data": { + "archetype": "oracle", + "time_series": [{"month": 1, "statuses": 3, "followers": 1}, {"month": 2, "statuses": 11, "followers": 0}], + "top_hashtags": [{"name": "nextcloud", "count": 7}], + "top_statuses": {"by_reblogs": "110", "by_replies": null, "by_favourites": "110"} + }, + "schema_version": 1, + "share_url": null, + "account_id": "42" + } + ], + "accounts": [], + "statuses": [] + } + """.trimIndent() + + @Test + fun `a wrapped report decodes whole, with a null share_url as no URL`() { + val report = AnnualReportEndpoints.all().decodeValue(wrapped).annualReports.single() + assertEquals(2025, report.year) + assertEquals(AnnualArchetype.Oracle, report.data.archetype) + assertEquals(14, report.data.totalStatuses) + assertEquals("nextcloud", report.data.topHashtags.first().name) + assertEquals(listOf("110"), report.data.topStatuses.ids) + assertNull(report.data.topStatuses.byReplies) + assertNull(report.shareUrl) + assertEquals("42", report.accountId) + } + + @Test + fun `an unknown archetype and a numeric account id do not fail the report`() { + val body = """{"annual_reports": [{"year": 2024, "data": {"archetype": "sphinx"}, "account_id": 9}]}""" + val report = AnnualReportEndpoints.year(2024).decodeValue(body).annualReports.single() + assertEquals(AnnualArchetype.Unknown, report.data.archetype) + assertEquals("9", report.accountId) + } + + @Test + fun `a report whose data has the wrong shape keeps its year`() { + val body = """{"annual_reports": [{"year": "2023", "data": [], "top_statuses": 5}]}""" + val report = AnnualReportEndpoints.all().decodeValue(body).annualReports.single() + assertEquals(2023, report.year) + assertEquals(AnnualArchetype.Unknown, report.data.archetype) + } + + @Test + fun `the state decodes the four words, an unknown fifth, and absence as ineligible`() { + val state = AnnualReportEndpoints.state(2025) + assertEquals(AnnualReportState.Available, state.decodeValue("""{"state":"available"}""")) + assertEquals(AnnualReportState.Unknown, state.decodeValue("""{"state":"melting"}""")) + assertEquals(AnnualReportState.Ineligible, state.decodeValue("{}")) + } + + @Test + fun `an admin account with an empty domain is local, and lenient booleans decide its standing`() { + val body = """[{"id": 1, "username": "bob", "domain": "other.example", "silenced": "true", "suspended": 1}, + {"id": "2", "username": "eve", "domain": "", "silenced": true}, + {"id": "3", "username": "ada", "account": []}]""" + val (bob, eve, ada) = AdminAccountEndpoints.accounts().decodeValue(body) + assertEquals(AdminStanding.Suspended, bob.standing) + assertEquals("@bob@other.example", bob.handle) + assertTrue(eve.isLocal) + assertEquals(AdminStanding.Silenced, eve.standing) + assertEquals(AdminStanding.Active, ada.standing) + assertNull(ada.account) + } + + @Test + fun `a report carries who, what and whether anybody has taken it`() { + val report = ModerationEndpoints.report("7").decodeValue( + """ + { + "id": "7", "action_taken": false, "action_taken_at": null, "category": "spam", + "comment": "posting the same link everywhere", "forwarded": true, + "created_at": "2026-01-02T03:04:05.000Z", "updated_at": "2026-01-02T03:04:05.000Z", + "account": {"id": "1", "username": "ada", "acct": "ada"}, + "target_account": {"id": "2", "username": "bob", "acct": "bob@other.example"}, + "assigned_account": null, "action_taken_by_account": null, "statuses": [{"broken": true}], "rules": [] + } + """.trimIndent(), + ) + assertEquals("7", report.id) + assertFalse(report.actionTaken) + assertTrue(report.forwarded) + assertFalse(report.isAssigned) + assertEquals("bob@other.example", report.targetAccount?.acct) + assertNotNull(report.createdAt) + assertTrue(report.statuses.isEmpty()) + } + + @Test + fun `weekly activity decodes the strings the server actually sends`() { + val week = InstanceEndpoints.activity() + .decodeValue("""[{"week":"1767139200","statuses":"41","logins":"0","registrations":"0"}]""").single() + assertEquals(1_767_139_200L, week.week) + assertEquals(41, week.statuses) + assertEquals(0, week.registrations) + } + + @Test + fun `a published domain block keys off its digest and defaults to suspend`() { + val (block, bare) = InstanceEndpoints.domainBlocks() + .decodeValue( + """[{"domain":"bad.example","digest":"abc","severity":"silence","comment":""},""" + + """{"domain":"x.example"}]""", + ) + assertEquals("abc", block.id) + assertEquals("silence", block.severity) + assertEquals("suspend", bare.severity) + assertEquals("x.example", bare.id) + } +} diff --git a/core/network/src/test/kotlin/social/aloha/core/network/dto/ShapeDecodingTest.kt b/core/network/src/test/kotlin/social/aloha/core/network/dto/ShapeDecodingTest.kt new file mode 100644 index 00000000..2d568494 --- /dev/null +++ b/core/network/src/test/kotlin/social/aloha/core/network/dto/ShapeDecodingTest.kt @@ -0,0 +1,159 @@ +// SPDX-FileCopyrightText: 2026 Aloha Social contributors +// SPDX-License-Identifier: MIT + +package social.aloha.core.network.dto + +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 +import social.aloha.core.model.MigrationEntry +import social.aloha.core.model.MigrationLine +import social.aloha.core.network.AlohaJson +import social.aloha.core.network.ApiRequest +import social.aloha.core.network.endpoints.AuthorizedAppEndpoints +import social.aloha.core.network.endpoints.ChannelEndpoints +import social.aloha.core.network.endpoints.DiscoveryEndpoints +import social.aloha.core.network.endpoints.GifEndpoints +import social.aloha.core.network.endpoints.MigrationEndpoints +import social.aloha.core.network.endpoints.ProfileEndpoints +import social.aloha.core.network.endpoints.SafetyEndpoints +import social.aloha.core.network.endpoints.StatisticsEndpoints +import social.aloha.core.network.endpoints.SubscriptionEndpoints +import social.aloha.core.network.endpoints.TeamEndpoints + +private fun ApiRequest.value(body: String): T = decoder.decode(AlohaJson, body).value + +private const val ACCOUNT = """{"id": "7", "username": "bob", "acct": "bob"}""" + +class ShapeDecodingTest { + @Test + fun `directory search reads the documented object and a bare array`() { + val documented = """{"accounts": [$ACCOUNT], "sources": [{"host": "a.example", "accounts": 3}]}""" + val results = DiscoveryEndpoints.directorySearch("bob").value(documented) + assertEquals("bob", results.accounts.single().acct) + assertEquals(3, results.sources.single().count) + assertEquals(1, DiscoveryEndpoints.directorySearch("bob").value("[$ACCOUNT]").accounts.size) + } + + @Test + fun `directory hashtags arrive as hashtags or tags`() { + assertEquals( + "aloha", + DiscoveryEndpoints.directoryHashtags().value("""{"tags": [{"name": "aloha"}]}""").hashtags.single().name, + ) + } + + @Test + fun `a follow graph suggestion is a wrapper or a bare account`() { + val body = """{"suggestions": [{"account": $ACCOUNT, "followers": 4, "through": [$ACCOUNT]}, $ACCOUNT, "junk"], + "asked": 10, "needs": 5}""" + val graph = DiscoveryEndpoints.followGraph().value(body) + assertEquals(listOf(4, 0), graph.suggestions.map { it.count }) + assertEquals(1, graph.suggestions.first().via.size) + assertEquals(10, graph.asked) + } + + @Test + fun `with no flag, enough follows makes the follow graph worthwhile`() { + assertTrue(DiscoveryEndpoints.followGraphStatus().value("""{"following": 12, "needs": 10}""").isWorthwhile) + assertFalse(DiscoveryEndpoints.followGraphStatus().value("""{"following": 2, "needs": 10}""").isWorthwhile) + assertFalse(DiscoveryEndpoints.followGraphStatus().value("""{"eligible": false, "needs": 0}""").isWorthwhile) + } + + @Test + fun `discover categories normalise their hashtags and fall back to the name as id`() { + val category = DiscoveryEndpoints.categories().value( + """{"categories": [{"name": "Nature", "tags": ["#Trees", " "]}]}""", + ) + .single() + assertEquals("nature", category.id) + assertEquals(listOf("trees"), category.hashtags) + } + + @Test + fun `a tagged person is an account-shaped object or a bare handle`() { + val body = """{"tagged_people": ["ada", {"id": 9, "username": "grace", "display_name": "Grace"}]}""" + val people = ProfileEndpoints.tagPeople("1", listOf("ada")).value(body) + assertEquals(listOf("ada", "grace"), people.map { it.acct }) + assertEquals("9", people[1].id) + } + + @Test + fun `a migration lookup reads both shapes and lists each handle once`() { + val results = """{"results": [{"handle": "ada@x.example", "found": true}, {"acct": "", "found": true}]}""" + assertEquals( + listOf(MigrationEntry("ada@x.example", true)), + MigrationEndpoints.findPeople(emptyList()).value(results).entries, + ) + val split = """{"found": ["ada@x.example", $ACCOUNT], "missing": ["ada@x.example", "eve@y.example"]}""" + val entries = MigrationEndpoints.findPeople(emptyList()).value(split).entries + assertEquals(listOf("ada@x.example", "bob", "eve@y.example"), entries.map { it.handle }) + assertEquals(listOf(true, true, false), entries.map { it.found }) + } + + @Test + fun `a migration report keeps every key as it came`() { + val report = MigrationEndpoints.importVideo( + "u", + false, + ).value("""{"skipped": 2, "done": true, "failed": ["a", "b"], "log": ["one"]}""") + assertEquals( + listOf(MigrationLine("done", "yes"), MigrationLine("failed", "2"), MigrationLine("skipped", "2")), + report.lines, + ) + assertEquals(listOf("one"), report.log) + } + + @Test + fun `statistics read numbers sent as strings and booleans`() { + val body = """{"posts": {"total": "12", "public": true, "odd": {}}, "by_month": {"2026-02": 1, "2026-01": 3}, + "window": {"days": "30", "capped": 1}, "languages": [{"name": "en", "count": "5"}]}""" + val statistics = StatisticsEndpoints.overview(30).value(body) + assertEquals(mapOf("total" to 12.0, "public" to 1.0), statistics.posts.values) + assertEquals(listOf("2026-01", "2026-02"), statistics.byMonth.sorted.map { it.first }) + assertEquals(30, statistics.window?.days) + assertEquals(5, statistics.languages.single().count) + } + + @Test + fun `an authorised app's scopes arrive as an array or one string`() { + val body = """[{"id": 1, "name": "A", "scopes": "read write"}, {"id": 2, "scopes": ["push"]}]""" + assertEquals( + listOf(listOf("read", "write"), listOf("push")), + AuthorizedAppEndpoints.all().value(body).map { + it.scopes + }, + ) + } + + @Test + fun `wrapped lists unwrap and a bare one reads as it is`() { + assertEquals(1, TeamEndpoints.all().value("""{"teams": [{"acct": "team", "display_name": "Team"}]}""").size) + assertEquals(1, ChannelEndpoints.all().value("""{"channels": [{"id": 1, "handle": "c"}]}""").size) + assertEquals( + "site.example", + SubscriptionEndpoints.feeds().value("""{"feeds": [{"id": 1, "link": "https://site.example"}]}""") + .single().displayTitle, + ) + assertEquals(1, SubscriptionEndpoints.timeline().value("""[{"id": 1, "title": "Entry"}]""").size) + assertEquals(listOf("a@b.example"), MigrationEndpoints.aliases().value("""{"aliases": ["a@b.example"]}""")) + } + + @Test + fun `the GIF library counts its page when the server sends no total`() { + val library = GifEndpoints.library("cat").value("""{"gifs": [{"slug": "noto-1f63a", "name": "smiley cat"}]}""") + assertEquals(1, library.total) + assertEquals("smiley cat", library.gifs.single().title) + } + + @Test + fun `announcements carry their reactions`() { + val body = """[{"id": 1, "content": "Hi", "all_day": true, + "reactions": [{"name": "🎉", "count": "3", "me": 1}]}]""" + val announcement = SafetyEndpoints.announcements().value(body).single() + assertTrue(announcement.allDay) + assertEquals(3, announcement.reactions.single().count) + assertTrue(announcement.reactions.single().me) + } +} diff --git a/core/network/src/test/kotlin/social/aloha/core/network/endpoints/EndpointsTest.kt b/core/network/src/test/kotlin/social/aloha/core/network/endpoints/EndpointsTest.kt new file mode 100644 index 00000000..62ffac95 --- /dev/null +++ b/core/network/src/test/kotlin/social/aloha/core/network/endpoints/EndpointsTest.kt @@ -0,0 +1,221 @@ +// SPDX-FileCopyrightText: 2026 Aloha Social contributors +// SPDX-License-Identifier: MIT + +package social.aloha.core.network.endpoints + +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 +import social.aloha.core.model.FilterAction +import social.aloha.core.model.FilterContext +import social.aloha.core.model.TimelineFilters +import social.aloha.core.model.TimelineSource +import social.aloha.core.network.Authentication +import social.aloha.core.network.Body +import social.aloha.core.network.Endpoint +import social.aloha.core.network.HttpMethod +import social.aloha.core.network.QueryItem + +private fun Endpoint.form(): List = (body as? Body.Form)?.fields.orEmpty() + +private fun Endpoint.formValue(name: String): String? = form().firstOrNull { it.name == name }?.value + +private fun Endpoint.queryValue(name: String): String? = query.firstOrNull { it.name == name }?.value + +private fun Endpoint.queryNames(): List = query.map { it.name } + +class EndpointsTest { + private val keywords = listOf( + KeywordDraft("spoilers", id = "9", wholeWord = true), + KeywordDraft("ending"), + KeywordDraft("gone", id = "4", destroy = true), + ) + + private fun draft( + context: List = listOf(FilterContext.Home), + expires: Long? = null, + keywords: List = emptyList(), + ) = FilterDraft("Spoilers", context, FilterAction.Hide, expires, keywords) + + @Test + fun `filter keywords go out in Rails' nested-attributes shape`() { + val endpoint = FilterEndpoints.create(draft(keywords = keywords)).endpoint + assertEquals(HttpMethod.POST, endpoint.method) + assertEquals("api/v2/filters", endpoint.path) + assertEquals("spoilers", endpoint.formValue("keywords_attributes[0][keyword]")) + assertEquals("1", endpoint.formValue("keywords_attributes[0][whole_word]")) + assertEquals("9", endpoint.formValue("keywords_attributes[0][id]")) + assertEquals("ending", endpoint.formValue("keywords_attributes[1][keyword]")) + assertNull(endpoint.formValue("keywords_attributes[1][id]")) + assertEquals("0", endpoint.formValue("keywords_attributes[1][whole_word]")) + } + + @Test + fun `a removed filter keyword is sent with _destroy rather than left out`() { + val endpoint = FilterEndpoints.update("3", draft(keywords = keywords)).endpoint + assertEquals(HttpMethod.PUT, endpoint.method) + assertEquals("api/v2/filters/3", endpoint.path) + assertEquals("1", endpoint.formValue("keywords_attributes[2][_destroy]")) + assertEquals("4", endpoint.formValue("keywords_attributes[2][id]")) + } + + @Test + fun `clearing a filter expiry sends an empty value, not nothing`() { + assertEquals("", FilterEndpoints.update("3", draft(expires = null)).endpoint.formValue("expires_in")) + assertEquals("1800", FilterEndpoints.create(draft(expires = 1800)).endpoint.formValue("expires_in")) + } + + @Test + fun `filter contexts are repeated and unknown ones are dropped`() { + val contexts = listOf(FilterContext.Home, FilterContext.Notifications, FilterContext.Unknown) + val endpoint = FilterEndpoints.create(draft(contexts)).endpoint + val sent = endpoint.form().filter { it.name == "context[]" }.map { it.value } + assertEquals(listOf("home", "notifications"), sent) + } + + @Test + fun `a status's link preview is a detail-screen request`() { + assertEquals("api/v1/statuses/42/card", StatusEndpoints.card("42").endpoint.path) + } + + @Test + fun `the directory sends an order, never local, and clamps its limits`() { + val active = SearchEndpoints.directory().endpoint + assertEquals("api/v1/directory", active.path) + assertEquals("active", active.queryValue("order")) + assertNull(active.queryValue("local")) + assertEquals(Authentication.None, active.authentication) + val new = SearchEndpoints.directory(DirectoryOrder.New, limit = 200, offset = -5).endpoint + assertEquals("new", new.queryValue("order")) + assertEquals("50", new.queryValue("limit")) + assertEquals("0", new.queryValue("offset")) + } + + @Test + fun `direct messages start from mutuals`() { + assertEquals("api/v1.1/direct/compose/mutuals", TimelineEndpoints.directMessageMutuals().endpoint.path) + } + + @Test + fun `conversations can be emptied and cleared`() { + val readAll = TimelineEndpoints.markAllConversationsRead().endpoint + assertEquals(HttpMethod.POST, readAll.method) + assertEquals("api/v1/conversations/read_all", readAll.path) + val delete = TimelineEndpoints.deleteConversation("5").endpoint + assertEquals(HttpMethod.DELETE, delete.method) + assertEquals("api/v1/conversations/5", delete.path) + } + + @Test + fun `anchors produce the right cursor parameter`() { + assertTrue(PageAnchor.Cold.queryItems().isEmpty()) + assertEquals("max_id", PageAnchor.OlderThan("41").queryItems().single().name) + assertEquals("min_id", PageAnchor.NewerThan("60").queryItems().single().name) + assertEquals("since_id", PageAnchor.ImmediatelyAfter("7").queryItems().single().name) + } + + @Test + fun `limit is clamped to the server's cap and never zero`() { + assertEquals("50", TimelineEndpoints.timeline(TimelineSource.Home, limit = 500).endpoint.queryValue("limit")) + assertEquals("1", TimelineEndpoints.timeline(TimelineSource.Home, limit = 0).endpoint.queryValue("limit")) + assertEquals("1", BlockEndpoints.blocks(limit = 0).endpoint.queryValue("limit")) + } + + @Test + fun `list members are paged by the header and never with limit`() { + assertNull(ListEndpoints.accounts("3").endpoint.queryValue("limit")) + } + + @Test + fun `only_video wins over only_media since every video is media`() { + val filters = TimelineFilters(onlyMedia = true, onlyVideo = true) + val names = TimelineEndpoints.timeline(TimelineSource.Home, filters).endpoint.queryNames() + assertTrue("only_video" in names) + assertFalse("only_media" in names) + } + + @Test + fun `a false flag is omitted rather than sent as false`() { + val names = TimelineEndpoints.timeline(TimelineSource.Home).endpoint.queryNames() + assertFalse("only_media" in names) + assertFalse("local" in names) + } + + @Test + fun `local narrows the public timeline`() { + val endpoint = TimelineEndpoints.timeline(TimelineSource.Local).endpoint + assertEquals("api/v1/timelines/public/", endpoint.path) + assertEquals("true", endpoint.queryValue("local")) + assertNull(TimelineEndpoints.timeline(TimelineSource.Federated).endpoint.queryValue("local")) + } + + @Test + fun `a list timeline has a route of its own`() { + assertEquals("api/v1/timelines/list/3", TimelineEndpoints.timeline(TimelineSource.List("3")).endpoint.path) + } + + @Test + fun `an account timeline excludes replies unless asked`() { + val source = TimelineSource.Account("7", includeReplies = false, onlyMedia = true) + val endpoint = TimelineEndpoints.timeline(source).endpoint + assertEquals("api/v1/accounts/7/statuses", endpoint.path) + assertEquals("true", endpoint.queryValue("exclude_replies")) + assertEquals("true", endpoint.queryValue("only_media")) + } + + @Test + fun `hashtags are normalised into the path`() { + val tagTimeline = TimelineEndpoints.timeline(TimelineSource.Hashtag("#NextCloud")).endpoint + assertEquals("api/v1/timelines/tag/nextcloud", tagTimeline.path) + assertEquals("api/v1/tags/swift/follow", TagEndpoints.follow("#Swift").endpoint.path) + } + + @Test + fun `a post carries its idempotency key and an edit does not`() { + val post = StatusPost(text = "Hello", idempotencyKey = "key-1") + assertEquals("key-1", ComposeEndpoints.post(post).endpoint.idempotencyKey) + assertNull(ComposeEndpoints.edit("9", post).endpoint.idempotencyKey) + } + + @Test + fun `a poll is dropped when media is attached`() { + val withMedia = StatusPost(text = "x", mediaIds = listOf("m1"), pollOptions = listOf("a", "b")) + val names = withMedia.formItems().map { it.name } + assertTrue("media_ids[]" in names) + assertFalse(names.any { it.startsWith("poll") }) + val poll = StatusPost(text = "x", pollOptions = listOf("a", "b")).formItems() + assertEquals(listOf("a", "b"), poll.filter { it.name == "poll[options][]" }.map { it.value }) + assertEquals("86400", poll.first { it.name == "poll[expires_in]" }.value) + } + + @Test + fun `a quote goes out under both names the server reads`() { + val items = StatusPost(text = "x", quotedId = "12").formItems() + assertEquals("12", items.first { it.name == "quoted_id" }.value) + assertEquals("12", items.first { it.name == "quote_id" }.value) + } + + @Test + fun `instance routes send no credential and preferences do`() { + assertEquals(Authentication.None, InstanceEndpoints.v2().endpoint.authentication) + assertEquals(Authentication.None, InstanceEndpoints.peers().endpoint.authentication) + assertEquals(Authentication.Bearer, InstanceEndpoints.preferences().endpoint.authentication) + } + + @Test + fun `markers are written only for the timelines named`() { + val endpoint = MarkerEndpoints.write(home = "5", notifications = null).endpoint + assertEquals(listOf(QueryItem("home[last_read_id]", "5")), endpoint.form()) + } + + @Test + fun `grouped notifications ask for their types and exclusions`() { + val request = NotificationEndpoints.grouped(types = listOf("mention"), excludeTypes = listOf("follow")) + val endpoint = request.endpoint + assertEquals("api/v2/notifications", endpoint.path) + assertEquals("mention", endpoint.queryValue("types[]")) + assertEquals("follow", endpoint.queryValue("exclude_types[]")) + } +} diff --git a/core/network/src/test/kotlin/social/aloha/core/network/endpoints/ExtensionEndpointsTest.kt b/core/network/src/test/kotlin/social/aloha/core/network/endpoints/ExtensionEndpointsTest.kt new file mode 100644 index 00000000..4e97b767 --- /dev/null +++ b/core/network/src/test/kotlin/social/aloha/core/network/endpoints/ExtensionEndpointsTest.kt @@ -0,0 +1,135 @@ +// SPDX-FileCopyrightText: 2026 Aloha Social contributors +// SPDX-License-Identifier: MIT + +package social.aloha.core.network.endpoints + +import java.util.Locale +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import social.aloha.core.network.Authentication +import social.aloha.core.network.Body +import social.aloha.core.network.Endpoint +import social.aloha.core.network.HttpMethod +import social.aloha.core.network.QueryItem + +private fun Endpoint.form(): List = (body as? Body.Form)?.fields.orEmpty() + +private fun Endpoint.formValue(name: String): String? = form().firstOrNull { it.name == name }?.value + +private fun Endpoint.queryValue(name: String): String? = query.firstOrNull { it.name == name }?.value + +class ExtensionEndpointsTest { + @Test + fun `Pixelfed names a story with sid, never in the path`() { + val viewers = StoryEndpoints.viewers("8").endpoint + assertEquals("api/v1.2/stories/viewers", viewers.path) + assertEquals("8", viewers.queryValue("sid")) + assertEquals("api/v1.2/stories/reactions", StoryEndpoints.reactions("8").endpoint.path) + assertEquals("8", StoryEndpoints.react("8", "🎉").endpoint.formValue("sid")) + assertEquals("api/v1.2/stories/comment", StoryEndpoints.comment("8", "hi").endpoint.path) + val expire = StoryEndpoints.selfExpire("8").endpoint + assertEquals("api/v1.1/stories/self-expire/8", expire.path) + assertEquals(HttpMethod.POST, expire.method) + } + + @Test + fun `a story reaction is clipped to twenty characters, never through an emoji`() { + assertEquals("🎉".repeat(20), StoryEndpoints.react("1", "🎉".repeat(40)).endpoint.formValue("reaction")) + } + + @Test + fun `a story's duration is clamped to what the server keeps`() { + assertEquals("30", StoryEndpoints.post("m", null, durationSeconds = 90).endpoint.formValue("duration")) + assertEquals("3", StoryEndpoints.post("m", null, durationSeconds = 1).endpoint.formValue("duration")) + } + + @Test + fun `a collection item is removed by path, not by form body`() { + val endpoint = CollectionEndpoints.removeItem("4", statusId = "91").endpoint + assertEquals(HttpMethod.DELETE, endpoint.method) + assertEquals("api/v1/collections/4/items/91", endpoint.path) + assertEquals(Body.None, endpoint.body) + } + + @Test + fun `deleting the Social account goes out on the Nextcloud credential`() { + val endpoint = SocialAccountEndpoints.delete("@ada@cloud.example").endpoint + assertEquals(HttpMethod.POST, endpoint.method) + assertEquals("api/v1/account/delete", endpoint.path) + assertEquals(Authentication.NextcloudSession, endpoint.authentication) + assertEquals("@ada@cloud.example", endpoint.formValue("confirm")) + } + + @Test + fun `the routes a bearer token cannot reach go out on the Nextcloud credential`() { + listOf( + MemoryEndpoints.onThisDay().endpoint, + MemoryEndpoints.recap().endpoint, + ReviewEndpoints.held().endpoint, + StatisticsEndpoints.overview(30).endpoint, + ChannelEndpoints.all().endpoint, + AuthorizedAppEndpoints.all().endpoint, + ).forEach { assertEquals(Authentication.NextcloudSession, it.authentication, it.path) } + assertEquals(Authentication.Bearer, InterestEndpoints.state().endpoint.authentication) + } + + @Test + fun `an empty language list still goes out, and untouched settings do not`() { + val endpoint = InterestEndpoints.settings(languages = emptyList()).endpoint + assertEquals(listOf(QueryItem("languages", "")), endpoint.form()) + assertEquals( + listOf("languages[]", "languages[]"), + InterestEndpoints.settings(languages = listOf("en", "de")).endpoint.form().map { + it.name + }, + ) + assertEquals("0", InterestEndpoints.settings(learning = false).endpoint.formValue("learning")) + } + + @Test + fun `the focal point is written with a dot whatever the device's locale`() { + val previous = Locale.getDefault() + Locale.setDefault(Locale.GERMANY) + try { + assertEquals("0.50,-1.00", MediaEndpoints.updateFocus("1", 0.5, -3.0).endpoint.formValue("focus")) + } finally { + Locale.setDefault(previous) + } + } + + @Test + fun `an alias is removed through the query, never a DELETE body`() { + val endpoint = MigrationEndpoints.removeAlias("a@b.example").endpoint + assertEquals("a@b.example", endpoint.queryValue("alias")) + assertEquals(Body.None, endpoint.body) + } + + @Test + fun `continue watching and GIF pages are capped and never ask for zero`() { + assertEquals("40", VideoEndpoints.continueWatching(limit = 100).endpoint.queryValue("limit")) + assertEquals("1", GifEndpoints.library(null, limit = 0).endpoint.queryValue("limit")) + assertEquals("0", GifEndpoints.library("", offset = -5).endpoint.queryValue("offset")) + assertTrue(GifEndpoints.library("").endpoint.query.none { it.name == "q" }) + } + + @Test + fun `list editing sends only a set replies policy`() { + val endpoint = ListEndpoints.update("1", "Friends", repliesPolicy = null, exclusive = true).endpoint + assertEquals(HttpMethod.PUT, endpoint.method) + assertEquals("true", endpoint.formValue("exclusive")) + assertTrue(endpoint.form().none { it.name == "replies_policy" }) + } + + @Test + fun `a credentials update sends only the fields that are set`() { + val update = CredentialsUpdate(displayName = "Ada", locked = true) + assertEquals(listOf("display_name", "locked"), update.parts().map { it.name }) + assertTrue(CredentialsUpdate().isEmpty) + } + + @Test + fun `discover posts name the media kind the server knows`() { + assertEquals("image", DiscoveryEndpoints.discoverPosts(DiscoverMedia.Images).endpoint.queryValue("media")) + } +} diff --git a/core/network/src/test/kotlin/social/aloha/core/network/endpoints/ModerationEndpointsTest.kt b/core/network/src/test/kotlin/social/aloha/core/network/endpoints/ModerationEndpointsTest.kt new file mode 100644 index 00000000..ef9eacc9 --- /dev/null +++ b/core/network/src/test/kotlin/social/aloha/core/network/endpoints/ModerationEndpointsTest.kt @@ -0,0 +1,99 @@ +// SPDX-FileCopyrightText: 2026 Aloha Social contributors +// SPDX-License-Identifier: MIT + +package social.aloha.core.network.endpoints + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Test +import social.aloha.core.model.AdminAccountAction +import social.aloha.core.network.Authentication +import social.aloha.core.network.Body +import social.aloha.core.network.Endpoint +import social.aloha.core.network.HttpMethod + +private fun Endpoint.formValue(name: String): String? = (body as? Body.Form)?.fields?.firstOrNull { + it.name == name +}?.value + +private fun Endpoint.queryValue(name: String): String? = query.firstOrNull { it.name == name }?.value + +class ModerationEndpointsTest { + @Test + fun `the reports queue defaults to unresolved and goes out on the bearer token`() { + val endpoint = ModerationEndpoints.reports().endpoint + assertEquals("api/v1/admin/reports", endpoint.path) + assertEquals("false", endpoint.queryValue("resolved")) + assertEquals(Authentication.Bearer, endpoint.authentication) + } + + @Test + fun `an account action carries the report it was taken from`() { + val endpoint = AdminAccountEndpoints.act( + "12", + AdminAccountAction.Suspend, + note = "spam", + reportId = "7", + ).endpoint + assertEquals(HttpMethod.POST, endpoint.method) + assertEquals("api/v1/admin/accounts/12/action", endpoint.path) + assertEquals("suspend", endpoint.formValue("type")) + assertEquals("spam", endpoint.formValue("text")) + assertEquals("7", endpoint.formValue("report_id")) + } + + @Test + fun `an action with no note and no report sends neither`() { + val endpoint = AdminAccountEndpoints.act("12", AdminAccountAction.Silence).endpoint + assertEquals("silence", endpoint.formValue("type")) + assertNull(endpoint.formValue("text")) + assertNull(endpoint.formValue("report_id")) + } + + @Test + fun `any is the absence of a filter, not a filter of its own`() { + val unfiltered = AdminAccountEndpoints.accounts().endpoint + assertNull(unfiltered.queryValue("origin")) + assertNull(unfiltered.queryValue("status")) + val filtered = AdminAccountEndpoints.accounts( + AdminAccountEndpoints.Origin.Remote, + AdminAccountEndpoints.Standing.Suspended, + ) + assertEquals("remote", filtered.endpoint.queryValue("origin")) + assertEquals("suspended", filtered.endpoint.queryValue("status")) + } + + @Test + fun `a trend decision names its kind in the path`() { + assertEquals( + "api/v1/admin/trends/tags/nextcloud/reject", + ModerationEndpoints.rejectTrend(ModerationEndpoints.TrendKind.Tags, "nextcloud").endpoint.path, + ) + assertEquals( + "api/v1/admin/trends/statuses/9/approve", + ModerationEndpoints.approveTrend(ModerationEndpoints.TrendKind.Statuses, "9").endpoint.path, + ) + } + + @Test + fun `report and account actions post to their own paths`() { + assertEquals("api/v1/admin/reports/7/assign_to_self", ModerationEndpoints.assignReportToSelf("7").endpoint.path) + assertEquals(HttpMethod.POST, ModerationEndpoints.resolveReport("7").endpoint.method) + assertEquals("api/v1/admin/accounts/3/unsensitive", AdminAccountEndpoints.unsensitive("3").endpoint.path) + } + + @Test + fun `the about-this-server routes need no viewer`() { + assertEquals(Authentication.None, InstanceEndpoints.activity().endpoint.authentication) + assertEquals(Authentication.None, InstanceEndpoints.domainBlocks().endpoint.authentication) + } + + @Test + fun `annual report routes are built per year`() { + assertEquals("api/v1/annual_reports", AnnualReportEndpoints.all().endpoint.path) + assertEquals("api/v1/annual_reports/2025", AnnualReportEndpoints.year(2025).endpoint.path) + assertEquals("api/v1/annual_reports/2025/state", AnnualReportEndpoints.state(2025).endpoint.path) + assertEquals(HttpMethod.POST, AnnualReportEndpoints.generate(2025).endpoint.method) + assertEquals("api/v1/annual_reports/2025/read", AnnualReportEndpoints.markRead(2025).endpoint.path) + } +} diff --git a/core/network/src/test/kotlin/social/aloha/core/network/oauth/OAuthTest.kt b/core/network/src/test/kotlin/social/aloha/core/network/oauth/OAuthTest.kt new file mode 100644 index 00000000..c5566a54 --- /dev/null +++ b/core/network/src/test/kotlin/social/aloha/core/network/oauth/OAuthTest.kt @@ -0,0 +1,226 @@ +// SPDX-FileCopyrightText: 2026 Aloha Social contributors +// SPDX-License-Identifier: MIT + +package social.aloha.core.network.oauth + +import java.security.MessageDigest +import java.util.Base64 +import java.util.concurrent.TimeUnit +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.test.runTest +import mockwebserver3.MockResponse +import mockwebserver3.MockWebServer +import okhttp3.HttpUrl +import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.OkHttpClient +import org.junit.jupiter.api.AfterEach +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.BeforeEach +import org.junit.jupiter.api.Test +import social.aloha.core.model.AccessToken +import social.aloha.core.model.AuthorizationServerMetadata +import social.aloha.core.model.ClientRegistration +import social.aloha.core.network.ApiError +import social.aloha.core.network.ApiResult +import social.aloha.core.network.RateLimiter + +class OAuthTest { + private val server = MockWebServer() + private lateinit var base: HttpUrl + private val client by lazy { OAuthClient(OkHttpClient(), RateLimiter(nowMillis = { 0L }), Dispatchers.IO) } + private val registration = ClientRegistration("client-1", "secret-1", "read write follow push") + + @BeforeEach + fun start() { + server.start() + base = server.url("/index.php/apps/social/") + } + + @AfterEach + fun stop() { + server.close() + } + + private fun pending(verifier: String) = + authorizationUrl(OAuthEndpoints.conventional(base), base, "client-1", OAuthIdentity.SCHEME_REDIRECT).second + .copy(state = "s1", verifier = verifier) + + private fun respond(body: String, code: Int = 200) = + server.enqueue(MockResponse.Builder().code(code).body(body).build()) + + private fun formOf(body: String?): Map = + "https://form.invalid/?${body.orEmpty()}".toHttpUrl().let { url -> + url.queryParameterNames.associateWith { url.queryParameter(it).orEmpty() } + } + + @Test + fun `the PKCE verifier is 86 unreserved characters and the challenge is its unpadded SHA-256`() { + val pkce = Pkce.generate() + assertEquals(86, pkce.verifier.length) + assertTrue(pkce.verifier.all { it.isLetterOrDigit() || it == '-' || it == '_' }) + val expected = Base64.getUrlEncoder().withoutPadding() + .encodeToString(MessageDigest.getInstance("SHA-256").digest(pkce.verifier.toByteArray())) + assertEquals(expected, pkce.challenge) + assertFalse(pkce.challenge.contains('=')) + } + + @Test + fun `the authorisation URL carries state, scopes and an S256 challenge`() { + val pkce = Pkce("verifier-".repeat(6)) + val (url, pending) = authorizationUrl( + OAuthEndpoints.conventional(base), + base, + clientId = "client-1", + redirectUri = OAuthIdentity.SCHEME_REDIRECT, + pkce = pkce, + state = "state-1", + ) + assertEquals("/index.php/apps/social/oauth/authorize", url.encodedPath) + assertEquals("code", url.queryParameter("response_type")) + assertEquals("state-1", url.queryParameter("state")) + assertEquals("read write follow push", url.queryParameter("scope")) + assertEquals(pkce.challenge, url.queryParameter("code_challenge")) + assertEquals("S256", url.queryParameter("code_challenge_method")) + assertEquals(pkce.verifier, pending.verifier) + assertFalse(pending.toString().contains(pkce.verifier)) + } + + @Test + fun `a scheme callback with the server's added slash yields the code`() { + assertEquals( + OAuthCallback.Code("abc"), + OAuthCallback.parse("alohasocial://oauth-callback/?code=abc&state=s1", expectedState = "s1"), + ) + assertEquals( + OAuthCallback.Code("abc"), + OAuthCallback.parse("https://aloha.social/oauth/callback?x=1&code=abc&state=s1#frag", "s1"), + ) + } + + @Test + fun `a callback for another state is dropped`() { + assertEquals( + OAuthCallback.StateMismatch, + OAuthCallback.parse("alohasocial://oauth-callback/?code=abc&state=evil", "s1"), + ) + assertEquals(OAuthCallback.StateMismatch, OAuthCallback.parse("alohasocial://oauth-callback/?code=abc", "s1")) + } + + @Test + fun `a denial is a denial, not a missing code`() { + assertEquals( + OAuthCallback.Denied("The user denied access"), + OAuthCallback.parse( + "alohasocial://oauth-callback/?error=access_denied" + + "&error_description=The%20user%20denied%20access&state=s1", + "s1", + ), + ) + // someone else's denial is not shown as ours + assertEquals( + OAuthCallback.StateMismatch, + OAuthCallback.parse("alohasocial://oauth-callback/?error=access_denied", "s1"), + ) + assertEquals(OAuthCallback.MissingCode, OAuthCallback.parse("alohasocial://oauth-callback/?state=s1", "s1")) + } + + @Test + fun `metadata endpoints on another origin are ignored in favour of the conventional ones`() { + val metadata = AuthorizationServerMetadata( + issuer = base.toString(), + authorizationEndpoint = "${base}oauth/authorize", + tokenEndpoint = "https://attacker.example/token", + ) + val endpoints = OAuthEndpoints.from(metadata, base) + assertEquals(base.host, endpoints.token.host) + assertEquals("/index.php/apps/social/oauth/token", endpoints.token.encodedPath) + } + + @Test + fun `registration sends the identity and both redirect URIs, one per line`() = runTest { + respond("""{"id":"2","client_id":"c","client_secret":"s","scopes":"read write follow push","vapid_key":""}""") + val result = client.register(base) + assertEquals(ApiResult.Success(ClientRegistration("c", "s", "read write follow push")), result) + val recorded = server.takeRequest(1, TimeUnit.SECONDS) + assertEquals("/index.php/apps/social/api/v1/apps", recorded?.url?.encodedPath) + val form = formOf(recorded?.body?.utf8()) + assertEquals("Aloha Social", form["client_name"]) + assertEquals("https://aloha.social/oauth/callback\nalohasocial://oauth-callback", form["redirect_uris"]) + assertEquals("https://aloha.social", form["website"]) + assertNull(recorded?.headers?.get("Authorization")) + } + + @Test + fun `a Mastodon registration with scopes as an array decodes`() = runTest { + respond("""{"client_id":"c","client_secret":"s","scopes":["read","write"]}""") + assertEquals("read write", (client.register(base) as ApiResult.Success).value.scopes) + } + + @Test + fun `the exchange sends the verifier and redirect, and no scope`() = runTest { + respond("""{"access_token":"t","token_type":"Bearer","scope":"read write follow push","created_at":1}""") + val pending = pending(verifier = "verifier-1") + val result = client.exchange(OAuthEndpoints.conventional(base).token, registration, pending, code = "code-1") + assertEquals(ApiResult.Success(AccessToken("t", "read write follow push")), result) + val form = formOf(server.takeRequest(1, TimeUnit.SECONDS)?.body?.utf8()) + assertEquals("authorization_code", form["grant_type"]) + assertEquals("verifier-1", form["code_verifier"]) + assertEquals("alohasocial://oauth-callback", form["redirect_uri"]) + assertEquals("secret-1", form["client_secret"]) + assertFalse(form.containsKey("scope")) + } + + @Test + fun `a spent code is an unauthorised answer, never retried`() = runTest { + respond("""{"error":"unknown client_id"}""", code = 401) + val pending = pending(verifier = "v") + val result = client.exchange(OAuthEndpoints.conventional(base).token, registration, pending, "used") + assertEquals(ApiResult.Failure(ApiError.Unauthorised("unknown client_id")), result) + assertEquals(1, server.requestCount) + } + + @Test + fun `revocation posts the token with the client credentials`() = runTest { + respond("{}") + assertEquals( + ApiResult.Success(Unit), + client.revoke(OAuthEndpoints.conventional(base), registration, AccessToken("t", "")), + ) + val form = formOf(server.takeRequest(1, TimeUnit.SECONDS)?.body?.utf8()) + assertEquals("t", form["token"]) + assertEquals("client-1", form["client_id"]) + } + + @Test + fun `userinfo is fetched with the new token`() = runTest { + respond( + """{"sub":"http://nextcloud.local/index.php/apps/social/@alice",""" + + """"name":"alice","preferred_username":"alice"}""", + ) + val info = ( + client.userInfo( + OAuthEndpoints.conventional(base).userinfo, + AccessToken("t", ""), + ) as ApiResult.Success + ).value + assertEquals("alice", info.preferredUsername) + assertEquals("Bearer t", server.takeRequest(1, TimeUnit.SECONDS)?.headers?.get("Authorization")) + } + + @Test + fun `metadata is read from under the API base, and absence is null`() = runTest { + respond( + """{"issuer":"$base","token_endpoint":"${base}oauth/token","code_challenge_methods_supported":["S256"]}""", + ) + assertEquals(base.toString(), client.metadata(base)?.issuer) + assertEquals( + "/index.php/apps/social/.well-known/oauth-authorization-server", + server.takeRequest(1, TimeUnit.SECONDS)?.url?.encodedPath, + ) + respond("""{"message":"oauth-authorization-server not supported"}""", code = 404) + assertNull(client.metadata(base)) + } +} diff --git a/core/network/src/test/kotlin/social/aloha/core/network/probe/RankRaceTest.kt b/core/network/src/test/kotlin/social/aloha/core/network/probe/RankRaceTest.kt new file mode 100644 index 00000000..c7e6509a --- /dev/null +++ b/core/network/src/test/kotlin/social/aloha/core/network/probe/RankRaceTest.kt @@ -0,0 +1,71 @@ +// SPDX-FileCopyrightText: 2026 Aloha Social contributors +// SPDX-License-Identifier: MIT + +package social.aloha.core.network.probe + +import kotlin.time.Duration.Companion.seconds +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.delay +import kotlinx.coroutines.test.currentTime +import kotlinx.coroutines.test.runTest +import okhttp3.HttpUrl.Companion.toHttpUrl +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +// virtual time is how a race between slow and fast candidates is tested without real waits +@OptIn(ExperimentalCoroutinesApi::class) +class RankRaceTest { + private val candidates = (1..3).map { + ProbeCandidate(it, "https://x.test/$it/".toHttpUrl(), CandidateKind.DomainRoot) + } + + @Test + fun `a fast worse candidate waits for a slow better one`() = runTest { + val winner = lowestRankFirst(candidates, 10.seconds) { candidate -> + delay(if (candidate.rank == 1) 3_000 else 10) + "answer ${candidate.rank}" + } + assertEquals(1, winner?.first?.rank) + } + + @Test + fun `the best success is taken once every better candidate failed, without waiting for worse ones`() = runTest { + val winner = lowestRankFirst(candidates, 10.seconds) { candidate -> + when (candidate.rank) { + 1 -> null.also { delay(100) } + 2 -> "two".also { delay(200) } + else -> "three".also { delay(9_000) } + } + } + assertEquals(2, winner?.first?.rank) + assertEquals(200L, currentTime) + } + + @Test + fun `nothing answering within the budget is no winner`() = runTest { + val winner = lowestRankFirst(candidates, 10.seconds) { + delay(20_000) + "late" + } + assertNull(winner) + assertEquals(10_000L, currentTime) + } + + @Test + fun `every candidate failing is no winner`() = runTest { + assertNull(lowestRankFirst(candidates, 10.seconds) { null }) + } + + @Test + fun `the reprobe gate opens at most once an hour per host`() { + var now = 0L + val gate = ReprobeGate(nowMillis = { now }) + assertTrue(gate.tryAcquire("cloud.example")) + assertEquals(false, gate.tryAcquire("cloud.example")) + assertTrue(gate.tryAcquire("other.example")) + now += 3_600_000L + assertTrue(gate.tryAcquire("cloud.example")) + } +} diff --git a/core/network/src/test/kotlin/social/aloha/core/network/probe/ServerAddressTest.kt b/core/network/src/test/kotlin/social/aloha/core/network/probe/ServerAddressTest.kt new file mode 100644 index 00000000..372c516b --- /dev/null +++ b/core/network/src/test/kotlin/social/aloha/core/network/probe/ServerAddressTest.kt @@ -0,0 +1,68 @@ +// SPDX-FileCopyrightText: 2026 Aloha Social contributors +// SPDX-License-Identifier: MIT + +package social.aloha.core.network.probe + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class ServerAddressTest { + @Test + fun `bare hosts, URLs, paths and handles all resolve to a host`() { + listOf( + "cloud.example.com", + "https://cloud.example.com/", + " CLOUD.Example.COM ", + "@alice@cloud.example.com", + "alice@cloud.example.com", + ).forEach { assertEquals("cloud.example.com", ServerAddress.parse(it)?.host, it) } + } + + @Test + fun `a typed path is kept as a hint, not assumed`() { + val address = ServerAddress.parse("cloud.example.com/nextcloud/") + assertEquals("cloud.example.com", address?.host) + assertEquals("nextcloud", address?.pathHint) + } + + @Test + fun `nonsense is refused`() { + assertNull(ServerAddress.parse("")) + assertNull(ServerAddress.parse("not a host")) + assertNull(ServerAddress.parse("@alice@")) + } + + @Test + fun `plain HTTP is refused unless cleartext is allowed`() { + assertNull(ServerAddress.parse("http://cloud.example.com")) + assertEquals( + "http://nextcloud.local/", + ServerAddress.parse("http://nextcloud.local", allowCleartext = true)?.origin.toString(), + ) + } + + @Test + fun `a non-default port survives`() { + assertEquals(8443, ServerAddress.parse("cloud.example.com:8443")?.origin?.port) + } + + @Test + fun `the five candidates appear in rank order`() { + val paths = candidatesFor(ServerAddress.parse("cloud.example.com/nextcloud") ?: error("parse")).map { + it.base.encodedPath + } + assertEquals( + listOf("/", "/index.php/apps/social/", "/apps/social/", "/nextcloud/", "/nextcloud/index.php/apps/social/"), + paths, + ) + } + + @Test + fun `without a path hint there are only three candidates, every one ending in a slash`() { + val candidates = candidatesFor(ServerAddress.parse("mastodon.example") ?: error("parse")) + assertEquals(listOf(1, 2, 3), candidates.map { it.rank }) + assertTrue(candidates.all { it.base.encodedPath.endsWith("/") }) + } +} diff --git a/core/network/src/test/kotlin/social/aloha/core/network/tls/TlsTest.kt b/core/network/src/test/kotlin/social/aloha/core/network/tls/TlsTest.kt new file mode 100644 index 00000000..580db987 --- /dev/null +++ b/core/network/src/test/kotlin/social/aloha/core/network/tls/TlsTest.kt @@ -0,0 +1,94 @@ +// SPDX-FileCopyrightText: 2026 Aloha Social contributors +// SPDX-License-Identifier: MIT + +package social.aloha.core.network.tls + +import java.io.File +import javax.net.ssl.SSLContext +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.test.runTest +import mockwebserver3.MockResponse +import mockwebserver3.MockWebServer +import okhttp3.OkHttpClient +import okhttp3.tls.HandshakeCertificates +import okhttp3.tls.HeldCertificate +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertInstanceOf +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import social.aloha.core.network.ApiClient +import social.aloha.core.network.ApiError +import social.aloha.core.network.ApiResult +import social.aloha.core.network.Authentication +import social.aloha.core.network.Credentials +import social.aloha.core.network.Endpoint +import social.aloha.core.network.RateLimiter +import social.aloha.core.network.unitRequest + +class TlsTest { + @TempDir + lateinit var directory: File + + private val selfSigned = HeldCertificate.Builder().addSubjectAlternativeName( + "localhost", + ).commonName("localhost").build() + private val server = MockWebServer().apply { + useHttps(HandshakeCertificates.Builder().heldCertificate(selfSigned).build().sslSocketFactory()) + } + + @AfterEach + fun stop() { + server.close() + } + + private fun client(store: UserTrustStore): ApiClient { + val trustManager = AlohaTrustManager(systemTrustManager(), store) + val tls = SSLContext.getInstance("TLS").apply { init(null, arrayOf(trustManager), null) } + val http = OkHttpClient.Builder().sslSocketFactory(tls.socketFactory, trustManager).build() + return ApiClient(server.url("/"), { Credentials(null) }, http, RateLimiter(nowMillis = { 0L }), Dispatchers.IO) + } + + private val ping = unitRequest(Endpoint("api/v1/instance", authentication = Authentication.None)) + + @Test + fun `a self-signed server is reported with its chain, not as a generic failure`() = runTest { + server.start() + val error = client(UserTrustStore(File(directory, "t.p12"))).execute(ping) + val untrusted = assertInstanceOf(ApiError.UntrustedCertificate::class.java, (error as ApiResult.Failure).error) + assertEquals("localhost", untrusted.host) + assertEquals(selfSigned.certificate, untrusted.chain.first()) + } + + @Test + fun `a certificate the person trusted for this host is accepted`() = runTest { + server.enqueue(MockResponse.Builder().body("{}").build()) + server.start() + val store = UserTrustStore(File(directory, "t.p12")) + store.trust("localhost", selfSigned.certificate) + assertEquals(ApiResult.Success(Unit), client(store).execute(ping)) + } + + @Test + fun `a certificate trusted for another host does not vouch for this one`() = runTest { + server.start() + val store = UserTrustStore(File(directory, "t.p12")) + store.trust("other.example", selfSigned.certificate) + assertInstanceOf(ApiResult.Failure::class.java, client(store).execute(ping)) + } + + @Test + fun `trust survives a restart`() { + val file = File(directory, "t.p12") + UserTrustStore(file).trust("Cloud.Example", selfSigned.certificate) + val reopened = UserTrustStore(file) + assertTrue(reopened.isTrusted("cloud.example", arrayOf(selfSigned.certificate))) + } + + @Test + fun `the fingerprint is colon-separated SHA-256 hex`() { + assertTrue(Regex("([0-9A-F]{2}:){31}[0-9A-F]{2}").matches(selfSigned.certificate.sha256Fingerprint())) + } +} diff --git a/core/testing/build.gradle.kts b/core/testing/build.gradle.kts index c60c878a..0626a338 100644 --- a/core/testing/build.gradle.kts +++ b/core/testing/build.gradle.kts @@ -4,3 +4,16 @@ plugins { alias(libs.plugins.aloha.android.library) } + +dependencies { + api(libs.okhttp.mockwebserver) + api(project(":core:datastore")) + implementation(libs.kotlinx.serialization.json) + testImplementation(project(":core:network")) + testImplementation(project(":core:data")) + testImplementation(project(":core:database")) + testImplementation(libs.kotlinx.coroutines.test) + testImplementation(libs.junit4) + testImplementation(libs.robolectric) + testImplementation(libs.androidx.test.core) +} diff --git a/core/testing/src/main/kotlin/social/aloha/core/testing/Fakes.kt b/core/testing/src/main/kotlin/social/aloha/core/testing/Fakes.kt new file mode 100644 index 00000000..0716a14d --- /dev/null +++ b/core/testing/src/main/kotlin/social/aloha/core/testing/Fakes.kt @@ -0,0 +1,57 @@ +// SPDX-FileCopyrightText: 2026 Aloha Social contributors +// SPDX-License-Identifier: MIT + +package social.aloha.core.testing + +import androidx.datastore.core.DataStore +import java.security.GeneralSecurityException +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import social.aloha.core.datastore.SecretCipher + +/** A DataStore held in memory: the same contract, no file, so tests run on any host. */ +public class InMemoryDataStore(initial: T) : DataStore { + private val mutex = Mutex() + private val state = MutableStateFlow(initial) + override val data: Flow = state + + /** What is stored right now, for assertions. */ + public val value: T get() = state.value + + override suspend fun updateData(transform: suspend (t: T) -> T): T = mutex.withLock { + transform(state.value).also { state.value = it } + } +} + +/** + * Stands in for the Keystore cipher, which does not exist on a JVM. XOR with a one-byte key is not + * encryption; it only keeps plain text out of the stored bytes so a test can tell. [lose] makes the + * next decryption fail, as an invalidated Keystore key does. + */ +public class FakeSecretCipher : SecretCipher { + private var key: Int? = null + public var lose: Boolean = false + + override fun encrypt(plain: ByteArray): ByteArray { + val k = key ?: KEY.also { key = it } + return byteArrayOf(MAGIC) + plain.map { (it.toInt() xor k).toByte() } + } + + override fun decrypt(blob: ByteArray): ByteArray { + val k = key?.takeUnless { lose } ?: throw GeneralSecurityException("key invalidated") + if (blob.firstOrNull() != MAGIC) throw GeneralSecurityException("not written with this key") + return blob.drop(1).map { (it.toInt() xor k).toByte() }.toByteArray() + } + + override fun reset() { + key = null + lose = false + } + + private companion object { + const val MAGIC: Byte = 42 + const val KEY = 0x5A + } +} diff --git a/core/testing/src/main/kotlin/social/aloha/core/testing/Fixtures.kt b/core/testing/src/main/kotlin/social/aloha/core/testing/Fixtures.kt new file mode 100644 index 00000000..dc7a1430 --- /dev/null +++ b/core/testing/src/main/kotlin/social/aloha/core/testing/Fixtures.kt @@ -0,0 +1,75 @@ +// SPDX-FileCopyrightText: 2026 Aloha Social contributors +// SPDX-License-Identifier: MIT + +package social.aloha.core.testing + +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.int +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive + +/** + * One recorded exchange: the request that was sent and the answer a real server gave. Captured from + * a live instance, secrets redacted, so tests are built on what servers actually do rather than on + * what their documentation says. + */ +public data class Fixture( + val name: String, + val method: String, + val path: String, + val query: Map>, + val status: Int, + val headers: Map, + val body: String, +) + +/** A corpus of fixtures under `fixtures//` on the classpath, listed by its `index.txt`. */ +public class FixtureCorpus(public val folder: String) { + public val fixtures: List by lazy { + resource("index.txt").lineSequence().map(String::trim).filter(String::isNotEmpty).map(::load).toList() + } + + /** The fixture stored as [name], e.g. `api/timeline-home.json`. */ + public fun named(name: String): Fixture = + fixtures.firstOrNull { it.name == name } ?: error("no fixture $name in $folder") + + private fun load(name: String): Fixture { + val root = Json.parseToJsonElement(resource(name)).jsonObject + val request = root.getValue("request").jsonObject + val target = request.getValue("path").jsonPrimitive.content + val body = root["body"] + return Fixture( + name = name, + method = request.getValue("method").jsonPrimitive.content, + path = target.substringBefore('?'), + query = parseQuery(target.substringAfter('?', missingDelimiterValue = "")), + status = root.getValue("status").jsonPrimitive.int, + headers = (root["headers"] as? JsonObject).orEmpty().mapValues { it.value.jsonPrimitive.content }, + body = when (body) { + null -> "" + is JsonPrimitive -> body.content + else -> body.toString() + }, + ) + } + + private fun resource(name: String): String { + val stream = javaClass.classLoader?.getResourceAsStream("fixtures/$folder/$name") + ?: error("fixture resource fixtures/$folder/$name is not on the classpath") + return stream.use { it.readBytes().decodeToString() } + } + + public companion object { + public val nextcloudSocial: FixtureCorpus by lazy { FixtureCorpus("nextcloud-social-0.26.97") } + public val mastodonSocial: FixtureCorpus by lazy { FixtureCorpus("mastodon-social-4.8.0-alpha.3") } + + internal fun parseQuery(query: String): Map> = query.split('&') + .filter { it.isNotEmpty() } + .map { it.substringBefore('=') to it.substringAfter('=', missingDelimiterValue = "") } + .groupBy({ decode(it.first) }, { decode(it.second) }) + + private fun decode(text: String): String = java.net.URLDecoder.decode(text, "UTF-8") + } +} diff --git a/core/testing/src/main/kotlin/social/aloha/core/testing/MockSocialServer.kt b/core/testing/src/main/kotlin/social/aloha/core/testing/MockSocialServer.kt new file mode 100644 index 00000000..648cc43f --- /dev/null +++ b/core/testing/src/main/kotlin/social/aloha/core/testing/MockSocialServer.kt @@ -0,0 +1,184 @@ +// SPDX-FileCopyrightText: 2026 Aloha Social contributors +// SPDX-License-Identifier: MIT + +package social.aloha.core.testing + +import java.util.concurrent.CopyOnWriteArrayList +import mockwebserver3.Dispatcher +import mockwebserver3.MockResponse +import mockwebserver3.MockWebServer +import mockwebserver3.RecordedRequest +import okhttp3.HttpUrl + +/** The server shapes the app has to cope with; sign-in has to succeed against all but the refusing ones. */ +public enum class MockServerConfiguration { + /** Nextcloud Social with the administrator's root rewrite rules: the API answers at `/` too. */ + NextcloudWithRewrite, + + /** Nextcloud Social without them: the API answers only under `/index.php/apps/social/`. */ + NextcloudWithoutRewrite, + + /** Nextcloud Social whose optional Nextcloud connection is available (Login Flow v2, OCS capabilities). */ + NextcloudConnected, + + /** Stock Mastodon: discovery documents captured from mastodon.social, no Nextcloud extension. */ + Mastodon, + + /** The Mastodon core only; every extension route answers 404. */ + CoreOnly, + + /** + * Answers the first read of every path with 429 and `Retry-After: 1`, then as Nextcloud Social + * with the rewrite rules. + */ + RateLimited, + + /** Nextcloud Social whose timelines carry one malformed entity in third place. */ + MalformedEntities, +} + +/** + * A MockWebServer that answers from the fixture corpus in the shape of one [configuration]. Absolute + * URLs in bodies and headers are rewritten to this server's origin, so cursors and endpoints the + * server names can be followed. Every request is recorded in [requests]. + */ +public class MockSocialServer(public val configuration: MockServerConfiguration) : AutoCloseable { + private val server = MockWebServer() + private val pins = mutableMapOf() + private val throttled: MutableSet = java.util.concurrent.ConcurrentHashMap.newKeySet() + private val recorded = CopyOnWriteArrayList() + + /** Every request this server answered, in order. */ + public val requests: List get() = recorded + + /** Where the Mastodon API lives in this configuration. */ + public val apiBase: HttpUrl + get() = server.url(if (configuration.servesApiAtRoot) "/" else "/index.php/apps/social/") + + /** The origin a person would type. */ + public val origin: HttpUrl get() = server.url("/") + + public fun start(): MockSocialServer = apply { + server.dispatcher = object : Dispatcher() { + override fun dispatch(request: RecordedRequest): MockResponse { + recorded += request + return answer(request) + } + } + server.start() + } + + /** Answers `method path` (path without query) with the fixture [name] from now on, e.g. a per-person variant. */ + public fun pin(method: String, path: String, name: String) { + pins["$method ${appPath(path)}"] = corpusFor(configuration).named(name) + } + + /** Answers the request a fixture was captured for with that fixture, whatever else would match. */ + public fun pinFixture(name: String) { + val fixture = corpusFor(configuration).named(name) + pins["${fixture.method} ${fixture.path}"] = fixture + } + + /** Answers `method path` with [status] and [body] from now on, for answers the corpus does not hold. */ + public fun pin(method: String, path: String, status: Int, body: String) { + pins["$method ${appPath(path)}"] = Fixture( + name = "pinned$path", + method = method, + path = appPath(path), + query = emptyMap(), + status = status, + headers = mapOf("content-type" to "application/json; charset=utf-8"), + body = body, + ) + } + + override fun close() { + server.close() + } + + private fun answer(request: RecordedRequest): MockResponse { + val routes = Routes(configuration) + val authorization = request.headers["Authorization"] + val path = routes.resolvePath(request.url.encodedPath) + val fixture = routes.synthesized(request.method, request.url, authorization) + ?: path?.let { routes.unauthorised(it, authorization) } + ?: path?.let { + pins["${request.method} $it"] ?: bestMatch(request.method, it, request.url) + }?.let { routes.adjust(it, path) } + return routes.refusal(request.method, request.url.encodedPath, throttled) ?: fixture?.let(::rewrite) + ?: notFound() + } + + /** + * The fixture for this request: same method and path, and every query parameter the fixture was + * captured with present in the request, except `limit`, which only breaks ties. A probe asking for + * `limit=1` gets the page captured with `limit=20`. + */ + private fun bestMatch(method: String, path: String, url: HttpUrl): Fixture? { + val query = url.queryParameterNames.associateWith { url.queryParameterValues(it).filterNotNull() } + return corpusFor(configuration).fixtures + .filter { + it.method == method && it.path.trimEnd('/') == path.trimEnd('/') && + configuration.includes(it.name) + } + .filter { fixture -> fixture.query.all { (key, values) -> key == LIMIT || query[key] == values } } + .sortedWith( + compareByDescending { it.query.keys.count { key -> key != LIMIT } } + .thenByDescending { it.query[LIMIT] == query[LIMIT] } + .thenBy { it.name.length }, + ) + .firstOrNull() + } + + private fun rewrite(fixture: Fixture): MockResponse { + val origin = server.url("/").toString().trimEnd('/') + fun swap(text: String) = CAPTURED_ORIGINS.fold(text) { acc, captured -> acc.replace(captured, origin) } + val builder = MockResponse.Builder().code(fixture.status).body(swap(fixture.body)) + fixture.headers.filterKeys { + it.lowercase() != "content-length" + }.forEach { (name, value) -> builder.addHeader(name, swap(value)) } + return builder.build() + } + + private fun notFound() = MockResponse.Builder().code(NOT_FOUND).body("""{"error":"Not found"}""").build() + + private companion object { + const val NOT_FOUND = 404 + const val LIMIT = "limit" + val CAPTURED_ORIGINS = listOf("http://nextcloud.local", "https://mastodon.social") + + fun corpusFor(configuration: MockServerConfiguration) = if (configuration == + MockServerConfiguration.Mastodon + ) { + FixtureCorpus.mastodonSocial + } else { + FixtureCorpus.nextcloudSocial + } + + fun appPath(path: String) = + if (path.startsWith("/index.php/apps/social")) path else "/index.php/apps/social$path" + } +} + +internal val MockServerConfiguration.servesApiAtRoot: Boolean + get() = this != MockServerConfiguration.NextcloudWithoutRewrite && + this != MockServerConfiguration.CoreOnly && + this != MockServerConfiguration.MalformedEntities + +/** + * Which corpus files a configuration answers from. `plain/` holds what a server without the rewrite + * rules answers and `root/` what one with them answers; a file only one of them has (the OCS + * capabilities, Pixelfed's config) does not depend on the rules and is shared. `errors/` answers + * only when a test pins it or a routing rule asks for it: it shares its paths with the successes. + */ +internal fun MockServerConfiguration.includes(name: String): Boolean { + val folder = name.substringBefore('/') + val file = name.substringAfter('/') + val corpus = FixtureCorpus.nextcloudSocial + return when { + this == MockServerConfiguration.Mastodon -> true + folder == "errors" -> false + servesApiAtRoot -> folder != "plain" || corpus.fixtures.none { it.name == "root/$file" } + else -> folder != "root" || corpus.fixtures.none { it.name == "plain/$file" } + } +} diff --git a/core/testing/src/main/kotlin/social/aloha/core/testing/Routes.kt b/core/testing/src/main/kotlin/social/aloha/core/testing/Routes.kt new file mode 100644 index 00000000..c09c73c0 --- /dev/null +++ b/core/testing/src/main/kotlin/social/aloha/core/testing/Routes.kt @@ -0,0 +1,182 @@ +// SPDX-FileCopyrightText: 2026 Aloha Social contributors +// SPDX-License-Identifier: MIT + +package social.aloha.core.testing + +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import mockwebserver3.MockResponse +import okhttp3.HttpUrl + +/** The tokens and client the mock hands out in place of the corpus's redacted ones. */ +public object MockCredentials { + public const val ACCESS_TOKEN: String = "mock-access-token" + public const val CLIENT_ID: String = "mock-client-id" + public const val CLIENT_SECRET: String = "mock-client-secret" + + /** A token the server treats as revoked: every authenticated route answers 401. */ + public const val REVOKED_TOKEN: String = "revoked-token" + + /** What Login Flow v2 hands out on the connected configuration. */ + public const val APP_PASSWORD: String = "mock-app-password" +} + +/** How one configuration routes, refuses and rewrites. */ +internal class Routes(private val configuration: MockServerConfiguration) { + private val nextcloud = configuration != MockServerConfiguration.Mastodon + + /** The rate-limited shape refuses the first read of every path, once, with `Retry-After: 1`. */ + fun refusal(method: String, path: String, seen: MutableSet): MockResponse? { + val throttled = configuration == MockServerConfiguration.RateLimited && method == "GET" && seen.add(path) + return if (throttled) { + MockResponse.Builder().code( + TOO_MANY, + ).addHeader("Retry-After", "1").body("""{"error":"Too many requests"}""").build() + } else { + null + } + } + + /** The path the corpus knows this request by, or null when the configuration has no such route. */ + fun resolvePath(path: String): String? { + if (configuration == MockServerConfiguration.CoreOnly && + EXTENSION_PREFIXES.any { path.contains(it) } + ) { + return null + } + val aliased = + nextcloud && configuration.servesApiAtRoot && (path.startsWith("/api/") || path.startsWith("/oauth/")) + return if (aliased) APP_PREFIX + path else path + } + + fun unauthorised(path: String, authorization: String?): Fixture? { + if (!nextcloud || PROTECTED.none { path.contains(it) }) return null + return when (authorization) { + null -> FixtureCorpus.nextcloudSocial.named("errors/401-no-token.json") + + "Bearer ${MockCredentials.REVOKED_TOKEN}" -> FixtureCorpus.nextcloudSocial.named( + "errors/401-bad-token.json", + ) + + else -> null + } + } + + /** Answers the corpus cannot hold: the sign-in routes of the Mastodon shape and revocation. */ + fun synthesized(method: String, url: HttpUrl, authorization: String?): Fixture? { + val path = resolvePath(url.encodedPath) ?: return null + return when { + method == "POST" && path.endsWith("/oauth/revoke") -> json(path, "{}") + configuration == MockServerConfiguration.NextcloudConnected -> loginFlow(method, path) + configuration == MockServerConfiguration.Mastodon -> mastodon(method, path, authorization) + else -> null + } + } + + /** The sign-in routes of the Mastodon shape, which the captured discovery documents do not include. */ + private fun mastodon(method: String, path: String, authorization: String?): Fixture? = when { + method == "POST" && path == "/api/v1/apps" -> json(path, REGISTRATION) + + method == "POST" && path == "/oauth/token" -> json(path, TOKEN) + + path == "/api/v1/accounts/verify_credentials" && authorization == null -> + json(path, """{"error":"The access token is invalid"}""", UNAUTHORISED) + + path == "/api/v1/accounts/verify_credentials" -> json(path, ACCOUNT) + + path.startsWith("/api/v1/timelines/") -> json(path, "[]") + + else -> null + } + + /** Login Flow v2 as Nextcloud answers it: start, then poll until the person approved in the browser. */ + private fun loginFlow(method: String, path: String): Fixture? = when { + method == "POST" && path == "/index.php/login/v2" -> json(path, LOGIN_FLOW_START) + method == "POST" && path == "/index.php/login/v2/poll" -> json(path, LOGIN_FLOW_RESULT) + else -> null + } + + /** The corpus answer as this configuration gives it. */ + fun adjust(fixture: Fixture, path: String): Fixture { + // corpus bodies are re-serialised compactly, so one spelling of each redaction covers them + val body = fixture.body + .replace("\"access_token\":\"REDACTED\"", "\"access_token\":\"${MockCredentials.ACCESS_TOKEN}\"") + .replace("\"client_id\":\"REDACTED\"", "\"client_id\":\"${MockCredentials.CLIENT_ID}\"") + .replace("\"client_secret\":\"REDACTED\"", "\"client_secret\":\"${MockCredentials.CLIENT_SECRET}\"") + val malformed = configuration == MockServerConfiguration.MalformedEntities && path.contains("/timelines/") + return fixture.copy(body = if (malformed) breakThirdEntry(body) else body) + } + + /** Replaces the third element with an entity that has no id and an account that is not an object. */ + private fun breakThirdEntry(body: String): String { + val array = Json.parseToJsonElement(body) as? JsonArray ?: return body + if (array.size < MALFORMED_INDEX + 1) return body + val broken = JsonObject( + mapOf( + "id" to kotlinx.serialization.json.JsonNull, + "account" to JsonPrimitive(MALFORMED_INDEX), + ), + ) + return JsonArray(array.toMutableList().apply { set(MALFORMED_INDEX, broken) }).toString() + } + + private fun json(path: String, body: String, status: Int = OK) = Fixture( + name = "synthesized$path", + method = "", + path = path, + query = emptyMap(), + status = status, + headers = mapOf("content-type" to "application/json; charset=utf-8"), + body = body, + ) + + private companion object { + const val OK = 200 + const val UNAUTHORISED = 401 + const val TOO_MANY = 429 + const val MALFORMED_INDEX = 2 + const val APP_PREFIX = "/index.php/apps/social" + + /** Routes that exist only on Nextcloud Social (or Pixelfed/PeerTube shapes it serves). */ + val EXTENSION_PREFIXES = listOf( + "/stories", "/collections", "/interests", "/videos/continue", "/watched", "/places", "/starter_packs", + "/highlights", "/reactions", "/discover/", "/api/v1.1/", "/api/v1.2/", "/api/v2/config", "/api/v1/config", + "/api/v2/notifications", "/timelines/interests", + ) + + /** Routes that need a viewer; the rest of the corpus answers anonymously. */ + val PROTECTED = listOf( + "/accounts/verify_credentials", "/timelines/home", "/timelines/list", "/notifications", "/bookmarks", + "/favourites", "/markers", "/conversations", "/lists", "/follow_requests", "/preferences", "/filters", + "/blocks", "/mutes", "/scheduled_statuses", "/oauth/userinfo", + ) + + const val LOGIN_FLOW_START = + """{"poll":{"token":"mock-poll-token","endpoint":"http://nextcloud.local/index.php/login/v2/poll"},""" + + """"login":"http://nextcloud.local/index.php/login/v2/flow/mock"}""" + + const val LOGIN_FLOW_RESULT = + """{"server":"http://nextcloud.local","loginName":"alice",""" + + """"appPassword":"${MockCredentials.APP_PASSWORD}"}""" + + const val REGISTRATION = + """{"id":"1","name":"Aloha Social","website":"https://aloha.social",""" + + """"scopes":["read","write","follow","push"],""" + + """"redirect_uris":["https://aloha.social/oauth/callback","alohasocial://oauth-callback"],""" + + """"client_id":"${MockCredentials.CLIENT_ID}","client_secret":"${MockCredentials.CLIENT_SECRET}"}""" + + const val TOKEN = + """{"access_token":"${MockCredentials.ACCESS_TOKEN}","token_type":"Bearer",""" + + """"scope":"read write follow push","created_at":1790000000}""" + + const val ACCOUNT = + """{"id":"109000000000000001","username":"tester","acct":"tester","display_name":"Test Person",""" + + """"locked":false,"bot":false,"created_at":"2026-01-01T00:00:00.000Z","note":"",""" + + """"url":"https://mastodon.social/@tester","avatar":"","avatar_static":"",""" + + """"header":"","header_static":"","followers_count":0,"following_count":0,"statuses_count":0,""" + + """"fields":[],"emojis":[],"source":{"privacy":"public","sensitive":false,"language":"en",""" + + """"note":"","fields":[],"follow_requests_count":0}}""" + } +} diff --git a/core/testing/src/main/resources/fixtures/mastodon-social-4.8.0-alpha.3/index.txt b/core/testing/src/main/resources/fixtures/mastodon-social-4.8.0-alpha.3/index.txt new file mode 100644 index 00000000..745f9c44 --- /dev/null +++ b/core/testing/src/main/resources/fixtures/mastodon-social-4.8.0-alpha.3/index.txt @@ -0,0 +1,5 @@ +instance-v1.json +instance-v2.json +nodeinfo-2.0.json +nodeinfo-links.json +oauth-authorization-server.json diff --git a/core/testing/src/main/resources/fixtures/mastodon-social-4.8.0-alpha.3/instance-v1.json b/core/testing/src/main/resources/fixtures/mastodon-social-4.8.0-alpha.3/instance-v1.json new file mode 100644 index 00000000..50f43004 --- /dev/null +++ b/core/testing/src/main/resources/fixtures/mastodon-social-4.8.0-alpha.3/instance-v1.json @@ -0,0 +1,317 @@ +{ + "request": { + "method": "GET", + "path": "/api/v1/instance" + }, + "status": 200, + "headers": { + "etag": "W/\"5d2d59b32ec28f9d5a35ac350b972799\"", + "cache-control": "max-age=300, public, stale-while-revalidate=30, stale-if-error=86400", + "content-type": "application/json; charset=utf-8" + }, + "body": { + "uri": "mastodon.social", + "title": "Mastodon", + "short_description": "The original server of Mastodon, operated by Mastodon GmbH for the common good.\r\n\r\n", + "description": "", + "email": "admin@example.invalid", + "version": "4.8.0-alpha.3+pr-40283-c9a1947", + "urls": { + "streaming_api": "wss://streaming.mastodon.social" + }, + "stats": { + "user_count": 3413975, + "status_count": 189949068, + "domain_count": 124325 + }, + "thumbnail": "https://files.mastodon.social/site_uploads/files/000/000/001/@1x/57c12f441d083cde.png", + "languages": [ + "en" + ], + "registrations": true, + "approval_required": false, + "invites_enabled": true, + "configuration": { + "accounts": { + "max_featured_tags": 10 + }, + "statuses": { + "max_characters": 500, + "max_media_attachments": 4, + "characters_reserved_per_url": 23 + }, + "media_attachments": { + "supported_mime_types": [ + "image/jpeg", + "image/png", + "image/gif", + "image/webp", + "video/webm", + "video/mp4", + "video/quicktime", + "video/ogg", + "audio/wave", + "audio/wav", + "audio/x-wav", + "audio/x-pn-wave", + "audio/vnd.wave", + "audio/ogg", + "audio/vorbis", + "audio/mpeg", + "audio/mp3", + "audio/webm", + "audio/flac", + "audio/aac", + "audio/m4a", + "audio/x-m4a", + "audio/mp4", + "audio/3gpp", + "video/x-ms-asf" + ], + "image_size_limit": 16777216, + "image_matrix_limit": 33177600, + "video_size_limit": 103809024, + "video_frame_rate_limit": 120, + "video_matrix_limit": 8294400 + }, + "polls": { + "max_options": 4, + "max_characters_per_option": 50, + "min_expiration": 300, + "max_expiration": 2629746 + } + }, + "contact_account": { + "id": "1", + "username": "admin", + "acct": "admin", + "display_name": "Instance admin", + "locked": false, + "bot": false, + "created_at": "2016-03-16T00:00:00.000Z", + "note": "", + "url": "https://mastodon.social/@admin", + "avatar": "", + "avatar_static": "", + "header": "", + "header_static": "", + "followers_count": 0, + "following_count": 0, + "statuses_count": 0, + "fields": [], + "emojis": [] + }, + "rules": [ + { + "id": "1", + "text": "Sexually explicit or violent media must be marked as sensitive or with a content warning", + "hint": "This includes content that is particularly provocative even if it may not show specific body parts, as well as dead bodies, bloody injuries, and other gore. Particularly obscene content may be prohibited entirely. Profile pictures and header images may not contain sexually explicit or violent media.", + "translations": { + "de": { + "text": "Sexuell explizite Darstellungen oder solche von Gewalt müssen mit einer Inhaltswarnung versehen werden.", + "hint": "Das beinhaltet ausdrücklich provokative Inhalte, auch wenn keine bestimmten Körperteile zu sehen sind, sowie Leichname, blutige Verletzungen und vergleichbare extreme Darstellungen. Besonders obszöne Inhalte können vollständig verboten werden. Profil- und Titelbilder dürfen keine sexuell expliziten oder Gewaltdarstellungen abbilden." + }, + "es": { + "text": "El contenido sexual explícito o violento debe marcarse como sensible o con una advertencia de contenido.", + "hint": "Esto incluye contenido especialmente provocativo, aunque no muestre partes específicas del cuerpo, así como cadáveres, heridas sangrientas y otras escenas sangrientas. Los contenidos especialmente obscenos pueden prohibirse por completo. Las fotos de perfil y las imágenes de cabecera no pueden contener contenido sexual explícito o violento." + }, + "fr": { + "text": "Le contenu érotique, pornographique ou violent doit être marqué comme sensible ou avoir un avertissement de contenu.", + "hint": "Cela inclut le contenu particulièrement provocateur même s’il ne montre pas particulièrement de parties intimes, ainsi que les photos de cadavres, de blessures sanglantes et autres scènes dérangeantes. Le contenu particulièrement obscène peut être complètement interdit. Les images de profil et d’en-tête ne doivent pas contenir de contenu sexuellement explicite ou violent." + }, + "nl": { + "text": "Seksueel expliciet of gewelddadig beeldmateriaal moet als ‘gevoelig’ gemarkeerd worden, of met een inhoudswaarschuwing gemarkeerd.", + "hint": "Dit omvat ook inhoud die gedeeltelijk aanstootgevend is zelfs als het geen lichaamsdelen toont, en ook dode lichamen, bloederige verwondingen en andere gruwelijkheden. Zeer obscene inhoud kan helemaal verboden worden. Profielfoto’s en headerafbeeldingen mogen geen seksueel expliciete of geweldadige inhoud hebben." + }, + "ru": { + "text": "Сексуально откровенные или жестокие изображения или видео должны быть помечены как чувствительные или скрыты под спойлер.", + "hint": "Это включает в себя особенно провоцирующий контент, даже если он не показывает определённые части тела, а также мёртвые тела, окровавленные раны и прочую кровь. Особенно жестокий контент может быть запрещён полностью. Аватары и обложки профилей не должны содержать сексуально откровенные или жестокие изображения." + }, + "zh-CN": { + "text": "色情或暴力媒体必须标记为敏感或有内容警告", + "hint": "这包括特别挑逗性的内容,即使它可能没有显示特定的身体部位,以及尸体、血腥伤害和其他血腥内容。特别淫秽的内容可能会被完全禁止。个人资料图片和头像不得包含色情或暴力内容。" + }, + "zh-TW": { + "text": "性暗示或暴力媒體必須標示為敏感或附帶內容警示", + "hint": "這包括特別挑釁的內容,即使它可能沒有顯示特定的身體部位,以及屍體、血腥傷害和其他血腥內容。特別猥褻的內容可能會被完全禁止。個人檔案圖片和標題圖片不得包含色情或暴力媒體。" + } + } + }, + { + "id": "2", + "text": "No racism, sexism, homophobia, transphobia, ableism, xenophobia, or casteism.", + "hint": "Transphobic behavior such as intentional misgendering and deadnaming is strictly prohibited. Promotion of \"conversion therapy\" is strictly prohibited. Criticism of governments and religions is permissible unless being used as a proxy for discrimination.", + "translations": { + "de": { + "text": "Kein(e) Rassismus, Sexismus, Homophobie, Transphobie, Behindertenfeindlichkeit, Fremdenfeindlichkeit oder Kasteismus.", + "hint": "Transphobes Verhalten wie die absichtliche Verwendung von falschen Anreden und Pronomen sowie Deadnaming ist strengstens verboten. Die Werbung für Konversionstherapien ist strengstens verboten. Die Kritik an Regierungen und Religionen ist zulässig, sofern sie nicht stellvertretend für Diskriminierung verwendet wird." + }, + "es": { + "text": "Prohibido el racismo, sexismo, homofobia, transfobia, ableísmo, xenofobia o casticismo.", + "hint": "Queda terminantemente prohibido el comportamiento transfóbico, como el uso intencionado de nombres falsos o el \"deadnaming\". La promoción de la \"terapia de conversión\" está estrictamente prohibida. Se permiten las críticas a gobiernos y religiones, a menos que se utilicen como excusa para la discriminación." + }, + "fr": { + "text": "Pas de racisme, sexisme, homophobie, transphobie, validisme, xénophobie ou discrimination de caste.", + "hint": "Les comportements intentionnellement transphobes, comme le mégenrage ou le morinommage (“deadnaming”) est strictement interdit. La promotion des “thérapies de conversion” est strictement interdite. La critique des gouvernements et religions est autorisée, sauf dans les cas ou elle est utilisée comme paravent pour de la discrimination." + }, + "nl": { + "text": "Geen racisme, seksisme, homofobie, transfobie, validisme, xenofobie of kastendiscriminatie.", + "hint": "Transfobisch gedrag zoals opzettelijk misgenderen en deadnaming is ten strengste verboden. Het promoten van “conversie therapie” is ten strengste verboden. Kritiek op overheden en religies is toegestaan tenzij het wordt gebruikt als proxy voor racisme." + }, + "ru": { + "text": "Запрещается расизм, сексизм, гомофобия, трансфобия, эйблизм, ксенофобия и кастовость.", + "hint": "Трансфобное поведение, такое как осознанный мисгендеринг и деднейминг, строго запрещено. Продвижение \"конверсионной терапии\" строго запрещено. Критика правительств и религий разрешена, кроме случаев, когда она используется как оправдание для дискриминации." + }, + "zh-CN": { + "text": "禁止种族主义、性别歧视、仇视同性恋、仇视变性者、异能者、仇外心理或种姓歧视。", + "hint": "严禁变性仇视行为,如故意误用性别和死名。严禁宣传 “转换疗法”。允许批评政府和宗教,除非被用作歧视的代名词。" + }, + "zh-TW": { + "text": "禁止種族主義、性別主義、恐同主義、恐變性主義、能力主義、仇外主義或種性主義。", + "hint": "嚴禁變性仇視行為,例如故意誤用性別和暱稱。嚴禁宣傳「扭轉治療」。允許批評政府和宗教,除非被用來代表歧視。" + } + } + }, + { + "id": "3", + "text": "No incitement of violence or promotion of violent ideologies", + "hint": "Calling for people or groups to be assassinated, murdered, or attacked physically is strictly prohibited. Support for violent groups or events is prohibited.", + "translations": { + "de": { + "text": "Keine Anstiftung zu Gewalt oder Förderung gewalttätiger Ideologien.", + "hint": "Der Aufruf zum Mord oder körperlichen Attacken auf Personen oder Gruppen ist strengstens verboten. Die Unterstützung gewalttätiger Gruppen oder Veranstaltungen ist verboten." + }, + "es": { + "text": "Prohibida la incitación a la violencia o la promoción de ideologías violentas.", + "hint": "Está estrictamente prohibido hacer llamamientos para que se asesine, mate o agreda físicamente a personas o grupos. Está prohibido apoyar a grupos o acontecimientos violentos." + }, + "fr": { + "text": "Pas d’incitation à la violence ou de promotion d’idéologies violentes.", + "hint": "L’appel au meutre, à la violence ou à l’action physique envers des personnes ou des groupes de personnes est strictement interdit. Le soutien à des groupes ou évènements violents est interdit." + }, + "nl": { + "text": "Niet aanzetten tot geweld of verspreiden van gewelddadige ideologieën.", + "hint": "Mensen of groepen aanzetten tot moord, aanslagen of fysieke aanvallen is ten strengste verboden. Steun voor gewelddadige groeperingen of evenementen is verboden." + }, + "ru": { + "text": "Запрещается подстрекательство к жестокости или продвижение жестоких идеологий.", + "hint": "Призывы к покушениям, убийствам и физическим атакам на людей или группы людей строго запрещены. Поддержка жестоких сообществ или событий запрещена." + }, + "zh-CN": { + "text": "不得煽动暴力或宣扬暴力意识形态", + "hint": "严禁呼吁暗杀、谋杀或人身攻击他人或团体。禁止支持暴力团体或事件。" + }, + "zh-TW": { + "text": "嚴禁煽動暴力或宣揚暴力意識形態", + "hint": "嚴禁呼籲暗殺、謀殺或人身攻擊他人或團體。嚴禁支持暴力團體或事件。" + } + } + }, + { + "id": "4", + "text": "No harassment, block evasion, dogpiling, or doxxing of others", + "hint": "Repeat attempts to communicate with users who have blocked you or creation of accounts solely to harass or insult individuals is strictly prohibited. Coordinated activity to attack other users is prohibited. Posting of private personal information about others is prohibited.", + "translations": { + "de": { + "text": "Keine Belästigungen, Umgehung von Blockierungen, Dogpiling oder Doxxing.", + "hint": "Wiederholte Kommunikationsversuche mit Profilen, die dich blockiert haben, oder die Erstellung neuer Konten, die ausschließlich dazu dienen, andere zu belästigen oder zu beleidigen, sind strengstens verboten. Koordinierte Angriffe auf andere Profile sind verboten. Die Veröffentlichung von privaten und persönlichen Informationen anderer ist verboten." + }, + "es": { + "text": "No acosar, evadir bloqueos, \"dogpiling\" (abuso en línea por grupos de acosadores) o doxeo de otros.", + "hint": "Está estrictamente prohibido intentar comunicarse repetidamente con usuarios que te hayan bloqueado o crear cuentas con el único fin de acosar o insultar a otras personas. Queda prohibida la actividad coordinada para atacar a otros usuarios. Está prohibido publicar información personal privada sobre otras personas." + }, + "fr": { + "text": "Pas de harcèlement, d’esquive de blocage, de “dogpiling”, ou de divulgation de données personnelles d’autrui.", + "hint": "Les tentatives répétées de communiquer avec des utilisateur·ices qui vont ont bloqué ainsi que la création de comptes pour harceler ou insulter autrui sont strictement interdites. Les attaques coordonnées contre autrui sont interdites. La publication de données personnelles d’autrui est interdite." + }, + "nl": { + "text": "Geen intimidatie, omzeilen van blokkades, groepsaanvallen of het verspreiden van privégegevens van anderen.", + "hint": "Herhaalde pogingen tot communiceren met gebruikers die je hebben geblokkeerd, of het maken van accounts enkel om personen te intimideren of beledigen is ten strengste verboden. Gecoördineerde activiteit om andere gebruikers aan te vallen is verboden. Het plaatsen van privé-gegevens van anderen is verboden." + }, + "ru": { + "text": "Запрещается травля, избегание блокировок, набеги и разглашение личной информации других лиц.", + "hint": "Повторяющиеся попытки взаимодействовать с пользователями, которые вас заблокировали, и создание учётных записей исключительно ради травли или оскорблений, строго запрещены. Скоординированная активность с целью атаки на других пользователей запрещена. Публикация личной информации других лиц запрещена." + }, + "zh-CN": { + "text": "不得骚扰、回避封禁、诽谤或攻击他人", + "hint": "严禁重复尝试与已封禁您的用户交流,或仅为骚扰或侮辱他人而创建账户。严禁协同攻击其他用户。禁止发布他人的私人个人信息。" + }, + "zh-TW": { + "text": "嚴禁騷擾、迴避封鎖、煽動或誣衊他人", + "hint": "嚴禁重複嘗試與已封鎖您的使用者溝通,或純粹為了騷擾或侮辱他人而建立帳號。禁止攻擊其他使用者的協調活動。禁止張貼他人的私人個人資訊。" + } + } + }, + { + "id": "7", + "text": "Do not share information widely-known to be false and misleading", + "hint": "False and misleading information and links from low-quality sources may not be posted, especially if they are likely to mislead or confuse others or endanger their safety.", + "translations": { + "de": { + "text": "Verbreitet keine Informationen, von denen allgemein bekannt ist, dass sie falsch oder irreführend.", + "hint": "Falsche und irreführende Informationen sowie Links aus unseriösen Quellen dürfen nicht gepostet werden, insbesondere wenn sie geeignet sind, andere in die Irre zu führen oder deren Sicherheit zu gefährden." + }, + "es": { + "text": "No compartas información ampliamente conocida por ser falsa y engañosa.", + "hint": "No está permitido publicar información falsa y engañosa ni enlaces de fuentes de baja calidad, especialmente si pueden inducir a error o confusión a otras personas o poner en peligro su seguridad." + }, + "fr": { + "text": "Ne partagez pas d’informations connues pour être fausses ou trompeuses.", + "hint": "Les informations fausses ou trompeuses, ainsi que les liens vers des sources de mauvaise qualité ne doivent pas être publiées, particulièrement si elles risquent de tromper, d’induire en erreur, de semer la confusion ou de mettre des personnes en danger." + }, + "nl": { + "text": "Deel geen informatie die algemeen bekend staat als onjuist en misleidend.", + "hint": "Onjuiste en misleidende informatie en links naar onbetrouwbare bronnen mogen niet geplaatst worden, zeker als deze waarschijnlijk anderen misleiden of verwarren, of hun veiligheid in gevaar brengt." + }, + "ru": { + "text": "Не публикуйте заведомо ложную и вводящую в заблуждение информацию.", + "hint": "Запрещена публикация ложной и вводящей в заблуждение информации и ссылок из низкокачественных источников, особенно если это может сбить других людей с толку, запутать их, или подвергнуть их опасности." + }, + "zh-CN": { + "text": "不得分享众所周知的虚假和误导性信息\r\n", + "hint": "不得发布虚假和误导性信息以及来自低质量来源的链接,尤其是可能误导或混淆他人或危及他人安全的信息。" + }, + "zh-TW": { + "text": "請勿分享廣為人知的虛假和誤導資訊", + "hint": "不得張貼虛假和誤導資訊以及來自低品質來源的連結,尤其是可能誤導或混淆他人或危害他人安全的資訊。" + } + } + }, + { + "id": "1008", + "text": "Content created by others must be attributed, and use of generative AI must be disclosed", + "hint": "Content created by others must clearly provide a reference to the author, creator, or source. For adult content, this should include performers. Accounts may not solely post AI-generated content.", + "translations": { + "de": { + "text": "Von anderen erstellte Inhalte müssen gekennzeichnet und die Nutzung generativer KI offengelegt werden.", + "hint": "Von anderen erstellte Inhalte müssen durch einen eindeutigen Verweis auf den/die Autor*in oder die Quelle gekennzeichnet werden. Bei nicht jugendfreien Inhalten sollte dies auch die Darsteller*innen einschließen. Konten dürfen nicht ausschließlich KI-generierte Inhalte posten." + }, + "es": { + "text": "Los contenidos creados por terceros deben ser atribuidos y el uso de IA debe ser revelado.", + "hint": "Los contenidos creados por terceros deben proporcionar claramente una referencia al autor, creador o fuente. En el caso de los contenidos para adultos, debe incluirse a los artistas. Las cuentas no pueden publicar únicamente contenidos generados por IA." + }, + "fr": { + "text": "Le contenu crée par autrui doit être attribué, et l’utilisation d’IA générative doit être marqué comme tel.", + "hint": "Le contenu créé par autrui doit fournir une référence explicite à son auteur·ice, créateur·ice ou sa source. Cela inclut les acteur·ice de contenu pour adulte. Les comptes publiant exclusivement du contenu généré par IA sont interdits." + }, + "nl": { + "text": "Inhoud gemaakt door anderen moet worden toegeschreven, en het gebruik van generatieve AI moet worden aangegeven.", + "hint": "Inhoud gemaakt door anderen moet een duidelijke referentie naar de auteur, maker of bron bevatten. Voor Content created by others must clearly provide a reference to the author, creator, or source. Voor inhoud voor volwassenen, moet het model worden vermeld. Accounts mogen niet uitsluitend door AI gegenereerde inhoud plaatsen." + }, + "ru": { + "text": "Контент, созданный другими, должен публиковаться с указанием авторства, а использование генеративного ИИ должно быть раскрыто.", + "hint": "Контент, созданный другими, должен явным образом сопровождаться отсылкой к автору, создателю или источнику. Для контента для взрослых это включает в себя актёров. Учётные записи не могут публиковать исключительно контент, сгенерированный с помощью ИИ." + }, + "zh-CN": { + "text": "他人创建的内容必须注明出处,使用人工智能产生的内容必须公开和披露说明", + "hint": "他人创建的内容必须明确注明作者、创建者或来源。对于成人内容,应包括表演者。账户不得仅发布人工智能生成的内容。" + }, + "zh-TW": { + "text": "由他人創作的內容必須註明來源,使用人工智能產生的內容必須公開和披露說明", + "hint": "他人創作的內容必須清楚提供作者、創作人或來源的參照。對於成人內容,這應該包括表演者。帳戶不得僅發佈人工智能產生的內容。" + } + } + } + ] + } +} diff --git a/core/testing/src/main/resources/fixtures/mastodon-social-4.8.0-alpha.3/instance-v2.json b/core/testing/src/main/resources/fixtures/mastodon-social-4.8.0-alpha.3/instance-v2.json new file mode 100644 index 00000000..bec5b505 --- /dev/null +++ b/core/testing/src/main/resources/fixtures/mastodon-social-4.8.0-alpha.3/instance-v2.json @@ -0,0 +1,408 @@ +{ + "request": { + "method": "GET", + "path": "/api/v2/instance" + }, + "status": 200, + "headers": { + "content-type": "application/json; charset=utf-8", + "cache-control": "max-age=300, public, stale-while-revalidate=30, stale-if-error=86400", + "etag": "W/\"bc6086ea6cbc4c56bde142ba9a627d2b\"" + }, + "body": { + "domain": "mastodon.social", + "title": "Mastodon", + "version": "4.8.0-alpha.3+pr-40283-c9a1947", + "source_url": "https://github.com/mastodon/mastodon", + "description": "The original server of Mastodon, operated by Mastodon GmbH for the common good.\r\n\r\n", + "usage": { + "users": { + "active_month": 268274 + } + }, + "thumbnail": { + "url": "https://files.mastodon.social/site_uploads/files/000/000/001/@1x/57c12f441d083cde.png", + "blurhash": "UeKUpFxuo~R%0nW;WCnhF6RjaJt757oJodS$", + "versions": { + "@1x": "https://files.mastodon.social/site_uploads/files/000/000/001/@1x/57c12f441d083cde.png", + "@2x": "https://files.mastodon.social/site_uploads/files/000/000/001/@2x/57c12f441d083cde.png" + }, + "description": "Colourful illustration of a Mastodon (a sort of elephant) carrying a bindle and joining a group of other Mastodons. The other Mastodons have many different colours and are holding up a Welcome sign." + }, + "icon": [ + { + "src": "https://mastodon.social/packs/assets/android-chrome-36x36-DLiBQg3N.png", + "size": "36x36" + }, + { + "src": "https://mastodon.social/packs/assets/android-chrome-48x48-C7lKWFwX.png", + "size": "48x48" + }, + { + "src": "https://mastodon.social/packs/assets/android-chrome-72x72-9LRpA3QN.png", + "size": "72x72" + }, + { + "src": "https://mastodon.social/packs/assets/android-chrome-96x96-BKKwkkY-.png", + "size": "96x96" + }, + { + "src": "https://mastodon.social/packs/assets/android-chrome-144x144-D-ewI-KZ.png", + "size": "144x144" + }, + { + "src": "https://mastodon.social/packs/assets/android-chrome-192x192-jYKJbpas.png", + "size": "192x192" + }, + { + "src": "https://mastodon.social/packs/assets/android-chrome-256x256-DXt2vsq7.png", + "size": "256x256" + }, + { + "src": "https://mastodon.social/packs/assets/android-chrome-384x384-CbK7cG33.png", + "size": "384x384" + }, + { + "src": "https://mastodon.social/packs/assets/android-chrome-512x512-Dz2ThkhV.png", + "size": "512x512" + } + ], + "languages": [ + "en" + ], + "configuration": { + "urls": { + "streaming": "wss://streaming.mastodon.social", + "status": "https://status.mastodon.social", + "about": "https://mastodon.social/about", + "privacy_policy": "https://mastodon.social/privacy-policy", + "terms_of_service": "https://mastodon.social/terms-of-service" + }, + "vapid": { + "public_key": "BCk-QqERU0q-CfYZjcuB6lnyyOYfJ2AifKqfeGIm7Z-HiTU5T9eTG5GxVA0_OH5mMlI4UkkDTpaZwozy0TzdZ2M=" + }, + "accounts": { + "max_display_name_length": 40, + "max_note_length": 500, + "max_avatar_description_length": 150, + "max_header_description_length": 150, + "max_featured_tags": 10, + "max_pinned_statuses": 5, + "max_profile_fields": 4, + "profile_field_name_limit": 255, + "profile_field_value_limit": 255 + }, + "statuses": { + "max_characters": 500, + "max_media_attachments": 4, + "characters_reserved_per_url": 23 + }, + "media_attachments": { + "description_limit": 10000, + "image_matrix_limit": 33177600, + "image_size_limit": 16777216, + "supported_mime_types": [ + "image/jpeg", + "image/png", + "image/gif", + "image/webp", + "video/webm", + "video/mp4", + "video/quicktime", + "video/ogg", + "audio/wave", + "audio/wav", + "audio/x-wav", + "audio/x-pn-wave", + "audio/vnd.wave", + "audio/ogg", + "audio/vorbis", + "audio/mpeg", + "audio/mp3", + "audio/webm", + "audio/flac", + "audio/aac", + "audio/m4a", + "audio/x-m4a", + "audio/mp4", + "audio/3gpp", + "video/x-ms-asf" + ], + "video_frame_rate_limit": 120, + "video_matrix_limit": 8294400, + "video_size_limit": 103809024 + }, + "polls": { + "max_options": 4, + "max_characters_per_option": 50, + "min_expiration": 300, + "max_expiration": 2629746 + }, + "translation": { + "enabled": true + }, + "timelines_access": { + "live_feeds": { + "local": "disabled", + "remote": "disabled" + }, + "hashtag_feeds": { + "local": "public", + "remote": "public" + }, + "trending_link_feeds": { + "local": "public", + "remote": "public" + } + }, + "limited_federation": false + }, + "registrations": { + "enabled": true, + "approval_required": false, + "reason_required": false, + "message": null, + "min_age": 16, + "url": null + }, + "api_versions": { + "mastodon": 11 + }, + "wrapstodon": null, + "contact": { + "email": "admin@example.invalid", + "account": { + "id": "1", + "username": "admin", + "acct": "admin", + "display_name": "Instance admin", + "locked": false, + "bot": false, + "created_at": "2016-03-16T00:00:00.000Z", + "note": "", + "url": "https://mastodon.social/@admin", + "avatar": "", + "avatar_static": "", + "header": "", + "header_static": "", + "followers_count": 0, + "following_count": 0, + "statuses_count": 0, + "fields": [], + "emojis": [] + } + }, + "rules": [ + { + "id": "1", + "text": "Sexually explicit or violent media must be marked as sensitive or with a content warning", + "hint": "This includes content that is particularly provocative even if it may not show specific body parts, as well as dead bodies, bloody injuries, and other gore. Particularly obscene content may be prohibited entirely. Profile pictures and header images may not contain sexually explicit or violent media.", + "translations": { + "de": { + "text": "Sexuell explizite Darstellungen oder solche von Gewalt müssen mit einer Inhaltswarnung versehen werden.", + "hint": "Das beinhaltet ausdrücklich provokative Inhalte, auch wenn keine bestimmten Körperteile zu sehen sind, sowie Leichname, blutige Verletzungen und vergleichbare extreme Darstellungen. Besonders obszöne Inhalte können vollständig verboten werden. Profil- und Titelbilder dürfen keine sexuell expliziten oder Gewaltdarstellungen abbilden." + }, + "es": { + "text": "El contenido sexual explícito o violento debe marcarse como sensible o con una advertencia de contenido.", + "hint": "Esto incluye contenido especialmente provocativo, aunque no muestre partes específicas del cuerpo, así como cadáveres, heridas sangrientas y otras escenas sangrientas. Los contenidos especialmente obscenos pueden prohibirse por completo. Las fotos de perfil y las imágenes de cabecera no pueden contener contenido sexual explícito o violento." + }, + "fr": { + "text": "Le contenu érotique, pornographique ou violent doit être marqué comme sensible ou avoir un avertissement de contenu.", + "hint": "Cela inclut le contenu particulièrement provocateur même s’il ne montre pas particulièrement de parties intimes, ainsi que les photos de cadavres, de blessures sanglantes et autres scènes dérangeantes. Le contenu particulièrement obscène peut être complètement interdit. Les images de profil et d’en-tête ne doivent pas contenir de contenu sexuellement explicite ou violent." + }, + "nl": { + "text": "Seksueel expliciet of gewelddadig beeldmateriaal moet als ‘gevoelig’ gemarkeerd worden, of met een inhoudswaarschuwing gemarkeerd.", + "hint": "Dit omvat ook inhoud die gedeeltelijk aanstootgevend is zelfs als het geen lichaamsdelen toont, en ook dode lichamen, bloederige verwondingen en andere gruwelijkheden. Zeer obscene inhoud kan helemaal verboden worden. Profielfoto’s en headerafbeeldingen mogen geen seksueel expliciete of geweldadige inhoud hebben." + }, + "ru": { + "text": "Сексуально откровенные или жестокие изображения или видео должны быть помечены как чувствительные или скрыты под спойлер.", + "hint": "Это включает в себя особенно провоцирующий контент, даже если он не показывает определённые части тела, а также мёртвые тела, окровавленные раны и прочую кровь. Особенно жестокий контент может быть запрещён полностью. Аватары и обложки профилей не должны содержать сексуально откровенные или жестокие изображения." + }, + "zh-CN": { + "text": "色情或暴力媒体必须标记为敏感或有内容警告", + "hint": "这包括特别挑逗性的内容,即使它可能没有显示特定的身体部位,以及尸体、血腥伤害和其他血腥内容。特别淫秽的内容可能会被完全禁止。个人资料图片和头像不得包含色情或暴力内容。" + }, + "zh-TW": { + "text": "性暗示或暴力媒體必須標示為敏感或附帶內容警示", + "hint": "這包括特別挑釁的內容,即使它可能沒有顯示特定的身體部位,以及屍體、血腥傷害和其他血腥內容。特別猥褻的內容可能會被完全禁止。個人檔案圖片和標題圖片不得包含色情或暴力媒體。" + } + } + }, + { + "id": "2", + "text": "No racism, sexism, homophobia, transphobia, ableism, xenophobia, or casteism.", + "hint": "Transphobic behavior such as intentional misgendering and deadnaming is strictly prohibited. Promotion of \"conversion therapy\" is strictly prohibited. Criticism of governments and religions is permissible unless being used as a proxy for discrimination.", + "translations": { + "de": { + "text": "Kein(e) Rassismus, Sexismus, Homophobie, Transphobie, Behindertenfeindlichkeit, Fremdenfeindlichkeit oder Kasteismus.", + "hint": "Transphobes Verhalten wie die absichtliche Verwendung von falschen Anreden und Pronomen sowie Deadnaming ist strengstens verboten. Die Werbung für Konversionstherapien ist strengstens verboten. Die Kritik an Regierungen und Religionen ist zulässig, sofern sie nicht stellvertretend für Diskriminierung verwendet wird." + }, + "es": { + "text": "Prohibido el racismo, sexismo, homofobia, transfobia, ableísmo, xenofobia o casticismo.", + "hint": "Queda terminantemente prohibido el comportamiento transfóbico, como el uso intencionado de nombres falsos o el \"deadnaming\". La promoción de la \"terapia de conversión\" está estrictamente prohibida. Se permiten las críticas a gobiernos y religiones, a menos que se utilicen como excusa para la discriminación." + }, + "fr": { + "text": "Pas de racisme, sexisme, homophobie, transphobie, validisme, xénophobie ou discrimination de caste.", + "hint": "Les comportements intentionnellement transphobes, comme le mégenrage ou le morinommage (“deadnaming”) est strictement interdit. La promotion des “thérapies de conversion” est strictement interdite. La critique des gouvernements et religions est autorisée, sauf dans les cas ou elle est utilisée comme paravent pour de la discrimination." + }, + "nl": { + "text": "Geen racisme, seksisme, homofobie, transfobie, validisme, xenofobie of kastendiscriminatie.", + "hint": "Transfobisch gedrag zoals opzettelijk misgenderen en deadnaming is ten strengste verboden. Het promoten van “conversie therapie” is ten strengste verboden. Kritiek op overheden en religies is toegestaan tenzij het wordt gebruikt als proxy voor racisme." + }, + "ru": { + "text": "Запрещается расизм, сексизм, гомофобия, трансфобия, эйблизм, ксенофобия и кастовость.", + "hint": "Трансфобное поведение, такое как осознанный мисгендеринг и деднейминг, строго запрещено. Продвижение \"конверсионной терапии\" строго запрещено. Критика правительств и религий разрешена, кроме случаев, когда она используется как оправдание для дискриминации." + }, + "zh-CN": { + "text": "禁止种族主义、性别歧视、仇视同性恋、仇视变性者、异能者、仇外心理或种姓歧视。", + "hint": "严禁变性仇视行为,如故意误用性别和死名。严禁宣传 “转换疗法”。允许批评政府和宗教,除非被用作歧视的代名词。" + }, + "zh-TW": { + "text": "禁止種族主義、性別主義、恐同主義、恐變性主義、能力主義、仇外主義或種性主義。", + "hint": "嚴禁變性仇視行為,例如故意誤用性別和暱稱。嚴禁宣傳「扭轉治療」。允許批評政府和宗教,除非被用來代表歧視。" + } + } + }, + { + "id": "3", + "text": "No incitement of violence or promotion of violent ideologies", + "hint": "Calling for people or groups to be assassinated, murdered, or attacked physically is strictly prohibited. Support for violent groups or events is prohibited.", + "translations": { + "de": { + "text": "Keine Anstiftung zu Gewalt oder Förderung gewalttätiger Ideologien.", + "hint": "Der Aufruf zum Mord oder körperlichen Attacken auf Personen oder Gruppen ist strengstens verboten. Die Unterstützung gewalttätiger Gruppen oder Veranstaltungen ist verboten." + }, + "es": { + "text": "Prohibida la incitación a la violencia o la promoción de ideologías violentas.", + "hint": "Está estrictamente prohibido hacer llamamientos para que se asesine, mate o agreda físicamente a personas o grupos. Está prohibido apoyar a grupos o acontecimientos violentos." + }, + "fr": { + "text": "Pas d’incitation à la violence ou de promotion d’idéologies violentes.", + "hint": "L’appel au meutre, à la violence ou à l’action physique envers des personnes ou des groupes de personnes est strictement interdit. Le soutien à des groupes ou évènements violents est interdit." + }, + "nl": { + "text": "Niet aanzetten tot geweld of verspreiden van gewelddadige ideologieën.", + "hint": "Mensen of groepen aanzetten tot moord, aanslagen of fysieke aanvallen is ten strengste verboden. Steun voor gewelddadige groeperingen of evenementen is verboden." + }, + "ru": { + "text": "Запрещается подстрекательство к жестокости или продвижение жестоких идеологий.", + "hint": "Призывы к покушениям, убийствам и физическим атакам на людей или группы людей строго запрещены. Поддержка жестоких сообществ или событий запрещена." + }, + "zh-CN": { + "text": "不得煽动暴力或宣扬暴力意识形态", + "hint": "严禁呼吁暗杀、谋杀或人身攻击他人或团体。禁止支持暴力团体或事件。" + }, + "zh-TW": { + "text": "嚴禁煽動暴力或宣揚暴力意識形態", + "hint": "嚴禁呼籲暗殺、謀殺或人身攻擊他人或團體。嚴禁支持暴力團體或事件。" + } + } + }, + { + "id": "4", + "text": "No harassment, block evasion, dogpiling, or doxxing of others", + "hint": "Repeat attempts to communicate with users who have blocked you or creation of accounts solely to harass or insult individuals is strictly prohibited. Coordinated activity to attack other users is prohibited. Posting of private personal information about others is prohibited.", + "translations": { + "de": { + "text": "Keine Belästigungen, Umgehung von Blockierungen, Dogpiling oder Doxxing.", + "hint": "Wiederholte Kommunikationsversuche mit Profilen, die dich blockiert haben, oder die Erstellung neuer Konten, die ausschließlich dazu dienen, andere zu belästigen oder zu beleidigen, sind strengstens verboten. Koordinierte Angriffe auf andere Profile sind verboten. Die Veröffentlichung von privaten und persönlichen Informationen anderer ist verboten." + }, + "es": { + "text": "No acosar, evadir bloqueos, \"dogpiling\" (abuso en línea por grupos de acosadores) o doxeo de otros.", + "hint": "Está estrictamente prohibido intentar comunicarse repetidamente con usuarios que te hayan bloqueado o crear cuentas con el único fin de acosar o insultar a otras personas. Queda prohibida la actividad coordinada para atacar a otros usuarios. Está prohibido publicar información personal privada sobre otras personas." + }, + "fr": { + "text": "Pas de harcèlement, d’esquive de blocage, de “dogpiling”, ou de divulgation de données personnelles d’autrui.", + "hint": "Les tentatives répétées de communiquer avec des utilisateur·ices qui vont ont bloqué ainsi que la création de comptes pour harceler ou insulter autrui sont strictement interdites. Les attaques coordonnées contre autrui sont interdites. La publication de données personnelles d’autrui est interdite." + }, + "nl": { + "text": "Geen intimidatie, omzeilen van blokkades, groepsaanvallen of het verspreiden van privégegevens van anderen.", + "hint": "Herhaalde pogingen tot communiceren met gebruikers die je hebben geblokkeerd, of het maken van accounts enkel om personen te intimideren of beledigen is ten strengste verboden. Gecoördineerde activiteit om andere gebruikers aan te vallen is verboden. Het plaatsen van privé-gegevens van anderen is verboden." + }, + "ru": { + "text": "Запрещается травля, избегание блокировок, набеги и разглашение личной информации других лиц.", + "hint": "Повторяющиеся попытки взаимодействовать с пользователями, которые вас заблокировали, и создание учётных записей исключительно ради травли или оскорблений, строго запрещены. Скоординированная активность с целью атаки на других пользователей запрещена. Публикация личной информации других лиц запрещена." + }, + "zh-CN": { + "text": "不得骚扰、回避封禁、诽谤或攻击他人", + "hint": "严禁重复尝试与已封禁您的用户交流,或仅为骚扰或侮辱他人而创建账户。严禁协同攻击其他用户。禁止发布他人的私人个人信息。" + }, + "zh-TW": { + "text": "嚴禁騷擾、迴避封鎖、煽動或誣衊他人", + "hint": "嚴禁重複嘗試與已封鎖您的使用者溝通,或純粹為了騷擾或侮辱他人而建立帳號。禁止攻擊其他使用者的協調活動。禁止張貼他人的私人個人資訊。" + } + } + }, + { + "id": "7", + "text": "Do not share information widely-known to be false and misleading", + "hint": "False and misleading information and links from low-quality sources may not be posted, especially if they are likely to mislead or confuse others or endanger their safety.", + "translations": { + "de": { + "text": "Verbreitet keine Informationen, von denen allgemein bekannt ist, dass sie falsch oder irreführend.", + "hint": "Falsche und irreführende Informationen sowie Links aus unseriösen Quellen dürfen nicht gepostet werden, insbesondere wenn sie geeignet sind, andere in die Irre zu führen oder deren Sicherheit zu gefährden." + }, + "es": { + "text": "No compartas información ampliamente conocida por ser falsa y engañosa.", + "hint": "No está permitido publicar información falsa y engañosa ni enlaces de fuentes de baja calidad, especialmente si pueden inducir a error o confusión a otras personas o poner en peligro su seguridad." + }, + "fr": { + "text": "Ne partagez pas d’informations connues pour être fausses ou trompeuses.", + "hint": "Les informations fausses ou trompeuses, ainsi que les liens vers des sources de mauvaise qualité ne doivent pas être publiées, particulièrement si elles risquent de tromper, d’induire en erreur, de semer la confusion ou de mettre des personnes en danger." + }, + "nl": { + "text": "Deel geen informatie die algemeen bekend staat als onjuist en misleidend.", + "hint": "Onjuiste en misleidende informatie en links naar onbetrouwbare bronnen mogen niet geplaatst worden, zeker als deze waarschijnlijk anderen misleiden of verwarren, of hun veiligheid in gevaar brengt." + }, + "ru": { + "text": "Не публикуйте заведомо ложную и вводящую в заблуждение информацию.", + "hint": "Запрещена публикация ложной и вводящей в заблуждение информации и ссылок из низкокачественных источников, особенно если это может сбить других людей с толку, запутать их, или подвергнуть их опасности." + }, + "zh-CN": { + "text": "不得分享众所周知的虚假和误导性信息\r\n", + "hint": "不得发布虚假和误导性信息以及来自低质量来源的链接,尤其是可能误导或混淆他人或危及他人安全的信息。" + }, + "zh-TW": { + "text": "請勿分享廣為人知的虛假和誤導資訊", + "hint": "不得張貼虛假和誤導資訊以及來自低品質來源的連結,尤其是可能誤導或混淆他人或危害他人安全的資訊。" + } + } + }, + { + "id": "1008", + "text": "Content created by others must be attributed, and use of generative AI must be disclosed", + "hint": "Content created by others must clearly provide a reference to the author, creator, or source. For adult content, this should include performers. Accounts may not solely post AI-generated content.", + "translations": { + "de": { + "text": "Von anderen erstellte Inhalte müssen gekennzeichnet und die Nutzung generativer KI offengelegt werden.", + "hint": "Von anderen erstellte Inhalte müssen durch einen eindeutigen Verweis auf den/die Autor*in oder die Quelle gekennzeichnet werden. Bei nicht jugendfreien Inhalten sollte dies auch die Darsteller*innen einschließen. Konten dürfen nicht ausschließlich KI-generierte Inhalte posten." + }, + "es": { + "text": "Los contenidos creados por terceros deben ser atribuidos y el uso de IA debe ser revelado.", + "hint": "Los contenidos creados por terceros deben proporcionar claramente una referencia al autor, creador o fuente. En el caso de los contenidos para adultos, debe incluirse a los artistas. Las cuentas no pueden publicar únicamente contenidos generados por IA." + }, + "fr": { + "text": "Le contenu crée par autrui doit être attribué, et l’utilisation d’IA générative doit être marqué comme tel.", + "hint": "Le contenu créé par autrui doit fournir une référence explicite à son auteur·ice, créateur·ice ou sa source. Cela inclut les acteur·ice de contenu pour adulte. Les comptes publiant exclusivement du contenu généré par IA sont interdits." + }, + "nl": { + "text": "Inhoud gemaakt door anderen moet worden toegeschreven, en het gebruik van generatieve AI moet worden aangegeven.", + "hint": "Inhoud gemaakt door anderen moet een duidelijke referentie naar de auteur, maker of bron bevatten. Voor Content created by others must clearly provide a reference to the author, creator, or source. Voor inhoud voor volwassenen, moet het model worden vermeld. Accounts mogen niet uitsluitend door AI gegenereerde inhoud plaatsen." + }, + "ru": { + "text": "Контент, созданный другими, должен публиковаться с указанием авторства, а использование генеративного ИИ должно быть раскрыто.", + "hint": "Контент, созданный другими, должен явным образом сопровождаться отсылкой к автору, создателю или источнику. Для контента для взрослых это включает в себя актёров. Учётные записи не могут публиковать исключительно контент, сгенерированный с помощью ИИ." + }, + "zh-CN": { + "text": "他人创建的内容必须注明出处,使用人工智能产生的内容必须公开和披露说明", + "hint": "他人创建的内容必须明确注明作者、创建者或来源。对于成人内容,应包括表演者。账户不得仅发布人工智能生成的内容。" + }, + "zh-TW": { + "text": "由他人創作的內容必須註明來源,使用人工智能產生的內容必須公開和披露說明", + "hint": "他人創作的內容必須清楚提供作者、創作人或來源的參照。對於成人內容,這應該包括表演者。帳戶不得僅發佈人工智能產生的內容。" + } + } + } + ] + } +} diff --git a/core/testing/src/main/resources/fixtures/mastodon-social-4.8.0-alpha.3/nodeinfo-2.0.json b/core/testing/src/main/resources/fixtures/mastodon-social-4.8.0-alpha.3/nodeinfo-2.0.json new file mode 100644 index 00000000..c83f47dc --- /dev/null +++ b/core/testing/src/main/resources/fixtures/mastodon-social-4.8.0-alpha.3/nodeinfo-2.0.json @@ -0,0 +1,39 @@ +{ + "request": { + "method": "GET", + "path": "/nodeinfo/2.0" + }, + "status": 200, + "headers": { + "etag": "W/\"e62365320f424f39a24f89cfd9e8a9c6\"", + "content-type": "application/json; charset=utf-8", + "cache-control": "max-age=1800, public" + }, + "body": { + "version": "2.0", + "software": { + "name": "mastodon", + "version": "4.8.0-alpha.3+pr-40283-c9a1947" + }, + "protocols": [ + "activitypub" + ], + "services": { + "outbound": [], + "inbound": [] + }, + "usage": { + "users": { + "total": 3413975, + "activeMonth": 268257, + "activeHalfyear": 640086 + }, + "localPosts": 189949068 + }, + "openRegistrations": true, + "metadata": { + "nodeName": "Mastodon", + "nodeDescription": "The original server of Mastodon, operated by Mastodon GmbH for the common good.\r\n\r\n" + } + } +} diff --git a/core/testing/src/main/resources/fixtures/mastodon-social-4.8.0-alpha.3/nodeinfo-links.json b/core/testing/src/main/resources/fixtures/mastodon-social-4.8.0-alpha.3/nodeinfo-links.json new file mode 100644 index 00000000..7793c85e --- /dev/null +++ b/core/testing/src/main/resources/fixtures/mastodon-social-4.8.0-alpha.3/nodeinfo-links.json @@ -0,0 +1,20 @@ +{ + "request": { + "method": "GET", + "path": "/.well-known/nodeinfo" + }, + "status": 200, + "headers": { + "cache-control": "max-age=259200, public", + "content-type": "application/json; charset=utf-8", + "etag": "W/\"2e5c6a3d0480ec4e1aca2c01d42f0238\"" + }, + "body": { + "links": [ + { + "rel": "http://nodeinfo.diaspora.software/ns/schema/2.0", + "href": "https://mastodon.social/nodeinfo/2.0" + } + ] + } +} diff --git a/core/testing/src/main/resources/fixtures/mastodon-social-4.8.0-alpha.3/oauth-authorization-server.json b/core/testing/src/main/resources/fixtures/mastodon-social-4.8.0-alpha.3/oauth-authorization-server.json new file mode 100644 index 00000000..19dbbc01 --- /dev/null +++ b/core/testing/src/main/resources/fixtures/mastodon-social-4.8.0-alpha.3/oauth-authorization-server.json @@ -0,0 +1,89 @@ +{ + "request": { + "method": "GET", + "path": "/.well-known/oauth-authorization-server" + }, + "status": 200, + "headers": { + "cache-control": "max-age=0, private, must-revalidate", + "content-type": "application/json; charset=utf-8", + "etag": "W/\"8cfb07f8314b0f4f25bcea927a8c1a48\"" + }, + "body": { + "issuer": "https://mastodon.social/", + "authorization_endpoint": "https://mastodon.social/oauth/authorize", + "token_endpoint": "https://mastodon.social/oauth/token", + "revocation_endpoint": "https://mastodon.social/oauth/revoke", + "userinfo_endpoint": "https://mastodon.social/oauth/userinfo", + "scopes_supported": [ + "read", + "profile", + "write", + "write:accounts", + "write:blocks", + "write:bookmarks", + "write:collections", + "write:conversations", + "write:favourites", + "write:filters", + "write:follows", + "write:lists", + "write:media", + "write:mutes", + "write:notifications", + "write:reports", + "write:statuses", + "read:accounts", + "read:blocks", + "read:bookmarks", + "read:collections", + "read:favourites", + "read:filters", + "read:follows", + "read:lists", + "read:mutes", + "read:notifications", + "read:search", + "read:statuses", + "follow", + "push", + "admin:read", + "admin:read:accounts", + "admin:read:reports", + "admin:read:domain_allows", + "admin:read:domain_blocks", + "admin:read:ip_blocks", + "admin:read:email_domain_blocks", + "admin:read:canonical_email_blocks", + "admin:write", + "admin:write:accounts", + "admin:write:reports", + "admin:write:domain_allows", + "admin:write:domain_blocks", + "admin:write:ip_blocks", + "admin:write:email_domain_blocks", + "admin:write:canonical_email_blocks" + ], + "response_types_supported": [ + "code" + ], + "response_modes_supported": [ + "query", + "fragment", + "form_post" + ], + "grant_types_supported": [ + "authorization_code", + "client_credentials" + ], + "token_endpoint_auth_methods_supported": [ + "client_secret_basic", + "client_secret_post" + ], + "code_challenge_methods_supported": [ + "S256" + ], + "service_documentation": "https://docs.joinmastodon.org/", + "app_registration_endpoint": "https://mastodon.social/api/v1/apps" + } +} diff --git a/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/accounts-alice-featured-tags.json b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/accounts-alice-featured-tags.json new file mode 100644 index 00000000..09429dd0 --- /dev/null +++ b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/accounts-alice-featured-tags.json @@ -0,0 +1,15 @@ +{ + "request": { + "method": "GET", + "path": "/index.php/apps/social/api/v1/accounts/6/featured_tags" + }, + "status": 200, + "headers": { + "content-type": "application/json; charset=utf-8", + "cache-control": "no-cache, no-store, must-revalidate", + "x-ratelimit-limit": "300", + "x-ratelimit-remaining": "258", + "x-ratelimit-reset": "1790637300" + }, + "body": [] +} diff --git a/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/accounts-alice-followers.json b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/accounts-alice-followers.json new file mode 100644 index 00000000..42ac9045 --- /dev/null +++ b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/accounts-alice-followers.json @@ -0,0 +1,71 @@ +{ + "request": { + "method": "GET", + "path": "/index.php/apps/social/api/v1/accounts/6/followers" + }, + "status": 200, + "headers": { + "content-type": "application/json; charset=utf-8", + "cache-control": "no-cache, no-store, must-revalidate", + "link": "; rel=\"prev\"", + "x-ratelimit-limit": "60", + "x-ratelimit-remaining": "59", + "x-ratelimit-reset": "1790637240" + }, + "body": [ + { + "id": "7", + "nid": "7", + "username": "bob", + "acct": "bob", + "display_name": "bob", + "locked": false, + "bot": false, + "discoverable": false, + "indexable": false, + "group": false, + "created_at": "2026-09-28T23:06:03.000Z", + "note": "", + "url": "http://nextcloud.local/index.php/apps/social/@bob", + "avatar": "", + "avatar_static": "", + "header": "http://nextcloud.local/index.php/avatar/bob/128", + "header_static": "http://nextcloud.local/index.php/avatar/bob/128", + "followers_count": 1, + "following_count": 2, + "statuses_count": 3, + "last_status_at": null, + "emojis": [], + "fields": [], + "pronouns": "", + "support_link": "" + }, + { + "id": "5", + "nid": "5", + "username": "admin", + "acct": "admin", + "display_name": "admin", + "locked": false, + "bot": false, + "discoverable": false, + "indexable": false, + "group": false, + "created_at": "2026-09-28T23:06:00.000Z", + "note": "", + "url": "http://nextcloud.local/index.php/apps/social/@admin", + "avatar": "", + "avatar_static": "", + "header": "http://nextcloud.local/index.php/avatar/admin/128", + "header_static": "http://nextcloud.local/index.php/avatar/admin/128", + "followers_count": 1, + "following_count": 1, + "statuses_count": 0, + "last_status_at": null, + "emojis": [], + "fields": [], + "pronouns": "", + "support_link": "" + } + ] +} diff --git a/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/accounts-alice-following.json b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/accounts-alice-following.json new file mode 100644 index 00000000..edbb5782 --- /dev/null +++ b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/accounts-alice-following.json @@ -0,0 +1,71 @@ +{ + "request": { + "method": "GET", + "path": "/index.php/apps/social/api/v1/accounts/6/following" + }, + "status": 200, + "headers": { + "content-type": "application/json; charset=utf-8", + "cache-control": "no-cache, no-store, must-revalidate", + "link": "; rel=\"prev\"", + "x-ratelimit-limit": "60", + "x-ratelimit-remaining": "59", + "x-ratelimit-reset": "1790637300" + }, + "body": [ + { + "id": "8", + "nid": "8", + "username": "jane", + "acct": "jane", + "display_name": "jane", + "locked": false, + "bot": false, + "discoverable": false, + "indexable": false, + "group": false, + "created_at": "2026-09-28T23:06:04.000Z", + "note": "", + "url": "http://nextcloud.local/index.php/apps/social/@jane", + "avatar": "", + "avatar_static": "", + "header": "http://nextcloud.local/index.php/avatar/jane/128", + "header_static": "http://nextcloud.local/index.php/avatar/jane/128", + "followers_count": 2, + "following_count": 1, + "statuses_count": 0, + "last_status_at": null, + "emojis": [], + "fields": [], + "pronouns": "", + "support_link": "" + }, + { + "id": "7", + "nid": "7", + "username": "bob", + "acct": "bob", + "display_name": "bob", + "locked": false, + "bot": false, + "discoverable": false, + "indexable": false, + "group": false, + "created_at": "2026-09-28T23:06:03.000Z", + "note": "", + "url": "http://nextcloud.local/index.php/apps/social/@bob", + "avatar": "", + "avatar_static": "", + "header": "http://nextcloud.local/index.php/avatar/bob/128", + "header_static": "http://nextcloud.local/index.php/avatar/bob/128", + "followers_count": 1, + "following_count": 2, + "statuses_count": 3, + "last_status_at": null, + "emojis": [], + "fields": [], + "pronouns": "", + "support_link": "" + } + ] +} diff --git a/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/accounts-alice-highlights.json b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/accounts-alice-highlights.json new file mode 100644 index 00000000..d8da2167 --- /dev/null +++ b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/accounts-alice-highlights.json @@ -0,0 +1,47 @@ +{ + "request": { + "method": "GET", + "path": "/index.php/apps/social/api/v1/accounts/6/highlights" + }, + "status": 200, + "headers": { + "content-type": "application/json; charset=utf-8", + "cache-control": "no-cache, no-store, must-revalidate", + "x-ratelimit-limit": "60", + "x-ratelimit-remaining": "59", + "x-ratelimit-reset": "1790640000" + }, + "body": { + "available": true, + "since": 1790636762, + "weeks": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 13 + ], + "week_starts": 1783555200, + "hashtags": [ + { + "name": "aloha", + "count": 1 + }, + { + "name": "android", + "count": 1 + }, + { + "name": "audio", + "count": 1 + } + ] + } +} diff --git a/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/accounts-alice-statuses-media.json b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/accounts-alice-statuses-media.json new file mode 100644 index 00000000..2397b579 --- /dev/null +++ b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/accounts-alice-statuses-media.json @@ -0,0 +1,560 @@ +{ + "request": { + "method": "GET", + "path": "/index.php/apps/social/api/v1/accounts/6/statuses?only_media=true&limit=5" + }, + "status": 200, + "headers": { + "content-type": "application/json; charset=utf-8", + "cache-control": "no-cache, no-store, must-revalidate", + "link": "; rel=\"next\", ; rel=\"prev\"", + "x-ratelimit-limit": "60", + "x-ratelimit-remaining": "57", + "x-ratelimit-reset": "1790637240" + }, + "body": [ + { + "id": "1790637112216295042", + "nid": "1790637112216295042", + "local": true, + "archived": false, + "view_count": null, + "tagged_people": [], + "interest": null, + "content": "

Picture from Berlin

", + "sensitive": false, + "spoiler_text": "", + "visibility": "public", + "language": "en", + "place": { + "id": "1", + "name": "Berlin", + "country": "DE", + "lat": "", + "long": "" + }, + "in_reply_to_id": null, + "in_reply_to_account_id": null, + "quote": null, + "quote_approval": { + "automatic": [ + "public" + ], + "manual": [], + "current_user": "automatic" + }, + "reply_approval": null, + "interaction_policy": null, + "dislikes_count": null, + "disliked": null, + "video": null, + "mentions": [], + "emojis": [], + "tags": [], + "replies_count": 0, + "reblogs_count": 0, + "favourites_count": 0, + "favourited": false, + "reblogged": false, + "muted": false, + "bookmarked": false, + "pinned": false, + "card": null, + "reactions": [], + "poll": null, + "uri": "http://nextcloud.local/index.php/apps/social/@alice/17906371127479253498", + "url": "http://nextcloud.local/index.php/apps/social/@alice/17906371127479253498", + "reblog": null, + "media_attachments": [ + { + "id": "15", + "type": "image", + "media_type": "image/jpeg", + "url": "http://nextcloud.local/index.php/apps/social/media/b88735cc-b169-40e0-9fa5-85e3be451c1a.jpeg", + "preview_url": "http://nextcloud.local/index.php/apps/social/media/e46d0547-3b30-46cd-8b5b-394bb7005a49.jpeg", + "hls_url": null, + "remote_url": null, + "cache_error": null, + "meta": { + "original": { + "width": 1080, + "height": 1350, + "size": "1080x1350", + "aspect": 0.8 + }, + "small": { + "width": 640, + "height": 800, + "size": "640x800", + "aspect": 0.8 + }, + "focus": { + "x": 0, + "y": 0 + } + }, + "description": "Another fractal, taken in Berlin", + "blurhash": "LyLow4+,$SN?w6S]WUjI[ibCSIbW" + } + ], + "created_at": "2026-09-28T23:11:52.000Z", + "edited_at": null, + "noindex": false, + "account": { + "id": "6", + "nid": "6", + "username": "alice", + "acct": "alice", + "display_name": "alice", + "locked": false, + "bot": false, + "discoverable": false, + "indexable": false, + "group": false, + "created_at": "2026-09-28T23:06:02.000Z", + "note": "", + "url": "http://nextcloud.local/index.php/apps/social/@alice", + "avatar": "", + "avatar_static": "", + "header": "http://nextcloud.local/index.php/avatar/alice/128", + "header_static": "http://nextcloud.local/index.php/avatar/alice/128", + "followers_count": 2, + "following_count": 2, + "statuses_count": 13, + "last_status_at": null, + "emojis": [], + "fields": [], + "pronouns": "", + "support_link": "" + }, + "filtered": [] + }, + { + "id": "1790637105549523409", + "nid": "1790637105549523409", + "local": true, + "archived": false, + "view_count": null, + "tagged_people": [], + "interest": null, + "content": "

A 15 second tone #audio

", + "sensitive": false, + "spoiler_text": "", + "visibility": "public", + "language": "en", + "place": null, + "in_reply_to_id": null, + "in_reply_to_account_id": null, + "quote": null, + "quote_approval": { + "automatic": [ + "public" + ], + "manual": [], + "current_user": "automatic" + }, + "reply_approval": null, + "interaction_policy": null, + "dislikes_count": null, + "disliked": null, + "video": null, + "mentions": [], + "emojis": [], + "tags": [ + { + "name": "audio", + "url": "http://nextcloud.local/index.php/apps/social/timeline/tags/audio" + } + ], + "replies_count": 0, + "reblogs_count": 0, + "favourites_count": 0, + "favourited": false, + "reblogged": false, + "muted": false, + "bookmarked": false, + "pinned": false, + "card": null, + "reactions": [], + "poll": null, + "uri": "http://nextcloud.local/index.php/apps/social/@alice/17906371052336860333", + "url": "http://nextcloud.local/index.php/apps/social/@alice/17906371052336860333", + "reblog": null, + "media_attachments": [ + { + "id": "14", + "type": "audio", + "media_type": "audio/mpeg", + "url": "http://nextcloud.local/index.php/apps/social/media/2ee64fb3-dda9-47f5-bac6-daa189149093.mpeg", + "preview_url": "http://nextcloud.local/index.php/apps/social/media/2ee64fb3-dda9-47f5-bac6-daa189149093.mpeg", + "hls_url": null, + "remote_url": null, + "cache_error": null, + "meta": { + "original": [], + "small": [], + "focus": { + "x": 0, + "y": 0 + } + }, + "description": "A 15 second 330 Hz tone", + "blurhash": null + } + ], + "created_at": "2026-09-28T23:11:45.000Z", + "edited_at": null, + "noindex": false, + "account": { + "id": "6", + "nid": "6", + "username": "alice", + "acct": "alice", + "display_name": "alice", + "locked": false, + "bot": false, + "discoverable": false, + "indexable": false, + "group": false, + "created_at": "2026-09-28T23:06:02.000Z", + "note": "", + "url": "http://nextcloud.local/index.php/apps/social/@alice", + "avatar": "", + "avatar_static": "", + "header": "http://nextcloud.local/index.php/avatar/alice/128", + "header_static": "http://nextcloud.local/index.php/avatar/alice/128", + "followers_count": 2, + "following_count": 2, + "statuses_count": 13, + "last_status_at": null, + "emojis": [], + "fields": [], + "pronouns": "", + "support_link": "" + }, + "filtered": [] + }, + { + "id": "1790637104958214061", + "nid": "1790637104958214061", + "local": true, + "archived": false, + "view_count": null, + "tagged_people": [], + "interest": null, + "content": "

Test video #video

00:00 Intro
00:10 Middle
00:20 End

", + "sensitive": false, + "spoiler_text": "", + "visibility": "public", + "language": "en", + "place": null, + "in_reply_to_id": null, + "in_reply_to_account_id": null, + "quote": null, + "quote_approval": { + "automatic": [ + "public" + ], + "manual": [], + "current_user": "automatic" + }, + "reply_approval": null, + "interaction_policy": null, + "dislikes_count": 0, + "disliked": false, + "video": { + "title": "Aloha test video" + }, + "mentions": [], + "emojis": [], + "tags": [ + { + "name": "video", + "url": "http://nextcloud.local/index.php/apps/social/timeline/tags/video" + } + ], + "replies_count": 0, + "reblogs_count": 0, + "favourites_count": 1, + "favourited": false, + "reblogged": false, + "muted": false, + "bookmarked": false, + "pinned": false, + "card": null, + "reactions": [], + "poll": null, + "uri": "http://nextcloud.local/index.php/apps/social/@alice/17906371045255950881", + "url": "http://nextcloud.local/index.php/apps/social/@alice/17906371045255950881", + "reblog": null, + "media_attachments": [ + { + "id": "12", + "type": "video", + "media_type": "video/mp4", + "url": "http://nextcloud.local/index.php/apps/social/media/09c96321-538a-4d33-a3e3-e326ba1c5a56.mp4", + "preview_url": "http://nextcloud.local/index.php/apps/social/media/09c96321-538a-4d33-a3e3-e326ba1c5a56.mp4", + "hls_url": null, + "remote_url": null, + "cache_error": null, + "meta": { + "original": [], + "small": [], + "focus": { + "x": 0, + "y": 0 + } + }, + "description": "A 30 second test pattern with a tone", + "blurhash": null + } + ], + "created_at": "2026-09-28T23:11:44.000Z", + "edited_at": null, + "noindex": false, + "account": { + "id": "6", + "nid": "6", + "username": "alice", + "acct": "alice", + "display_name": "alice", + "locked": false, + "bot": false, + "discoverable": false, + "indexable": false, + "group": false, + "created_at": "2026-09-28T23:06:02.000Z", + "note": "", + "url": "http://nextcloud.local/index.php/apps/social/@alice", + "avatar": "", + "avatar_static": "", + "header": "http://nextcloud.local/index.php/avatar/alice/128", + "header_static": "http://nextcloud.local/index.php/avatar/alice/128", + "followers_count": 2, + "following_count": 2, + "statuses_count": 13, + "last_status_at": null, + "emojis": [], + "fields": [], + "pronouns": "", + "support_link": "" + }, + "filtered": [] + }, + { + "id": "1790637104870044831", + "nid": "1790637104870044831", + "local": true, + "archived": false, + "view_count": null, + "tagged_people": [], + "interest": null, + "content": "

Vertical test #shorts

", + "sensitive": false, + "spoiler_text": "", + "visibility": "public", + "language": "en", + "place": null, + "in_reply_to_id": null, + "in_reply_to_account_id": null, + "quote": null, + "quote_approval": { + "automatic": [ + "public" + ], + "manual": [], + "current_user": "automatic" + }, + "reply_approval": null, + "interaction_policy": null, + "dislikes_count": 0, + "disliked": false, + "video": null, + "mentions": [], + "emojis": [], + "tags": [ + { + "name": "shorts", + "url": "http://nextcloud.local/index.php/apps/social/timeline/tags/shorts" + } + ], + "replies_count": 0, + "reblogs_count": 0, + "favourites_count": 0, + "favourited": false, + "reblogged": false, + "muted": false, + "bookmarked": false, + "pinned": false, + "card": null, + "reactions": [], + "poll": null, + "uri": "http://nextcloud.local/index.php/apps/social/@alice/17906371044200157676", + "url": "http://nextcloud.local/index.php/apps/social/@alice/17906371044200157676", + "reblog": null, + "media_attachments": [ + { + "id": "13", + "type": "video", + "media_type": "video/mp4", + "url": "http://nextcloud.local/index.php/apps/social/media/9ca843a2-645e-439f-901c-3fd1d58c22b6.mp4", + "preview_url": "http://nextcloud.local/index.php/apps/social/media/9ca843a2-645e-439f-901c-3fd1d58c22b6.mp4", + "hls_url": null, + "remote_url": null, + "cache_error": null, + "meta": { + "original": [], + "small": [], + "focus": { + "x": 0, + "y": 0 + } + }, + "description": "A 12 second vertical test pattern", + "blurhash": null + } + ], + "created_at": "2026-09-28T23:11:44.000Z", + "edited_at": null, + "noindex": false, + "account": { + "id": "6", + "nid": "6", + "username": "alice", + "acct": "alice", + "display_name": "alice", + "locked": false, + "bot": false, + "discoverable": false, + "indexable": false, + "group": false, + "created_at": "2026-09-28T23:06:02.000Z", + "note": "", + "url": "http://nextcloud.local/index.php/apps/social/@alice", + "avatar": "", + "avatar_static": "", + "header": "http://nextcloud.local/index.php/avatar/alice/128", + "header_static": "http://nextcloud.local/index.php/avatar/alice/128", + "followers_count": 2, + "following_count": 2, + "statuses_count": 13, + "last_status_at": null, + "emojis": [], + "fields": [], + "pronouns": "", + "support_link": "" + }, + "filtered": [] + }, + { + "id": "1790637103267719986", + "nid": "1790637103267719986", + "local": true, + "archived": false, + "view_count": null, + "tagged_people": [], + "interest": null, + "content": "

A picture without alt text, marked sensitive

", + "sensitive": true, + "spoiler_text": "", + "visibility": "public", + "language": "en", + "place": null, + "in_reply_to_id": null, + "in_reply_to_account_id": null, + "quote": null, + "quote_approval": { + "automatic": [ + "public" + ], + "manual": [], + "current_user": "automatic" + }, + "reply_approval": null, + "interaction_policy": null, + "dislikes_count": null, + "disliked": null, + "video": null, + "mentions": [], + "emojis": [], + "tags": [], + "replies_count": 0, + "reblogs_count": 0, + "favourites_count": 0, + "favourited": false, + "reblogged": false, + "muted": false, + "bookmarked": false, + "pinned": false, + "card": null, + "reactions": [], + "poll": null, + "uri": "http://nextcloud.local/index.php/apps/social/@alice/17906371033997341160", + "url": "http://nextcloud.local/index.php/apps/social/@alice/17906371033997341160", + "reblog": null, + "media_attachments": [ + { + "id": "11", + "type": "image", + "media_type": "image/png", + "url": "http://nextcloud.local/index.php/apps/social/media/347a8309-ea20-4900-8e4c-aff1e8b6a98b.png", + "preview_url": "http://nextcloud.local/index.php/apps/social/media/6bb97fab-b644-4c51-8b1b-8ea5921fdef0.png", + "hls_url": null, + "remote_url": null, + "cache_error": null, + "meta": { + "original": { + "width": 1080, + "height": 1080, + "size": "1080x1080", + "aspect": 1 + }, + "small": { + "width": 800, + "height": 800, + "size": "800x800", + "aspect": 1 + }, + "focus": { + "x": 0, + "y": 0 + } + }, + "description": null, + "blurhash": "LRF~:K*DD7L+S^o@$T+kR5wiM_IU" + } + ], + "created_at": "2026-09-28T23:11:43.000Z", + "edited_at": null, + "noindex": false, + "account": { + "id": "6", + "nid": "6", + "username": "alice", + "acct": "alice", + "display_name": "alice", + "locked": false, + "bot": false, + "discoverable": false, + "indexable": false, + "group": false, + "created_at": "2026-09-28T23:06:02.000Z", + "note": "", + "url": "http://nextcloud.local/index.php/apps/social/@alice", + "avatar": "", + "avatar_static": "", + "header": "http://nextcloud.local/index.php/avatar/alice/128", + "header_static": "http://nextcloud.local/index.php/avatar/alice/128", + "followers_count": 2, + "following_count": 2, + "statuses_count": 13, + "last_status_at": null, + "emojis": [], + "fields": [], + "pronouns": "", + "support_link": "" + }, + "filtered": [] + } + ] +} diff --git a/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/accounts-alice-statuses-pinned.json b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/accounts-alice-statuses-pinned.json new file mode 100644 index 00000000..fbfef134 --- /dev/null +++ b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/accounts-alice-statuses-pinned.json @@ -0,0 +1,103 @@ +{ + "request": { + "method": "GET", + "path": "/index.php/apps/social/api/v1/accounts/6/statuses?pinned=true" + }, + "status": 200, + "headers": { + "content-type": "application/json; charset=utf-8", + "cache-control": "no-cache, no-store, must-revalidate", + "x-ratelimit-limit": "60", + "x-ratelimit-remaining": "58", + "x-ratelimit-reset": "1790637240" + }, + "body": [ + { + "id": "1790637085797595891", + "nid": "1790637085797595891", + "local": true, + "archived": false, + "view_count": null, + "tagged_people": [], + "interest": null, + "content": "

Aloha from the dev instance! Say hi, @bob@nextcloud.local #aloha #android

", + "sensitive": false, + "spoiler_text": "", + "visibility": "public", + "language": "en", + "place": null, + "in_reply_to_id": null, + "in_reply_to_account_id": null, + "quote": null, + "quote_approval": { + "automatic": [ + "public" + ], + "manual": [], + "current_user": "automatic" + }, + "reply_approval": null, + "interaction_policy": null, + "dislikes_count": null, + "disliked": null, + "video": null, + "mentions": [], + "emojis": [], + "tags": [ + { + "name": "aloha", + "url": "http://nextcloud.local/index.php/apps/social/timeline/tags/aloha" + }, + { + "name": "android", + "url": "http://nextcloud.local/index.php/apps/social/timeline/tags/android" + } + ], + "replies_count": 1, + "reblogs_count": 0, + "favourites_count": 1, + "favourited": false, + "reblogged": false, + "muted": false, + "bookmarked": false, + "pinned": true, + "card": null, + "reactions": [], + "poll": null, + "uri": "http://nextcloud.local/index.php/apps/social/@alice/17906370853806247191", + "url": "http://nextcloud.local/index.php/apps/social/@alice/17906370853806247191", + "reblog": null, + "media_attachments": [], + "created_at": "2026-09-28T23:11:25.000Z", + "edited_at": null, + "noindex": false, + "account": { + "id": "6", + "nid": "6", + "username": "alice", + "acct": "alice", + "display_name": "alice", + "locked": false, + "bot": false, + "discoverable": false, + "indexable": false, + "group": false, + "created_at": "2026-09-28T23:06:02.000Z", + "note": "", + "url": "http://nextcloud.local/index.php/apps/social/@alice", + "avatar": "", + "avatar_static": "", + "header": "http://nextcloud.local/index.php/avatar/alice/128", + "header_static": "http://nextcloud.local/index.php/avatar/alice/128", + "followers_count": 2, + "following_count": 2, + "statuses_count": 13, + "last_status_at": null, + "emojis": [], + "fields": [], + "pronouns": "", + "support_link": "" + } + } + ] +} diff --git a/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/accounts-alice-statuses.json b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/accounts-alice-statuses.json new file mode 100644 index 00000000..b33da75e --- /dev/null +++ b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/accounts-alice-statuses.json @@ -0,0 +1,475 @@ +{ + "request": { + "method": "GET", + "path": "/index.php/apps/social/api/v1/accounts/6/statuses?limit=5" + }, + "status": 200, + "headers": { + "content-type": "application/json; charset=utf-8", + "cache-control": "no-cache, no-store, must-revalidate", + "link": "; rel=\"next\", ; rel=\"prev\"", + "x-ratelimit-limit": "60", + "x-ratelimit-remaining": "59", + "x-ratelimit-reset": "1790637240" + }, + "body": [ + { + "id": "1790637115422878628", + "nid": "1790637115422878628", + "local": true, + "archived": false, + "view_count": null, + "tagged_people": [], + "interest": null, + "content": "

@bob@nextcloud.local and back again

", + "sensitive": false, + "spoiler_text": "", + "visibility": "public", + "language": "en", + "place": null, + "in_reply_to_id": "1790637115351395147", + "in_reply_to_account_id": "7", + "quote": null, + "quote_approval": { + "automatic": [ + "public" + ], + "manual": [], + "current_user": "automatic" + }, + "reply_approval": null, + "interaction_policy": null, + "dislikes_count": null, + "disliked": null, + "video": null, + "mentions": [], + "emojis": [], + "tags": [], + "replies_count": 0, + "reblogs_count": 0, + "favourites_count": 0, + "favourited": false, + "reblogged": false, + "muted": false, + "bookmarked": false, + "pinned": false, + "card": null, + "reactions": [], + "poll": null, + "uri": "http://nextcloud.local/index.php/apps/social/@alice/17906371159153077137", + "url": "http://nextcloud.local/index.php/apps/social/@alice/17906371159153077137", + "reblog": null, + "media_attachments": [], + "created_at": "2026-09-28T23:11:55.000Z", + "edited_at": null, + "noindex": false, + "account": { + "id": "6", + "nid": "6", + "username": "alice", + "acct": "alice", + "display_name": "alice", + "locked": false, + "bot": false, + "discoverable": false, + "indexable": false, + "group": false, + "created_at": "2026-09-28T23:06:02.000Z", + "note": "", + "url": "http://nextcloud.local/index.php/apps/social/@alice", + "avatar": "", + "avatar_static": "", + "header": "http://nextcloud.local/index.php/avatar/alice/128", + "header_static": "http://nextcloud.local/index.php/avatar/alice/128", + "followers_count": 2, + "following_count": 2, + "statuses_count": 13, + "last_status_at": null, + "emojis": [], + "fields": [], + "pronouns": "", + "support_link": "" + }, + "filtered": [] + }, + { + "id": "1790637112216295042", + "nid": "1790637112216295042", + "local": true, + "archived": false, + "view_count": null, + "tagged_people": [], + "interest": null, + "content": "

Picture from Berlin

", + "sensitive": false, + "spoiler_text": "", + "visibility": "public", + "language": "en", + "place": { + "id": "1", + "name": "Berlin", + "country": "DE", + "lat": "", + "long": "" + }, + "in_reply_to_id": null, + "in_reply_to_account_id": null, + "quote": null, + "quote_approval": { + "automatic": [ + "public" + ], + "manual": [], + "current_user": "automatic" + }, + "reply_approval": null, + "interaction_policy": null, + "dislikes_count": null, + "disliked": null, + "video": null, + "mentions": [], + "emojis": [], + "tags": [], + "replies_count": 0, + "reblogs_count": 0, + "favourites_count": 0, + "favourited": false, + "reblogged": false, + "muted": false, + "bookmarked": false, + "pinned": false, + "card": null, + "reactions": [], + "poll": null, + "uri": "http://nextcloud.local/index.php/apps/social/@alice/17906371127479253498", + "url": "http://nextcloud.local/index.php/apps/social/@alice/17906371127479253498", + "reblog": null, + "media_attachments": [ + { + "id": "15", + "type": "image", + "media_type": "image/jpeg", + "url": "http://nextcloud.local/index.php/apps/social/media/b88735cc-b169-40e0-9fa5-85e3be451c1a.jpeg", + "preview_url": "http://nextcloud.local/index.php/apps/social/media/e46d0547-3b30-46cd-8b5b-394bb7005a49.jpeg", + "hls_url": null, + "remote_url": null, + "cache_error": null, + "meta": { + "original": { + "width": 1080, + "height": 1350, + "size": "1080x1350", + "aspect": 0.8 + }, + "small": { + "width": 640, + "height": 800, + "size": "640x800", + "aspect": 0.8 + }, + "focus": { + "x": 0, + "y": 0 + } + }, + "description": "Another fractal, taken in Berlin", + "blurhash": "LyLow4+,$SN?w6S]WUjI[ibCSIbW" + } + ], + "created_at": "2026-09-28T23:11:52.000Z", + "edited_at": null, + "noindex": false, + "account": { + "id": "6", + "nid": "6", + "username": "alice", + "acct": "alice", + "display_name": "alice", + "locked": false, + "bot": false, + "discoverable": false, + "indexable": false, + "group": false, + "created_at": "2026-09-28T23:06:02.000Z", + "note": "", + "url": "http://nextcloud.local/index.php/apps/social/@alice", + "avatar": "", + "avatar_static": "", + "header": "http://nextcloud.local/index.php/avatar/alice/128", + "header_static": "http://nextcloud.local/index.php/avatar/alice/128", + "followers_count": 2, + "following_count": 2, + "statuses_count": 13, + "last_status_at": null, + "emojis": [], + "fields": [], + "pronouns": "", + "support_link": "" + }, + "filtered": [] + }, + { + "id": "1790637112034926731", + "nid": "1790637112034926731", + "local": true, + "archived": false, + "view_count": null, + "tagged_people": [], + "interest": null, + "content": "

First draft, edited

", + "sensitive": false, + "spoiler_text": "", + "visibility": "public", + "language": "en", + "place": null, + "in_reply_to_id": null, + "in_reply_to_account_id": null, + "quote": null, + "quote_approval": { + "automatic": [ + "public" + ], + "manual": [], + "current_user": "automatic" + }, + "reply_approval": null, + "interaction_policy": null, + "dislikes_count": null, + "disliked": null, + "video": null, + "mentions": [], + "emojis": [], + "tags": [], + "replies_count": 0, + "reblogs_count": 0, + "favourites_count": 0, + "favourited": false, + "reblogged": false, + "muted": false, + "bookmarked": false, + "pinned": false, + "card": null, + "reactions": [], + "poll": null, + "uri": "http://nextcloud.local/index.php/apps/social/@alice/17906371127199851091", + "url": "http://nextcloud.local/index.php/apps/social/@alice/17906371127199851091", + "reblog": null, + "media_attachments": [], + "created_at": "2026-09-28T23:11:52.000Z", + "edited_at": "2026-09-28T23:11:52.000Z", + "noindex": false, + "account": { + "id": "6", + "nid": "6", + "username": "alice", + "acct": "alice", + "display_name": "alice", + "locked": false, + "bot": false, + "discoverable": false, + "indexable": false, + "group": false, + "created_at": "2026-09-28T23:06:02.000Z", + "note": "", + "url": "http://nextcloud.local/index.php/apps/social/@alice", + "avatar": "", + "avatar_static": "", + "header": "http://nextcloud.local/index.php/avatar/alice/128", + "header_static": "http://nextcloud.local/index.php/avatar/alice/128", + "followers_count": 2, + "following_count": 2, + "statuses_count": 13, + "last_status_at": null, + "emojis": [], + "fields": [], + "pronouns": "", + "support_link": "" + }, + "filtered": [] + }, + { + "id": "1790637105561356153", + "nid": "1790637105561356153", + "local": true, + "archived": false, + "view_count": null, + "tagged_people": [], + "interest": null, + "content": "

Nextcloud Social on GitHub: https://github.com/nextcloud/social

", + "sensitive": false, + "spoiler_text": "", + "visibility": "public", + "language": "en", + "place": null, + "in_reply_to_id": null, + "in_reply_to_account_id": null, + "quote": null, + "quote_approval": { + "automatic": [ + "public" + ], + "manual": [], + "current_user": "automatic" + }, + "reply_approval": null, + "interaction_policy": null, + "dislikes_count": null, + "disliked": null, + "video": null, + "mentions": [], + "emojis": [], + "tags": [], + "replies_count": 0, + "reblogs_count": 0, + "favourites_count": 0, + "favourited": false, + "reblogged": false, + "muted": false, + "bookmarked": false, + "pinned": false, + "card": null, + "reactions": [], + "poll": null, + "uri": "http://nextcloud.local/index.php/apps/social/@alice/17906371057334696925", + "url": "http://nextcloud.local/index.php/apps/social/@alice/17906371057334696925", + "reblog": null, + "media_attachments": [], + "created_at": "2026-09-28T23:11:45.000Z", + "edited_at": null, + "noindex": false, + "account": { + "id": "6", + "nid": "6", + "username": "alice", + "acct": "alice", + "display_name": "alice", + "locked": false, + "bot": false, + "discoverable": false, + "indexable": false, + "group": false, + "created_at": "2026-09-28T23:06:02.000Z", + "note": "", + "url": "http://nextcloud.local/index.php/apps/social/@alice", + "avatar": "", + "avatar_static": "", + "header": "http://nextcloud.local/index.php/avatar/alice/128", + "header_static": "http://nextcloud.local/index.php/avatar/alice/128", + "followers_count": 2, + "following_count": 2, + "statuses_count": 13, + "last_status_at": null, + "emojis": [], + "fields": [], + "pronouns": "", + "support_link": "" + }, + "filtered": [] + }, + { + "id": "1790637105549523409", + "nid": "1790637105549523409", + "local": true, + "archived": false, + "view_count": null, + "tagged_people": [], + "interest": null, + "content": "

A 15 second tone #audio

", + "sensitive": false, + "spoiler_text": "", + "visibility": "public", + "language": "en", + "place": null, + "in_reply_to_id": null, + "in_reply_to_account_id": null, + "quote": null, + "quote_approval": { + "automatic": [ + "public" + ], + "manual": [], + "current_user": "automatic" + }, + "reply_approval": null, + "interaction_policy": null, + "dislikes_count": null, + "disliked": null, + "video": null, + "mentions": [], + "emojis": [], + "tags": [ + { + "name": "audio", + "url": "http://nextcloud.local/index.php/apps/social/timeline/tags/audio" + } + ], + "replies_count": 0, + "reblogs_count": 0, + "favourites_count": 0, + "favourited": false, + "reblogged": false, + "muted": false, + "bookmarked": false, + "pinned": false, + "card": null, + "reactions": [], + "poll": null, + "uri": "http://nextcloud.local/index.php/apps/social/@alice/17906371052336860333", + "url": "http://nextcloud.local/index.php/apps/social/@alice/17906371052336860333", + "reblog": null, + "media_attachments": [ + { + "id": "14", + "type": "audio", + "media_type": "audio/mpeg", + "url": "http://nextcloud.local/index.php/apps/social/media/2ee64fb3-dda9-47f5-bac6-daa189149093.mpeg", + "preview_url": "http://nextcloud.local/index.php/apps/social/media/2ee64fb3-dda9-47f5-bac6-daa189149093.mpeg", + "hls_url": null, + "remote_url": null, + "cache_error": null, + "meta": { + "original": [], + "small": [], + "focus": { + "x": 0, + "y": 0 + } + }, + "description": "A 15 second 330 Hz tone", + "blurhash": null + } + ], + "created_at": "2026-09-28T23:11:45.000Z", + "edited_at": null, + "noindex": false, + "account": { + "id": "6", + "nid": "6", + "username": "alice", + "acct": "alice", + "display_name": "alice", + "locked": false, + "bot": false, + "discoverable": false, + "indexable": false, + "group": false, + "created_at": "2026-09-28T23:06:02.000Z", + "note": "", + "url": "http://nextcloud.local/index.php/apps/social/@alice", + "avatar": "", + "avatar_static": "", + "header": "http://nextcloud.local/index.php/avatar/alice/128", + "header_static": "http://nextcloud.local/index.php/avatar/alice/128", + "followers_count": 2, + "following_count": 2, + "statuses_count": 13, + "last_status_at": null, + "emojis": [], + "fields": [], + "pronouns": "", + "support_link": "" + }, + "filtered": [] + } + ] +} diff --git a/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/accounts-alice.json b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/accounts-alice.json new file mode 100644 index 00000000..00351bc3 --- /dev/null +++ b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/accounts-alice.json @@ -0,0 +1,41 @@ +{ + "request": { + "method": "GET", + "path": "/index.php/apps/social/api/v1/accounts/6" + }, + "status": 200, + "headers": { + "content-type": "application/json; charset=utf-8", + "cache-control": "no-cache, no-store, must-revalidate", + "x-ratelimit-limit": "60", + "x-ratelimit-remaining": "59", + "x-ratelimit-reset": "1790637240" + }, + "body": { + "id": "6", + "nid": "6", + "username": "alice", + "acct": "alice", + "display_name": "alice", + "locked": false, + "bot": false, + "discoverable": false, + "indexable": false, + "group": false, + "created_at": "2026-09-28T23:06:02.000Z", + "note": "", + "url": "http://nextcloud.local/index.php/apps/social/@alice", + "avatar": "", + "avatar_static": "", + "header": "http://nextcloud.local/index.php/avatar/alice/128", + "header_static": "http://nextcloud.local/index.php/avatar/alice/128", + "followers_count": 2, + "following_count": 2, + "statuses_count": 13, + "last_status_at": null, + "emojis": [], + "fields": [], + "pronouns": "", + "support_link": "" + } +} diff --git a/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/accounts-jane-locked.json b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/accounts-jane-locked.json new file mode 100644 index 00000000..b280ef0c --- /dev/null +++ b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/accounts-jane-locked.json @@ -0,0 +1,41 @@ +{ + "request": { + "method": "GET", + "path": "/index.php/apps/social/api/v1/accounts/8" + }, + "status": 200, + "headers": { + "content-type": "application/json; charset=utf-8", + "cache-control": "no-cache, no-store, must-revalidate", + "x-ratelimit-limit": "60", + "x-ratelimit-remaining": "58", + "x-ratelimit-reset": "1790637240" + }, + "body": { + "id": "8", + "nid": "8", + "username": "jane", + "acct": "jane", + "display_name": "jane", + "locked": false, + "bot": false, + "discoverable": false, + "indexable": false, + "group": false, + "created_at": "2026-09-28T23:06:04.000Z", + "note": "", + "url": "http://nextcloud.local/index.php/apps/social/@jane", + "avatar": "", + "avatar_static": "", + "header": "http://nextcloud.local/index.php/avatar/jane/128", + "header_static": "http://nextcloud.local/index.php/avatar/jane/128", + "followers_count": 2, + "following_count": 1, + "statuses_count": 0, + "last_status_at": null, + "emojis": [], + "fields": [], + "pronouns": "", + "support_link": "" + } +} diff --git a/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/accounts-lookup-bob.json b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/accounts-lookup-bob.json new file mode 100644 index 00000000..47aedc45 --- /dev/null +++ b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/accounts-lookup-bob.json @@ -0,0 +1,41 @@ +{ + "request": { + "method": "GET", + "path": "/index.php/apps/social/api/v1/accounts/lookup?acct=bob" + }, + "status": 200, + "headers": { + "content-type": "application/json; charset=utf-8", + "cache-control": "no-cache, no-store, must-revalidate", + "x-ratelimit-limit": "30", + "x-ratelimit-remaining": "29", + "x-ratelimit-reset": "1790637240" + }, + "body": { + "id": "7", + "nid": "7", + "username": "bob", + "acct": "bob", + "display_name": "bob", + "locked": false, + "bot": false, + "discoverable": false, + "indexable": false, + "group": false, + "created_at": "2026-09-28T23:06:03.000Z", + "note": "", + "url": "http://nextcloud.local/index.php/apps/social/@bob", + "avatar": "", + "avatar_static": "", + "header": "http://nextcloud.local/index.php/avatar/bob/128", + "header_static": "http://nextcloud.local/index.php/avatar/bob/128", + "followers_count": 1, + "following_count": 2, + "statuses_count": 3, + "last_status_at": null, + "emojis": [], + "fields": [], + "pronouns": "", + "support_link": "" + } +} diff --git a/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/accounts-relationships.json b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/accounts-relationships.json new file mode 100644 index 00000000..3e7692b3 --- /dev/null +++ b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/accounts-relationships.json @@ -0,0 +1,70 @@ +{ + "request": { + "method": "GET", + "path": "/index.php/apps/social/api/v1/accounts/relationships?id[]=7&id[]=8&id[]=5" + }, + "status": 200, + "headers": { + "content-type": "application/json; charset=utf-8", + "cache-control": "no-cache, no-store, must-revalidate", + "x-ratelimit-limit": "300", + "x-ratelimit-remaining": "259", + "x-ratelimit-reset": "1790637300" + }, + "body": [ + { + "id": "5", + "following": false, + "showing_reblogs": true, + "notifying": false, + "followed_by": true, + "blocking": false, + "blocked_by": false, + "muting": false, + "muting_notifications": false, + "requested": false, + "domain_blocking": false, + "endorsed": false, + "requested_by": false, + "note": "", + "mute_expires_at": null, + "languages": null + }, + { + "id": "7", + "following": true, + "showing_reblogs": true, + "notifying": false, + "followed_by": true, + "blocking": false, + "blocked_by": false, + "muting": false, + "muting_notifications": false, + "requested": false, + "domain_blocking": false, + "endorsed": false, + "requested_by": false, + "note": "", + "mute_expires_at": null, + "languages": null + }, + { + "id": "8", + "following": true, + "showing_reblogs": true, + "notifying": false, + "followed_by": false, + "blocking": false, + "blocked_by": false, + "muting": false, + "muting_notifications": false, + "requested": false, + "domain_blocking": false, + "endorsed": false, + "requested_by": false, + "note": "", + "mute_expires_at": null, + "languages": null + } + ] +} diff --git a/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/accounts-search.json b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/accounts-search.json new file mode 100644 index 00000000..fe0ee981 --- /dev/null +++ b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/accounts-search.json @@ -0,0 +1,43 @@ +{ + "request": { + "method": "GET", + "path": "/index.php/apps/social/api/v1/accounts/search?q=ja&limit=8&resolve=false" + }, + "status": 200, + "headers": { + "content-type": "application/json; charset=utf-8", + "cache-control": "no-cache, no-store, must-revalidate", + "x-ratelimit-limit": "10", + "x-ratelimit-remaining": "9", + "x-ratelimit-reset": "1790637240" + }, + "body": [ + { + "id": "8", + "nid": "8", + "username": "jane", + "acct": "jane", + "display_name": "jane", + "locked": false, + "bot": false, + "discoverable": false, + "indexable": false, + "group": false, + "created_at": "2026-09-28T23:06:04.000Z", + "note": "", + "url": "http://nextcloud.local/index.php/apps/social/@jane", + "avatar": "", + "avatar_static": "", + "header": "http://nextcloud.local/index.php/avatar/jane/128", + "header_static": "http://nextcloud.local/index.php/avatar/jane/128", + "followers_count": 2, + "following_count": 1, + "statuses_count": 0, + "last_status_at": null, + "emojis": [], + "fields": [], + "pronouns": "", + "support_link": "" + } + ] +} diff --git a/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/accounts-verify-credentials.json b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/accounts-verify-credentials.json new file mode 100644 index 00000000..96f9f6ba --- /dev/null +++ b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/accounts-verify-credentials.json @@ -0,0 +1,49 @@ +{ + "request": { + "method": "GET", + "path": "/index.php/apps/social/api/v1/accounts/verify_credentials" + }, + "status": 200, + "headers": { + "content-type": "application/json; charset=utf-8", + "cache-control": "no-cache, no-store, must-revalidate", + "x-ratelimit-limit": "300", + "x-ratelimit-remaining": "260", + "x-ratelimit-reset": "1790637300" + }, + "body": { + "id": "6", + "nid": "6", + "username": "alice", + "acct": "alice", + "display_name": "alice", + "locked": false, + "bot": false, + "discoverable": false, + "indexable": false, + "group": false, + "created_at": "2026-09-28T23:06:02.000Z", + "note": "", + "url": "http://nextcloud.local/index.php/apps/social/@alice", + "avatar": "http://nextcloud.local/index.php/avatar/alice/128", + "avatar_static": "http://nextcloud.local/index.php/avatar/alice/128", + "header": "http://nextcloud.local/index.php/avatar/alice/128", + "header_static": "http://nextcloud.local/index.php/avatar/alice/128", + "followers_count": 2, + "following_count": 2, + "statuses_count": 13, + "last_status_at": null, + "emojis": [], + "fields": [], + "pronouns": "", + "support_link": "", + "source": { + "privacy": "public", + "sensitive": false, + "language": "en", + "note": "", + "fields": [], + "follow_requests_count": 0 + } + } +} diff --git a/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/announcements.json b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/announcements.json new file mode 100644 index 00000000..5bedb306 --- /dev/null +++ b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/announcements.json @@ -0,0 +1,15 @@ +{ + "request": { + "method": "GET", + "path": "/index.php/apps/social/api/v1/announcements" + }, + "status": 200, + "headers": { + "content-type": "application/json; charset=utf-8", + "cache-control": "no-cache, no-store, must-revalidate", + "x-ratelimit-limit": "300", + "x-ratelimit-remaining": "199", + "x-ratelimit-reset": "1790637300" + }, + "body": [] +} diff --git a/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/annual-reports.json b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/annual-reports.json new file mode 100644 index 00000000..dc157c47 --- /dev/null +++ b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/annual-reports.json @@ -0,0 +1,488 @@ +{ + "request": { + "method": "GET", + "path": "/index.php/apps/social/api/v1/annual_reports" + }, + "status": 200, + "headers": { + "content-type": "application/json; charset=utf-8", + "cache-control": "no-cache, no-store, must-revalidate", + "x-ratelimit-limit": "300", + "x-ratelimit-remaining": "195", + "x-ratelimit-reset": "1790637300" + }, + "body": { + "annual_reports": [ + { + "year": 2026, + "data": { + "archetype": "oracle", + "time_series": [ + { + "month": 1, + "statuses": 0, + "followers": 0 + }, + { + "month": 2, + "statuses": 0, + "followers": 0 + }, + { + "month": 3, + "statuses": 0, + "followers": 0 + }, + { + "month": 4, + "statuses": 0, + "followers": 0 + }, + { + "month": 5, + "statuses": 0, + "followers": 0 + }, + { + "month": 6, + "statuses": 0, + "followers": 0 + }, + { + "month": 7, + "statuses": 0, + "followers": 0 + }, + { + "month": 8, + "statuses": 0, + "followers": 0 + }, + { + "month": 9, + "statuses": 14, + "followers": 2 + }, + { + "month": 10, + "statuses": 0, + "followers": 0 + }, + { + "month": 11, + "statuses": 0, + "followers": 0 + }, + { + "month": 12, + "statuses": 0, + "followers": 0 + } + ], + "top_hashtags": [ + { + "name": "audio", + "count": 1 + }, + { + "name": "video", + "count": 1 + }, + { + "name": "shorts", + "count": 1 + }, + { + "name": "photography", + "count": 1 + }, + { + "name": "aloha", + "count": 1 + }, + { + "name": "android", + "count": 1 + } + ], + "top_statuses": { + "by_reblogs": "1790637096330770789", + "by_replies": "1790637085797595891", + "by_favourites": "1790637104958214061" + } + }, + "schema_version": 1, + "share_url": null, + "account_id": "0" + } + ], + "accounts": [ + { + "id": "0", + "nid": 0, + "username": "alice", + "acct": "alice", + "display_name": "alice", + "locked": false, + "bot": false, + "discoverable": false, + "indexable": false, + "group": false, + "created_at": "2026-09-28T23:06:02.000Z", + "note": "", + "url": "http://nextcloud.local/index.php/apps/social/@alice", + "avatar": "", + "avatar_static": "", + "header": "", + "header_static": "", + "followers_count": 0, + "following_count": 0, + "statuses_count": 0, + "last_status_at": null, + "emojis": [], + "fields": [], + "pronouns": "", + "support_link": "" + } + ], + "statuses": [ + { + "id": "1790637096330770789", + "nid": "1790637096330770789", + "local": true, + "archived": false, + "view_count": null, + "tagged_people": [], + "interest": null, + "content": "

Two test pictures #photography

", + "sensitive": false, + "spoiler_text": "", + "visibility": "public", + "language": "en", + "place": null, + "in_reply_to_id": null, + "in_reply_to_account_id": null, + "quote": null, + "quote_approval": { + "automatic": [ + "public" + ], + "manual": [], + "current_user": "automatic" + }, + "reply_approval": null, + "interaction_policy": null, + "dislikes_count": null, + "disliked": null, + "video": null, + "mentions": [], + "emojis": [], + "tags": [ + { + "name": "photography", + "url": "http://nextcloud.local/index.php/apps/social/timeline/tags/photography" + } + ], + "replies_count": 0, + "reblogs_count": 1, + "favourites_count": 0, + "favourited": false, + "reblogged": false, + "muted": false, + "bookmarked": false, + "pinned": false, + "card": null, + "reactions": [], + "poll": null, + "uri": "http://nextcloud.local/index.php/apps/social/@alice/17906370966999687509", + "url": "http://nextcloud.local/index.php/apps/social/@alice/17906370966999687509", + "reblog": null, + "media_attachments": [ + { + "id": "9", + "type": "image", + "media_type": "image/jpeg", + "url": "http://nextcloud.local/index.php/apps/social/media/48044adf-b771-4343-9607-c7fc93f16ad1.jpeg", + "preview_url": "http://nextcloud.local/index.php/apps/social/media/2c66bfd7-0029-47e1-b6ef-23dc7d422e2b.jpeg", + "hls_url": null, + "remote_url": null, + "cache_error": null, + "meta": { + "original": { + "width": 1600, + "height": 1200, + "size": "1600x1200", + "aspect": 1.3333333333333333 + }, + "small": { + "width": 800, + "height": 600, + "size": "800x600", + "aspect": 1.3333333333333333 + }, + "focus": { + "x": 0.3, + "y": -0.2 + } + }, + "description": "A test card pattern in bright colours", + "blurhash": "LVL4]9|Tnk_gsESdfjn,baayW;jZ" + }, + { + "id": "10", + "type": "image", + "media_type": "image/jpeg", + "url": "http://nextcloud.local/index.php/apps/social/media/1e936701-3c1a-49c2-a58a-bc7b0d5a9c27.jpeg", + "preview_url": "http://nextcloud.local/index.php/apps/social/media/43d878b8-d9fd-42fa-9f7e-38dea1357b60.jpeg", + "hls_url": null, + "remote_url": null, + "cache_error": null, + "meta": { + "original": { + "width": 1080, + "height": 1350, + "size": "1080x1350", + "aspect": 0.8 + }, + "small": { + "width": 640, + "height": 800, + "size": "640x800", + "aspect": 0.8 + }, + "focus": { + "x": 0, + "y": 0 + } + }, + "description": "A Mandelbrot fractal, portrait", + "blurhash": "LyLow4+,$SN?w6S]WUjI[ibCSIbW" + } + ], + "created_at": "2026-09-28T23:11:36.000Z", + "edited_at": null, + "noindex": false, + "account": { + "id": "6", + "nid": "6", + "username": "alice", + "acct": "alice", + "display_name": "alice", + "locked": false, + "bot": false, + "discoverable": false, + "indexable": false, + "group": false, + "created_at": "2026-09-28T23:06:02.000Z", + "note": "", + "url": "http://nextcloud.local/index.php/apps/social/@alice", + "avatar": "", + "avatar_static": "", + "header": "http://nextcloud.local/index.php/avatar/alice/128", + "header_static": "http://nextcloud.local/index.php/avatar/alice/128", + "followers_count": 2, + "following_count": 2, + "statuses_count": 13, + "last_status_at": null, + "emojis": [], + "fields": [], + "pronouns": "", + "support_link": "" + } + }, + { + "id": "1790637085797595891", + "nid": "1790637085797595891", + "local": true, + "archived": false, + "view_count": null, + "tagged_people": [], + "interest": null, + "content": "

Aloha from the dev instance! Say hi, @bob@nextcloud.local #aloha #android

", + "sensitive": false, + "spoiler_text": "", + "visibility": "public", + "language": "en", + "place": null, + "in_reply_to_id": null, + "in_reply_to_account_id": null, + "quote": null, + "quote_approval": { + "automatic": [ + "public" + ], + "manual": [], + "current_user": "automatic" + }, + "reply_approval": null, + "interaction_policy": null, + "dislikes_count": null, + "disliked": null, + "video": null, + "mentions": [], + "emojis": [], + "tags": [ + { + "name": "aloha", + "url": "http://nextcloud.local/index.php/apps/social/timeline/tags/aloha" + }, + { + "name": "android", + "url": "http://nextcloud.local/index.php/apps/social/timeline/tags/android" + } + ], + "replies_count": 1, + "reblogs_count": 0, + "favourites_count": 1, + "favourited": false, + "reblogged": false, + "muted": false, + "bookmarked": false, + "pinned": false, + "card": null, + "reactions": [], + "poll": null, + "uri": "http://nextcloud.local/index.php/apps/social/@alice/17906370853806247191", + "url": "http://nextcloud.local/index.php/apps/social/@alice/17906370853806247191", + "reblog": null, + "media_attachments": [], + "created_at": "2026-09-28T23:11:25.000Z", + "edited_at": null, + "noindex": false, + "account": { + "id": "6", + "nid": "6", + "username": "alice", + "acct": "alice", + "display_name": "alice", + "locked": false, + "bot": false, + "discoverable": false, + "indexable": false, + "group": false, + "created_at": "2026-09-28T23:06:02.000Z", + "note": "", + "url": "http://nextcloud.local/index.php/apps/social/@alice", + "avatar": "", + "avatar_static": "", + "header": "http://nextcloud.local/index.php/avatar/alice/128", + "header_static": "http://nextcloud.local/index.php/avatar/alice/128", + "followers_count": 2, + "following_count": 2, + "statuses_count": 13, + "last_status_at": null, + "emojis": [], + "fields": [], + "pronouns": "", + "support_link": "" + } + }, + { + "id": "1790637104958214061", + "nid": "1790637104958214061", + "local": true, + "archived": false, + "view_count": null, + "tagged_people": [], + "interest": null, + "content": "

Test video #video

00:00 Intro
00:10 Middle
00:20 End

", + "sensitive": false, + "spoiler_text": "", + "visibility": "public", + "language": "en", + "place": null, + "in_reply_to_id": null, + "in_reply_to_account_id": null, + "quote": null, + "quote_approval": { + "automatic": [ + "public" + ], + "manual": [], + "current_user": "automatic" + }, + "reply_approval": null, + "interaction_policy": null, + "dislikes_count": 0, + "disliked": false, + "video": { + "title": "Aloha test video" + }, + "mentions": [], + "emojis": [], + "tags": [ + { + "name": "video", + "url": "http://nextcloud.local/index.php/apps/social/timeline/tags/video" + } + ], + "replies_count": 0, + "reblogs_count": 0, + "favourites_count": 1, + "favourited": false, + "reblogged": false, + "muted": false, + "bookmarked": false, + "pinned": false, + "card": null, + "reactions": [], + "poll": null, + "uri": "http://nextcloud.local/index.php/apps/social/@alice/17906371045255950881", + "url": "http://nextcloud.local/index.php/apps/social/@alice/17906371045255950881", + "reblog": null, + "media_attachments": [ + { + "id": "12", + "type": "video", + "media_type": "video/mp4", + "url": "http://nextcloud.local/index.php/apps/social/media/09c96321-538a-4d33-a3e3-e326ba1c5a56.mp4", + "preview_url": "http://nextcloud.local/index.php/apps/social/media/09c96321-538a-4d33-a3e3-e326ba1c5a56.mp4", + "hls_url": null, + "remote_url": null, + "cache_error": null, + "meta": { + "original": [], + "small": [], + "focus": { + "x": 0, + "y": 0 + } + }, + "description": "A 30 second test pattern with a tone", + "blurhash": null + } + ], + "created_at": "2026-09-28T23:11:44.000Z", + "edited_at": null, + "noindex": false, + "account": { + "id": "6", + "nid": "6", + "username": "alice", + "acct": "alice", + "display_name": "alice", + "locked": false, + "bot": false, + "discoverable": false, + "indexable": false, + "group": false, + "created_at": "2026-09-28T23:06:02.000Z", + "note": "", + "url": "http://nextcloud.local/index.php/apps/social/@alice", + "avatar": "", + "avatar_static": "", + "header": "http://nextcloud.local/index.php/avatar/alice/128", + "header_static": "http://nextcloud.local/index.php/avatar/alice/128", + "followers_count": 2, + "following_count": 2, + "statuses_count": 13, + "last_status_at": null, + "emojis": [], + "fields": [], + "pronouns": "", + "support_link": "" + } + } + ] + } +} diff --git a/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/apps-register.json b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/apps-register.json new file mode 100644 index 00000000..85cdab68 --- /dev/null +++ b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/apps-register.json @@ -0,0 +1,24 @@ +{ + "request": { + "method": "POST", + "path": "/index.php/apps/social/api/v1/apps" + }, + "status": 200, + "headers": { + "content-type": "application/json; charset=utf-8", + "cache-control": "no-cache, no-store, must-revalidate", + "x-ratelimit-limit": "300", + "x-ratelimit-remaining": "264", + "x-ratelimit-reset": "1790637300" + }, + "body": { + "id": "2", + "name": "Aloha fixture capture", + "website": "https://aloha.social", + "scopes": "read write follow push", + "client_id": "REDACTED", + "client_secret": "REDACTED", + "redirect_uri": "urn:ietf:wg:oauth:2.0:oob", + "vapid_key": "" + } +} diff --git a/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/apps-verify-credentials.json b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/apps-verify-credentials.json new file mode 100644 index 00000000..3476ac93 --- /dev/null +++ b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/apps-verify-credentials.json @@ -0,0 +1,19 @@ +{ + "request": { + "method": "GET", + "path": "/index.php/apps/social/api/v1/apps/verify_credentials" + }, + "status": 200, + "headers": { + "content-type": "application/json; charset=utf-8", + "cache-control": "no-cache, no-store, must-revalidate", + "x-ratelimit-limit": "300", + "x-ratelimit-remaining": "261", + "x-ratelimit-reset": "1790637300" + }, + "body": { + "name": "Aloha Social dev fixtures", + "website": "https://aloha.social", + "vapid_key": "" + } +} diff --git a/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/blocks.json b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/blocks.json new file mode 100644 index 00000000..9c0d98c0 --- /dev/null +++ b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/blocks.json @@ -0,0 +1,15 @@ +{ + "request": { + "method": "GET", + "path": "/index.php/apps/social/api/v1/blocks" + }, + "status": 200, + "headers": { + "content-type": "application/json; charset=utf-8", + "cache-control": "no-cache, no-store, must-revalidate", + "x-ratelimit-limit": "300", + "x-ratelimit-remaining": "256", + "x-ratelimit-reset": "1790637300" + }, + "body": [] +} diff --git a/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/bookmarks-bob.json b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/bookmarks-bob.json new file mode 100644 index 00000000..a6ec1f11 --- /dev/null +++ b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/bookmarks-bob.json @@ -0,0 +1,124 @@ +{ + "request": { + "method": "GET", + "path": "/index.php/apps/social/api/v1/bookmarks" + }, + "status": 200, + "headers": { + "content-type": "application/json; charset=utf-8", + "cache-control": "no-cache, no-store, must-revalidate", + "link": "; rel=\"prev\"", + "x-ratelimit-limit": "300", + "x-ratelimit-remaining": "250", + "x-ratelimit-reset": "1790637300" + }, + "body": [ + { + "id": "1790637104958214061", + "nid": "1790637104958214061", + "local": true, + "archived": false, + "view_count": null, + "tagged_people": [], + "interest": null, + "content": "

Test video #video

00:00 Intro
00:10 Middle
00:20 End

", + "sensitive": false, + "spoiler_text": "", + "visibility": "public", + "language": "en", + "place": null, + "in_reply_to_id": null, + "in_reply_to_account_id": null, + "quote": null, + "quote_approval": { + "automatic": [ + "public" + ], + "manual": [], + "current_user": "automatic" + }, + "reply_approval": null, + "interaction_policy": null, + "dislikes_count": 0, + "disliked": false, + "video": { + "title": "Aloha test video" + }, + "mentions": [], + "emojis": [], + "tags": [ + { + "name": "video", + "url": "http://nextcloud.local/index.php/apps/social/timeline/tags/video" + } + ], + "replies_count": 0, + "reblogs_count": 0, + "favourites_count": 1, + "favourited": false, + "reblogged": false, + "muted": false, + "bookmarked": true, + "pinned": false, + "card": null, + "reactions": [], + "poll": null, + "uri": "http://nextcloud.local/index.php/apps/social/@alice/17906371045255950881", + "url": "http://nextcloud.local/index.php/apps/social/@alice/17906371045255950881", + "reblog": null, + "media_attachments": [ + { + "id": "12", + "type": "video", + "media_type": "video/mp4", + "url": "http://nextcloud.local/index.php/apps/social/media/09c96321-538a-4d33-a3e3-e326ba1c5a56.mp4", + "preview_url": "http://nextcloud.local/index.php/apps/social/media/09c96321-538a-4d33-a3e3-e326ba1c5a56.mp4", + "hls_url": null, + "remote_url": null, + "cache_error": null, + "meta": { + "original": [], + "small": [], + "focus": { + "x": 0, + "y": 0 + } + }, + "description": "A 30 second test pattern with a tone", + "blurhash": null + } + ], + "created_at": "2026-09-28T23:11:44.000Z", + "edited_at": null, + "noindex": false, + "account": { + "id": "6", + "nid": "6", + "username": "alice", + "acct": "alice", + "display_name": "alice", + "locked": false, + "bot": false, + "discoverable": false, + "indexable": false, + "group": false, + "created_at": "2026-09-28T23:06:02.000Z", + "note": "", + "url": "http://nextcloud.local/index.php/apps/social/@alice", + "avatar": "", + "avatar_static": "", + "header": "http://nextcloud.local/index.php/avatar/alice/128", + "header_static": "http://nextcloud.local/index.php/avatar/alice/128", + "followers_count": 2, + "following_count": 2, + "statuses_count": 13, + "last_status_at": null, + "emojis": [], + "fields": [], + "pronouns": "", + "support_link": "" + }, + "filtered": [] + } + ] +} diff --git a/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/collection-items.json b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/collection-items.json new file mode 100644 index 00000000..0ed2800e --- /dev/null +++ b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/collection-items.json @@ -0,0 +1,160 @@ +{ + "request": { + "method": "GET", + "path": "/index.php/apps/social/api/v1/collections/1/items" + }, + "status": 200, + "headers": { + "content-type": "application/json; charset=utf-8", + "cache-control": "no-cache, no-store, must-revalidate", + "x-ratelimit-limit": "300", + "x-ratelimit-remaining": "185", + "x-ratelimit-reset": "1790637300" + }, + "body": [ + { + "id": "1790637096330770789", + "nid": "1790637096330770789", + "local": true, + "archived": false, + "view_count": null, + "tagged_people": [], + "interest": null, + "content": "

Two test pictures #photography

", + "sensitive": false, + "spoiler_text": "", + "visibility": "public", + "language": "en", + "place": null, + "in_reply_to_id": null, + "in_reply_to_account_id": null, + "quote": null, + "quote_approval": { + "automatic": [ + "public" + ], + "manual": [], + "current_user": "automatic" + }, + "reply_approval": null, + "interaction_policy": null, + "dislikes_count": null, + "disliked": null, + "video": null, + "mentions": [], + "emojis": [], + "tags": [ + { + "name": "photography", + "url": "http://nextcloud.local/index.php/apps/social/timeline/tags/photography" + } + ], + "replies_count": 0, + "reblogs_count": 1, + "favourites_count": 0, + "favourited": false, + "reblogged": false, + "muted": false, + "bookmarked": false, + "pinned": false, + "card": null, + "reactions": [], + "poll": null, + "uri": "http://nextcloud.local/index.php/apps/social/@alice/17906370966999687509", + "url": "http://nextcloud.local/index.php/apps/social/@alice/17906370966999687509", + "reblog": null, + "media_attachments": [ + { + "id": "9", + "type": "image", + "media_type": "image/jpeg", + "url": "http://nextcloud.local/index.php/apps/social/media/48044adf-b771-4343-9607-c7fc93f16ad1.jpeg", + "preview_url": "http://nextcloud.local/index.php/apps/social/media/2c66bfd7-0029-47e1-b6ef-23dc7d422e2b.jpeg", + "hls_url": null, + "remote_url": null, + "cache_error": null, + "meta": { + "original": { + "width": 1600, + "height": 1200, + "size": "1600x1200", + "aspect": 1.3333333333333333 + }, + "small": { + "width": 800, + "height": 600, + "size": "800x600", + "aspect": 1.3333333333333333 + }, + "focus": { + "x": 0.3, + "y": -0.2 + } + }, + "description": "A test card pattern in bright colours", + "blurhash": "LVL4]9|Tnk_gsESdfjn,baayW;jZ" + }, + { + "id": "10", + "type": "image", + "media_type": "image/jpeg", + "url": "http://nextcloud.local/index.php/apps/social/media/1e936701-3c1a-49c2-a58a-bc7b0d5a9c27.jpeg", + "preview_url": "http://nextcloud.local/index.php/apps/social/media/43d878b8-d9fd-42fa-9f7e-38dea1357b60.jpeg", + "hls_url": null, + "remote_url": null, + "cache_error": null, + "meta": { + "original": { + "width": 1080, + "height": 1350, + "size": "1080x1350", + "aspect": 0.8 + }, + "small": { + "width": 640, + "height": 800, + "size": "640x800", + "aspect": 0.8 + }, + "focus": { + "x": 0, + "y": 0 + } + }, + "description": "A Mandelbrot fractal, portrait", + "blurhash": "LyLow4+,$SN?w6S]WUjI[ibCSIbW" + } + ], + "created_at": "2026-09-28T23:11:36.000Z", + "edited_at": null, + "noindex": false, + "account": { + "id": "6", + "nid": "6", + "username": "alice", + "acct": "alice", + "display_name": "alice", + "locked": false, + "bot": false, + "discoverable": false, + "indexable": false, + "group": false, + "created_at": "2026-09-28T23:06:02.000Z", + "note": "", + "url": "http://nextcloud.local/index.php/apps/social/@alice", + "avatar": "", + "avatar_static": "", + "header": "http://nextcloud.local/index.php/avatar/alice/128", + "header_static": "http://nextcloud.local/index.php/avatar/alice/128", + "followers_count": 2, + "following_count": 2, + "statuses_count": 13, + "last_status_at": null, + "emojis": [], + "fields": [], + "pronouns": "", + "support_link": "" + } + } + ] +} diff --git a/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/collection.json b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/collection.json new file mode 100644 index 00000000..8037e801 --- /dev/null +++ b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/collection.json @@ -0,0 +1,196 @@ +{ + "request": { + "method": "GET", + "path": "/index.php/apps/social/api/v1/collections/1" + }, + "status": 200, + "headers": { + "content-type": "application/json; charset=utf-8", + "cache-control": "no-cache, no-store, must-revalidate", + "x-ratelimit-limit": "300", + "x-ratelimit-remaining": "186", + "x-ratelimit-reset": "1790637300" + }, + "body": { + "id": "1", + "title": "Test album", + "description": "Pictures for the fixtures", + "visibility": "public", + "created_at": "2026-09-28T23:11:55+00:00", + "updated_at": "2026-09-28T23:11:55+00:00", + "size": 1, + "account": { + "id": "6", + "nid": "6", + "username": "alice", + "acct": "alice", + "display_name": "alice", + "locked": false, + "bot": false, + "discoverable": false, + "indexable": false, + "group": false, + "created_at": "2026-09-28T23:06:02.000Z", + "note": "", + "url": "http://nextcloud.local/index.php/apps/social/@alice", + "avatar": "", + "avatar_static": "", + "header": "http://nextcloud.local/index.php/avatar/alice/128", + "header_static": "http://nextcloud.local/index.php/avatar/alice/128", + "followers_count": 2, + "following_count": 2, + "statuses_count": 13, + "last_status_at": null, + "emojis": [], + "fields": [], + "pronouns": "", + "support_link": "" + }, + "posts": [ + { + "id": "1790637096330770789", + "nid": "1790637096330770789", + "local": true, + "archived": false, + "view_count": null, + "tagged_people": [], + "interest": null, + "content": "

Two test pictures #photography

", + "sensitive": false, + "spoiler_text": "", + "visibility": "public", + "language": "en", + "place": null, + "in_reply_to_id": null, + "in_reply_to_account_id": null, + "quote": null, + "quote_approval": { + "automatic": [ + "public" + ], + "manual": [], + "current_user": "automatic" + }, + "reply_approval": null, + "interaction_policy": null, + "dislikes_count": null, + "disliked": null, + "video": null, + "mentions": [], + "emojis": [], + "tags": [ + { + "name": "photography", + "url": "http://nextcloud.local/index.php/apps/social/timeline/tags/photography" + } + ], + "replies_count": 0, + "reblogs_count": 1, + "favourites_count": 0, + "favourited": false, + "reblogged": false, + "muted": false, + "bookmarked": false, + "pinned": false, + "card": null, + "reactions": [], + "poll": null, + "uri": "http://nextcloud.local/index.php/apps/social/@alice/17906370966999687509", + "url": "http://nextcloud.local/index.php/apps/social/@alice/17906370966999687509", + "reblog": null, + "media_attachments": [ + { + "id": "9", + "type": "image", + "media_type": "image/jpeg", + "url": "http://nextcloud.local/index.php/apps/social/media/48044adf-b771-4343-9607-c7fc93f16ad1.jpeg", + "preview_url": "http://nextcloud.local/index.php/apps/social/media/2c66bfd7-0029-47e1-b6ef-23dc7d422e2b.jpeg", + "hls_url": null, + "remote_url": null, + "cache_error": null, + "meta": { + "original": { + "width": 1600, + "height": 1200, + "size": "1600x1200", + "aspect": 1.3333333333333333 + }, + "small": { + "width": 800, + "height": 600, + "size": "800x600", + "aspect": 1.3333333333333333 + }, + "focus": { + "x": 0.3, + "y": -0.2 + } + }, + "description": "A test card pattern in bright colours", + "blurhash": "LVL4]9|Tnk_gsESdfjn,baayW;jZ" + }, + { + "id": "10", + "type": "image", + "media_type": "image/jpeg", + "url": "http://nextcloud.local/index.php/apps/social/media/1e936701-3c1a-49c2-a58a-bc7b0d5a9c27.jpeg", + "preview_url": "http://nextcloud.local/index.php/apps/social/media/43d878b8-d9fd-42fa-9f7e-38dea1357b60.jpeg", + "hls_url": null, + "remote_url": null, + "cache_error": null, + "meta": { + "original": { + "width": 1080, + "height": 1350, + "size": "1080x1350", + "aspect": 0.8 + }, + "small": { + "width": 640, + "height": 800, + "size": "640x800", + "aspect": 0.8 + }, + "focus": { + "x": 0, + "y": 0 + } + }, + "description": "A Mandelbrot fractal, portrait", + "blurhash": "LyLow4+,$SN?w6S]WUjI[ibCSIbW" + } + ], + "created_at": "2026-09-28T23:11:36.000Z", + "edited_at": null, + "noindex": false, + "account": { + "id": "6", + "nid": "6", + "username": "alice", + "acct": "alice", + "display_name": "alice", + "locked": false, + "bot": false, + "discoverable": false, + "indexable": false, + "group": false, + "created_at": "2026-09-28T23:06:02.000Z", + "note": "", + "url": "http://nextcloud.local/index.php/apps/social/@alice", + "avatar": "", + "avatar_static": "", + "header": "http://nextcloud.local/index.php/avatar/alice/128", + "header_static": "http://nextcloud.local/index.php/avatar/alice/128", + "followers_count": 2, + "following_count": 2, + "statuses_count": 13, + "last_status_at": null, + "emojis": [], + "fields": [], + "pronouns": "", + "support_link": "" + } + } + ] + } +} diff --git a/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/collections-account-alice.json b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/collections-account-alice.json new file mode 100644 index 00000000..0359584d --- /dev/null +++ b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/collections-account-alice.json @@ -0,0 +1,198 @@ +{ + "request": { + "method": "GET", + "path": "/index.php/apps/social/api/v1/accounts/6/collections" + }, + "status": 200, + "headers": { + "content-type": "application/json; charset=utf-8", + "cache-control": "no-cache, no-store, must-revalidate", + "x-ratelimit-limit": "300", + "x-ratelimit-remaining": "184", + "x-ratelimit-reset": "1790637300" + }, + "body": [ + { + "id": "1", + "title": "Test album", + "description": "Pictures for the fixtures", + "visibility": "public", + "created_at": "2026-09-28T23:11:55+00:00", + "updated_at": "2026-09-28T23:11:55+00:00", + "size": 1, + "account": { + "id": "6", + "nid": "6", + "username": "alice", + "acct": "alice", + "display_name": "alice", + "locked": false, + "bot": false, + "discoverable": false, + "indexable": false, + "group": false, + "created_at": "2026-09-28T23:06:02.000Z", + "note": "", + "url": "http://nextcloud.local/index.php/apps/social/@alice", + "avatar": "", + "avatar_static": "", + "header": "http://nextcloud.local/index.php/avatar/alice/128", + "header_static": "http://nextcloud.local/index.php/avatar/alice/128", + "followers_count": 2, + "following_count": 2, + "statuses_count": 13, + "last_status_at": null, + "emojis": [], + "fields": [], + "pronouns": "", + "support_link": "" + }, + "posts": [ + { + "id": "1790637096330770789", + "nid": "1790637096330770789", + "local": true, + "archived": false, + "view_count": null, + "tagged_people": [], + "interest": null, + "content": "

Two test pictures #photography

", + "sensitive": false, + "spoiler_text": "", + "visibility": "public", + "language": "en", + "place": null, + "in_reply_to_id": null, + "in_reply_to_account_id": null, + "quote": null, + "quote_approval": { + "automatic": [ + "public" + ], + "manual": [], + "current_user": "automatic" + }, + "reply_approval": null, + "interaction_policy": null, + "dislikes_count": null, + "disliked": null, + "video": null, + "mentions": [], + "emojis": [], + "tags": [ + { + "name": "photography", + "url": "http://nextcloud.local/index.php/apps/social/timeline/tags/photography" + } + ], + "replies_count": 0, + "reblogs_count": 1, + "favourites_count": 0, + "favourited": false, + "reblogged": false, + "muted": false, + "bookmarked": false, + "pinned": false, + "card": null, + "reactions": [], + "poll": null, + "uri": "http://nextcloud.local/index.php/apps/social/@alice/17906370966999687509", + "url": "http://nextcloud.local/index.php/apps/social/@alice/17906370966999687509", + "reblog": null, + "media_attachments": [ + { + "id": "9", + "type": "image", + "media_type": "image/jpeg", + "url": "http://nextcloud.local/index.php/apps/social/media/48044adf-b771-4343-9607-c7fc93f16ad1.jpeg", + "preview_url": "http://nextcloud.local/index.php/apps/social/media/2c66bfd7-0029-47e1-b6ef-23dc7d422e2b.jpeg", + "hls_url": null, + "remote_url": null, + "cache_error": null, + "meta": { + "original": { + "width": 1600, + "height": 1200, + "size": "1600x1200", + "aspect": 1.3333333333333333 + }, + "small": { + "width": 800, + "height": 600, + "size": "800x600", + "aspect": 1.3333333333333333 + }, + "focus": { + "x": 0.3, + "y": -0.2 + } + }, + "description": "A test card pattern in bright colours", + "blurhash": "LVL4]9|Tnk_gsESdfjn,baayW;jZ" + }, + { + "id": "10", + "type": "image", + "media_type": "image/jpeg", + "url": "http://nextcloud.local/index.php/apps/social/media/1e936701-3c1a-49c2-a58a-bc7b0d5a9c27.jpeg", + "preview_url": "http://nextcloud.local/index.php/apps/social/media/43d878b8-d9fd-42fa-9f7e-38dea1357b60.jpeg", + "hls_url": null, + "remote_url": null, + "cache_error": null, + "meta": { + "original": { + "width": 1080, + "height": 1350, + "size": "1080x1350", + "aspect": 0.8 + }, + "small": { + "width": 640, + "height": 800, + "size": "640x800", + "aspect": 0.8 + }, + "focus": { + "x": 0, + "y": 0 + } + }, + "description": "A Mandelbrot fractal, portrait", + "blurhash": "LyLow4+,$SN?w6S]WUjI[ibCSIbW" + } + ], + "created_at": "2026-09-28T23:11:36.000Z", + "edited_at": null, + "noindex": false, + "account": { + "id": "6", + "nid": "6", + "username": "alice", + "acct": "alice", + "display_name": "alice", + "locked": false, + "bot": false, + "discoverable": false, + "indexable": false, + "group": false, + "created_at": "2026-09-28T23:06:02.000Z", + "note": "", + "url": "http://nextcloud.local/index.php/apps/social/@alice", + "avatar": "", + "avatar_static": "", + "header": "http://nextcloud.local/index.php/avatar/alice/128", + "header_static": "http://nextcloud.local/index.php/avatar/alice/128", + "followers_count": 2, + "following_count": 2, + "statuses_count": 13, + "last_status_at": null, + "emojis": [], + "fields": [], + "pronouns": "", + "support_link": "" + } + } + ] + } + ] +} diff --git a/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/collections-alice.json b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/collections-alice.json new file mode 100644 index 00000000..3a06bf97 --- /dev/null +++ b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/collections-alice.json @@ -0,0 +1,198 @@ +{ + "request": { + "method": "GET", + "path": "/index.php/apps/social/api/v1/collections" + }, + "status": 200, + "headers": { + "content-type": "application/json; charset=utf-8", + "cache-control": "no-cache, no-store, must-revalidate", + "x-ratelimit-limit": "300", + "x-ratelimit-remaining": "187", + "x-ratelimit-reset": "1790637300" + }, + "body": [ + { + "id": "1", + "title": "Test album", + "description": "Pictures for the fixtures", + "visibility": "public", + "created_at": "2026-09-28T23:11:55+00:00", + "updated_at": "2026-09-28T23:11:55+00:00", + "size": 1, + "account": { + "id": "6", + "nid": "6", + "username": "alice", + "acct": "alice", + "display_name": "alice", + "locked": false, + "bot": false, + "discoverable": false, + "indexable": false, + "group": false, + "created_at": "2026-09-28T23:06:02.000Z", + "note": "", + "url": "http://nextcloud.local/index.php/apps/social/@alice", + "avatar": "", + "avatar_static": "", + "header": "http://nextcloud.local/index.php/avatar/alice/128", + "header_static": "http://nextcloud.local/index.php/avatar/alice/128", + "followers_count": 2, + "following_count": 2, + "statuses_count": 13, + "last_status_at": null, + "emojis": [], + "fields": [], + "pronouns": "", + "support_link": "" + }, + "posts": [ + { + "id": "1790637096330770789", + "nid": "1790637096330770789", + "local": true, + "archived": false, + "view_count": null, + "tagged_people": [], + "interest": null, + "content": "

Two test pictures #photography

", + "sensitive": false, + "spoiler_text": "", + "visibility": "public", + "language": "en", + "place": null, + "in_reply_to_id": null, + "in_reply_to_account_id": null, + "quote": null, + "quote_approval": { + "automatic": [ + "public" + ], + "manual": [], + "current_user": "automatic" + }, + "reply_approval": null, + "interaction_policy": null, + "dislikes_count": null, + "disliked": null, + "video": null, + "mentions": [], + "emojis": [], + "tags": [ + { + "name": "photography", + "url": "http://nextcloud.local/index.php/apps/social/timeline/tags/photography" + } + ], + "replies_count": 0, + "reblogs_count": 1, + "favourites_count": 0, + "favourited": false, + "reblogged": false, + "muted": false, + "bookmarked": false, + "pinned": false, + "card": null, + "reactions": [], + "poll": null, + "uri": "http://nextcloud.local/index.php/apps/social/@alice/17906370966999687509", + "url": "http://nextcloud.local/index.php/apps/social/@alice/17906370966999687509", + "reblog": null, + "media_attachments": [ + { + "id": "9", + "type": "image", + "media_type": "image/jpeg", + "url": "http://nextcloud.local/index.php/apps/social/media/48044adf-b771-4343-9607-c7fc93f16ad1.jpeg", + "preview_url": "http://nextcloud.local/index.php/apps/social/media/2c66bfd7-0029-47e1-b6ef-23dc7d422e2b.jpeg", + "hls_url": null, + "remote_url": null, + "cache_error": null, + "meta": { + "original": { + "width": 1600, + "height": 1200, + "size": "1600x1200", + "aspect": 1.3333333333333333 + }, + "small": { + "width": 800, + "height": 600, + "size": "800x600", + "aspect": 1.3333333333333333 + }, + "focus": { + "x": 0.3, + "y": -0.2 + } + }, + "description": "A test card pattern in bright colours", + "blurhash": "LVL4]9|Tnk_gsESdfjn,baayW;jZ" + }, + { + "id": "10", + "type": "image", + "media_type": "image/jpeg", + "url": "http://nextcloud.local/index.php/apps/social/media/1e936701-3c1a-49c2-a58a-bc7b0d5a9c27.jpeg", + "preview_url": "http://nextcloud.local/index.php/apps/social/media/43d878b8-d9fd-42fa-9f7e-38dea1357b60.jpeg", + "hls_url": null, + "remote_url": null, + "cache_error": null, + "meta": { + "original": { + "width": 1080, + "height": 1350, + "size": "1080x1350", + "aspect": 0.8 + }, + "small": { + "width": 640, + "height": 800, + "size": "640x800", + "aspect": 0.8 + }, + "focus": { + "x": 0, + "y": 0 + } + }, + "description": "A Mandelbrot fractal, portrait", + "blurhash": "LyLow4+,$SN?w6S]WUjI[ibCSIbW" + } + ], + "created_at": "2026-09-28T23:11:36.000Z", + "edited_at": null, + "noindex": false, + "account": { + "id": "6", + "nid": "6", + "username": "alice", + "acct": "alice", + "display_name": "alice", + "locked": false, + "bot": false, + "discoverable": false, + "indexable": false, + "group": false, + "created_at": "2026-09-28T23:06:02.000Z", + "note": "", + "url": "http://nextcloud.local/index.php/apps/social/@alice", + "avatar": "", + "avatar_static": "", + "header": "http://nextcloud.local/index.php/avatar/alice/128", + "header_static": "http://nextcloud.local/index.php/avatar/alice/128", + "followers_count": 2, + "following_count": 2, + "statuses_count": 13, + "last_status_at": null, + "emojis": [], + "fields": [], + "pronouns": "", + "support_link": "" + } + } + ] + } + ] +} diff --git a/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/conversations-bob.json b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/conversations-bob.json new file mode 100644 index 00000000..6e28eb2c --- /dev/null +++ b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/conversations-bob.json @@ -0,0 +1,126 @@ +{ + "request": { + "method": "GET", + "path": "/index.php/apps/social/api/v1/conversations" + }, + "status": 200, + "headers": { + "content-type": "application/json; charset=utf-8", + "cache-control": "no-cache, no-store, must-revalidate", + "link": "; rel=\"prev\"", + "x-ratelimit-limit": "300", + "x-ratelimit-remaining": "249", + "x-ratelimit-reset": "1790637300" + }, + "body": [ + { + "id": "1790637086268689343", + "unread": true, + "accounts": [ + { + "id": "6", + "nid": "6", + "username": "alice", + "acct": "alice", + "display_name": "alice", + "locked": false, + "bot": false, + "discoverable": false, + "indexable": false, + "group": false, + "created_at": "2026-09-28T23:06:02.000Z", + "note": "", + "url": "http://nextcloud.local/index.php/apps/social/@alice", + "avatar": "", + "avatar_static": "", + "header": "http://nextcloud.local/index.php/avatar/alice/128", + "header_static": "http://nextcloud.local/index.php/avatar/alice/128", + "followers_count": 2, + "following_count": 2, + "statuses_count": 13, + "last_status_at": null, + "emojis": [], + "fields": [], + "pronouns": "", + "support_link": "" + } + ], + "last_status": { + "id": "1790637086268689343", + "nid": "1790637086268689343", + "local": true, + "archived": false, + "view_count": null, + "tagged_people": [], + "interest": null, + "content": "

@bob@nextcloud.local this is a direct message.

", + "sensitive": false, + "spoiler_text": "", + "visibility": "direct", + "language": "en", + "place": null, + "in_reply_to_id": null, + "in_reply_to_account_id": null, + "quote": null, + "quote_approval": { + "automatic": [], + "manual": [], + "current_user": "denied" + }, + "reply_approval": null, + "interaction_policy": null, + "dislikes_count": null, + "disliked": null, + "video": null, + "mentions": [], + "emojis": [], + "tags": [], + "replies_count": 0, + "reblogs_count": 0, + "favourites_count": 0, + "favourited": false, + "reblogged": false, + "muted": false, + "bookmarked": false, + "pinned": false, + "card": null, + "reactions": [], + "poll": null, + "uri": "http://nextcloud.local/index.php/apps/social/@alice/17906370863679564895", + "url": "http://nextcloud.local/index.php/apps/social/@alice/17906370863679564895", + "reblog": null, + "media_attachments": [], + "created_at": "2026-09-28T23:11:26.000Z", + "edited_at": null, + "noindex": false, + "account": { + "id": "6", + "nid": "6", + "username": "alice", + "acct": "alice", + "display_name": "alice", + "locked": false, + "bot": false, + "discoverable": false, + "indexable": false, + "group": false, + "created_at": "2026-09-28T23:06:02.000Z", + "note": "", + "url": "http://nextcloud.local/index.php/apps/social/@alice", + "avatar": "", + "avatar_static": "", + "header": "http://nextcloud.local/index.php/avatar/alice/128", + "header_static": "http://nextcloud.local/index.php/avatar/alice/128", + "followers_count": 2, + "following_count": 2, + "statuses_count": 13, + "last_status_at": null, + "emojis": [], + "fields": [], + "pronouns": "", + "support_link": "" + } + } + } + ] +} diff --git a/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/conversations-unread-count.json b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/conversations-unread-count.json new file mode 100644 index 00000000..518d9293 --- /dev/null +++ b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/conversations-unread-count.json @@ -0,0 +1,17 @@ +{ + "request": { + "method": "GET", + "path": "/index.php/apps/social/api/v1/conversations/unread_count" + }, + "status": 200, + "headers": { + "content-type": "application/json; charset=utf-8", + "cache-control": "no-cache, no-store, must-revalidate", + "x-ratelimit-limit": "300", + "x-ratelimit-remaining": "248", + "x-ratelimit-reset": "1790637300" + }, + "body": { + "count": 1 + } +} diff --git a/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/custom-emojis.json b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/custom-emojis.json new file mode 100644 index 00000000..a706943c --- /dev/null +++ b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/custom-emojis.json @@ -0,0 +1,16 @@ +{ + "request": { + "method": "GET", + "path": "/index.php/apps/social/api/v1/custom_emojis" + }, + "status": 200, + "headers": { + "content-type": "application/json; charset=utf-8", + "cache-control": "private, no-cache", + "etag": "\"a58caa41a87fcece526fa814095e68a3c87ef0d7\"", + "x-ratelimit-limit": "300", + "x-ratelimit-remaining": "200", + "x-ratelimit-reset": "1790637300" + }, + "body": [] +} diff --git a/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/directory.json b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/directory.json new file mode 100644 index 00000000..c8684e6b --- /dev/null +++ b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/directory.json @@ -0,0 +1,15 @@ +{ + "request": { + "method": "GET", + "path": "/index.php/apps/social/api/v1/directory?order=active" + }, + "status": 200, + "headers": { + "content-type": "application/json; charset=utf-8", + "cache-control": "no-cache, no-store, must-revalidate", + "x-ratelimit-limit": "300", + "x-ratelimit-remaining": "201", + "x-ratelimit-reset": "1790637300" + }, + "body": [] +} diff --git a/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/discover-posts.json b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/discover-posts.json new file mode 100644 index 00000000..e8d94d1d --- /dev/null +++ b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/discover-posts.json @@ -0,0 +1,698 @@ +{ + "request": { + "method": "GET", + "path": "/index.php/apps/social/api/v2/discover/posts" + }, + "status": 200, + "headers": { + "content-type": "application/json; charset=utf-8", + "cache-control": "no-cache, no-store, must-revalidate", + "x-ratelimit-limit": "300", + "x-ratelimit-remaining": "183", + "x-ratelimit-reset": "1790637300" + }, + "body": [ + { + "id": "1790637104958214061", + "nid": "1790637104958214061", + "local": true, + "archived": false, + "view_count": null, + "tagged_people": [], + "interest": null, + "content": "

Test video #video

00:00 Intro
00:10 Middle
00:20 End

", + "sensitive": false, + "spoiler_text": "", + "visibility": "public", + "language": "en", + "place": null, + "in_reply_to_id": null, + "in_reply_to_account_id": null, + "quote": null, + "quote_approval": { + "automatic": [ + "public" + ], + "manual": [], + "current_user": "automatic" + }, + "reply_approval": null, + "interaction_policy": null, + "dislikes_count": 0, + "disliked": false, + "video": { + "title": "Aloha test video" + }, + "mentions": [], + "emojis": [], + "tags": [ + { + "name": "video", + "url": "http://nextcloud.local/index.php/apps/social/timeline/tags/video" + } + ], + "replies_count": 0, + "reblogs_count": 0, + "favourites_count": 1, + "favourited": false, + "reblogged": false, + "muted": false, + "bookmarked": false, + "pinned": false, + "card": null, + "reactions": [], + "poll": null, + "uri": "http://nextcloud.local/index.php/apps/social/@alice/17906371045255950881", + "url": "http://nextcloud.local/index.php/apps/social/@alice/17906371045255950881", + "reblog": null, + "media_attachments": [ + { + "id": "12", + "type": "video", + "media_type": "video/mp4", + "url": "http://nextcloud.local/index.php/apps/social/media/09c96321-538a-4d33-a3e3-e326ba1c5a56.mp4", + "preview_url": "http://nextcloud.local/index.php/apps/social/media/09c96321-538a-4d33-a3e3-e326ba1c5a56.mp4", + "hls_url": null, + "remote_url": null, + "cache_error": null, + "meta": { + "original": [], + "small": [], + "focus": { + "x": 0, + "y": 0 + } + }, + "description": "A 30 second test pattern with a tone", + "blurhash": null + } + ], + "created_at": "2026-09-28T23:11:44.000Z", + "edited_at": null, + "noindex": false, + "account": { + "id": "6", + "nid": "6", + "username": "alice", + "acct": "alice", + "display_name": "alice", + "locked": false, + "bot": false, + "discoverable": false, + "indexable": false, + "group": false, + "created_at": "2026-09-28T23:06:02.000Z", + "note": "", + "url": "http://nextcloud.local/index.php/apps/social/@alice", + "avatar": "", + "avatar_static": "", + "header": "http://nextcloud.local/index.php/avatar/alice/128", + "header_static": "http://nextcloud.local/index.php/avatar/alice/128", + "followers_count": 2, + "following_count": 2, + "statuses_count": 13, + "last_status_at": null, + "emojis": [], + "fields": [], + "pronouns": "", + "support_link": "" + } + }, + { + "id": "1790637096330770789", + "nid": "1790637096330770789", + "local": true, + "archived": false, + "view_count": null, + "tagged_people": [], + "interest": null, + "content": "

Two test pictures #photography

", + "sensitive": false, + "spoiler_text": "", + "visibility": "public", + "language": "en", + "place": null, + "in_reply_to_id": null, + "in_reply_to_account_id": null, + "quote": null, + "quote_approval": { + "automatic": [ + "public" + ], + "manual": [], + "current_user": "automatic" + }, + "reply_approval": null, + "interaction_policy": null, + "dislikes_count": null, + "disliked": null, + "video": null, + "mentions": [], + "emojis": [], + "tags": [ + { + "name": "photography", + "url": "http://nextcloud.local/index.php/apps/social/timeline/tags/photography" + } + ], + "replies_count": 0, + "reblogs_count": 1, + "favourites_count": 0, + "favourited": false, + "reblogged": false, + "muted": false, + "bookmarked": false, + "pinned": false, + "card": null, + "reactions": [], + "poll": null, + "uri": "http://nextcloud.local/index.php/apps/social/@alice/17906370966999687509", + "url": "http://nextcloud.local/index.php/apps/social/@alice/17906370966999687509", + "reblog": null, + "media_attachments": [ + { + "id": "9", + "type": "image", + "media_type": "image/jpeg", + "url": "http://nextcloud.local/index.php/apps/social/media/48044adf-b771-4343-9607-c7fc93f16ad1.jpeg", + "preview_url": "http://nextcloud.local/index.php/apps/social/media/2c66bfd7-0029-47e1-b6ef-23dc7d422e2b.jpeg", + "hls_url": null, + "remote_url": null, + "cache_error": null, + "meta": { + "original": { + "width": 1600, + "height": 1200, + "size": "1600x1200", + "aspect": 1.3333333333333333 + }, + "small": { + "width": 800, + "height": 600, + "size": "800x600", + "aspect": 1.3333333333333333 + }, + "focus": { + "x": 0.3, + "y": -0.2 + } + }, + "description": "A test card pattern in bright colours", + "blurhash": "LVL4]9|Tnk_gsESdfjn,baayW;jZ" + }, + { + "id": "10", + "type": "image", + "media_type": "image/jpeg", + "url": "http://nextcloud.local/index.php/apps/social/media/1e936701-3c1a-49c2-a58a-bc7b0d5a9c27.jpeg", + "preview_url": "http://nextcloud.local/index.php/apps/social/media/43d878b8-d9fd-42fa-9f7e-38dea1357b60.jpeg", + "hls_url": null, + "remote_url": null, + "cache_error": null, + "meta": { + "original": { + "width": 1080, + "height": 1350, + "size": "1080x1350", + "aspect": 0.8 + }, + "small": { + "width": 640, + "height": 800, + "size": "640x800", + "aspect": 0.8 + }, + "focus": { + "x": 0, + "y": 0 + } + }, + "description": "A Mandelbrot fractal, portrait", + "blurhash": "LyLow4+,$SN?w6S]WUjI[ibCSIbW" + } + ], + "created_at": "2026-09-28T23:11:36.000Z", + "edited_at": null, + "noindex": false, + "account": { + "id": "6", + "nid": "6", + "username": "alice", + "acct": "alice", + "display_name": "alice", + "locked": false, + "bot": false, + "discoverable": false, + "indexable": false, + "group": false, + "created_at": "2026-09-28T23:06:02.000Z", + "note": "", + "url": "http://nextcloud.local/index.php/apps/social/@alice", + "avatar": "", + "avatar_static": "", + "header": "http://nextcloud.local/index.php/avatar/alice/128", + "header_static": "http://nextcloud.local/index.php/avatar/alice/128", + "followers_count": 2, + "following_count": 2, + "statuses_count": 13, + "last_status_at": null, + "emojis": [], + "fields": [], + "pronouns": "", + "support_link": "" + } + }, + { + "id": "1790637112216295042", + "nid": "1790637112216295042", + "local": true, + "archived": false, + "view_count": null, + "tagged_people": [], + "interest": null, + "content": "

Picture from Berlin

", + "sensitive": false, + "spoiler_text": "", + "visibility": "public", + "language": "en", + "place": { + "id": "1", + "name": "Berlin", + "country": "DE", + "lat": "", + "long": "" + }, + "in_reply_to_id": null, + "in_reply_to_account_id": null, + "quote": null, + "quote_approval": { + "automatic": [ + "public" + ], + "manual": [], + "current_user": "automatic" + }, + "reply_approval": null, + "interaction_policy": null, + "dislikes_count": null, + "disliked": null, + "video": null, + "mentions": [], + "emojis": [], + "tags": [], + "replies_count": 0, + "reblogs_count": 0, + "favourites_count": 0, + "favourited": false, + "reblogged": false, + "muted": false, + "bookmarked": false, + "pinned": false, + "card": null, + "reactions": [], + "poll": null, + "uri": "http://nextcloud.local/index.php/apps/social/@alice/17906371127479253498", + "url": "http://nextcloud.local/index.php/apps/social/@alice/17906371127479253498", + "reblog": null, + "media_attachments": [ + { + "id": "15", + "type": "image", + "media_type": "image/jpeg", + "url": "http://nextcloud.local/index.php/apps/social/media/b88735cc-b169-40e0-9fa5-85e3be451c1a.jpeg", + "preview_url": "http://nextcloud.local/index.php/apps/social/media/e46d0547-3b30-46cd-8b5b-394bb7005a49.jpeg", + "hls_url": null, + "remote_url": null, + "cache_error": null, + "meta": { + "original": { + "width": 1080, + "height": 1350, + "size": "1080x1350", + "aspect": 0.8 + }, + "small": { + "width": 640, + "height": 800, + "size": "640x800", + "aspect": 0.8 + }, + "focus": { + "x": 0, + "y": 0 + } + }, + "description": "Another fractal, taken in Berlin", + "blurhash": "LyLow4+,$SN?w6S]WUjI[ibCSIbW" + } + ], + "created_at": "2026-09-28T23:11:52.000Z", + "edited_at": null, + "noindex": false, + "account": { + "id": "6", + "nid": "6", + "username": "alice", + "acct": "alice", + "display_name": "alice", + "locked": false, + "bot": false, + "discoverable": false, + "indexable": false, + "group": false, + "created_at": "2026-09-28T23:06:02.000Z", + "note": "", + "url": "http://nextcloud.local/index.php/apps/social/@alice", + "avatar": "", + "avatar_static": "", + "header": "http://nextcloud.local/index.php/avatar/alice/128", + "header_static": "http://nextcloud.local/index.php/avatar/alice/128", + "followers_count": 2, + "following_count": 2, + "statuses_count": 13, + "last_status_at": null, + "emojis": [], + "fields": [], + "pronouns": "", + "support_link": "" + } + }, + { + "id": "1790637105549523409", + "nid": "1790637105549523409", + "local": true, + "archived": false, + "view_count": null, + "tagged_people": [], + "interest": null, + "content": "

A 15 second tone #audio

", + "sensitive": false, + "spoiler_text": "", + "visibility": "public", + "language": "en", + "place": null, + "in_reply_to_id": null, + "in_reply_to_account_id": null, + "quote": null, + "quote_approval": { + "automatic": [ + "public" + ], + "manual": [], + "current_user": "automatic" + }, + "reply_approval": null, + "interaction_policy": null, + "dislikes_count": null, + "disliked": null, + "video": null, + "mentions": [], + "emojis": [], + "tags": [ + { + "name": "audio", + "url": "http://nextcloud.local/index.php/apps/social/timeline/tags/audio" + } + ], + "replies_count": 0, + "reblogs_count": 0, + "favourites_count": 0, + "favourited": false, + "reblogged": false, + "muted": false, + "bookmarked": false, + "pinned": false, + "card": null, + "reactions": [], + "poll": null, + "uri": "http://nextcloud.local/index.php/apps/social/@alice/17906371052336860333", + "url": "http://nextcloud.local/index.php/apps/social/@alice/17906371052336860333", + "reblog": null, + "media_attachments": [ + { + "id": "14", + "type": "audio", + "media_type": "audio/mpeg", + "url": "http://nextcloud.local/index.php/apps/social/media/2ee64fb3-dda9-47f5-bac6-daa189149093.mpeg", + "preview_url": "http://nextcloud.local/index.php/apps/social/media/2ee64fb3-dda9-47f5-bac6-daa189149093.mpeg", + "hls_url": null, + "remote_url": null, + "cache_error": null, + "meta": { + "original": [], + "small": [], + "focus": { + "x": 0, + "y": 0 + } + }, + "description": "A 15 second 330 Hz tone", + "blurhash": null + } + ], + "created_at": "2026-09-28T23:11:45.000Z", + "edited_at": null, + "noindex": false, + "account": { + "id": "6", + "nid": "6", + "username": "alice", + "acct": "alice", + "display_name": "alice", + "locked": false, + "bot": false, + "discoverable": false, + "indexable": false, + "group": false, + "created_at": "2026-09-28T23:06:02.000Z", + "note": "", + "url": "http://nextcloud.local/index.php/apps/social/@alice", + "avatar": "", + "avatar_static": "", + "header": "http://nextcloud.local/index.php/avatar/alice/128", + "header_static": "http://nextcloud.local/index.php/avatar/alice/128", + "followers_count": 2, + "following_count": 2, + "statuses_count": 13, + "last_status_at": null, + "emojis": [], + "fields": [], + "pronouns": "", + "support_link": "" + } + }, + { + "id": "1790637104870044831", + "nid": "1790637104870044831", + "local": true, + "archived": false, + "view_count": null, + "tagged_people": [], + "interest": null, + "content": "

Vertical test #shorts

", + "sensitive": false, + "spoiler_text": "", + "visibility": "public", + "language": "en", + "place": null, + "in_reply_to_id": null, + "in_reply_to_account_id": null, + "quote": null, + "quote_approval": { + "automatic": [ + "public" + ], + "manual": [], + "current_user": "automatic" + }, + "reply_approval": null, + "interaction_policy": null, + "dislikes_count": 0, + "disliked": false, + "video": null, + "mentions": [], + "emojis": [], + "tags": [ + { + "name": "shorts", + "url": "http://nextcloud.local/index.php/apps/social/timeline/tags/shorts" + } + ], + "replies_count": 0, + "reblogs_count": 0, + "favourites_count": 0, + "favourited": false, + "reblogged": false, + "muted": false, + "bookmarked": false, + "pinned": false, + "card": null, + "reactions": [], + "poll": null, + "uri": "http://nextcloud.local/index.php/apps/social/@alice/17906371044200157676", + "url": "http://nextcloud.local/index.php/apps/social/@alice/17906371044200157676", + "reblog": null, + "media_attachments": [ + { + "id": "13", + "type": "video", + "media_type": "video/mp4", + "url": "http://nextcloud.local/index.php/apps/social/media/9ca843a2-645e-439f-901c-3fd1d58c22b6.mp4", + "preview_url": "http://nextcloud.local/index.php/apps/social/media/9ca843a2-645e-439f-901c-3fd1d58c22b6.mp4", + "hls_url": null, + "remote_url": null, + "cache_error": null, + "meta": { + "original": [], + "small": [], + "focus": { + "x": 0, + "y": 0 + } + }, + "description": "A 12 second vertical test pattern", + "blurhash": null + } + ], + "created_at": "2026-09-28T23:11:44.000Z", + "edited_at": null, + "noindex": false, + "account": { + "id": "6", + "nid": "6", + "username": "alice", + "acct": "alice", + "display_name": "alice", + "locked": false, + "bot": false, + "discoverable": false, + "indexable": false, + "group": false, + "created_at": "2026-09-28T23:06:02.000Z", + "note": "", + "url": "http://nextcloud.local/index.php/apps/social/@alice", + "avatar": "", + "avatar_static": "", + "header": "http://nextcloud.local/index.php/avatar/alice/128", + "header_static": "http://nextcloud.local/index.php/avatar/alice/128", + "followers_count": 2, + "following_count": 2, + "statuses_count": 13, + "last_status_at": null, + "emojis": [], + "fields": [], + "pronouns": "", + "support_link": "" + } + }, + { + "id": "1790637103267719986", + "nid": "1790637103267719986", + "local": true, + "archived": false, + "view_count": null, + "tagged_people": [], + "interest": null, + "content": "

A picture without alt text, marked sensitive

", + "sensitive": true, + "spoiler_text": "", + "visibility": "public", + "language": "en", + "place": null, + "in_reply_to_id": null, + "in_reply_to_account_id": null, + "quote": null, + "quote_approval": { + "automatic": [ + "public" + ], + "manual": [], + "current_user": "automatic" + }, + "reply_approval": null, + "interaction_policy": null, + "dislikes_count": null, + "disliked": null, + "video": null, + "mentions": [], + "emojis": [], + "tags": [], + "replies_count": 0, + "reblogs_count": 0, + "favourites_count": 0, + "favourited": false, + "reblogged": false, + "muted": false, + "bookmarked": false, + "pinned": false, + "card": null, + "reactions": [], + "poll": null, + "uri": "http://nextcloud.local/index.php/apps/social/@alice/17906371033997341160", + "url": "http://nextcloud.local/index.php/apps/social/@alice/17906371033997341160", + "reblog": null, + "media_attachments": [ + { + "id": "11", + "type": "image", + "media_type": "image/png", + "url": "http://nextcloud.local/index.php/apps/social/media/347a8309-ea20-4900-8e4c-aff1e8b6a98b.png", + "preview_url": "http://nextcloud.local/index.php/apps/social/media/6bb97fab-b644-4c51-8b1b-8ea5921fdef0.png", + "hls_url": null, + "remote_url": null, + "cache_error": null, + "meta": { + "original": { + "width": 1080, + "height": 1080, + "size": "1080x1080", + "aspect": 1 + }, + "small": { + "width": 800, + "height": 800, + "size": "800x800", + "aspect": 1 + }, + "focus": { + "x": 0, + "y": 0 + } + }, + "description": null, + "blurhash": "LRF~:K*DD7L+S^o@$T+kR5wiM_IU" + } + ], + "created_at": "2026-09-28T23:11:43.000Z", + "edited_at": null, + "noindex": false, + "account": { + "id": "6", + "nid": "6", + "username": "alice", + "acct": "alice", + "display_name": "alice", + "locked": false, + "bot": false, + "discoverable": false, + "indexable": false, + "group": false, + "created_at": "2026-09-28T23:06:02.000Z", + "note": "", + "url": "http://nextcloud.local/index.php/apps/social/@alice", + "avatar": "", + "avatar_static": "", + "header": "http://nextcloud.local/index.php/avatar/alice/128", + "header_static": "http://nextcloud.local/index.php/avatar/alice/128", + "followers_count": 2, + "following_count": 2, + "statuses_count": 13, + "last_status_at": null, + "emojis": [], + "fields": [], + "pronouns": "", + "support_link": "" + } + } + ] +} diff --git a/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/favourites-bob.json b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/favourites-bob.json new file mode 100644 index 00000000..9535d9c3 --- /dev/null +++ b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/favourites-bob.json @@ -0,0 +1,111 @@ +{ + "request": { + "method": "GET", + "path": "/index.php/apps/social/api/v1/favourites/" + }, + "status": 200, + "headers": { + "content-type": "application/json; charset=utf-8", + "cache-control": "no-cache, no-store, must-revalidate", + "link": "; rel=\"prev\"", + "x-ratelimit-limit": "300", + "x-ratelimit-remaining": "251", + "x-ratelimit-reset": "1790637300" + }, + "body": [ + { + "id": "1790637085797595891", + "nid": "1790637085797595891", + "local": true, + "archived": false, + "view_count": null, + "tagged_people": [], + "interest": null, + "content": "

Aloha from the dev instance! Say hi, @bob@nextcloud.local #aloha #android

", + "sensitive": false, + "spoiler_text": "", + "visibility": "public", + "language": "en", + "place": null, + "in_reply_to_id": null, + "in_reply_to_account_id": null, + "quote": null, + "quote_approval": { + "automatic": [ + "public" + ], + "manual": [], + "current_user": "automatic" + }, + "reply_approval": null, + "interaction_policy": null, + "dislikes_count": null, + "disliked": null, + "video": null, + "mentions": [], + "emojis": [], + "tags": [ + { + "name": "aloha", + "url": "http://nextcloud.local/index.php/apps/social/timeline/tags/aloha" + }, + { + "name": "android", + "url": "http://nextcloud.local/index.php/apps/social/timeline/tags/android" + } + ], + "replies_count": 1, + "reblogs_count": 0, + "favourites_count": 1, + "favourited": true, + "reblogged": false, + "muted": false, + "bookmarked": false, + "pinned": false, + "card": null, + "reactions": [ + { + "name": "🎉", + "count": 1, + "me": true + } + ], + "poll": null, + "uri": "http://nextcloud.local/index.php/apps/social/@alice/17906370853806247191", + "url": "http://nextcloud.local/index.php/apps/social/@alice/17906370853806247191", + "reblog": null, + "media_attachments": [], + "created_at": "2026-09-28T23:11:25.000Z", + "edited_at": null, + "noindex": false, + "account": { + "id": "6", + "nid": "6", + "username": "alice", + "acct": "alice", + "display_name": "alice", + "locked": false, + "bot": false, + "discoverable": false, + "indexable": false, + "group": false, + "created_at": "2026-09-28T23:06:02.000Z", + "note": "", + "url": "http://nextcloud.local/index.php/apps/social/@alice", + "avatar": "", + "avatar_static": "", + "header": "http://nextcloud.local/index.php/avatar/alice/128", + "header_static": "http://nextcloud.local/index.php/avatar/alice/128", + "followers_count": 2, + "following_count": 2, + "statuses_count": 13, + "last_status_at": null, + "emojis": [], + "fields": [], + "pronouns": "", + "support_link": "" + }, + "filtered": [] + } + ] +} diff --git a/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/filters-v2-jane.json b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/filters-v2-jane.json new file mode 100644 index 00000000..6ac041a8 --- /dev/null +++ b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/filters-v2-jane.json @@ -0,0 +1,34 @@ +{ + "request": { + "method": "GET", + "path": "/index.php/apps/social/api/v2/filters" + }, + "status": 200, + "headers": { + "content-type": "application/json; charset=utf-8", + "cache-control": "no-cache, no-store, must-revalidate", + "x-ratelimit-limit": "300", + "x-ratelimit-remaining": "207", + "x-ratelimit-reset": "1790637300" + }, + "body": [ + { + "id": "1", + "title": "No spoilers", + "context": [ + "home", + "public" + ], + "expires_at": null, + "filter_action": "warn", + "keywords": [ + { + "id": "1", + "keyword": "butler", + "whole_word": false + } + ], + "statuses": [] + } + ] +} diff --git a/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/follow-requests-jane.json b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/follow-requests-jane.json new file mode 100644 index 00000000..ab4d7645 --- /dev/null +++ b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/follow-requests-jane.json @@ -0,0 +1,15 @@ +{ + "request": { + "method": "GET", + "path": "/index.php/apps/social/api/v1/follow_requests" + }, + "status": 200, + "headers": { + "content-type": "application/json; charset=utf-8", + "cache-control": "no-cache, no-store, must-revalidate", + "x-ratelimit-limit": "300", + "x-ratelimit-remaining": "257", + "x-ratelimit-reset": "1790637300" + }, + "body": [] +} diff --git a/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/followed-tags.json b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/followed-tags.json new file mode 100644 index 00000000..b04eb7c8 --- /dev/null +++ b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/followed-tags.json @@ -0,0 +1,16 @@ +{ + "request": { + "method": "GET", + "path": "/index.php/apps/social/api/v1/followed_tags" + }, + "status": 200, + "headers": { + "content-type": "application/json; charset=utf-8", + "cache-control": "private, no-cache", + "etag": "\"a58caa41a87fcece526fa814095e68a3c87ef0d7\"", + "x-ratelimit-limit": "300", + "x-ratelimit-remaining": "206", + "x-ratelimit-reset": "1790637300" + }, + "body": [] +} diff --git a/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/gifs.json b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/gifs.json new file mode 100644 index 00000000..0f9f7eb1 --- /dev/null +++ b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/gifs.json @@ -0,0 +1,50 @@ +{ + "request": { + "method": "GET", + "path": "/index.php/apps/social/api/v1/gifs?q=cat&limit=5" + }, + "status": 200, + "headers": { + "content-type": "application/json; charset=utf-8", + "cache-control": "no-cache, no-store, must-revalidate", + "x-ratelimit-limit": "300", + "x-ratelimit-remaining": "192", + "x-ratelimit-reset": "1790637300" + }, + "body": { + "gifs": [ + { + "slug": "noto-1f63a", + "title": "smiley cat", + "url": "http://nextcloud.local/index.php/apps/social/gif/noto-1f63a", + "media_type": "image/webp" + }, + { + "slug": "noto-1f638", + "title": "smile cat", + "url": "http://nextcloud.local/index.php/apps/social/gif/noto-1f638", + "media_type": "image/webp" + }, + { + "slug": "noto-1f639", + "title": "joy cat", + "url": "http://nextcloud.local/index.php/apps/social/gif/noto-1f639", + "media_type": "image/webp" + }, + { + "slug": "noto-1f63b", + "title": "heart eyes cat", + "url": "http://nextcloud.local/index.php/apps/social/gif/noto-1f63b", + "media_type": "image/webp" + }, + { + "slug": "noto-1f63c", + "title": "smirk cat", + "url": "http://nextcloud.local/index.php/apps/social/gif/noto-1f63c", + "media_type": "image/webp" + } + ], + "total": 9, + "attribution": "Noto Animated Emoji, by Google, licensed CC BY 4.0" + } +} diff --git a/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/instance-privacy-policy.json b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/instance-privacy-policy.json new file mode 100644 index 00000000..deea5be0 --- /dev/null +++ b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/instance-privacy-policy.json @@ -0,0 +1,17 @@ +{ + "request": { + "method": "GET", + "path": "/index.php/apps/social/api/v1/instance/privacy_policy" + }, + "status": 404, + "headers": { + "content-type": "application/json; charset=utf-8", + "cache-control": "no-cache, no-store, must-revalidate", + "x-ratelimit-limit": "300", + "x-ratelimit-remaining": "196", + "x-ratelimit-reset": "1790637300" + }, + "body": { + "error": "this server has published no privacy policy" + } +} diff --git a/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/instance-rules.json b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/instance-rules.json new file mode 100644 index 00000000..f12917f2 --- /dev/null +++ b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/instance-rules.json @@ -0,0 +1,15 @@ +{ + "request": { + "method": "GET", + "path": "/index.php/apps/social/api/v1/instance/rules" + }, + "status": 200, + "headers": { + "content-type": "application/json; charset=utf-8", + "cache-control": "no-cache, no-store, must-revalidate", + "x-ratelimit-limit": "300", + "x-ratelimit-remaining": "198", + "x-ratelimit-reset": "1790637300" + }, + "body": [] +} diff --git a/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/instance-translation-languages.json b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/instance-translation-languages.json new file mode 100644 index 00000000..04fdf4bc --- /dev/null +++ b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/instance-translation-languages.json @@ -0,0 +1,15 @@ +{ + "request": { + "method": "GET", + "path": "/index.php/apps/social/api/v1/instance/translation_languages" + }, + "status": 200, + "headers": { + "content-type": "application/json; charset=utf-8", + "cache-control": "no-cache, no-store, must-revalidate", + "x-ratelimit-limit": "300", + "x-ratelimit-remaining": "197", + "x-ratelimit-reset": "1790637300" + }, + "body": {} +} diff --git a/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/interests.json b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/interests.json new file mode 100644 index 00000000..cbece89d --- /dev/null +++ b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/interests.json @@ -0,0 +1,27 @@ +{ + "request": { + "method": "GET", + "path": "/index.php/apps/social/api/v1/interests" + }, + "status": 200, + "headers": { + "content-type": "application/json; charset=utf-8", + "cache-control": "no-cache, no-store, must-revalidate", + "x-ratelimit-limit": "300", + "x-ratelimit-remaining": "194", + "x-ratelimit-reset": "1790637300" + }, + "body": { + "settings": { + "enabled": true, + "learning": true, + "paused": false, + "languages": [], + "noticeAcknowledged": false + }, + "interests": [], + "candidates": [], + "thin": true, + "cap": 30 + } +} diff --git a/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/list-accounts-jane.json b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/list-accounts-jane.json new file mode 100644 index 00000000..187ed0e7 --- /dev/null +++ b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/list-accounts-jane.json @@ -0,0 +1,44 @@ +{ + "request": { + "method": "GET", + "path": "/index.php/apps/social/api/v1/lists/1/accounts" + }, + "status": 200, + "headers": { + "content-type": "application/json; charset=utf-8", + "cache-control": "no-cache, no-store, must-revalidate", + "link": "; rel=\"prev\"", + "x-ratelimit-limit": "300", + "x-ratelimit-remaining": "208", + "x-ratelimit-reset": "1790637300" + }, + "body": [ + { + "id": "5", + "nid": "5", + "username": "admin", + "acct": "admin", + "display_name": "admin", + "locked": false, + "bot": false, + "discoverable": false, + "indexable": false, + "group": false, + "created_at": "2026-09-28T23:06:00.000Z", + "note": "", + "url": "http://nextcloud.local/index.php/apps/social/@admin", + "avatar": "", + "avatar_static": "", + "header": "http://nextcloud.local/index.php/avatar/admin/128", + "header_static": "http://nextcloud.local/index.php/avatar/admin/128", + "followers_count": 1, + "following_count": 1, + "statuses_count": 0, + "last_status_at": null, + "emojis": [], + "fields": [], + "pronouns": "", + "support_link": "" + } + ] +} diff --git a/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/lists-jane.json b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/lists-jane.json new file mode 100644 index 00000000..f885d5ca --- /dev/null +++ b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/lists-jane.json @@ -0,0 +1,24 @@ +{ + "request": { + "method": "GET", + "path": "/index.php/apps/social/api/v1/lists" + }, + "status": 200, + "headers": { + "content-type": "application/json; charset=utf-8", + "cache-control": "private, no-cache", + "etag": "\"e1c064339408d0d30ed26dbb3ef3fab70442c210\"", + "x-ratelimit-limit": "300", + "x-ratelimit-remaining": "209", + "x-ratelimit-reset": "1790637300" + }, + "body": [ + { + "id": "1", + "title": "Friends", + "replies_policy": "list", + "exclusive": false, + "nextcloud_group": null + } + ] +} diff --git a/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/markers.json b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/markers.json new file mode 100644 index 00000000..26dc994d --- /dev/null +++ b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/markers.json @@ -0,0 +1,21 @@ +{ + "request": { + "method": "GET", + "path": "/index.php/apps/social/api/v1/markers?timeline[]=home&timeline[]=notifications" + }, + "status": 200, + "headers": { + "content-type": "application/json; charset=utf-8", + "cache-control": "no-cache, no-store, must-revalidate", + "x-ratelimit-limit": "300", + "x-ratelimit-remaining": "247", + "x-ratelimit-reset": "1790637300" + }, + "body": { + "home": { + "last_read_id": "1790637085797595891", + "version": 2, + "updated_at": "2026-09-28T23:11:58.000Z" + } + } +} diff --git a/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/mutes.json b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/mutes.json new file mode 100644 index 00000000..b7a10aa8 --- /dev/null +++ b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/mutes.json @@ -0,0 +1,15 @@ +{ + "request": { + "method": "GET", + "path": "/index.php/apps/social/api/v1/mutes" + }, + "status": 200, + "headers": { + "content-type": "application/json; charset=utf-8", + "cache-control": "no-cache, no-store, must-revalidate", + "x-ratelimit-limit": "300", + "x-ratelimit-remaining": "255", + "x-ratelimit-reset": "1790637300" + }, + "body": [] +} diff --git a/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/notifications-policy-v2.json b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/notifications-policy-v2.json new file mode 100644 index 00000000..d5c10da8 --- /dev/null +++ b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/notifications-policy-v2.json @@ -0,0 +1,25 @@ +{ + "request": { + "method": "GET", + "path": "/index.php/apps/social/api/v2/notifications/policy" + }, + "status": 200, + "headers": { + "content-type": "application/json; charset=utf-8", + "cache-control": "no-cache, no-store, must-revalidate", + "x-ratelimit-limit": "300", + "x-ratelimit-remaining": "212", + "x-ratelimit-reset": "1790637300" + }, + "body": { + "for_not_following": "accept", + "for_not_followers": "accept", + "for_new_accounts": "accept", + "for_private_mentions": "accept", + "for_limited_accounts": "accept", + "summary": { + "pending_requests_count": 0, + "pending_notifications_count": 0 + } + } +} diff --git a/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/notifications-requests.json b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/notifications-requests.json new file mode 100644 index 00000000..37567601 --- /dev/null +++ b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/notifications-requests.json @@ -0,0 +1,15 @@ +{ + "request": { + "method": "GET", + "path": "/index.php/apps/social/api/v1/notifications/requests" + }, + "status": 200, + "headers": { + "content-type": "application/json; charset=utf-8", + "cache-control": "no-cache, no-store, must-revalidate", + "x-ratelimit-limit": "300", + "x-ratelimit-remaining": "211", + "x-ratelimit-reset": "1790637300" + }, + "body": [] +} diff --git a/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/notifications-v1-jane.json b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/notifications-v1-jane.json new file mode 100644 index 00000000..309b000c --- /dev/null +++ b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/notifications-v1-jane.json @@ -0,0 +1,85 @@ +{ + "request": { + "method": "GET", + "path": "/index.php/apps/social/api/v1/notifications?limit=20" + }, + "status": 200, + "headers": { + "content-type": "application/json; charset=utf-8", + "cache-control": "no-cache, no-store, must-revalidate", + "link": "; rel=\"prev\"", + "x-ratelimit-limit": "300", + "x-ratelimit-remaining": "210", + "x-ratelimit-reset": "1790637300" + }, + "body": [ + { + "id": "1790637117407970673", + "type": "follow", + "created_at": "2026-09-28T23:11:57.000Z", + "status": null, + "account": { + "id": "6", + "nid": "6", + "username": "alice", + "acct": "alice", + "display_name": "alice", + "locked": false, + "bot": false, + "discoverable": false, + "indexable": false, + "group": false, + "created_at": "2026-09-28T23:06:02.000Z", + "note": "", + "url": "http://nextcloud.local/index.php/apps/social/@alice", + "avatar": "", + "avatar_static": "", + "header": "http://nextcloud.local/index.php/avatar/alice/128", + "header_static": "http://nextcloud.local/index.php/avatar/alice/128", + "followers_count": 2, + "following_count": 2, + "statuses_count": 13, + "last_status_at": null, + "emojis": [], + "fields": [], + "pronouns": "", + "support_link": "" + }, + "attachment": [] + }, + { + "id": "1790636770479623906", + "type": "follow", + "created_at": "2026-09-28T23:06:10.000Z", + "status": null, + "account": { + "id": "7", + "nid": "7", + "username": "bob", + "acct": "bob", + "display_name": "bob", + "locked": false, + "bot": false, + "discoverable": false, + "indexable": false, + "group": false, + "created_at": "2026-09-28T23:06:03.000Z", + "note": "", + "url": "http://nextcloud.local/index.php/apps/social/@bob", + "avatar": "", + "avatar_static": "", + "header": "http://nextcloud.local/index.php/avatar/bob/128", + "header_static": "http://nextcloud.local/index.php/avatar/bob/128", + "followers_count": 1, + "following_count": 2, + "statuses_count": 3, + "last_status_at": null, + "emojis": [], + "fields": [], + "pronouns": "", + "support_link": "" + }, + "attachment": [] + } + ] +} diff --git a/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/notifications-v1-unread-count.json b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/notifications-v1-unread-count.json new file mode 100644 index 00000000..4b833c06 --- /dev/null +++ b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/notifications-v1-unread-count.json @@ -0,0 +1,18 @@ +{ + "request": { + "method": "GET", + "path": "/index.php/apps/social/api/v1/notifications/unread_count" + }, + "status": 200, + "headers": { + "content-type": "application/json; charset=utf-8", + "cache-control": "private, no-cache", + "etag": "\"1790637118839388302-0\"", + "x-ratelimit-limit": "300", + "x-ratelimit-remaining": "213", + "x-ratelimit-reset": "1790637300" + }, + "body": { + "count": 6 + } +} diff --git a/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/notifications-v1.json b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/notifications-v1.json new file mode 100644 index 00000000..d00a07b0 --- /dev/null +++ b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/notifications-v1.json @@ -0,0 +1,613 @@ +{ + "request": { + "method": "GET", + "path": "/index.php/apps/social/api/v1/notifications?limit=20" + }, + "status": 200, + "headers": { + "content-type": "application/json; charset=utf-8", + "cache-control": "no-cache, no-store, must-revalidate", + "link": "; rel=\"prev\"", + "x-ratelimit-limit": "300", + "x-ratelimit-remaining": "216", + "x-ratelimit-reset": "1790637300" + }, + "body": [ + { + "id": "1790637118839388302", + "type": "favourite", + "created_at": "2026-09-28T23:11:58.000Z", + "status": { + "id": "1790637104958214061", + "nid": "1790637104958214061", + "local": true, + "archived": false, + "view_count": null, + "tagged_people": [], + "interest": null, + "content": "

Test video #video

00:00 Intro
00:10 Middle
00:20 End

", + "sensitive": false, + "spoiler_text": "", + "visibility": "public", + "language": "en", + "place": null, + "in_reply_to_id": null, + "in_reply_to_account_id": null, + "quote": null, + "quote_approval": { + "automatic": [ + "public" + ], + "manual": [], + "current_user": "automatic" + }, + "reply_approval": null, + "interaction_policy": null, + "dislikes_count": 0, + "disliked": false, + "video": { + "title": "Aloha test video" + }, + "mentions": [], + "emojis": [], + "tags": [], + "replies_count": 0, + "reblogs_count": 0, + "favourites_count": 1, + "favourited": false, + "reblogged": false, + "muted": false, + "bookmarked": false, + "pinned": false, + "card": null, + "reactions": [], + "poll": null, + "uri": "http://nextcloud.local/index.php/apps/social/@alice/17906371045255950881", + "url": "http://nextcloud.local/index.php/apps/social/@alice/17906371045255950881", + "reblog": null, + "media_attachments": [ + { + "id": "12", + "type": "video", + "media_type": "video/mp4", + "url": "http://nextcloud.local/index.php/apps/social/media/09c96321-538a-4d33-a3e3-e326ba1c5a56.mp4", + "preview_url": "http://nextcloud.local/index.php/apps/social/media/09c96321-538a-4d33-a3e3-e326ba1c5a56.mp4", + "hls_url": null, + "remote_url": null, + "cache_error": null, + "meta": { + "original": [], + "small": [], + "focus": { + "x": 0, + "y": 0 + } + }, + "description": "A 30 second test pattern with a tone", + "blurhash": null + } + ], + "created_at": "2026-09-28T23:11:44.000Z", + "edited_at": null, + "noindex": false, + "account": { + "id": "0", + "nid": 0, + "username": "alice", + "acct": "alice", + "display_name": "alice", + "locked": false, + "bot": false, + "discoverable": false, + "indexable": false, + "group": false, + "created_at": "2026-09-28T23:06:02.000Z", + "note": "", + "url": "http://nextcloud.local/index.php/apps/social/@alice", + "avatar": "", + "avatar_static": "", + "header": "http://nextcloud.local/index.php/avatar/alice/128", + "header_static": "http://nextcloud.local/index.php/avatar/alice/128", + "followers_count": 2, + "following_count": 2, + "statuses_count": 13, + "last_status_at": null, + "emojis": [], + "fields": [], + "pronouns": "", + "support_link": "" + } + }, + "account": { + "id": "5", + "nid": "5", + "username": "admin", + "acct": "admin", + "display_name": "admin", + "locked": false, + "bot": false, + "discoverable": false, + "indexable": false, + "group": false, + "created_at": "2026-09-28T23:06:00.000Z", + "note": "", + "url": "http://nextcloud.local/index.php/apps/social/@admin", + "avatar": "", + "avatar_static": "", + "header": "http://nextcloud.local/index.php/avatar/admin/128", + "header_static": "http://nextcloud.local/index.php/avatar/admin/128", + "followers_count": 1, + "following_count": 1, + "statuses_count": 0, + "last_status_at": null, + "emojis": [], + "fields": [], + "pronouns": "", + "support_link": "" + }, + "attachment": [] + }, + { + "id": "1790637116755468768", + "type": "reblog", + "created_at": "2026-09-28T23:11:56.000Z", + "status": { + "id": "1790637096330770789", + "nid": "1790637096330770789", + "local": true, + "archived": false, + "view_count": null, + "tagged_people": [], + "interest": null, + "content": "

Two test pictures #photography

", + "sensitive": false, + "spoiler_text": "", + "visibility": "public", + "language": "en", + "place": null, + "in_reply_to_id": null, + "in_reply_to_account_id": null, + "quote": null, + "quote_approval": { + "automatic": [ + "public" + ], + "manual": [], + "current_user": "automatic" + }, + "reply_approval": null, + "interaction_policy": null, + "dislikes_count": null, + "disliked": null, + "video": null, + "mentions": [], + "emojis": [], + "tags": [], + "replies_count": 0, + "reblogs_count": 1, + "favourites_count": 0, + "favourited": false, + "reblogged": false, + "muted": false, + "bookmarked": false, + "pinned": false, + "card": null, + "reactions": [], + "poll": null, + "uri": "http://nextcloud.local/index.php/apps/social/@alice/17906370966999687509", + "url": "http://nextcloud.local/index.php/apps/social/@alice/17906370966999687509", + "reblog": null, + "media_attachments": [ + { + "id": "9", + "type": "image", + "media_type": "image/jpeg", + "url": "http://nextcloud.local/index.php/apps/social/media/48044adf-b771-4343-9607-c7fc93f16ad1.jpeg", + "preview_url": "http://nextcloud.local/index.php/apps/social/media/2c66bfd7-0029-47e1-b6ef-23dc7d422e2b.jpeg", + "hls_url": null, + "remote_url": null, + "cache_error": null, + "meta": { + "original": { + "width": 1600, + "height": 1200, + "size": "1600x1200", + "aspect": 1.3333333333333333 + }, + "small": { + "width": 800, + "height": 600, + "size": "800x600", + "aspect": 1.3333333333333333 + }, + "focus": { + "x": 0.3, + "y": -0.2 + } + }, + "description": "A test card pattern in bright colours", + "blurhash": "LVL4]9|Tnk_gsESdfjn,baayW;jZ" + }, + { + "id": "10", + "type": "image", + "media_type": "image/jpeg", + "url": "http://nextcloud.local/index.php/apps/social/media/1e936701-3c1a-49c2-a58a-bc7b0d5a9c27.jpeg", + "preview_url": "http://nextcloud.local/index.php/apps/social/media/43d878b8-d9fd-42fa-9f7e-38dea1357b60.jpeg", + "hls_url": null, + "remote_url": null, + "cache_error": null, + "meta": { + "original": { + "width": 1080, + "height": 1350, + "size": "1080x1350", + "aspect": 0.8 + }, + "small": { + "width": 640, + "height": 800, + "size": "640x800", + "aspect": 0.8 + }, + "focus": { + "x": 0, + "y": 0 + } + }, + "description": "A Mandelbrot fractal, portrait", + "blurhash": "LyLow4+,$SN?w6S]WUjI[ibCSIbW" + } + ], + "created_at": "2026-09-28T23:11:36.000Z", + "edited_at": null, + "noindex": false, + "account": { + "id": "0", + "nid": 0, + "username": "alice", + "acct": "alice", + "display_name": "alice", + "locked": false, + "bot": false, + "discoverable": false, + "indexable": false, + "group": false, + "created_at": "2026-09-28T23:06:02.000Z", + "note": "", + "url": "http://nextcloud.local/index.php/apps/social/@alice", + "avatar": "", + "avatar_static": "", + "header": "http://nextcloud.local/index.php/avatar/alice/128", + "header_static": "http://nextcloud.local/index.php/avatar/alice/128", + "followers_count": 2, + "following_count": 2, + "statuses_count": 13, + "last_status_at": null, + "emojis": [], + "fields": [], + "pronouns": "", + "support_link": "" + } + }, + "account": { + "id": "7", + "nid": "7", + "username": "bob", + "acct": "bob", + "display_name": "bob", + "locked": false, + "bot": false, + "discoverable": false, + "indexable": false, + "group": false, + "created_at": "2026-09-28T23:06:03.000Z", + "note": "", + "url": "http://nextcloud.local/index.php/apps/social/@bob", + "avatar": "", + "avatar_static": "", + "header": "http://nextcloud.local/index.php/avatar/bob/128", + "header_static": "http://nextcloud.local/index.php/avatar/bob/128", + "followers_count": 1, + "following_count": 2, + "statuses_count": 3, + "last_status_at": null, + "emojis": [], + "fields": [], + "pronouns": "", + "support_link": "" + }, + "attachment": [] + }, + { + "id": "1790637116332591777", + "type": "favourite", + "created_at": "2026-09-28T23:11:56.000Z", + "status": { + "id": "1790637085797595891", + "nid": "1790637085797595891", + "local": true, + "archived": false, + "view_count": null, + "tagged_people": [], + "interest": null, + "content": "

Aloha from the dev instance! Say hi, @bob@nextcloud.local #aloha #android

", + "sensitive": false, + "spoiler_text": "", + "visibility": "public", + "language": "en", + "place": null, + "in_reply_to_id": null, + "in_reply_to_account_id": null, + "quote": null, + "quote_approval": { + "automatic": [ + "public" + ], + "manual": [], + "current_user": "automatic" + }, + "reply_approval": null, + "interaction_policy": null, + "dislikes_count": null, + "disliked": null, + "video": null, + "mentions": [], + "emojis": [], + "tags": [], + "replies_count": 1, + "reblogs_count": 0, + "favourites_count": 1, + "favourited": false, + "reblogged": false, + "muted": false, + "bookmarked": false, + "pinned": false, + "card": null, + "reactions": [], + "poll": null, + "uri": "http://nextcloud.local/index.php/apps/social/@alice/17906370853806247191", + "url": "http://nextcloud.local/index.php/apps/social/@alice/17906370853806247191", + "reblog": null, + "media_attachments": [], + "created_at": "2026-09-28T23:11:25.000Z", + "edited_at": null, + "noindex": false, + "account": { + "id": "0", + "nid": 0, + "username": "alice", + "acct": "alice", + "display_name": "alice", + "locked": false, + "bot": false, + "discoverable": false, + "indexable": false, + "group": false, + "created_at": "2026-09-28T23:06:02.000Z", + "note": "", + "url": "http://nextcloud.local/index.php/apps/social/@alice", + "avatar": "", + "avatar_static": "", + "header": "http://nextcloud.local/index.php/avatar/alice/128", + "header_static": "http://nextcloud.local/index.php/avatar/alice/128", + "followers_count": 2, + "following_count": 2, + "statuses_count": 13, + "last_status_at": null, + "emojis": [], + "fields": [], + "pronouns": "", + "support_link": "" + } + }, + "account": { + "id": "7", + "nid": "7", + "username": "bob", + "acct": "bob", + "display_name": "bob", + "locked": false, + "bot": false, + "discoverable": false, + "indexable": false, + "group": false, + "created_at": "2026-09-28T23:06:03.000Z", + "note": "", + "url": "http://nextcloud.local/index.php/apps/social/@bob", + "avatar": "", + "avatar_static": "", + "header": "http://nextcloud.local/index.php/avatar/bob/128", + "header_static": "http://nextcloud.local/index.php/avatar/bob/128", + "followers_count": 1, + "following_count": 2, + "statuses_count": 3, + "last_status_at": null, + "emojis": [], + "fields": [], + "pronouns": "", + "support_link": "" + }, + "attachment": [] + }, + { + "id": "1790637115154396558", + "type": "mention", + "created_at": "2026-09-28T23:11:55.000Z", + "status": { + "id": "1790637115351395147", + "nid": "1790637115351395147", + "local": true, + "archived": false, + "view_count": null, + "tagged_people": [], + "interest": null, + "content": "

@alice@nextcloud.local hi back!

", + "sensitive": false, + "spoiler_text": "", + "visibility": "public", + "language": "en", + "place": null, + "in_reply_to_id": "1790637085797595891", + "in_reply_to_account_id": "6", + "quote": null, + "quote_approval": { + "automatic": [ + "public" + ], + "manual": [], + "current_user": "automatic" + }, + "reply_approval": null, + "interaction_policy": null, + "dislikes_count": null, + "disliked": null, + "video": null, + "mentions": [], + "emojis": [], + "tags": [], + "replies_count": 1, + "reblogs_count": 0, + "favourites_count": 0, + "favourited": false, + "reblogged": false, + "muted": false, + "bookmarked": false, + "pinned": false, + "card": null, + "reactions": [], + "poll": null, + "uri": "http://nextcloud.local/index.php/apps/social/@bob/17906371156396819743", + "url": "http://nextcloud.local/index.php/apps/social/@bob/17906371156396819743", + "reblog": null, + "media_attachments": [], + "created_at": "2026-09-28T23:11:55.000Z", + "edited_at": null, + "noindex": false, + "account": { + "id": "0", + "nid": 0, + "username": "bob", + "acct": "bob", + "display_name": "bob", + "locked": false, + "bot": false, + "discoverable": false, + "indexable": false, + "group": false, + "created_at": "2026-09-28T23:06:03.000Z", + "note": "", + "url": "http://nextcloud.local/index.php/apps/social/@bob", + "avatar": "", + "avatar_static": "", + "header": "http://nextcloud.local/index.php/avatar/bob/128", + "header_static": "http://nextcloud.local/index.php/avatar/bob/128", + "followers_count": 1, + "following_count": 2, + "statuses_count": 3, + "last_status_at": null, + "emojis": [], + "fields": [], + "pronouns": "", + "support_link": "" + } + }, + "account": { + "id": "7", + "nid": "7", + "username": "bob", + "acct": "bob", + "display_name": "bob", + "locked": false, + "bot": false, + "discoverable": false, + "indexable": false, + "group": false, + "created_at": "2026-09-28T23:06:03.000Z", + "note": "", + "url": "http://nextcloud.local/index.php/apps/social/@bob", + "avatar": "", + "avatar_static": "", + "header": "http://nextcloud.local/index.php/avatar/bob/128", + "header_static": "http://nextcloud.local/index.php/avatar/bob/128", + "followers_count": 1, + "following_count": 2, + "statuses_count": 3, + "last_status_at": null, + "emojis": [], + "fields": [], + "pronouns": "", + "support_link": "" + }, + "attachment": [] + }, + { + "id": "1790636768956409282", + "type": "follow", + "created_at": "2026-09-28T23:06:08.000Z", + "status": null, + "account": { + "id": "5", + "nid": "5", + "username": "admin", + "acct": "admin", + "display_name": "admin", + "locked": false, + "bot": false, + "discoverable": false, + "indexable": false, + "group": false, + "created_at": "2026-09-28T23:06:00.000Z", + "note": "", + "url": "http://nextcloud.local/index.php/apps/social/@admin", + "avatar": "", + "avatar_static": "", + "header": "http://nextcloud.local/index.php/avatar/admin/128", + "header_static": "http://nextcloud.local/index.php/avatar/admin/128", + "followers_count": 1, + "following_count": 1, + "statuses_count": 0, + "last_status_at": null, + "emojis": [], + "fields": [], + "pronouns": "", + "support_link": "" + }, + "attachment": [] + }, + { + "id": "1790636767706345208", + "type": "follow", + "created_at": "2026-09-28T23:06:07.000Z", + "status": null, + "account": { + "id": "7", + "nid": "7", + "username": "bob", + "acct": "bob", + "display_name": "bob", + "locked": false, + "bot": false, + "discoverable": false, + "indexable": false, + "group": false, + "created_at": "2026-09-28T23:06:03.000Z", + "note": "", + "url": "http://nextcloud.local/index.php/apps/social/@bob", + "avatar": "", + "avatar_static": "", + "header": "http://nextcloud.local/index.php/avatar/bob/128", + "header_static": "http://nextcloud.local/index.php/avatar/bob/128", + "followers_count": 1, + "following_count": 2, + "statuses_count": 3, + "last_status_at": null, + "emojis": [], + "fields": [], + "pronouns": "", + "support_link": "" + }, + "attachment": [] + } + ] +} diff --git a/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/notifications-v2-unread-count.json b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/notifications-v2-unread-count.json new file mode 100644 index 00000000..55d54866 --- /dev/null +++ b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/notifications-v2-unread-count.json @@ -0,0 +1,17 @@ +{ + "request": { + "method": "GET", + "path": "/index.php/apps/social/api/v2/notifications/unread_count" + }, + "status": 200, + "headers": { + "content-type": "application/json; charset=utf-8", + "cache-control": "no-cache, no-store, must-revalidate", + "x-ratelimit-limit": "300", + "x-ratelimit-remaining": "214", + "x-ratelimit-reset": "1790637300" + }, + "body": { + "count": 5 + } +} diff --git a/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/notifications-v2.json b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/notifications-v2.json new file mode 100644 index 00000000..478b6514 --- /dev/null +++ b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/notifications-v2.json @@ -0,0 +1,537 @@ +{ + "request": { + "method": "GET", + "path": "/index.php/apps/social/api/v2/notifications?limit=20" + }, + "status": 200, + "headers": { + "content-type": "application/json; charset=utf-8", + "cache-control": "no-cache, no-store, must-revalidate", + "x-ratelimit-limit": "300", + "x-ratelimit-remaining": "215", + "x-ratelimit-reset": "1790637300" + }, + "body": { + "notification_groups": [ + { + "group_key": "favourite-1790637104958214061", + "notifications_count": 1, + "type": "favourite", + "most_recent_notification_id": "1790637118839388302", + "page_min_id": "1790637118839388302", + "page_max_id": "1790637118839388302", + "latest_page_notification_at": "2026-09-28T23:11:58.000Z", + "sample_account_ids": [ + "5" + ], + "status_id": "1790637104958214061" + }, + { + "group_key": "reblog-1790637096330770789", + "notifications_count": 1, + "type": "reblog", + "most_recent_notification_id": "1790637116755468768", + "page_min_id": "1790637116755468768", + "page_max_id": "1790637116755468768", + "latest_page_notification_at": "2026-09-28T23:11:56.000Z", + "sample_account_ids": [ + "7" + ], + "status_id": "1790637096330770789" + }, + { + "group_key": "favourite-1790637085797595891", + "notifications_count": 1, + "type": "favourite", + "most_recent_notification_id": "1790637116332591777", + "page_min_id": "1790637116332591777", + "page_max_id": "1790637116332591777", + "latest_page_notification_at": "2026-09-28T23:11:56.000Z", + "sample_account_ids": [ + "7" + ], + "status_id": "1790637085797595891" + }, + { + "group_key": "ungrouped-1790637115154396558", + "notifications_count": 1, + "type": "mention", + "most_recent_notification_id": "1790637115154396558", + "page_min_id": "1790637115154396558", + "page_max_id": "1790637115154396558", + "latest_page_notification_at": "2026-09-28T23:11:55.000Z", + "sample_account_ids": [ + "7" + ], + "status_id": "1790637115351395147" + }, + { + "group_key": "follow-all", + "notifications_count": 2, + "type": "follow", + "most_recent_notification_id": "1790636768956409282", + "page_min_id": "1790636767706345208", + "page_max_id": "1790636768956409282", + "latest_page_notification_at": "2026-09-28T23:06:08.000Z", + "sample_account_ids": [ + "5", + "7" + ] + } + ], + "accounts": [ + { + "id": "5", + "nid": "5", + "username": "admin", + "acct": "admin", + "display_name": "admin", + "locked": false, + "bot": false, + "discoverable": false, + "indexable": false, + "group": false, + "created_at": "2026-09-28T23:06:00.000Z", + "note": "", + "url": "http://nextcloud.local/index.php/apps/social/@admin", + "avatar": "", + "avatar_static": "", + "header": "http://nextcloud.local/index.php/avatar/admin/128", + "header_static": "http://nextcloud.local/index.php/avatar/admin/128", + "followers_count": 1, + "following_count": 1, + "statuses_count": 0, + "last_status_at": null, + "emojis": [], + "fields": [], + "pronouns": "", + "support_link": "" + }, + { + "id": "7", + "nid": "7", + "username": "bob", + "acct": "bob", + "display_name": "bob", + "locked": false, + "bot": false, + "discoverable": false, + "indexable": false, + "group": false, + "created_at": "2026-09-28T23:06:03.000Z", + "note": "", + "url": "http://nextcloud.local/index.php/apps/social/@bob", + "avatar": "", + "avatar_static": "", + "header": "http://nextcloud.local/index.php/avatar/bob/128", + "header_static": "http://nextcloud.local/index.php/avatar/bob/128", + "followers_count": 1, + "following_count": 2, + "statuses_count": 3, + "last_status_at": null, + "emojis": [], + "fields": [], + "pronouns": "", + "support_link": "" + } + ], + "statuses": [ + { + "id": "1790637104958214061", + "nid": "1790637104958214061", + "local": true, + "archived": false, + "view_count": null, + "tagged_people": [], + "interest": null, + "content": "

Test video #video

00:00 Intro
00:10 Middle
00:20 End

", + "sensitive": false, + "spoiler_text": "", + "visibility": "public", + "language": "en", + "place": null, + "in_reply_to_id": null, + "in_reply_to_account_id": null, + "quote": null, + "quote_approval": { + "automatic": [ + "public" + ], + "manual": [], + "current_user": "automatic" + }, + "reply_approval": null, + "interaction_policy": null, + "dislikes_count": 0, + "disliked": false, + "video": { + "title": "Aloha test video" + }, + "mentions": [], + "emojis": [], + "tags": [], + "replies_count": 0, + "reblogs_count": 0, + "favourites_count": 1, + "favourited": false, + "reblogged": false, + "muted": false, + "bookmarked": false, + "pinned": false, + "card": null, + "reactions": [], + "poll": null, + "uri": "http://nextcloud.local/index.php/apps/social/@alice/17906371045255950881", + "url": "http://nextcloud.local/index.php/apps/social/@alice/17906371045255950881", + "reblog": null, + "media_attachments": [ + { + "id": "12", + "type": "video", + "media_type": "video/mp4", + "url": "http://nextcloud.local/index.php/apps/social/media/09c96321-538a-4d33-a3e3-e326ba1c5a56.mp4", + "preview_url": "http://nextcloud.local/index.php/apps/social/media/09c96321-538a-4d33-a3e3-e326ba1c5a56.mp4", + "hls_url": null, + "remote_url": null, + "cache_error": null, + "meta": { + "original": [], + "small": [], + "focus": { + "x": 0, + "y": 0 + } + }, + "description": "A 30 second test pattern with a tone", + "blurhash": null + } + ], + "created_at": "2026-09-28T23:11:44.000Z", + "edited_at": null, + "noindex": false, + "account": { + "id": "0", + "nid": 0, + "username": "alice", + "acct": "alice", + "display_name": "alice", + "locked": false, + "bot": false, + "discoverable": false, + "indexable": false, + "group": false, + "created_at": "2026-09-28T23:06:02.000Z", + "note": "", + "url": "http://nextcloud.local/index.php/apps/social/@alice", + "avatar": "", + "avatar_static": "", + "header": "http://nextcloud.local/index.php/avatar/alice/128", + "header_static": "http://nextcloud.local/index.php/avatar/alice/128", + "followers_count": 2, + "following_count": 2, + "statuses_count": 13, + "last_status_at": null, + "emojis": [], + "fields": [], + "pronouns": "", + "support_link": "" + } + }, + { + "id": "1790637096330770789", + "nid": "1790637096330770789", + "local": true, + "archived": false, + "view_count": null, + "tagged_people": [], + "interest": null, + "content": "

Two test pictures #photography

", + "sensitive": false, + "spoiler_text": "", + "visibility": "public", + "language": "en", + "place": null, + "in_reply_to_id": null, + "in_reply_to_account_id": null, + "quote": null, + "quote_approval": { + "automatic": [ + "public" + ], + "manual": [], + "current_user": "automatic" + }, + "reply_approval": null, + "interaction_policy": null, + "dislikes_count": null, + "disliked": null, + "video": null, + "mentions": [], + "emojis": [], + "tags": [], + "replies_count": 0, + "reblogs_count": 1, + "favourites_count": 0, + "favourited": false, + "reblogged": false, + "muted": false, + "bookmarked": false, + "pinned": false, + "card": null, + "reactions": [], + "poll": null, + "uri": "http://nextcloud.local/index.php/apps/social/@alice/17906370966999687509", + "url": "http://nextcloud.local/index.php/apps/social/@alice/17906370966999687509", + "reblog": null, + "media_attachments": [ + { + "id": "9", + "type": "image", + "media_type": "image/jpeg", + "url": "http://nextcloud.local/index.php/apps/social/media/48044adf-b771-4343-9607-c7fc93f16ad1.jpeg", + "preview_url": "http://nextcloud.local/index.php/apps/social/media/2c66bfd7-0029-47e1-b6ef-23dc7d422e2b.jpeg", + "hls_url": null, + "remote_url": null, + "cache_error": null, + "meta": { + "original": { + "width": 1600, + "height": 1200, + "size": "1600x1200", + "aspect": 1.3333333333333333 + }, + "small": { + "width": 800, + "height": 600, + "size": "800x600", + "aspect": 1.3333333333333333 + }, + "focus": { + "x": 0.3, + "y": -0.2 + } + }, + "description": "A test card pattern in bright colours", + "blurhash": "LVL4]9|Tnk_gsESdfjn,baayW;jZ" + }, + { + "id": "10", + "type": "image", + "media_type": "image/jpeg", + "url": "http://nextcloud.local/index.php/apps/social/media/1e936701-3c1a-49c2-a58a-bc7b0d5a9c27.jpeg", + "preview_url": "http://nextcloud.local/index.php/apps/social/media/43d878b8-d9fd-42fa-9f7e-38dea1357b60.jpeg", + "hls_url": null, + "remote_url": null, + "cache_error": null, + "meta": { + "original": { + "width": 1080, + "height": 1350, + "size": "1080x1350", + "aspect": 0.8 + }, + "small": { + "width": 640, + "height": 800, + "size": "640x800", + "aspect": 0.8 + }, + "focus": { + "x": 0, + "y": 0 + } + }, + "description": "A Mandelbrot fractal, portrait", + "blurhash": "LyLow4+,$SN?w6S]WUjI[ibCSIbW" + } + ], + "created_at": "2026-09-28T23:11:36.000Z", + "edited_at": null, + "noindex": false, + "account": { + "id": "0", + "nid": 0, + "username": "alice", + "acct": "alice", + "display_name": "alice", + "locked": false, + "bot": false, + "discoverable": false, + "indexable": false, + "group": false, + "created_at": "2026-09-28T23:06:02.000Z", + "note": "", + "url": "http://nextcloud.local/index.php/apps/social/@alice", + "avatar": "", + "avatar_static": "", + "header": "http://nextcloud.local/index.php/avatar/alice/128", + "header_static": "http://nextcloud.local/index.php/avatar/alice/128", + "followers_count": 2, + "following_count": 2, + "statuses_count": 13, + "last_status_at": null, + "emojis": [], + "fields": [], + "pronouns": "", + "support_link": "" + } + }, + { + "id": "1790637085797595891", + "nid": "1790637085797595891", + "local": true, + "archived": false, + "view_count": null, + "tagged_people": [], + "interest": null, + "content": "

Aloha from the dev instance! Say hi, @bob@nextcloud.local #aloha #android

", + "sensitive": false, + "spoiler_text": "", + "visibility": "public", + "language": "en", + "place": null, + "in_reply_to_id": null, + "in_reply_to_account_id": null, + "quote": null, + "quote_approval": { + "automatic": [ + "public" + ], + "manual": [], + "current_user": "automatic" + }, + "reply_approval": null, + "interaction_policy": null, + "dislikes_count": null, + "disliked": null, + "video": null, + "mentions": [], + "emojis": [], + "tags": [], + "replies_count": 1, + "reblogs_count": 0, + "favourites_count": 1, + "favourited": false, + "reblogged": false, + "muted": false, + "bookmarked": false, + "pinned": false, + "card": null, + "reactions": [], + "poll": null, + "uri": "http://nextcloud.local/index.php/apps/social/@alice/17906370853806247191", + "url": "http://nextcloud.local/index.php/apps/social/@alice/17906370853806247191", + "reblog": null, + "media_attachments": [], + "created_at": "2026-09-28T23:11:25.000Z", + "edited_at": null, + "noindex": false, + "account": { + "id": "0", + "nid": 0, + "username": "alice", + "acct": "alice", + "display_name": "alice", + "locked": false, + "bot": false, + "discoverable": false, + "indexable": false, + "group": false, + "created_at": "2026-09-28T23:06:02.000Z", + "note": "", + "url": "http://nextcloud.local/index.php/apps/social/@alice", + "avatar": "", + "avatar_static": "", + "header": "http://nextcloud.local/index.php/avatar/alice/128", + "header_static": "http://nextcloud.local/index.php/avatar/alice/128", + "followers_count": 2, + "following_count": 2, + "statuses_count": 13, + "last_status_at": null, + "emojis": [], + "fields": [], + "pronouns": "", + "support_link": "" + } + }, + { + "id": "1790637115351395147", + "nid": "1790637115351395147", + "local": true, + "archived": false, + "view_count": null, + "tagged_people": [], + "interest": null, + "content": "

@alice@nextcloud.local hi back!

", + "sensitive": false, + "spoiler_text": "", + "visibility": "public", + "language": "en", + "place": null, + "in_reply_to_id": "1790637085797595891", + "in_reply_to_account_id": "6", + "quote": null, + "quote_approval": { + "automatic": [ + "public" + ], + "manual": [], + "current_user": "automatic" + }, + "reply_approval": null, + "interaction_policy": null, + "dislikes_count": null, + "disliked": null, + "video": null, + "mentions": [], + "emojis": [], + "tags": [], + "replies_count": 1, + "reblogs_count": 0, + "favourites_count": 0, + "favourited": false, + "reblogged": false, + "muted": false, + "bookmarked": false, + "pinned": false, + "card": null, + "reactions": [], + "poll": null, + "uri": "http://nextcloud.local/index.php/apps/social/@bob/17906371156396819743", + "url": "http://nextcloud.local/index.php/apps/social/@bob/17906371156396819743", + "reblog": null, + "media_attachments": [], + "created_at": "2026-09-28T23:11:55.000Z", + "edited_at": null, + "noindex": false, + "account": { + "id": "0", + "nid": 0, + "username": "bob", + "acct": "bob", + "display_name": "bob", + "locked": false, + "bot": false, + "discoverable": false, + "indexable": false, + "group": false, + "created_at": "2026-09-28T23:06:03.000Z", + "note": "", + "url": "http://nextcloud.local/index.php/apps/social/@bob", + "avatar": "", + "avatar_static": "", + "header": "http://nextcloud.local/index.php/avatar/bob/128", + "header_static": "http://nextcloud.local/index.php/avatar/bob/128", + "followers_count": 1, + "following_count": 2, + "statuses_count": 3, + "last_status_at": null, + "emojis": [], + "fields": [], + "pronouns": "", + "support_link": "" + } + } + ] + } +} diff --git a/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/oauth-token.json b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/oauth-token.json new file mode 100644 index 00000000..0a3b3a98 --- /dev/null +++ b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/oauth-token.json @@ -0,0 +1,14 @@ +{ + "request": { + "method": "POST", + "path": "/index.php/apps/social/oauth/token" + }, + "status": 200, + "headers": {}, + "body": { + "access_token": "REDACTED", + "token_type": "Bearer", + "scope": "read write follow push", + "created_at": 1790636918 + } +} diff --git a/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/oauth-userinfo.json b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/oauth-userinfo.json new file mode 100644 index 00000000..d3244038 --- /dev/null +++ b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/oauth-userinfo.json @@ -0,0 +1,21 @@ +{ + "request": { + "method": "GET", + "path": "/index.php/apps/social/oauth/userinfo" + }, + "status": 200, + "headers": { + "content-type": "application/json; charset=utf-8", + "cache-control": "no-cache, no-store, must-revalidate", + "x-ratelimit-limit": "300", + "x-ratelimit-remaining": "262", + "x-ratelimit-reset": "1790637300" + }, + "body": { + "sub": "http://nextcloud.local/index.php/apps/social/@alice", + "name": "alice", + "preferred_username": "alice", + "profile": "http://nextcloud.local/index.php/apps/social/@alice", + "picture": "" + } +} diff --git a/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/pixelfed-compose-settings.json b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/pixelfed-compose-settings.json new file mode 100644 index 00000000..0bf6b7ad --- /dev/null +++ b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/pixelfed-compose-settings.json @@ -0,0 +1,54 @@ +{ + "request": { + "method": "GET", + "path": "/index.php/apps/social/api/v1.1/compose/settings" + }, + "status": 200, + "headers": { + "content-type": "application/json; charset=utf-8", + "cache-control": "no-cache, no-store, must-revalidate", + "x-ratelimit-limit": "300", + "x-ratelimit-remaining": "182", + "x-ratelimit-reset": "1790637300" + }, + "body": { + "allowed_media_types": [ + "image/jpeg", + "image/gif", + "image/png", + "image/webp", + "image/avif", + "image/heic", + "video/mp4", + "video/webm", + "video/quicktime", + "audio/mpeg", + "audio/mp4", + "audio/ogg", + "audio/opus", + "audio/wav", + "audio/x-wav", + "audio/flac", + "audio/aac", + "application/pdf", + "text/plain", + "text/markdown", + "text/csv", + "application/zip", + "application/epub+zip", + "application/vnd.oasis.opendocument.text", + "application/vnd.oasis.opendocument.spreadsheet", + "application/vnd.oasis.opendocument.presentation", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "application/vnd.openxmlformats-officedocument.presentationml.presentation" + ], + "max_caption_length": 5000, + "default_license": 1, + "media_descriptions": false, + "max_file_size": 10485760, + "max_media_attachments": 10, + "max_altext_length": 1500, + "default_scope": "public" + } +} diff --git a/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/places-search.json b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/places-search.json new file mode 100644 index 00000000..68bb56e9 --- /dev/null +++ b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/places-search.json @@ -0,0 +1,23 @@ +{ + "request": { + "method": "GET", + "path": "/index.php/apps/social/api/v1/places/search?q=Ber" + }, + "status": 200, + "headers": { + "content-type": "application/json; charset=utf-8", + "cache-control": "no-cache, no-store, must-revalidate", + "x-ratelimit-limit": "120", + "x-ratelimit-remaining": "119", + "x-ratelimit-reset": "1790637300" + }, + "body": [ + { + "id": "1", + "name": "Berlin", + "country": "DE", + "lat": "", + "long": "" + } + ] +} diff --git a/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/poll.json b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/poll.json new file mode 100644 index 00000000..dc61c667 --- /dev/null +++ b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/poll.json @@ -0,0 +1,39 @@ +{ + "request": { + "method": "GET", + "path": "/index.php/apps/social/api/v1/polls/1790637087495743511" + }, + "status": 200, + "headers": { + "content-type": "application/json; charset=utf-8", + "cache-control": "no-cache, no-store, must-revalidate", + "x-ratelimit-limit": "300", + "x-ratelimit-remaining": "218", + "x-ratelimit-reset": "1790637300" + }, + "body": { + "id": "1790637087495743511", + "expires_at": "2026-09-29T23:11:27Z", + "expired": false, + "multiple": false, + "votes_count": 1, + "voters_count": 1, + "options": [ + { + "title": "Photos", + "votes_count": 1 + }, + { + "title": "Video", + "votes_count": 0 + }, + { + "title": "Shorts", + "votes_count": 0 + } + ], + "emojis": [], + "voted": false, + "own_votes": [] + } +} diff --git a/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/preferences.json b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/preferences.json new file mode 100644 index 00000000..b220de6c --- /dev/null +++ b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/preferences.json @@ -0,0 +1,21 @@ +{ + "request": { + "method": "GET", + "path": "/index.php/apps/social/api/v1/preferences" + }, + "status": 200, + "headers": { + "content-type": "application/json; charset=utf-8", + "cache-control": "no-cache, no-store, must-revalidate", + "x-ratelimit-limit": "300", + "x-ratelimit-remaining": "254", + "x-ratelimit-reset": "1790637300" + }, + "body": { + "posting:default:visibility": "public", + "posting:default:sensitive": false, + "posting:default:language": "en", + "reading:expand:media": "default", + "reading:expand:spoilers": false + } +} diff --git a/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/scheduled-statuses.json b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/scheduled-statuses.json new file mode 100644 index 00000000..89c9ffa2 --- /dev/null +++ b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/scheduled-statuses.json @@ -0,0 +1,36 @@ +{ + "request": { + "method": "GET", + "path": "/index.php/apps/social/api/v1/scheduled_statuses" + }, + "status": 200, + "headers": { + "content-type": "application/json; charset=utf-8", + "cache-control": "no-cache, no-store, must-revalidate", + "x-ratelimit-limit": "300", + "x-ratelimit-remaining": "217", + "x-ratelimit-reset": "1790637300" + }, + "body": [ + { + "id": "1", + "scheduled_at": "2026-09-29T23:11:52.000Z", + "params": { + "text": "Posted tomorrow", + "media_ids": [], + "poll": null, + "in_reply_to_id": null, + "quoted_status_id": null, + "sensitive": false, + "spoiler_text": "", + "visibility": "public", + "language": "", + "application_id": null, + "idempotency": null, + "scheduled_at": null, + "with_rate_limit": null + }, + "media_attachments": [] + } + ] +} diff --git a/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/search-account.json b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/search-account.json new file mode 100644 index 00000000..15e7ed44 --- /dev/null +++ b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/search-account.json @@ -0,0 +1,47 @@ +{ + "request": { + "method": "GET", + "path": "/index.php/apps/social/api/v2/search?q=bob&type=accounts" + }, + "status": 200, + "headers": { + "content-type": "application/json; charset=utf-8", + "cache-control": "no-cache, no-store, must-revalidate", + "x-ratelimit-limit": "10", + "x-ratelimit-remaining": "8", + "x-ratelimit-reset": "1790637300" + }, + "body": { + "accounts": [ + { + "id": "7", + "nid": "7", + "username": "bob", + "acct": "bob", + "display_name": "bob", + "locked": false, + "bot": false, + "discoverable": false, + "indexable": false, + "group": false, + "created_at": "2026-09-28T23:06:03.000Z", + "note": "", + "url": "http://nextcloud.local/index.php/apps/social/@bob", + "avatar": "", + "avatar_static": "", + "header": "http://nextcloud.local/index.php/avatar/bob/128", + "header_static": "http://nextcloud.local/index.php/avatar/bob/128", + "followers_count": 1, + "following_count": 2, + "statuses_count": 3, + "last_status_at": null, + "emojis": [], + "fields": [], + "pronouns": "", + "support_link": "" + } + ], + "statuses": [], + "hashtags": [] + } +} diff --git a/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/search-aloha.json b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/search-aloha.json new file mode 100644 index 00000000..070c33e4 --- /dev/null +++ b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/search-aloha.json @@ -0,0 +1,190 @@ +{ + "request": { + "method": "GET", + "path": "/index.php/apps/social/api/v2/search?q=aloha&limit=5" + }, + "status": 200, + "headers": { + "content-type": "application/json; charset=utf-8", + "cache-control": "no-cache, no-store, must-revalidate", + "x-ratelimit-limit": "10", + "x-ratelimit-remaining": "9", + "x-ratelimit-reset": "1790637300" + }, + "body": { + "accounts": [], + "statuses": [ + { + "id": "1790637117344364158", + "nid": "1790637117344364158", + "local": true, + "archived": false, + "view_count": null, + "tagged_people": [], + "interest": null, + "content": "

Bob's own post #aloha

", + "sensitive": false, + "spoiler_text": "", + "visibility": "public", + "language": "en", + "place": null, + "in_reply_to_id": null, + "in_reply_to_account_id": null, + "quote": null, + "quote_approval": { + "automatic": [ + "public" + ], + "manual": [], + "current_user": "automatic" + }, + "reply_approval": null, + "interaction_policy": null, + "dislikes_count": null, + "disliked": null, + "video": null, + "mentions": [], + "emojis": [], + "tags": [ + { + "name": "aloha", + "url": "http://nextcloud.local/index.php/apps/social/timeline/tags/aloha" + } + ], + "replies_count": 0, + "reblogs_count": 0, + "favourites_count": 0, + "favourited": false, + "reblogged": false, + "muted": false, + "bookmarked": false, + "pinned": false, + "card": null, + "reactions": [], + "poll": null, + "uri": "http://nextcloud.local/index.php/apps/social/@bob/17906371177254273993", + "url": "http://nextcloud.local/index.php/apps/social/@bob/17906371177254273993", + "reblog": null, + "media_attachments": [], + "created_at": "2026-09-28T23:11:57.000Z", + "edited_at": null, + "noindex": false, + "account": { + "id": "7", + "nid": "7", + "username": "bob", + "acct": "bob", + "display_name": "bob", + "locked": false, + "bot": false, + "discoverable": false, + "indexable": false, + "group": false, + "created_at": "2026-09-28T23:06:03.000Z", + "note": "", + "url": "http://nextcloud.local/index.php/apps/social/@bob", + "avatar": "", + "avatar_static": "", + "header": "http://nextcloud.local/index.php/avatar/bob/128", + "header_static": "http://nextcloud.local/index.php/avatar/bob/128", + "followers_count": 1, + "following_count": 2, + "statuses_count": 3, + "last_status_at": null, + "emojis": [], + "fields": [], + "pronouns": "", + "support_link": "" + } + }, + { + "id": "1790637085797595891", + "nid": "1790637085797595891", + "local": true, + "archived": false, + "view_count": null, + "tagged_people": [], + "interest": null, + "content": "

Aloha from the dev instance! Say hi, @bob@nextcloud.local #aloha #android

", + "sensitive": false, + "spoiler_text": "", + "visibility": "public", + "language": "en", + "place": null, + "in_reply_to_id": null, + "in_reply_to_account_id": null, + "quote": null, + "quote_approval": { + "automatic": [ + "public" + ], + "manual": [], + "current_user": "automatic" + }, + "reply_approval": null, + "interaction_policy": null, + "dislikes_count": null, + "disliked": null, + "video": null, + "mentions": [], + "emojis": [], + "tags": [ + { + "name": "aloha", + "url": "http://nextcloud.local/index.php/apps/social/timeline/tags/aloha" + }, + { + "name": "android", + "url": "http://nextcloud.local/index.php/apps/social/timeline/tags/android" + } + ], + "replies_count": 1, + "reblogs_count": 0, + "favourites_count": 1, + "favourited": false, + "reblogged": false, + "muted": false, + "bookmarked": false, + "pinned": false, + "card": null, + "reactions": [], + "poll": null, + "uri": "http://nextcloud.local/index.php/apps/social/@alice/17906370853806247191", + "url": "http://nextcloud.local/index.php/apps/social/@alice/17906370853806247191", + "reblog": null, + "media_attachments": [], + "created_at": "2026-09-28T23:11:25.000Z", + "edited_at": null, + "noindex": false, + "account": { + "id": "6", + "nid": "6", + "username": "alice", + "acct": "alice", + "display_name": "alice", + "locked": false, + "bot": false, + "discoverable": false, + "indexable": false, + "group": false, + "created_at": "2026-09-28T23:06:02.000Z", + "note": "", + "url": "http://nextcloud.local/index.php/apps/social/@alice", + "avatar": "", + "avatar_static": "", + "header": "http://nextcloud.local/index.php/avatar/alice/128", + "header_static": "http://nextcloud.local/index.php/avatar/alice/128", + "followers_count": 2, + "following_count": 2, + "statuses_count": 13, + "last_status_at": null, + "emojis": [], + "fields": [], + "pronouns": "", + "support_link": "" + } + } + ], + "hashtags": [] + } +} diff --git a/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/search-hashtags.json b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/search-hashtags.json new file mode 100644 index 00000000..6168c723 --- /dev/null +++ b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/search-hashtags.json @@ -0,0 +1,19 @@ +{ + "request": { + "method": "GET", + "path": "/index.php/apps/social/api/v2/search?q=alo&type=hashtags" + }, + "status": 200, + "headers": { + "content-type": "application/json; charset=utf-8", + "cache-control": "no-cache, no-store, must-revalidate", + "x-ratelimit-limit": "10", + "x-ratelimit-remaining": "7", + "x-ratelimit-reset": "1790637300" + }, + "body": { + "accounts": [], + "statuses": [], + "hashtags": [] + } +} diff --git a/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/starter-packs.json b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/starter-packs.json new file mode 100644 index 00000000..f72abb82 --- /dev/null +++ b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/starter-packs.json @@ -0,0 +1,34 @@ +{ + "request": { + "method": "GET", + "path": "/index.php/apps/social/api/v1/starter_packs" + }, + "status": 200, + "headers": { + "content-type": "application/json; charset=utf-8", + "cache-control": "no-cache, no-store, must-revalidate", + "x-ratelimit-limit": "300", + "x-ratelimit-remaining": "193", + "x-ratelimit-reset": "1790637300" + }, + "body": [ + { + "id": "fediverse-projects", + "name": "The projects behind the network", + "description": "The official accounts of the software this app talks to. A reasonable first follow for anyone who wants to know what is changing under them.", + "source": "builtin", + "size": 3, + "accounts": [], + "unresolved": [] + }, + { + "id": "photography", + "name": "Photography", + "description": "Where the pictures are. Pixelfed is the photo-sharing side of the same network, and these are its own accounts.", + "source": "builtin", + "size": 2, + "accounts": [], + "unresolved": [] + } + ] +} diff --git a/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/status-audio.json b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/status-audio.json new file mode 100644 index 00000000..a4e594f7 --- /dev/null +++ b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/status-audio.json @@ -0,0 +1,118 @@ +{ + "request": { + "method": "GET", + "path": "/index.php/apps/social/api/v1/statuses/1790637105549523409" + }, + "status": 200, + "headers": { + "content-type": "application/json; charset=utf-8", + "cache-control": "no-cache, no-store, must-revalidate", + "x-ratelimit-limit": "300", + "x-ratelimit-remaining": "236", + "x-ratelimit-reset": "1790637300" + }, + "body": { + "id": "1790637105549523409", + "nid": "1790637105549523409", + "local": true, + "archived": false, + "view_count": 0, + "tagged_people": [], + "interest": null, + "content": "

A 15 second tone #audio

", + "sensitive": false, + "spoiler_text": "", + "visibility": "public", + "language": "en", + "place": null, + "in_reply_to_id": null, + "in_reply_to_account_id": null, + "quote": null, + "quote_approval": { + "automatic": [ + "public" + ], + "manual": [], + "current_user": "automatic" + }, + "reply_approval": null, + "interaction_policy": null, + "dislikes_count": null, + "disliked": null, + "video": null, + "mentions": [], + "emojis": [], + "tags": [ + { + "name": "audio", + "url": "http://nextcloud.local/index.php/apps/social/timeline/tags/audio" + } + ], + "replies_count": 0, + "reblogs_count": 0, + "favourites_count": 0, + "favourited": false, + "reblogged": false, + "muted": false, + "bookmarked": false, + "pinned": false, + "card": null, + "reactions": [], + "poll": null, + "uri": "http://nextcloud.local/index.php/apps/social/@alice/17906371052336860333", + "url": "http://nextcloud.local/index.php/apps/social/@alice/17906371052336860333", + "reblog": null, + "media_attachments": [ + { + "id": "14", + "type": "audio", + "media_type": "audio/mpeg", + "url": "http://nextcloud.local/index.php/apps/social/media/2ee64fb3-dda9-47f5-bac6-daa189149093.mpeg", + "preview_url": "http://nextcloud.local/index.php/apps/social/media/2ee64fb3-dda9-47f5-bac6-daa189149093.mpeg", + "hls_url": null, + "remote_url": null, + "cache_error": null, + "meta": { + "original": [], + "small": [], + "focus": { + "x": 0, + "y": 0 + } + }, + "description": "A 15 second 330 Hz tone", + "blurhash": null + } + ], + "created_at": "2026-09-28T23:11:45.000Z", + "edited_at": null, + "noindex": false, + "account": { + "id": "6", + "nid": "6", + "username": "alice", + "acct": "alice", + "display_name": "alice", + "locked": false, + "bot": false, + "discoverable": false, + "indexable": false, + "group": false, + "created_at": "2026-09-28T23:06:02.000Z", + "note": "", + "url": "http://nextcloud.local/index.php/apps/social/@alice", + "avatar": "", + "avatar_static": "", + "header": "http://nextcloud.local/index.php/avatar/alice/128", + "header_static": "http://nextcloud.local/index.php/avatar/alice/128", + "followers_count": 2, + "following_count": 2, + "statuses_count": 13, + "last_status_at": null, + "emojis": [], + "fields": [], + "pronouns": "", + "support_link": "" + } + } +} diff --git a/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/status-bob.json b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/status-bob.json new file mode 100644 index 00000000..e601d557 --- /dev/null +++ b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/status-bob.json @@ -0,0 +1,97 @@ +{ + "request": { + "method": "GET", + "path": "/index.php/apps/social/api/v1/statuses/1790637117344364158" + }, + "status": 200, + "headers": { + "content-type": "application/json; charset=utf-8", + "cache-control": "no-cache, no-store, must-revalidate", + "x-ratelimit-limit": "300", + "x-ratelimit-remaining": "229", + "x-ratelimit-reset": "1790637300" + }, + "body": { + "id": "1790637117344364158", + "nid": "1790637117344364158", + "local": true, + "archived": false, + "view_count": null, + "tagged_people": [], + "interest": null, + "content": "

Bob's own post #aloha

", + "sensitive": false, + "spoiler_text": "", + "visibility": "public", + "language": "en", + "place": null, + "in_reply_to_id": null, + "in_reply_to_account_id": null, + "quote": null, + "quote_approval": { + "automatic": [ + "public" + ], + "manual": [], + "current_user": "automatic" + }, + "reply_approval": null, + "interaction_policy": null, + "dislikes_count": null, + "disliked": null, + "video": null, + "mentions": [], + "emojis": [], + "tags": [ + { + "name": "aloha", + "url": "http://nextcloud.local/index.php/apps/social/timeline/tags/aloha" + } + ], + "replies_count": 0, + "reblogs_count": 0, + "favourites_count": 0, + "favourited": false, + "reblogged": false, + "muted": false, + "bookmarked": false, + "pinned": false, + "card": null, + "reactions": [], + "poll": null, + "uri": "http://nextcloud.local/index.php/apps/social/@bob/17906371177254273993", + "url": "http://nextcloud.local/index.php/apps/social/@bob/17906371177254273993", + "reblog": null, + "media_attachments": [], + "created_at": "2026-09-28T23:11:57.000Z", + "edited_at": null, + "noindex": false, + "account": { + "id": "7", + "nid": "7", + "username": "bob", + "acct": "bob", + "display_name": "bob", + "locked": false, + "bot": false, + "discoverable": false, + "indexable": false, + "group": false, + "created_at": "2026-09-28T23:06:03.000Z", + "note": "", + "url": "http://nextcloud.local/index.php/apps/social/@bob", + "avatar": "", + "avatar_static": "", + "header": "http://nextcloud.local/index.php/avatar/bob/128", + "header_static": "http://nextcloud.local/index.php/avatar/bob/128", + "followers_count": 1, + "following_count": 2, + "statuses_count": 3, + "last_status_at": null, + "emojis": [], + "fields": [], + "pronouns": "", + "support_link": "" + } + } +} diff --git a/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/status-cw.json b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/status-cw.json new file mode 100644 index 00000000..b70d8369 --- /dev/null +++ b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/status-cw.json @@ -0,0 +1,92 @@ +{ + "request": { + "method": "GET", + "path": "/index.php/apps/social/api/v1/statuses/1790637086177868305" + }, + "status": 200, + "headers": { + "content-type": "application/json; charset=utf-8", + "cache-control": "no-cache, no-store, must-revalidate", + "x-ratelimit-limit": "300", + "x-ratelimit-remaining": "242", + "x-ratelimit-reset": "1790637300" + }, + "body": { + "id": "1790637086177868305", + "nid": "1790637086177868305", + "local": true, + "archived": false, + "view_count": 0, + "tagged_people": [], + "interest": null, + "content": "

The butler did it.

", + "sensitive": true, + "spoiler_text": "Spoiler: season finale", + "visibility": "public", + "language": "en", + "place": null, + "in_reply_to_id": null, + "in_reply_to_account_id": null, + "quote": null, + "quote_approval": { + "automatic": [ + "public" + ], + "manual": [], + "current_user": "automatic" + }, + "reply_approval": null, + "interaction_policy": null, + "dislikes_count": null, + "disliked": null, + "video": null, + "mentions": [], + "emojis": [], + "tags": [], + "replies_count": 0, + "reblogs_count": 0, + "favourites_count": 0, + "favourited": false, + "reblogged": false, + "muted": false, + "bookmarked": false, + "pinned": false, + "card": null, + "reactions": [], + "poll": null, + "uri": "http://nextcloud.local/index.php/apps/social/@alice/17906370868446468411", + "url": "http://nextcloud.local/index.php/apps/social/@alice/17906370868446468411", + "reblog": null, + "media_attachments": [], + "created_at": "2026-09-28T23:11:26.000Z", + "edited_at": null, + "noindex": false, + "account": { + "id": "6", + "nid": "6", + "username": "alice", + "acct": "alice", + "display_name": "alice", + "locked": false, + "bot": false, + "discoverable": false, + "indexable": false, + "group": false, + "created_at": "2026-09-28T23:06:02.000Z", + "note": "", + "url": "http://nextcloud.local/index.php/apps/social/@alice", + "avatar": "", + "avatar_static": "", + "header": "http://nextcloud.local/index.php/avatar/alice/128", + "header_static": "http://nextcloud.local/index.php/avatar/alice/128", + "followers_count": 2, + "following_count": 2, + "statuses_count": 13, + "last_status_at": null, + "emojis": [], + "fields": [], + "pronouns": "", + "support_link": "" + } + } +} diff --git a/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/status-direct.json b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/status-direct.json new file mode 100644 index 00000000..abfc57ec --- /dev/null +++ b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/status-direct.json @@ -0,0 +1,90 @@ +{ + "request": { + "method": "GET", + "path": "/index.php/apps/social/api/v1/statuses/1790637086268689343" + }, + "status": 200, + "headers": { + "content-type": "application/json; charset=utf-8", + "cache-control": "no-cache, no-store, must-revalidate", + "x-ratelimit-limit": "300", + "x-ratelimit-remaining": "243", + "x-ratelimit-reset": "1790637300" + }, + "body": { + "id": "1790637086268689343", + "nid": "1790637086268689343", + "local": true, + "archived": false, + "view_count": 0, + "tagged_people": [], + "interest": null, + "content": "

@bob@nextcloud.local this is a direct message.

", + "sensitive": false, + "spoiler_text": "", + "visibility": "direct", + "language": "en", + "place": null, + "in_reply_to_id": null, + "in_reply_to_account_id": null, + "quote": null, + "quote_approval": { + "automatic": [], + "manual": [], + "current_user": "automatic" + }, + "reply_approval": null, + "interaction_policy": null, + "dislikes_count": null, + "disliked": null, + "video": null, + "mentions": [], + "emojis": [], + "tags": [], + "replies_count": 0, + "reblogs_count": 0, + "favourites_count": 0, + "favourited": false, + "reblogged": false, + "muted": false, + "bookmarked": false, + "pinned": false, + "card": null, + "reactions": [], + "poll": null, + "uri": "http://nextcloud.local/index.php/apps/social/@alice/17906370863679564895", + "url": "http://nextcloud.local/index.php/apps/social/@alice/17906370863679564895", + "reblog": null, + "media_attachments": [], + "created_at": "2026-09-28T23:11:26.000Z", + "edited_at": null, + "noindex": false, + "account": { + "id": "6", + "nid": "6", + "username": "alice", + "acct": "alice", + "display_name": "alice", + "locked": false, + "bot": false, + "discoverable": false, + "indexable": false, + "group": false, + "created_at": "2026-09-28T23:06:02.000Z", + "note": "", + "url": "http://nextcloud.local/index.php/apps/social/@alice", + "avatar": "", + "avatar_static": "", + "header": "http://nextcloud.local/index.php/avatar/alice/128", + "header_static": "http://nextcloud.local/index.php/avatar/alice/128", + "followers_count": 2, + "following_count": 2, + "statuses_count": 13, + "last_status_at": null, + "emojis": [], + "fields": [], + "pronouns": "", + "support_link": "" + } + } +} diff --git a/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/status-edited-history.json b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/status-edited-history.json new file mode 100644 index 00000000..2bdfd797 --- /dev/null +++ b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/status-edited-history.json @@ -0,0 +1,88 @@ +{ + "request": { + "method": "GET", + "path": "/index.php/apps/social/api/v1/statuses/1790637112034926731/history" + }, + "status": 200, + "headers": { + "content-type": "application/json; charset=utf-8", + "cache-control": "no-cache, no-store, must-revalidate", + "x-ratelimit-limit": "300", + "x-ratelimit-remaining": "223", + "x-ratelimit-reset": "1790637300" + }, + "body": [ + { + "content": "

Frist draft

", + "spoiler_text": "", + "sensitive": false, + "created_at": "2026-09-28T23:11:52+00:00", + "account": { + "id": "6", + "nid": "6", + "username": "alice", + "acct": "alice", + "display_name": "alice", + "locked": false, + "bot": false, + "discoverable": false, + "indexable": false, + "group": false, + "created_at": "2026-09-28T23:06:02.000Z", + "note": "", + "url": "http://nextcloud.local/index.php/apps/social/@alice", + "avatar": "", + "avatar_static": "", + "header": "http://nextcloud.local/index.php/avatar/alice/128", + "header_static": "http://nextcloud.local/index.php/avatar/alice/128", + "followers_count": 2, + "following_count": 2, + "statuses_count": 13, + "last_status_at": null, + "emojis": [], + "fields": [], + "pronouns": "", + "support_link": "" + }, + "poll": null, + "media_attachments": [], + "emojis": [] + }, + { + "content": "

First draft, edited

", + "spoiler_text": "", + "sensitive": false, + "created_at": "2026-09-28T23:11:52Z", + "account": { + "id": "6", + "nid": "6", + "username": "alice", + "acct": "alice", + "display_name": "alice", + "locked": false, + "bot": false, + "discoverable": false, + "indexable": false, + "group": false, + "created_at": "2026-09-28T23:06:02.000Z", + "note": "", + "url": "http://nextcloud.local/index.php/apps/social/@alice", + "avatar": "", + "avatar_static": "", + "header": "http://nextcloud.local/index.php/avatar/alice/128", + "header_static": "http://nextcloud.local/index.php/avatar/alice/128", + "followers_count": 2, + "following_count": 2, + "statuses_count": 13, + "last_status_at": null, + "emojis": [], + "fields": [], + "pronouns": "", + "support_link": "" + }, + "poll": null, + "media_attachments": [], + "emojis": [] + } + ] +} diff --git a/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/status-edited-source.json b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/status-edited-source.json new file mode 100644 index 00000000..d352d32a --- /dev/null +++ b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/status-edited-source.json @@ -0,0 +1,19 @@ +{ + "request": { + "method": "GET", + "path": "/index.php/apps/social/api/v1/statuses/1790637112034926731/source" + }, + "status": 200, + "headers": { + "content-type": "application/json; charset=utf-8", + "cache-control": "no-cache, no-store, must-revalidate", + "x-ratelimit-limit": "300", + "x-ratelimit-remaining": "222", + "x-ratelimit-reset": "1790637300" + }, + "body": { + "id": "1790637112034926731", + "text": "First draft, edited", + "spoiler_text": "" + } +} diff --git a/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/status-edited.json b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/status-edited.json new file mode 100644 index 00000000..7fd78584 --- /dev/null +++ b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/status-edited.json @@ -0,0 +1,92 @@ +{ + "request": { + "method": "GET", + "path": "/index.php/apps/social/api/v1/statuses/1790637112034926731" + }, + "status": 200, + "headers": { + "content-type": "application/json; charset=utf-8", + "cache-control": "no-cache, no-store, must-revalidate", + "x-ratelimit-limit": "300", + "x-ratelimit-remaining": "233", + "x-ratelimit-reset": "1790637300" + }, + "body": { + "id": "1790637112034926731", + "nid": "1790637112034926731", + "local": true, + "archived": false, + "view_count": 0, + "tagged_people": [], + "interest": null, + "content": "

First draft, edited

", + "sensitive": false, + "spoiler_text": "", + "visibility": "public", + "language": "en", + "place": null, + "in_reply_to_id": null, + "in_reply_to_account_id": null, + "quote": null, + "quote_approval": { + "automatic": [ + "public" + ], + "manual": [], + "current_user": "automatic" + }, + "reply_approval": null, + "interaction_policy": null, + "dislikes_count": null, + "disliked": null, + "video": null, + "mentions": [], + "emojis": [], + "tags": [], + "replies_count": 0, + "reblogs_count": 0, + "favourites_count": 0, + "favourited": false, + "reblogged": false, + "muted": false, + "bookmarked": false, + "pinned": false, + "card": null, + "reactions": [], + "poll": null, + "uri": "http://nextcloud.local/index.php/apps/social/@alice/17906371127199851091", + "url": "http://nextcloud.local/index.php/apps/social/@alice/17906371127199851091", + "reblog": null, + "media_attachments": [], + "created_at": "2026-09-28T23:11:52.000Z", + "edited_at": "2026-09-28T23:11:52.000Z", + "noindex": false, + "account": { + "id": "6", + "nid": "6", + "username": "alice", + "acct": "alice", + "display_name": "alice", + "locked": false, + "bot": false, + "discoverable": false, + "indexable": false, + "group": false, + "created_at": "2026-09-28T23:06:02.000Z", + "note": "", + "url": "http://nextcloud.local/index.php/apps/social/@alice", + "avatar": "", + "avatar_static": "", + "header": "http://nextcloud.local/index.php/avatar/alice/128", + "header_static": "http://nextcloud.local/index.php/avatar/alice/128", + "followers_count": 2, + "following_count": 2, + "statuses_count": 13, + "last_status_at": null, + "emojis": [], + "fields": [], + "pronouns": "", + "support_link": "" + } + } +} diff --git a/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/status-link-card.json b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/status-link-card.json new file mode 100644 index 00000000..5a3d061c --- /dev/null +++ b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/status-link-card.json @@ -0,0 +1,15 @@ +{ + "request": { + "method": "GET", + "path": "/index.php/apps/social/api/v1/statuses/1790637105561356153/card" + }, + "status": 200, + "headers": { + "content-type": "application/json; charset=utf-8", + "cache-control": "no-cache, no-store, must-revalidate", + "x-ratelimit-limit": "300", + "x-ratelimit-remaining": "221", + "x-ratelimit-reset": "1790637300" + }, + "body": {} +} diff --git a/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/status-link.json b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/status-link.json new file mode 100644 index 00000000..e9ada3b4 --- /dev/null +++ b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/status-link.json @@ -0,0 +1,92 @@ +{ + "request": { + "method": "GET", + "path": "/index.php/apps/social/api/v1/statuses/1790637105561356153" + }, + "status": 200, + "headers": { + "content-type": "application/json; charset=utf-8", + "cache-control": "no-cache, no-store, must-revalidate", + "x-ratelimit-limit": "300", + "x-ratelimit-remaining": "235", + "x-ratelimit-reset": "1790637300" + }, + "body": { + "id": "1790637105561356153", + "nid": "1790637105561356153", + "local": true, + "archived": false, + "view_count": 0, + "tagged_people": [], + "interest": null, + "content": "

Nextcloud Social on GitHub: https://github.com/nextcloud/social

", + "sensitive": false, + "spoiler_text": "", + "visibility": "public", + "language": "en", + "place": null, + "in_reply_to_id": null, + "in_reply_to_account_id": null, + "quote": null, + "quote_approval": { + "automatic": [ + "public" + ], + "manual": [], + "current_user": "automatic" + }, + "reply_approval": null, + "interaction_policy": null, + "dislikes_count": null, + "disliked": null, + "video": null, + "mentions": [], + "emojis": [], + "tags": [], + "replies_count": 0, + "reblogs_count": 0, + "favourites_count": 0, + "favourited": false, + "reblogged": false, + "muted": false, + "bookmarked": false, + "pinned": false, + "card": null, + "reactions": [], + "poll": null, + "uri": "http://nextcloud.local/index.php/apps/social/@alice/17906371057334696925", + "url": "http://nextcloud.local/index.php/apps/social/@alice/17906371057334696925", + "reblog": null, + "media_attachments": [], + "created_at": "2026-09-28T23:11:45.000Z", + "edited_at": null, + "noindex": false, + "account": { + "id": "6", + "nid": "6", + "username": "alice", + "acct": "alice", + "display_name": "alice", + "locked": false, + "bot": false, + "discoverable": false, + "indexable": false, + "group": false, + "created_at": "2026-09-28T23:06:02.000Z", + "note": "", + "url": "http://nextcloud.local/index.php/apps/social/@alice", + "avatar": "", + "avatar_static": "", + "header": "http://nextcloud.local/index.php/avatar/alice/128", + "header_static": "http://nextcloud.local/index.php/avatar/alice/128", + "followers_count": 2, + "following_count": 2, + "statuses_count": 13, + "last_status_at": null, + "emojis": [], + "fields": [], + "pronouns": "", + "support_link": "" + } + } +} diff --git a/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/status-photos-reblogged-by.json b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/status-photos-reblogged-by.json new file mode 100644 index 00000000..ed7a8929 --- /dev/null +++ b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/status-photos-reblogged-by.json @@ -0,0 +1,43 @@ +{ + "request": { + "method": "GET", + "path": "/index.php/apps/social/api/v1/statuses/1790637096330770789/reblogged_by" + }, + "status": 200, + "headers": { + "content-type": "application/json; charset=utf-8", + "cache-control": "no-cache, no-store, must-revalidate", + "x-ratelimit-limit": "300", + "x-ratelimit-remaining": "226", + "x-ratelimit-reset": "1790637300" + }, + "body": [ + { + "id": "0", + "nid": 0, + "username": "bob", + "acct": "bob", + "display_name": "bob", + "locked": false, + "bot": false, + "discoverable": false, + "indexable": false, + "group": false, + "created_at": "2026-09-28T23:06:03.000Z", + "note": "", + "url": "http://nextcloud.local/index.php/apps/social/@bob", + "avatar": "", + "avatar_static": "", + "header": "http://nextcloud.local/index.php/avatar/bob/128", + "header_static": "http://nextcloud.local/index.php/avatar/bob/128", + "followers_count": 0, + "following_count": 0, + "statuses_count": 0, + "last_status_at": null, + "emojis": [], + "fields": [], + "pronouns": "", + "support_link": "" + } + ] +} diff --git a/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/status-photos.json b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/status-photos.json new file mode 100644 index 00000000..77b09a8e --- /dev/null +++ b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/status-photos.json @@ -0,0 +1,158 @@ +{ + "request": { + "method": "GET", + "path": "/index.php/apps/social/api/v1/statuses/1790637096330770789" + }, + "status": 200, + "headers": { + "content-type": "application/json; charset=utf-8", + "cache-control": "no-cache, no-store, must-revalidate", + "x-ratelimit-limit": "300", + "x-ratelimit-remaining": "240", + "x-ratelimit-reset": "1790637300" + }, + "body": { + "id": "1790637096330770789", + "nid": "1790637096330770789", + "local": true, + "archived": false, + "view_count": 0, + "tagged_people": [], + "interest": null, + "content": "

Two test pictures #photography

", + "sensitive": false, + "spoiler_text": "", + "visibility": "public", + "language": "en", + "place": null, + "in_reply_to_id": null, + "in_reply_to_account_id": null, + "quote": null, + "quote_approval": { + "automatic": [ + "public" + ], + "manual": [], + "current_user": "automatic" + }, + "reply_approval": null, + "interaction_policy": null, + "dislikes_count": null, + "disliked": null, + "video": null, + "mentions": [], + "emojis": [], + "tags": [ + { + "name": "photography", + "url": "http://nextcloud.local/index.php/apps/social/timeline/tags/photography" + } + ], + "replies_count": 0, + "reblogs_count": 1, + "favourites_count": 0, + "favourited": false, + "reblogged": false, + "muted": false, + "bookmarked": false, + "pinned": false, + "card": null, + "reactions": [], + "poll": null, + "uri": "http://nextcloud.local/index.php/apps/social/@alice/17906370966999687509", + "url": "http://nextcloud.local/index.php/apps/social/@alice/17906370966999687509", + "reblog": null, + "media_attachments": [ + { + "id": "9", + "type": "image", + "media_type": "image/jpeg", + "url": "http://nextcloud.local/index.php/apps/social/media/48044adf-b771-4343-9607-c7fc93f16ad1.jpeg", + "preview_url": "http://nextcloud.local/index.php/apps/social/media/2c66bfd7-0029-47e1-b6ef-23dc7d422e2b.jpeg", + "hls_url": null, + "remote_url": null, + "cache_error": null, + "meta": { + "original": { + "width": 1600, + "height": 1200, + "size": "1600x1200", + "aspect": 1.3333333333333333 + }, + "small": { + "width": 800, + "height": 600, + "size": "800x600", + "aspect": 1.3333333333333333 + }, + "focus": { + "x": 0.3, + "y": -0.2 + } + }, + "description": "A test card pattern in bright colours", + "blurhash": "LVL4]9|Tnk_gsESdfjn,baayW;jZ" + }, + { + "id": "10", + "type": "image", + "media_type": "image/jpeg", + "url": "http://nextcloud.local/index.php/apps/social/media/1e936701-3c1a-49c2-a58a-bc7b0d5a9c27.jpeg", + "preview_url": "http://nextcloud.local/index.php/apps/social/media/43d878b8-d9fd-42fa-9f7e-38dea1357b60.jpeg", + "hls_url": null, + "remote_url": null, + "cache_error": null, + "meta": { + "original": { + "width": 1080, + "height": 1350, + "size": "1080x1350", + "aspect": 0.8 + }, + "small": { + "width": 640, + "height": 800, + "size": "640x800", + "aspect": 0.8 + }, + "focus": { + "x": 0, + "y": 0 + } + }, + "description": "A Mandelbrot fractal, portrait", + "blurhash": "LyLow4+,$SN?w6S]WUjI[ibCSIbW" + } + ], + "created_at": "2026-09-28T23:11:36.000Z", + "edited_at": null, + "noindex": false, + "account": { + "id": "6", + "nid": "6", + "username": "alice", + "acct": "alice", + "display_name": "alice", + "locked": false, + "bot": false, + "discoverable": false, + "indexable": false, + "group": false, + "created_at": "2026-09-28T23:06:02.000Z", + "note": "", + "url": "http://nextcloud.local/index.php/apps/social/@alice", + "avatar": "", + "avatar_static": "", + "header": "http://nextcloud.local/index.php/avatar/alice/128", + "header_static": "http://nextcloud.local/index.php/avatar/alice/128", + "followers_count": 2, + "following_count": 2, + "statuses_count": 13, + "last_status_at": null, + "emojis": [], + "fields": [], + "pronouns": "", + "support_link": "" + } + } +} diff --git a/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/status-place.json b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/status-place.json new file mode 100644 index 00000000..bf081e8c --- /dev/null +++ b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/status-place.json @@ -0,0 +1,123 @@ +{ + "request": { + "method": "GET", + "path": "/index.php/apps/social/api/v1/statuses/1790637112216295042" + }, + "status": 200, + "headers": { + "content-type": "application/json; charset=utf-8", + "cache-control": "no-cache, no-store, must-revalidate", + "x-ratelimit-limit": "300", + "x-ratelimit-remaining": "234", + "x-ratelimit-reset": "1790637300" + }, + "body": { + "id": "1790637112216295042", + "nid": "1790637112216295042", + "local": true, + "archived": false, + "view_count": 0, + "tagged_people": [], + "interest": null, + "content": "

Picture from Berlin

", + "sensitive": false, + "spoiler_text": "", + "visibility": "public", + "language": "en", + "place": null, + "in_reply_to_id": null, + "in_reply_to_account_id": null, + "quote": null, + "quote_approval": { + "automatic": [ + "public" + ], + "manual": [], + "current_user": "automatic" + }, + "reply_approval": null, + "interaction_policy": null, + "dislikes_count": null, + "disliked": null, + "video": null, + "mentions": [], + "emojis": [], + "tags": [], + "replies_count": 0, + "reblogs_count": 0, + "favourites_count": 0, + "favourited": false, + "reblogged": false, + "muted": false, + "bookmarked": false, + "pinned": false, + "card": null, + "reactions": [], + "poll": null, + "uri": "http://nextcloud.local/index.php/apps/social/@alice/17906371127479253498", + "url": "http://nextcloud.local/index.php/apps/social/@alice/17906371127479253498", + "reblog": null, + "media_attachments": [ + { + "id": "15", + "type": "image", + "media_type": "image/jpeg", + "url": "http://nextcloud.local/index.php/apps/social/media/b88735cc-b169-40e0-9fa5-85e3be451c1a.jpeg", + "preview_url": "http://nextcloud.local/index.php/apps/social/media/e46d0547-3b30-46cd-8b5b-394bb7005a49.jpeg", + "hls_url": null, + "remote_url": null, + "cache_error": null, + "meta": { + "original": { + "width": 1080, + "height": 1350, + "size": "1080x1350", + "aspect": 0.8 + }, + "small": { + "width": 640, + "height": 800, + "size": "640x800", + "aspect": 0.8 + }, + "focus": { + "x": 0, + "y": 0 + } + }, + "description": "Another fractal, taken in Berlin", + "blurhash": "LyLow4+,$SN?w6S]WUjI[ibCSIbW" + } + ], + "created_at": "2026-09-28T23:11:52.000Z", + "edited_at": null, + "noindex": false, + "account": { + "id": "6", + "nid": "6", + "username": "alice", + "acct": "alice", + "display_name": "alice", + "locked": false, + "bot": false, + "discoverable": false, + "indexable": false, + "group": false, + "created_at": "2026-09-28T23:06:02.000Z", + "note": "", + "url": "http://nextcloud.local/index.php/apps/social/@alice", + "avatar": "", + "avatar_static": "", + "header": "http://nextcloud.local/index.php/avatar/alice/128", + "header_static": "http://nextcloud.local/index.php/avatar/alice/128", + "followers_count": 2, + "following_count": 2, + "statuses_count": 13, + "last_status_at": null, + "emojis": [], + "fields": [], + "pronouns": "", + "support_link": "" + } + } +} diff --git a/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/status-poll.json b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/status-poll.json new file mode 100644 index 00000000..25123e7b --- /dev/null +++ b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/status-poll.json @@ -0,0 +1,144 @@ +{ + "request": { + "method": "GET", + "path": "/index.php/apps/social/api/v1/statuses/1790637087495743511" + }, + "status": 200, + "headers": { + "content-type": "application/json; charset=utf-8", + "cache-control": "no-cache, no-store, must-revalidate", + "x-ratelimit-limit": "300", + "x-ratelimit-remaining": "241", + "x-ratelimit-reset": "1790637300" + }, + "body": { + "id": "1790637087495743511", + "nid": "1790637087495743511", + "local": true, + "archived": false, + "view_count": 1, + "tagged_people": [], + "interest": null, + "content": "

Which mode do you use most?

", + "sensitive": false, + "spoiler_text": "", + "visibility": "public", + "language": "en", + "place": null, + "in_reply_to_id": null, + "in_reply_to_account_id": null, + "quote": null, + "quote_approval": { + "automatic": [ + "public" + ], + "manual": [], + "current_user": "automatic" + }, + "reply_approval": null, + "interaction_policy": null, + "dislikes_count": null, + "disliked": null, + "video": null, + "mentions": [], + "emojis": [], + "tags": [], + "replies_count": 0, + "reblogs_count": 0, + "favourites_count": 0, + "favourited": false, + "reblogged": false, + "muted": false, + "bookmarked": false, + "pinned": false, + "card": null, + "reactions": [], + "poll": { + "id": "1790637087495743511", + "expires_at": "2026-09-29T23:11:27Z", + "expired": false, + "multiple": false, + "votes_count": 1, + "voters_count": 1, + "options": [ + { + "title": "Photos", + "votes_count": 1 + }, + { + "title": "Video", + "votes_count": 0 + }, + { + "title": "Shorts", + "votes_count": 0 + } + ], + "emojis": [], + "voted": false, + "own_votes": [] + }, + "uri": "http://nextcloud.local/index.php/apps/social/@alice/17906370875255636478", + "url": "http://nextcloud.local/index.php/apps/social/@alice/17906370875255636478", + "reblog": null, + "media_attachments": [], + "created_at": "2026-09-28T23:11:27.000Z", + "edited_at": null, + "noindex": false, + "account": { + "id": "6", + "nid": "6", + "username": "alice", + "acct": "alice", + "display_name": "alice", + "locked": false, + "bot": false, + "discoverable": false, + "indexable": false, + "group": false, + "created_at": "2026-09-28T23:06:02.000Z", + "note": "", + "url": "http://nextcloud.local/index.php/apps/social/@alice", + "avatar": "", + "avatar_static": "", + "header": "http://nextcloud.local/index.php/avatar/alice/128", + "header_static": "http://nextcloud.local/index.php/avatar/alice/128", + "followers_count": 2, + "following_count": 2, + "statuses_count": 13, + "last_status_at": null, + "emojis": [], + "fields": [], + "pronouns": "", + "support_link": "" + }, + "oneOf": [ + { + "type": "Note", + "name": "Photos", + "replies": { + "type": "Collection", + "totalItems": 1 + } + }, + { + "type": "Note", + "name": "Video", + "replies": { + "type": "Collection", + "totalItems": 0 + } + }, + { + "type": "Note", + "name": "Shorts", + "replies": { + "type": "Collection", + "totalItems": 0 + } + } + ], + "endTime": "2026-09-29T23:11:27Z", + "votersCount": 1 + } +} diff --git a/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/status-private.json b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/status-private.json new file mode 100644 index 00000000..647e20cf --- /dev/null +++ b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/status-private.json @@ -0,0 +1,90 @@ +{ + "request": { + "method": "GET", + "path": "/index.php/apps/social/api/v1/statuses/1790637086257046711" + }, + "status": 200, + "headers": { + "content-type": "application/json; charset=utf-8", + "cache-control": "no-cache, no-store, must-revalidate", + "x-ratelimit-limit": "300", + "x-ratelimit-remaining": "244", + "x-ratelimit-reset": "1790637300" + }, + "body": { + "id": "1790637086257046711", + "nid": "1790637086257046711", + "local": true, + "archived": false, + "view_count": 0, + "tagged_people": [], + "interest": null, + "content": "

Only my followers see this.

", + "sensitive": false, + "spoiler_text": "", + "visibility": "private", + "language": "en", + "place": null, + "in_reply_to_id": null, + "in_reply_to_account_id": null, + "quote": null, + "quote_approval": { + "automatic": [], + "manual": [], + "current_user": "automatic" + }, + "reply_approval": null, + "interaction_policy": null, + "dislikes_count": null, + "disliked": null, + "video": null, + "mentions": [], + "emojis": [], + "tags": [], + "replies_count": 0, + "reblogs_count": 0, + "favourites_count": 0, + "favourited": false, + "reblogged": false, + "muted": false, + "bookmarked": false, + "pinned": false, + "card": null, + "reactions": [], + "poll": null, + "uri": "http://nextcloud.local/index.php/apps/social/@alice/17906370861140078716", + "url": "http://nextcloud.local/index.php/apps/social/@alice/17906370861140078716", + "reblog": null, + "media_attachments": [], + "created_at": "2026-09-28T23:11:26.000Z", + "edited_at": null, + "noindex": false, + "account": { + "id": "6", + "nid": "6", + "username": "alice", + "acct": "alice", + "display_name": "alice", + "locked": false, + "bot": false, + "discoverable": false, + "indexable": false, + "group": false, + "created_at": "2026-09-28T23:06:02.000Z", + "note": "", + "url": "http://nextcloud.local/index.php/apps/social/@alice", + "avatar": "", + "avatar_static": "", + "header": "http://nextcloud.local/index.php/avatar/alice/128", + "header_static": "http://nextcloud.local/index.php/avatar/alice/128", + "followers_count": 2, + "following_count": 2, + "statuses_count": 13, + "last_status_at": null, + "emojis": [], + "fields": [], + "pronouns": "", + "support_link": "" + } + } +} diff --git a/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/status-public-card-none.json b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/status-public-card-none.json new file mode 100644 index 00000000..4d0a50e5 --- /dev/null +++ b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/status-public-card-none.json @@ -0,0 +1,15 @@ +{ + "request": { + "method": "GET", + "path": "/index.php/apps/social/api/v1/statuses/1790637085797595891/card" + }, + "status": 200, + "headers": { + "content-type": "application/json; charset=utf-8", + "cache-control": "no-cache, no-store, must-revalidate", + "x-ratelimit-limit": "300", + "x-ratelimit-remaining": "220", + "x-ratelimit-reset": "1790637300" + }, + "body": {} +} diff --git a/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/status-public-context.json b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/status-public-context.json new file mode 100644 index 00000000..a97bdca3 --- /dev/null +++ b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/status-public-context.json @@ -0,0 +1,177 @@ +{ + "request": { + "method": "GET", + "path": "/index.php/apps/social/api/v1/statuses/1790637085797595891/context" + }, + "status": 200, + "headers": { + "content-type": "application/json; charset=utf-8", + "cache-control": "no-cache, no-store, must-revalidate", + "x-ratelimit-limit": "300", + "x-ratelimit-remaining": "228", + "x-ratelimit-reset": "1790637300" + }, + "body": { + "ancestors": [], + "descendants": [ + { + "id": "1790637115351395147", + "nid": "1790637115351395147", + "local": true, + "archived": false, + "view_count": null, + "tagged_people": [], + "interest": null, + "content": "

@alice@nextcloud.local hi back!

", + "sensitive": false, + "spoiler_text": "", + "visibility": "public", + "language": "en", + "place": null, + "in_reply_to_id": "1790637085797595891", + "in_reply_to_account_id": "6", + "quote": null, + "quote_approval": { + "automatic": [ + "public" + ], + "manual": [], + "current_user": "automatic" + }, + "reply_approval": null, + "interaction_policy": null, + "dislikes_count": null, + "disliked": null, + "video": null, + "mentions": [], + "emojis": [], + "tags": [], + "replies_count": 1, + "reblogs_count": 0, + "favourites_count": 0, + "favourited": false, + "reblogged": false, + "muted": false, + "bookmarked": false, + "pinned": false, + "card": null, + "reactions": [], + "poll": null, + "uri": "http://nextcloud.local/index.php/apps/social/@bob/17906371156396819743", + "url": "http://nextcloud.local/index.php/apps/social/@bob/17906371156396819743", + "reblog": null, + "media_attachments": [], + "created_at": "2026-09-28T23:11:55.000Z", + "edited_at": null, + "noindex": false, + "account": { + "id": "7", + "nid": "7", + "username": "bob", + "acct": "bob", + "display_name": "bob", + "locked": false, + "bot": false, + "discoverable": false, + "indexable": false, + "group": false, + "created_at": "2026-09-28T23:06:03.000Z", + "note": "", + "url": "http://nextcloud.local/index.php/apps/social/@bob", + "avatar": "", + "avatar_static": "", + "header": "http://nextcloud.local/index.php/avatar/bob/128", + "header_static": "http://nextcloud.local/index.php/avatar/bob/128", + "followers_count": 1, + "following_count": 2, + "statuses_count": 3, + "last_status_at": null, + "emojis": [], + "fields": [], + "pronouns": "", + "support_link": "" + }, + "filtered": [] + }, + { + "id": "1790637115422878628", + "nid": "1790637115422878628", + "local": true, + "archived": false, + "view_count": null, + "tagged_people": [], + "interest": null, + "content": "

@bob@nextcloud.local and back again

", + "sensitive": false, + "spoiler_text": "", + "visibility": "public", + "language": "en", + "place": null, + "in_reply_to_id": "1790637115351395147", + "in_reply_to_account_id": "7", + "quote": null, + "quote_approval": { + "automatic": [ + "public" + ], + "manual": [], + "current_user": "automatic" + }, + "reply_approval": null, + "interaction_policy": null, + "dislikes_count": null, + "disliked": null, + "video": null, + "mentions": [], + "emojis": [], + "tags": [], + "replies_count": 0, + "reblogs_count": 0, + "favourites_count": 0, + "favourited": false, + "reblogged": false, + "muted": false, + "bookmarked": false, + "pinned": false, + "card": null, + "reactions": [], + "poll": null, + "uri": "http://nextcloud.local/index.php/apps/social/@alice/17906371159153077137", + "url": "http://nextcloud.local/index.php/apps/social/@alice/17906371159153077137", + "reblog": null, + "media_attachments": [], + "created_at": "2026-09-28T23:11:55.000Z", + "edited_at": null, + "noindex": false, + "account": { + "id": "6", + "nid": "6", + "username": "alice", + "acct": "alice", + "display_name": "alice", + "locked": false, + "bot": false, + "discoverable": false, + "indexable": false, + "group": false, + "created_at": "2026-09-28T23:06:02.000Z", + "note": "", + "url": "http://nextcloud.local/index.php/apps/social/@alice", + "avatar": "", + "avatar_static": "", + "header": "http://nextcloud.local/index.php/avatar/alice/128", + "header_static": "http://nextcloud.local/index.php/avatar/alice/128", + "followers_count": 2, + "following_count": 2, + "statuses_count": 13, + "last_status_at": null, + "emojis": [], + "fields": [], + "pronouns": "", + "support_link": "" + }, + "filtered": [] + } + ] + } +} diff --git a/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/status-public-delivery.json b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/status-public-delivery.json new file mode 100644 index 00000000..2f487d2a --- /dev/null +++ b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/status-public-delivery.json @@ -0,0 +1,25 @@ +{ + "request": { + "method": "GET", + "path": "/index.php/apps/social/api/v1/statuses/1790637085797595891/delivery" + }, + "status": 200, + "headers": { + "content-type": "application/json; charset=utf-8", + "cache-control": "no-cache, no-store, must-revalidate", + "x-ratelimit-limit": "300", + "x-ratelimit-remaining": "219", + "x-ratelimit-reset": "1790637300" + }, + "body": { + "id": "1790637085797595891", + "delivered": 0, + "sending": 0, + "waiting": 0, + "failing": 0, + "abandoned": 0, + "total": 0, + "retention": 604800, + "instances": [] + } +} diff --git a/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/status-public-favourited-by.json b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/status-public-favourited-by.json new file mode 100644 index 00000000..8b155589 --- /dev/null +++ b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/status-public-favourited-by.json @@ -0,0 +1,43 @@ +{ + "request": { + "method": "GET", + "path": "/index.php/apps/social/api/v1/statuses/1790637085797595891/favourited_by" + }, + "status": 200, + "headers": { + "content-type": "application/json; charset=utf-8", + "cache-control": "no-cache, no-store, must-revalidate", + "x-ratelimit-limit": "300", + "x-ratelimit-remaining": "227", + "x-ratelimit-reset": "1790637300" + }, + "body": [ + { + "id": "0", + "nid": 0, + "username": "bob", + "acct": "bob", + "display_name": "bob", + "locked": false, + "bot": false, + "discoverable": false, + "indexable": false, + "group": false, + "created_at": "2026-09-28T23:06:03.000Z", + "note": "", + "url": "http://nextcloud.local/index.php/apps/social/@bob", + "avatar": "", + "avatar_static": "", + "header": "http://nextcloud.local/index.php/avatar/bob/128", + "header_static": "http://nextcloud.local/index.php/avatar/bob/128", + "followers_count": 0, + "following_count": 0, + "statuses_count": 0, + "last_status_at": null, + "emojis": [], + "fields": [], + "pronouns": "", + "support_link": "" + } + ] +} diff --git a/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/status-public-quotes.json b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/status-public-quotes.json new file mode 100644 index 00000000..2482184e --- /dev/null +++ b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/status-public-quotes.json @@ -0,0 +1,183 @@ +{ + "request": { + "method": "GET", + "path": "/index.php/apps/social/api/v1/statuses/1790637085797595891/quotes" + }, + "status": 200, + "headers": { + "content-type": "application/json; charset=utf-8", + "cache-control": "no-cache, no-store, must-revalidate", + "x-ratelimit-limit": "300", + "x-ratelimit-remaining": "225", + "x-ratelimit-reset": "1790637300" + }, + "body": [ + { + "id": "1790637116374990863", + "nid": "1790637116374990863", + "local": true, + "archived": false, + "view_count": null, + "tagged_people": [], + "interest": null, + "content": "

Quoting this one

", + "sensitive": false, + "spoiler_text": "", + "visibility": "public", + "language": "en", + "place": null, + "in_reply_to_id": null, + "in_reply_to_account_id": null, + "quote": { + "state": "accepted", + "quoted_status": { + "id": "1790637085797595891", + "nid": "1790637085797595891", + "local": true, + "archived": false, + "view_count": null, + "tagged_people": [], + "interest": null, + "content": "

Aloha from the dev instance! Say hi, @bob@nextcloud.local #aloha #android

", + "sensitive": false, + "spoiler_text": "", + "visibility": "public", + "language": "en", + "place": null, + "in_reply_to_id": null, + "in_reply_to_account_id": null, + "quote": null, + "quote_approval": { + "automatic": [ + "public" + ], + "manual": [], + "current_user": "automatic" + }, + "reply_approval": null, + "interaction_policy": null, + "dislikes_count": null, + "disliked": null, + "video": null, + "mentions": [], + "emojis": [], + "tags": [ + { + "name": "aloha", + "url": "http://nextcloud.local/index.php/apps/social/timeline/tags/aloha" + }, + { + "name": "android", + "url": "http://nextcloud.local/index.php/apps/social/timeline/tags/android" + } + ], + "replies_count": 1, + "reblogs_count": 0, + "favourites_count": 1, + "favourited": false, + "reblogged": false, + "muted": false, + "bookmarked": false, + "pinned": false, + "card": null, + "reactions": [], + "poll": null, + "uri": "http://nextcloud.local/index.php/apps/social/@alice/17906370853806247191", + "url": "http://nextcloud.local/index.php/apps/social/@alice/17906370853806247191", + "reblog": null, + "media_attachments": [], + "created_at": "2026-09-28T23:11:25.000Z", + "edited_at": null, + "noindex": false, + "account": { + "id": "6", + "nid": "6", + "username": "alice", + "acct": "alice", + "display_name": "alice", + "locked": false, + "bot": false, + "discoverable": false, + "indexable": false, + "group": false, + "created_at": "2026-09-28T23:06:02.000Z", + "note": "", + "url": "http://nextcloud.local/index.php/apps/social/@alice", + "avatar": "", + "avatar_static": "", + "header": "http://nextcloud.local/index.php/avatar/alice/128", + "header_static": "http://nextcloud.local/index.php/avatar/alice/128", + "followers_count": 2, + "following_count": 2, + "statuses_count": 13, + "last_status_at": null, + "emojis": [], + "fields": [], + "pronouns": "", + "support_link": "" + } + } + }, + "quote_approval": { + "automatic": [ + "public" + ], + "manual": [], + "current_user": "automatic" + }, + "reply_approval": null, + "interaction_policy": null, + "dislikes_count": null, + "disliked": null, + "video": null, + "mentions": [], + "emojis": [], + "tags": [], + "replies_count": 0, + "reblogs_count": 0, + "favourites_count": 0, + "favourited": false, + "reblogged": false, + "muted": false, + "bookmarked": false, + "pinned": false, + "card": null, + "reactions": [], + "poll": null, + "uri": "http://nextcloud.local/index.php/apps/social/@bob/17906371165948083557", + "url": "http://nextcloud.local/index.php/apps/social/@bob/17906371165948083557", + "reblog": null, + "media_attachments": [], + "created_at": "2026-09-28T23:11:56.000Z", + "edited_at": null, + "noindex": false, + "account": { + "id": "7", + "nid": "7", + "username": "bob", + "acct": "bob", + "display_name": "bob", + "locked": false, + "bot": false, + "discoverable": false, + "indexable": false, + "group": false, + "created_at": "2026-09-28T23:06:03.000Z", + "note": "", + "url": "http://nextcloud.local/index.php/apps/social/@bob", + "avatar": "", + "avatar_static": "", + "header": "http://nextcloud.local/index.php/avatar/bob/128", + "header_static": "http://nextcloud.local/index.php/avatar/bob/128", + "followers_count": 1, + "following_count": 2, + "statuses_count": 3, + "last_status_at": null, + "emojis": [], + "fields": [], + "pronouns": "", + "support_link": "" + } + } + ] +} diff --git a/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/status-public-reactions.json b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/status-public-reactions.json new file mode 100644 index 00000000..3f081331 --- /dev/null +++ b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/status-public-reactions.json @@ -0,0 +1,21 @@ +{ + "request": { + "method": "GET", + "path": "/index.php/apps/social/api/v1/statuses/1790637085797595891/reactions" + }, + "status": 200, + "headers": { + "content-type": "application/json; charset=utf-8", + "cache-control": "no-cache, no-store, must-revalidate", + "x-ratelimit-limit": "300", + "x-ratelimit-remaining": "224", + "x-ratelimit-reset": "1790637300" + }, + "body": [ + { + "name": "🎉", + "count": 1, + "me": false + } + ] +} diff --git a/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/status-public.json b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/status-public.json new file mode 100644 index 00000000..7e0bffd7 --- /dev/null +++ b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/status-public.json @@ -0,0 +1,101 @@ +{ + "request": { + "method": "GET", + "path": "/index.php/apps/social/api/v1/statuses/1790637085797595891" + }, + "status": 200, + "headers": { + "content-type": "application/json; charset=utf-8", + "cache-control": "no-cache, no-store, must-revalidate", + "x-ratelimit-limit": "300", + "x-ratelimit-remaining": "246", + "x-ratelimit-reset": "1790637300" + }, + "body": { + "id": "1790637085797595891", + "nid": "1790637085797595891", + "local": true, + "archived": false, + "view_count": 0, + "tagged_people": [], + "interest": null, + "content": "

Aloha from the dev instance! Say hi, @bob@nextcloud.local #aloha #android

", + "sensitive": false, + "spoiler_text": "", + "visibility": "public", + "language": "en", + "place": null, + "in_reply_to_id": null, + "in_reply_to_account_id": null, + "quote": null, + "quote_approval": { + "automatic": [ + "public" + ], + "manual": [], + "current_user": "automatic" + }, + "reply_approval": null, + "interaction_policy": null, + "dislikes_count": null, + "disliked": null, + "video": null, + "mentions": [], + "emojis": [], + "tags": [ + { + "name": "aloha", + "url": "http://nextcloud.local/index.php/apps/social/timeline/tags/aloha" + }, + { + "name": "android", + "url": "http://nextcloud.local/index.php/apps/social/timeline/tags/android" + } + ], + "replies_count": 1, + "reblogs_count": 0, + "favourites_count": 1, + "favourited": false, + "reblogged": false, + "muted": false, + "bookmarked": false, + "pinned": false, + "card": null, + "reactions": [], + "poll": null, + "uri": "http://nextcloud.local/index.php/apps/social/@alice/17906370853806247191", + "url": "http://nextcloud.local/index.php/apps/social/@alice/17906370853806247191", + "reblog": null, + "media_attachments": [], + "created_at": "2026-09-28T23:11:25.000Z", + "edited_at": null, + "noindex": false, + "account": { + "id": "6", + "nid": "6", + "username": "alice", + "acct": "alice", + "display_name": "alice", + "locked": false, + "bot": false, + "discoverable": false, + "indexable": false, + "group": false, + "created_at": "2026-09-28T23:06:02.000Z", + "note": "", + "url": "http://nextcloud.local/index.php/apps/social/@alice", + "avatar": "", + "avatar_static": "", + "header": "http://nextcloud.local/index.php/avatar/alice/128", + "header_static": "http://nextcloud.local/index.php/avatar/alice/128", + "followers_count": 2, + "following_count": 2, + "statuses_count": 13, + "last_status_at": null, + "emojis": [], + "fields": [], + "pronouns": "", + "support_link": "" + } + } +} diff --git a/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/status-quote.json b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/status-quote.json new file mode 100644 index 00000000..ce975b7b --- /dev/null +++ b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/status-quote.json @@ -0,0 +1,181 @@ +{ + "request": { + "method": "GET", + "path": "/index.php/apps/social/api/v1/statuses/1790637116374990863" + }, + "status": 200, + "headers": { + "content-type": "application/json; charset=utf-8", + "cache-control": "no-cache, no-store, must-revalidate", + "x-ratelimit-limit": "300", + "x-ratelimit-remaining": "230", + "x-ratelimit-reset": "1790637300" + }, + "body": { + "id": "1790637116374990863", + "nid": "1790637116374990863", + "local": true, + "archived": false, + "view_count": null, + "tagged_people": [], + "interest": null, + "content": "

Quoting this one

", + "sensitive": false, + "spoiler_text": "", + "visibility": "public", + "language": "en", + "place": null, + "in_reply_to_id": null, + "in_reply_to_account_id": null, + "quote": { + "state": "accepted", + "quoted_status": { + "id": "1790637085797595891", + "nid": "1790637085797595891", + "local": true, + "archived": false, + "view_count": null, + "tagged_people": [], + "interest": null, + "content": "

Aloha from the dev instance! Say hi, @bob@nextcloud.local #aloha #android

", + "sensitive": false, + "spoiler_text": "", + "visibility": "public", + "language": "en", + "place": null, + "in_reply_to_id": null, + "in_reply_to_account_id": null, + "quote": null, + "quote_approval": { + "automatic": [ + "public" + ], + "manual": [], + "current_user": "automatic" + }, + "reply_approval": null, + "interaction_policy": null, + "dislikes_count": null, + "disliked": null, + "video": null, + "mentions": [], + "emojis": [], + "tags": [ + { + "name": "aloha", + "url": "http://nextcloud.local/index.php/apps/social/timeline/tags/aloha" + }, + { + "name": "android", + "url": "http://nextcloud.local/index.php/apps/social/timeline/tags/android" + } + ], + "replies_count": 1, + "reblogs_count": 0, + "favourites_count": 1, + "favourited": false, + "reblogged": false, + "muted": false, + "bookmarked": false, + "pinned": false, + "card": null, + "reactions": [], + "poll": null, + "uri": "http://nextcloud.local/index.php/apps/social/@alice/17906370853806247191", + "url": "http://nextcloud.local/index.php/apps/social/@alice/17906370853806247191", + "reblog": null, + "media_attachments": [], + "created_at": "2026-09-28T23:11:25.000Z", + "edited_at": null, + "noindex": false, + "account": { + "id": "6", + "nid": "6", + "username": "alice", + "acct": "alice", + "display_name": "alice", + "locked": false, + "bot": false, + "discoverable": false, + "indexable": false, + "group": false, + "created_at": "2026-09-28T23:06:02.000Z", + "note": "", + "url": "http://nextcloud.local/index.php/apps/social/@alice", + "avatar": "", + "avatar_static": "", + "header": "http://nextcloud.local/index.php/avatar/alice/128", + "header_static": "http://nextcloud.local/index.php/avatar/alice/128", + "followers_count": 2, + "following_count": 2, + "statuses_count": 13, + "last_status_at": null, + "emojis": [], + "fields": [], + "pronouns": "", + "support_link": "" + } + } + }, + "quote_approval": { + "automatic": [ + "public" + ], + "manual": [], + "current_user": "automatic" + }, + "reply_approval": null, + "interaction_policy": null, + "dislikes_count": null, + "disliked": null, + "video": null, + "mentions": [], + "emojis": [], + "tags": [], + "replies_count": 0, + "reblogs_count": 0, + "favourites_count": 0, + "favourited": false, + "reblogged": false, + "muted": false, + "bookmarked": false, + "pinned": false, + "card": null, + "reactions": [], + "poll": null, + "uri": "http://nextcloud.local/index.php/apps/social/@bob/17906371165948083557", + "url": "http://nextcloud.local/index.php/apps/social/@bob/17906371165948083557", + "reblog": null, + "media_attachments": [], + "created_at": "2026-09-28T23:11:56.000Z", + "edited_at": null, + "noindex": false, + "account": { + "id": "7", + "nid": "7", + "username": "bob", + "acct": "bob", + "display_name": "bob", + "locked": false, + "bot": false, + "discoverable": false, + "indexable": false, + "group": false, + "created_at": "2026-09-28T23:06:03.000Z", + "note": "", + "url": "http://nextcloud.local/index.php/apps/social/@bob", + "avatar": "", + "avatar_static": "", + "header": "http://nextcloud.local/index.php/avatar/bob/128", + "header_static": "http://nextcloud.local/index.php/avatar/bob/128", + "followers_count": 1, + "following_count": 2, + "statuses_count": 3, + "last_status_at": null, + "emojis": [], + "fields": [], + "pronouns": "", + "support_link": "" + } + } +} diff --git a/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/status-reply.json b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/status-reply.json new file mode 100644 index 00000000..b32839c1 --- /dev/null +++ b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/status-reply.json @@ -0,0 +1,92 @@ +{ + "request": { + "method": "GET", + "path": "/index.php/apps/social/api/v1/statuses/1790637115351395147" + }, + "status": 200, + "headers": { + "content-type": "application/json; charset=utf-8", + "cache-control": "no-cache, no-store, must-revalidate", + "x-ratelimit-limit": "300", + "x-ratelimit-remaining": "232", + "x-ratelimit-reset": "1790637300" + }, + "body": { + "id": "1790637115351395147", + "nid": "1790637115351395147", + "local": true, + "archived": false, + "view_count": null, + "tagged_people": [], + "interest": null, + "content": "

@alice@nextcloud.local hi back!

", + "sensitive": false, + "spoiler_text": "", + "visibility": "public", + "language": "en", + "place": null, + "in_reply_to_id": "1790637085797595891", + "in_reply_to_account_id": "6", + "quote": null, + "quote_approval": { + "automatic": [ + "public" + ], + "manual": [], + "current_user": "automatic" + }, + "reply_approval": null, + "interaction_policy": null, + "dislikes_count": null, + "disliked": null, + "video": null, + "mentions": [], + "emojis": [], + "tags": [], + "replies_count": 1, + "reblogs_count": 0, + "favourites_count": 0, + "favourited": false, + "reblogged": false, + "muted": false, + "bookmarked": false, + "pinned": false, + "card": null, + "reactions": [], + "poll": null, + "uri": "http://nextcloud.local/index.php/apps/social/@bob/17906371156396819743", + "url": "http://nextcloud.local/index.php/apps/social/@bob/17906371156396819743", + "reblog": null, + "media_attachments": [], + "created_at": "2026-09-28T23:11:55.000Z", + "edited_at": null, + "noindex": false, + "account": { + "id": "7", + "nid": "7", + "username": "bob", + "acct": "bob", + "display_name": "bob", + "locked": false, + "bot": false, + "discoverable": false, + "indexable": false, + "group": false, + "created_at": "2026-09-28T23:06:03.000Z", + "note": "", + "url": "http://nextcloud.local/index.php/apps/social/@bob", + "avatar": "", + "avatar_static": "", + "header": "http://nextcloud.local/index.php/avatar/bob/128", + "header_static": "http://nextcloud.local/index.php/avatar/bob/128", + "followers_count": 1, + "following_count": 2, + "statuses_count": 3, + "last_status_at": null, + "emojis": [], + "fields": [], + "pronouns": "", + "support_link": "" + } + } +} diff --git a/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/status-reply_2.json b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/status-reply_2.json new file mode 100644 index 00000000..0f841947 --- /dev/null +++ b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/status-reply_2.json @@ -0,0 +1,92 @@ +{ + "request": { + "method": "GET", + "path": "/index.php/apps/social/api/v1/statuses/1790637115422878628" + }, + "status": 200, + "headers": { + "content-type": "application/json; charset=utf-8", + "cache-control": "no-cache, no-store, must-revalidate", + "x-ratelimit-limit": "300", + "x-ratelimit-remaining": "231", + "x-ratelimit-reset": "1790637300" + }, + "body": { + "id": "1790637115422878628", + "nid": "1790637115422878628", + "local": true, + "archived": false, + "view_count": 0, + "tagged_people": [], + "interest": null, + "content": "

@bob@nextcloud.local and back again

", + "sensitive": false, + "spoiler_text": "", + "visibility": "public", + "language": "en", + "place": null, + "in_reply_to_id": "1790637115351395147", + "in_reply_to_account_id": "7", + "quote": null, + "quote_approval": { + "automatic": [ + "public" + ], + "manual": [], + "current_user": "automatic" + }, + "reply_approval": null, + "interaction_policy": null, + "dislikes_count": null, + "disliked": null, + "video": null, + "mentions": [], + "emojis": [], + "tags": [], + "replies_count": 0, + "reblogs_count": 0, + "favourites_count": 0, + "favourited": false, + "reblogged": false, + "muted": false, + "bookmarked": false, + "pinned": false, + "card": null, + "reactions": [], + "poll": null, + "uri": "http://nextcloud.local/index.php/apps/social/@alice/17906371159153077137", + "url": "http://nextcloud.local/index.php/apps/social/@alice/17906371159153077137", + "reblog": null, + "media_attachments": [], + "created_at": "2026-09-28T23:11:55.000Z", + "edited_at": null, + "noindex": false, + "account": { + "id": "6", + "nid": "6", + "username": "alice", + "acct": "alice", + "display_name": "alice", + "locked": false, + "bot": false, + "discoverable": false, + "indexable": false, + "group": false, + "created_at": "2026-09-28T23:06:02.000Z", + "note": "", + "url": "http://nextcloud.local/index.php/apps/social/@alice", + "avatar": "", + "avatar_static": "", + "header": "http://nextcloud.local/index.php/avatar/alice/128", + "header_static": "http://nextcloud.local/index.php/avatar/alice/128", + "followers_count": 2, + "following_count": 2, + "statuses_count": 13, + "last_status_at": null, + "emojis": [], + "fields": [], + "pronouns": "", + "support_link": "" + } + } +} diff --git a/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/status-sensitive_no_alt.json b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/status-sensitive_no_alt.json new file mode 100644 index 00000000..8b1a3925 --- /dev/null +++ b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/status-sensitive_no_alt.json @@ -0,0 +1,123 @@ +{ + "request": { + "method": "GET", + "path": "/index.php/apps/social/api/v1/statuses/1790637103267719986" + }, + "status": 200, + "headers": { + "content-type": "application/json; charset=utf-8", + "cache-control": "no-cache, no-store, must-revalidate", + "x-ratelimit-limit": "300", + "x-ratelimit-remaining": "239", + "x-ratelimit-reset": "1790637300" + }, + "body": { + "id": "1790637103267719986", + "nid": "1790637103267719986", + "local": true, + "archived": false, + "view_count": 0, + "tagged_people": [], + "interest": null, + "content": "

A picture without alt text, marked sensitive

", + "sensitive": true, + "spoiler_text": "", + "visibility": "public", + "language": "en", + "place": null, + "in_reply_to_id": null, + "in_reply_to_account_id": null, + "quote": null, + "quote_approval": { + "automatic": [ + "public" + ], + "manual": [], + "current_user": "automatic" + }, + "reply_approval": null, + "interaction_policy": null, + "dislikes_count": null, + "disliked": null, + "video": null, + "mentions": [], + "emojis": [], + "tags": [], + "replies_count": 0, + "reblogs_count": 0, + "favourites_count": 0, + "favourited": false, + "reblogged": false, + "muted": false, + "bookmarked": false, + "pinned": false, + "card": null, + "reactions": [], + "poll": null, + "uri": "http://nextcloud.local/index.php/apps/social/@alice/17906371033997341160", + "url": "http://nextcloud.local/index.php/apps/social/@alice/17906371033997341160", + "reblog": null, + "media_attachments": [ + { + "id": "11", + "type": "image", + "media_type": "image/png", + "url": "http://nextcloud.local/index.php/apps/social/media/347a8309-ea20-4900-8e4c-aff1e8b6a98b.png", + "preview_url": "http://nextcloud.local/index.php/apps/social/media/6bb97fab-b644-4c51-8b1b-8ea5921fdef0.png", + "hls_url": null, + "remote_url": null, + "cache_error": null, + "meta": { + "original": { + "width": 1080, + "height": 1080, + "size": "1080x1080", + "aspect": 1 + }, + "small": { + "width": 800, + "height": 800, + "size": "800x800", + "aspect": 1 + }, + "focus": { + "x": 0, + "y": 0 + } + }, + "description": null, + "blurhash": "LRF~:K*DD7L+S^o@$T+kR5wiM_IU" + } + ], + "created_at": "2026-09-28T23:11:43.000Z", + "edited_at": null, + "noindex": false, + "account": { + "id": "6", + "nid": "6", + "username": "alice", + "acct": "alice", + "display_name": "alice", + "locked": false, + "bot": false, + "discoverable": false, + "indexable": false, + "group": false, + "created_at": "2026-09-28T23:06:02.000Z", + "note": "", + "url": "http://nextcloud.local/index.php/apps/social/@alice", + "avatar": "", + "avatar_static": "", + "header": "http://nextcloud.local/index.php/avatar/alice/128", + "header_static": "http://nextcloud.local/index.php/avatar/alice/128", + "followers_count": 2, + "following_count": 2, + "statuses_count": 13, + "last_status_at": null, + "emojis": [], + "fields": [], + "pronouns": "", + "support_link": "" + } + } +} diff --git a/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/status-short.json b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/status-short.json new file mode 100644 index 00000000..a9fe08a4 --- /dev/null +++ b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/status-short.json @@ -0,0 +1,118 @@ +{ + "request": { + "method": "GET", + "path": "/index.php/apps/social/api/v1/statuses/1790637104870044831" + }, + "status": 200, + "headers": { + "content-type": "application/json; charset=utf-8", + "cache-control": "no-cache, no-store, must-revalidate", + "x-ratelimit-limit": "300", + "x-ratelimit-remaining": "237", + "x-ratelimit-reset": "1790637300" + }, + "body": { + "id": "1790637104870044831", + "nid": "1790637104870044831", + "local": true, + "archived": false, + "view_count": 0, + "tagged_people": [], + "interest": null, + "content": "

Vertical test #shorts

", + "sensitive": false, + "spoiler_text": "", + "visibility": "public", + "language": "en", + "place": null, + "in_reply_to_id": null, + "in_reply_to_account_id": null, + "quote": null, + "quote_approval": { + "automatic": [ + "public" + ], + "manual": [], + "current_user": "automatic" + }, + "reply_approval": null, + "interaction_policy": null, + "dislikes_count": 0, + "disliked": false, + "video": null, + "mentions": [], + "emojis": [], + "tags": [ + { + "name": "shorts", + "url": "http://nextcloud.local/index.php/apps/social/timeline/tags/shorts" + } + ], + "replies_count": 0, + "reblogs_count": 0, + "favourites_count": 0, + "favourited": false, + "reblogged": false, + "muted": false, + "bookmarked": false, + "pinned": false, + "card": null, + "reactions": [], + "poll": null, + "uri": "http://nextcloud.local/index.php/apps/social/@alice/17906371044200157676", + "url": "http://nextcloud.local/index.php/apps/social/@alice/17906371044200157676", + "reblog": null, + "media_attachments": [ + { + "id": "13", + "type": "video", + "media_type": "video/mp4", + "url": "http://nextcloud.local/index.php/apps/social/media/9ca843a2-645e-439f-901c-3fd1d58c22b6.mp4", + "preview_url": "http://nextcloud.local/index.php/apps/social/media/9ca843a2-645e-439f-901c-3fd1d58c22b6.mp4", + "hls_url": null, + "remote_url": null, + "cache_error": null, + "meta": { + "original": [], + "small": [], + "focus": { + "x": 0, + "y": 0 + } + }, + "description": "A 12 second vertical test pattern", + "blurhash": null + } + ], + "created_at": "2026-09-28T23:11:44.000Z", + "edited_at": null, + "noindex": false, + "account": { + "id": "6", + "nid": "6", + "username": "alice", + "acct": "alice", + "display_name": "alice", + "locked": false, + "bot": false, + "discoverable": false, + "indexable": false, + "group": false, + "created_at": "2026-09-28T23:06:02.000Z", + "note": "", + "url": "http://nextcloud.local/index.php/apps/social/@alice", + "avatar": "", + "avatar_static": "", + "header": "http://nextcloud.local/index.php/avatar/alice/128", + "header_static": "http://nextcloud.local/index.php/avatar/alice/128", + "followers_count": 2, + "following_count": 2, + "statuses_count": 13, + "last_status_at": null, + "emojis": [], + "fields": [], + "pronouns": "", + "support_link": "" + } + } +} diff --git a/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/status-unlisted.json b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/status-unlisted.json new file mode 100644 index 00000000..1073896c --- /dev/null +++ b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/status-unlisted.json @@ -0,0 +1,92 @@ +{ + "request": { + "method": "GET", + "path": "/index.php/apps/social/api/v1/statuses/1790637086680883443" + }, + "status": 200, + "headers": { + "content-type": "application/json; charset=utf-8", + "cache-control": "no-cache, no-store, must-revalidate", + "x-ratelimit-limit": "300", + "x-ratelimit-remaining": "245", + "x-ratelimit-reset": "1790637300" + }, + "body": { + "id": "1790637086680883443", + "nid": "1790637086680883443", + "local": true, + "archived": false, + "view_count": 0, + "tagged_people": [], + "interest": null, + "content": "

An unlisted note for the timeline tests.

", + "sensitive": false, + "spoiler_text": "", + "visibility": "unlisted", + "language": "en", + "place": null, + "in_reply_to_id": null, + "in_reply_to_account_id": null, + "quote": null, + "quote_approval": { + "automatic": [ + "public" + ], + "manual": [], + "current_user": "automatic" + }, + "reply_approval": null, + "interaction_policy": null, + "dislikes_count": null, + "disliked": null, + "video": null, + "mentions": [], + "emojis": [], + "tags": [], + "replies_count": 0, + "reblogs_count": 0, + "favourites_count": 0, + "favourited": false, + "reblogged": false, + "muted": false, + "bookmarked": false, + "pinned": false, + "card": null, + "reactions": [], + "poll": null, + "uri": "http://nextcloud.local/index.php/apps/social/@alice/17906370862830940734", + "url": "http://nextcloud.local/index.php/apps/social/@alice/17906370862830940734", + "reblog": null, + "media_attachments": [], + "created_at": "2026-09-28T23:11:26.000Z", + "edited_at": null, + "noindex": false, + "account": { + "id": "6", + "nid": "6", + "username": "alice", + "acct": "alice", + "display_name": "alice", + "locked": false, + "bot": false, + "discoverable": false, + "indexable": false, + "group": false, + "created_at": "2026-09-28T23:06:02.000Z", + "note": "", + "url": "http://nextcloud.local/index.php/apps/social/@alice", + "avatar": "", + "avatar_static": "", + "header": "http://nextcloud.local/index.php/avatar/alice/128", + "header_static": "http://nextcloud.local/index.php/avatar/alice/128", + "followers_count": 2, + "following_count": 2, + "statuses_count": 13, + "last_status_at": null, + "emojis": [], + "fields": [], + "pronouns": "", + "support_link": "" + } + } +} diff --git a/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/status-video.json b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/status-video.json new file mode 100644 index 00000000..f657356f --- /dev/null +++ b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/status-video.json @@ -0,0 +1,120 @@ +{ + "request": { + "method": "GET", + "path": "/index.php/apps/social/api/v1/statuses/1790637104958214061" + }, + "status": 200, + "headers": { + "content-type": "application/json; charset=utf-8", + "cache-control": "no-cache, no-store, must-revalidate", + "x-ratelimit-limit": "300", + "x-ratelimit-remaining": "238", + "x-ratelimit-reset": "1790637300" + }, + "body": { + "id": "1790637104958214061", + "nid": "1790637104958214061", + "local": true, + "archived": false, + "view_count": 0, + "tagged_people": [], + "interest": null, + "content": "

Test video #video

00:00 Intro
00:10 Middle
00:20 End

", + "sensitive": false, + "spoiler_text": "", + "visibility": "public", + "language": "en", + "place": null, + "in_reply_to_id": null, + "in_reply_to_account_id": null, + "quote": null, + "quote_approval": { + "automatic": [ + "public" + ], + "manual": [], + "current_user": "automatic" + }, + "reply_approval": null, + "interaction_policy": null, + "dislikes_count": 0, + "disliked": false, + "video": { + "title": "Aloha test video" + }, + "mentions": [], + "emojis": [], + "tags": [ + { + "name": "video", + "url": "http://nextcloud.local/index.php/apps/social/timeline/tags/video" + } + ], + "replies_count": 0, + "reblogs_count": 0, + "favourites_count": 1, + "favourited": false, + "reblogged": false, + "muted": false, + "bookmarked": false, + "pinned": false, + "card": null, + "reactions": [], + "poll": null, + "uri": "http://nextcloud.local/index.php/apps/social/@alice/17906371045255950881", + "url": "http://nextcloud.local/index.php/apps/social/@alice/17906371045255950881", + "reblog": null, + "media_attachments": [ + { + "id": "12", + "type": "video", + "media_type": "video/mp4", + "url": "http://nextcloud.local/index.php/apps/social/media/09c96321-538a-4d33-a3e3-e326ba1c5a56.mp4", + "preview_url": "http://nextcloud.local/index.php/apps/social/media/09c96321-538a-4d33-a3e3-e326ba1c5a56.mp4", + "hls_url": null, + "remote_url": null, + "cache_error": null, + "meta": { + "original": [], + "small": [], + "focus": { + "x": 0, + "y": 0 + } + }, + "description": "A 30 second test pattern with a tone", + "blurhash": null + } + ], + "created_at": "2026-09-28T23:11:44.000Z", + "edited_at": null, + "noindex": false, + "account": { + "id": "6", + "nid": "6", + "username": "alice", + "acct": "alice", + "display_name": "alice", + "locked": false, + "bot": false, + "discoverable": false, + "indexable": false, + "group": false, + "created_at": "2026-09-28T23:06:02.000Z", + "note": "", + "url": "http://nextcloud.local/index.php/apps/social/@alice", + "avatar": "", + "avatar_static": "", + "header": "http://nextcloud.local/index.php/avatar/alice/128", + "header_static": "http://nextcloud.local/index.php/avatar/alice/128", + "followers_count": 2, + "following_count": 2, + "statuses_count": 13, + "last_status_at": null, + "emojis": [], + "fields": [], + "pronouns": "", + "support_link": "" + } + } +} diff --git a/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/stories-account-alice.json b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/stories-account-alice.json new file mode 100644 index 00000000..3c20437f --- /dev/null +++ b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/stories-account-alice.json @@ -0,0 +1,83 @@ +{ + "request": { + "method": "GET", + "path": "/index.php/apps/social/api/v1/accounts/6/stories" + }, + "status": 200, + "headers": { + "content-type": "application/json; charset=utf-8", + "cache-control": "no-cache, no-store, must-revalidate", + "x-ratelimit-limit": "300", + "x-ratelimit-remaining": "189", + "x-ratelimit-reset": "1790637300" + }, + "body": [ + { + "id": "1", + "account_id": "http://nextcloud.local/index.php/apps/social/@alice", + "caption": "Morning colours", + "duration": 8, + "created_at": "2026-09-28T23:11:54+00:00", + "expires_at": "2026-09-29T23:11:54+00:00", + "seen": false, + "view_count": 0, + "media": { + "id": "16", + "type": "image", + "media_type": "image/jpeg", + "url": "http://nextcloud.local/index.php/apps/social/media/cb5b6a0e-ce7a-49fb-bd9a-0e63c9a345c5.jpeg", + "preview_url": "http://nextcloud.local/index.php/apps/social/media/709f698e-f1f1-4c9f-8deb-fa8360f82593.jpeg", + "hls_url": null, + "remote_url": null, + "cache_error": null, + "meta": { + "original": { + "width": 1080, + "height": 1920, + "size": "1080x1920", + "aspect": 0.5625 + }, + "small": { + "width": 450, + "height": 800, + "size": "450x800", + "aspect": 0.5625 + }, + "focus": { + "x": 0, + "y": 0 + } + }, + "description": "A colour gradient", + "blurhash": "L^J{-L|sS4JUS4jtsmWqfQa|jto0" + }, + "account": { + "id": "6", + "nid": "6", + "username": "alice", + "acct": "alice", + "display_name": "alice", + "locked": false, + "bot": false, + "discoverable": false, + "indexable": false, + "group": false, + "created_at": "2026-09-28T23:06:02.000Z", + "note": "", + "url": "http://nextcloud.local/index.php/apps/social/@alice", + "avatar": "", + "avatar_static": "", + "header": "http://nextcloud.local/index.php/avatar/alice/128", + "header_static": "http://nextcloud.local/index.php/avatar/alice/128", + "followers_count": 2, + "following_count": 2, + "statuses_count": 13, + "last_status_at": null, + "emojis": [], + "fields": [], + "pronouns": "", + "support_link": "" + } + } + ] +} diff --git a/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/stories-carousel-bob.json b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/stories-carousel-bob.json new file mode 100644 index 00000000..ffe90c48 --- /dev/null +++ b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/stories-carousel-bob.json @@ -0,0 +1,83 @@ +{ + "request": { + "method": "GET", + "path": "/index.php/apps/social/api/v1/stories/carousel" + }, + "status": 200, + "headers": { + "content-type": "application/json; charset=utf-8", + "cache-control": "no-cache, no-store, must-revalidate", + "x-ratelimit-limit": "300", + "x-ratelimit-remaining": "191", + "x-ratelimit-reset": "1790637300" + }, + "body": [ + { + "id": "1", + "account_id": "http://nextcloud.local/index.php/apps/social/@alice", + "caption": "Morning colours", + "duration": 8, + "created_at": "2026-09-28T23:11:54+00:00", + "expires_at": "2026-09-29T23:11:54+00:00", + "seen": false, + "view_count": 0, + "media": { + "id": "16", + "type": "image", + "media_type": "image/jpeg", + "url": "http://nextcloud.local/index.php/apps/social/media/cb5b6a0e-ce7a-49fb-bd9a-0e63c9a345c5.jpeg", + "preview_url": "http://nextcloud.local/index.php/apps/social/media/709f698e-f1f1-4c9f-8deb-fa8360f82593.jpeg", + "hls_url": null, + "remote_url": null, + "cache_error": null, + "meta": { + "original": { + "width": 1080, + "height": 1920, + "size": "1080x1920", + "aspect": 0.5625 + }, + "small": { + "width": 450, + "height": 800, + "size": "450x800", + "aspect": 0.5625 + }, + "focus": { + "x": 0, + "y": 0 + } + }, + "description": "A colour gradient", + "blurhash": "L^J{-L|sS4JUS4jtsmWqfQa|jto0" + }, + "account": { + "id": "6", + "nid": "6", + "username": "alice", + "acct": "alice", + "display_name": "alice", + "locked": false, + "bot": false, + "discoverable": false, + "indexable": false, + "group": false, + "created_at": "2026-09-28T23:06:02.000Z", + "note": "", + "url": "http://nextcloud.local/index.php/apps/social/@alice", + "avatar": "", + "avatar_static": "", + "header": "http://nextcloud.local/index.php/avatar/alice/128", + "header_static": "http://nextcloud.local/index.php/avatar/alice/128", + "followers_count": 2, + "following_count": 2, + "statuses_count": 13, + "last_status_at": null, + "emojis": [], + "fields": [], + "pronouns": "", + "support_link": "" + } + } + ] +} diff --git a/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/stories-self-alice.json b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/stories-self-alice.json new file mode 100644 index 00000000..cfec3a84 --- /dev/null +++ b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/stories-self-alice.json @@ -0,0 +1,83 @@ +{ + "request": { + "method": "GET", + "path": "/index.php/apps/social/api/v1/stories/self" + }, + "status": 200, + "headers": { + "content-type": "application/json; charset=utf-8", + "cache-control": "no-cache, no-store, must-revalidate", + "x-ratelimit-limit": "300", + "x-ratelimit-remaining": "190", + "x-ratelimit-reset": "1790637300" + }, + "body": [ + { + "id": "1", + "account_id": "http://nextcloud.local/index.php/apps/social/@alice", + "caption": "Morning colours", + "duration": 8, + "created_at": "2026-09-28T23:11:54+00:00", + "expires_at": "2026-09-29T23:11:54+00:00", + "seen": false, + "view_count": 0, + "media": { + "id": "16", + "type": "image", + "media_type": "image/jpeg", + "url": "http://nextcloud.local/index.php/apps/social/media/cb5b6a0e-ce7a-49fb-bd9a-0e63c9a345c5.jpeg", + "preview_url": "http://nextcloud.local/index.php/apps/social/media/709f698e-f1f1-4c9f-8deb-fa8360f82593.jpeg", + "hls_url": null, + "remote_url": null, + "cache_error": null, + "meta": { + "original": { + "width": 1080, + "height": 1920, + "size": "1080x1920", + "aspect": 0.5625 + }, + "small": { + "width": 450, + "height": 800, + "size": "450x800", + "aspect": 0.5625 + }, + "focus": { + "x": 0, + "y": 0 + } + }, + "description": "A colour gradient", + "blurhash": "L^J{-L|sS4JUS4jtsmWqfQa|jto0" + }, + "account": { + "id": "6", + "nid": "6", + "username": "alice", + "acct": "alice", + "display_name": "alice", + "locked": false, + "bot": false, + "discoverable": false, + "indexable": false, + "group": false, + "created_at": "2026-09-28T23:06:02.000Z", + "note": "", + "url": "http://nextcloud.local/index.php/apps/social/@alice", + "avatar": "", + "avatar_static": "", + "header": "http://nextcloud.local/index.php/avatar/alice/128", + "header_static": "http://nextcloud.local/index.php/avatar/alice/128", + "followers_count": 2, + "following_count": 2, + "statuses_count": 13, + "last_status_at": null, + "emojis": [], + "fields": [], + "pronouns": "", + "support_link": "" + } + } + ] +} diff --git a/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/stories-v12-carousel-bob.json b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/stories-v12-carousel-bob.json new file mode 100644 index 00000000..d9ec3a30 --- /dev/null +++ b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/stories-v12-carousel-bob.json @@ -0,0 +1,43 @@ +{ + "request": { + "method": "GET", + "path": "/index.php/apps/social/api/v1.2/stories/carousel" + }, + "status": 200, + "headers": { + "content-type": "application/json; charset=utf-8", + "cache-control": "no-cache, no-store, must-revalidate", + "x-ratelimit-limit": "300", + "x-ratelimit-remaining": "188", + "x-ratelimit-reset": "1790637300" + }, + "body": { + "self": null, + "nodes": [ + { + "id": "6", + "user": { + "id": "6", + "username": "alice", + "username_acct": "alice", + "avatar": "", + "local": true, + "is_author": false + }, + "nodes": [ + { + "id": "1", + "pid": "6", + "type": "photo", + "src": "http://nextcloud.local/index.php/apps/social/media/cb5b6a0e-ce7a-49fb-bd9a-0e63c9a345c5.jpeg", + "duration": 8, + "seen": false, + "created_at": "2026-09-28T23:11:54+00:00" + } + ], + "url": "http://nextcloud.local/index.php/apps/social/@alice", + "seen": false + } + ] + } +} diff --git a/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/suggestions-v2.json b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/suggestions-v2.json new file mode 100644 index 00000000..95197ce4 --- /dev/null +++ b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/suggestions-v2.json @@ -0,0 +1,49 @@ +{ + "request": { + "method": "GET", + "path": "/index.php/apps/social/api/v2/suggestions" + }, + "status": 200, + "headers": { + "content-type": "application/json; charset=utf-8", + "cache-control": "no-cache, no-store, must-revalidate", + "x-ratelimit-limit": "300", + "x-ratelimit-remaining": "202", + "x-ratelimit-reset": "1790637300" + }, + "body": [ + { + "source": "past_interactions", + "sources": [ + "friends_of_friends" + ], + "account": { + "id": "5", + "nid": "5", + "username": "admin", + "acct": "admin", + "display_name": "admin", + "locked": false, + "bot": false, + "discoverable": false, + "indexable": false, + "group": false, + "created_at": "2026-09-28T23:06:00.000Z", + "note": "", + "url": "http://nextcloud.local/index.php/apps/social/@admin", + "avatar": "", + "avatar_static": "", + "header": "http://nextcloud.local/index.php/avatar/admin/128", + "header_static": "http://nextcloud.local/index.php/avatar/admin/128", + "followers_count": 1, + "following_count": 1, + "statuses_count": 0, + "last_status_at": null, + "emojis": [], + "fields": [], + "pronouns": "", + "support_link": "" + } + } + ] +} diff --git a/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/timeline-home-bob-sees-boost.json b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/timeline-home-bob-sees-boost.json new file mode 100644 index 00000000..f165e52b --- /dev/null +++ b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/timeline-home-bob-sees-boost.json @@ -0,0 +1,1735 @@ +{ + "request": { + "method": "GET", + "path": "/index.php/apps/social/api/v1/timelines/home?limit=20" + }, + "status": 200, + "headers": { + "content-type": "application/json; charset=utf-8", + "cache-control": "no-cache, no-store, must-revalidate", + "link": "; rel=\"prev\"", + "x-ratelimit-limit": "60", + "x-ratelimit-remaining": "52", + "x-ratelimit-reset": "1790637300" + }, + "body": [ + { + "id": "1790637117344364158", + "nid": "1790637117344364158", + "local": true, + "archived": false, + "view_count": null, + "tagged_people": [], + "interest": null, + "content": "

Bob's own post #aloha

", + "sensitive": false, + "spoiler_text": "", + "visibility": "public", + "language": "en", + "place": null, + "in_reply_to_id": null, + "in_reply_to_account_id": null, + "quote": null, + "quote_approval": { + "automatic": [ + "public" + ], + "manual": [], + "current_user": "automatic" + }, + "reply_approval": null, + "interaction_policy": null, + "dislikes_count": null, + "disliked": null, + "video": null, + "mentions": [], + "emojis": [], + "tags": [ + { + "name": "aloha", + "url": "http://nextcloud.local/index.php/apps/social/timeline/tags/aloha" + } + ], + "replies_count": 0, + "reblogs_count": 0, + "favourites_count": 0, + "favourited": false, + "reblogged": false, + "muted": false, + "bookmarked": false, + "pinned": false, + "card": null, + "reactions": [], + "poll": null, + "uri": "http://nextcloud.local/index.php/apps/social/@bob/17906371177254273993", + "url": "http://nextcloud.local/index.php/apps/social/@bob/17906371177254273993", + "reblog": null, + "media_attachments": [], + "created_at": "2026-09-28T23:11:57.000Z", + "edited_at": null, + "noindex": false, + "account": { + "id": "7", + "nid": "7", + "username": "bob", + "acct": "bob", + "display_name": "bob", + "locked": false, + "bot": false, + "discoverable": false, + "indexable": false, + "group": false, + "created_at": "2026-09-28T23:06:03.000Z", + "note": "", + "url": "http://nextcloud.local/index.php/apps/social/@bob", + "avatar": "", + "avatar_static": "", + "header": "http://nextcloud.local/index.php/avatar/bob/128", + "header_static": "http://nextcloud.local/index.php/avatar/bob/128", + "followers_count": 1, + "following_count": 2, + "statuses_count": 3, + "last_status_at": null, + "emojis": [], + "fields": [], + "pronouns": "", + "support_link": "" + }, + "filtered": [] + }, + { + "id": "1790637116374990863", + "nid": "1790637116374990863", + "local": true, + "archived": false, + "view_count": null, + "tagged_people": [], + "interest": null, + "content": "

Quoting this one

", + "sensitive": false, + "spoiler_text": "", + "visibility": "public", + "language": "en", + "place": null, + "in_reply_to_id": null, + "in_reply_to_account_id": null, + "quote": { + "state": "accepted", + "quoted_status": { + "id": "1790637085797595891", + "nid": "1790637085797595891", + "local": true, + "archived": false, + "view_count": null, + "tagged_people": [], + "interest": null, + "content": "

Aloha from the dev instance! Say hi, @bob@nextcloud.local #aloha #android

", + "sensitive": false, + "spoiler_text": "", + "visibility": "public", + "language": "en", + "place": null, + "in_reply_to_id": null, + "in_reply_to_account_id": null, + "quote": null, + "quote_approval": { + "automatic": [ + "public" + ], + "manual": [], + "current_user": "automatic" + }, + "reply_approval": null, + "interaction_policy": null, + "dislikes_count": null, + "disliked": null, + "video": null, + "mentions": [], + "emojis": [], + "tags": [ + { + "name": "aloha", + "url": "http://nextcloud.local/index.php/apps/social/timeline/tags/aloha" + }, + { + "name": "android", + "url": "http://nextcloud.local/index.php/apps/social/timeline/tags/android" + } + ], + "replies_count": 1, + "reblogs_count": 0, + "favourites_count": 1, + "favourited": true, + "reblogged": false, + "muted": false, + "bookmarked": false, + "pinned": false, + "card": null, + "reactions": [], + "poll": null, + "uri": "http://nextcloud.local/index.php/apps/social/@alice/17906370853806247191", + "url": "http://nextcloud.local/index.php/apps/social/@alice/17906370853806247191", + "reblog": null, + "media_attachments": [], + "created_at": "2026-09-28T23:11:25.000Z", + "edited_at": null, + "noindex": false, + "account": { + "id": "6", + "nid": "6", + "username": "alice", + "acct": "alice", + "display_name": "alice", + "locked": false, + "bot": false, + "discoverable": false, + "indexable": false, + "group": false, + "created_at": "2026-09-28T23:06:02.000Z", + "note": "", + "url": "http://nextcloud.local/index.php/apps/social/@alice", + "avatar": "", + "avatar_static": "", + "header": "http://nextcloud.local/index.php/avatar/alice/128", + "header_static": "http://nextcloud.local/index.php/avatar/alice/128", + "followers_count": 2, + "following_count": 2, + "statuses_count": 13, + "last_status_at": null, + "emojis": [], + "fields": [], + "pronouns": "", + "support_link": "" + } + } + }, + "quote_approval": { + "automatic": [ + "public" + ], + "manual": [], + "current_user": "automatic" + }, + "reply_approval": null, + "interaction_policy": null, + "dislikes_count": null, + "disliked": null, + "video": null, + "mentions": [], + "emojis": [], + "tags": [], + "replies_count": 0, + "reblogs_count": 0, + "favourites_count": 0, + "favourited": false, + "reblogged": false, + "muted": false, + "bookmarked": false, + "pinned": false, + "card": null, + "reactions": [], + "poll": null, + "uri": "http://nextcloud.local/index.php/apps/social/@bob/17906371165948083557", + "url": "http://nextcloud.local/index.php/apps/social/@bob/17906371165948083557", + "reblog": null, + "media_attachments": [], + "created_at": "2026-09-28T23:11:56.000Z", + "edited_at": null, + "noindex": false, + "account": { + "id": "7", + "nid": "7", + "username": "bob", + "acct": "bob", + "display_name": "bob", + "locked": false, + "bot": false, + "discoverable": false, + "indexable": false, + "group": false, + "created_at": "2026-09-28T23:06:03.000Z", + "note": "", + "url": "http://nextcloud.local/index.php/apps/social/@bob", + "avatar": "", + "avatar_static": "", + "header": "http://nextcloud.local/index.php/avatar/bob/128", + "header_static": "http://nextcloud.local/index.php/avatar/bob/128", + "followers_count": 1, + "following_count": 2, + "statuses_count": 3, + "last_status_at": null, + "emojis": [], + "fields": [], + "pronouns": "", + "support_link": "" + }, + "filtered": [] + }, + { + "id": "1790637115422878628", + "nid": "1790637115422878628", + "local": true, + "archived": false, + "view_count": null, + "tagged_people": [], + "interest": null, + "content": "

@bob@nextcloud.local and back again

", + "sensitive": false, + "spoiler_text": "", + "visibility": "public", + "language": "en", + "place": null, + "in_reply_to_id": "1790637115351395147", + "in_reply_to_account_id": "7", + "quote": null, + "quote_approval": { + "automatic": [ + "public" + ], + "manual": [], + "current_user": "automatic" + }, + "reply_approval": null, + "interaction_policy": null, + "dislikes_count": null, + "disliked": null, + "video": null, + "mentions": [], + "emojis": [], + "tags": [], + "replies_count": 0, + "reblogs_count": 0, + "favourites_count": 0, + "favourited": false, + "reblogged": false, + "muted": false, + "bookmarked": false, + "pinned": false, + "card": null, + "reactions": [], + "poll": null, + "uri": "http://nextcloud.local/index.php/apps/social/@alice/17906371159153077137", + "url": "http://nextcloud.local/index.php/apps/social/@alice/17906371159153077137", + "reblog": null, + "media_attachments": [], + "created_at": "2026-09-28T23:11:55.000Z", + "edited_at": null, + "noindex": false, + "account": { + "id": "6", + "nid": "6", + "username": "alice", + "acct": "alice", + "display_name": "alice", + "locked": false, + "bot": false, + "discoverable": false, + "indexable": false, + "group": false, + "created_at": "2026-09-28T23:06:02.000Z", + "note": "", + "url": "http://nextcloud.local/index.php/apps/social/@alice", + "avatar": "", + "avatar_static": "", + "header": "http://nextcloud.local/index.php/avatar/alice/128", + "header_static": "http://nextcloud.local/index.php/avatar/alice/128", + "followers_count": 2, + "following_count": 2, + "statuses_count": 13, + "last_status_at": null, + "emojis": [], + "fields": [], + "pronouns": "", + "support_link": "" + }, + "filtered": [] + }, + { + "id": "1790637115351395147", + "nid": "1790637115351395147", + "local": true, + "archived": false, + "view_count": null, + "tagged_people": [], + "interest": null, + "content": "

@alice@nextcloud.local hi back!

", + "sensitive": false, + "spoiler_text": "", + "visibility": "public", + "language": "en", + "place": null, + "in_reply_to_id": "1790637085797595891", + "in_reply_to_account_id": "6", + "quote": null, + "quote_approval": { + "automatic": [ + "public" + ], + "manual": [], + "current_user": "automatic" + }, + "reply_approval": null, + "interaction_policy": null, + "dislikes_count": null, + "disliked": null, + "video": null, + "mentions": [], + "emojis": [], + "tags": [], + "replies_count": 1, + "reblogs_count": 0, + "favourites_count": 0, + "favourited": false, + "reblogged": false, + "muted": false, + "bookmarked": false, + "pinned": false, + "card": null, + "reactions": [], + "poll": null, + "uri": "http://nextcloud.local/index.php/apps/social/@bob/17906371156396819743", + "url": "http://nextcloud.local/index.php/apps/social/@bob/17906371156396819743", + "reblog": null, + "media_attachments": [], + "created_at": "2026-09-28T23:11:55.000Z", + "edited_at": null, + "noindex": false, + "account": { + "id": "7", + "nid": "7", + "username": "bob", + "acct": "bob", + "display_name": "bob", + "locked": false, + "bot": false, + "discoverable": false, + "indexable": false, + "group": false, + "created_at": "2026-09-28T23:06:03.000Z", + "note": "", + "url": "http://nextcloud.local/index.php/apps/social/@bob", + "avatar": "", + "avatar_static": "", + "header": "http://nextcloud.local/index.php/avatar/bob/128", + "header_static": "http://nextcloud.local/index.php/avatar/bob/128", + "followers_count": 1, + "following_count": 2, + "statuses_count": 3, + "last_status_at": null, + "emojis": [], + "fields": [], + "pronouns": "", + "support_link": "" + }, + "filtered": [] + }, + { + "id": "1790637112216295042", + "nid": "1790637112216295042", + "local": true, + "archived": false, + "view_count": null, + "tagged_people": [], + "interest": null, + "content": "

Picture from Berlin

", + "sensitive": false, + "spoiler_text": "", + "visibility": "public", + "language": "en", + "place": { + "id": "1", + "name": "Berlin", + "country": "DE", + "lat": "", + "long": "" + }, + "in_reply_to_id": null, + "in_reply_to_account_id": null, + "quote": null, + "quote_approval": { + "automatic": [ + "public" + ], + "manual": [], + "current_user": "automatic" + }, + "reply_approval": null, + "interaction_policy": null, + "dislikes_count": null, + "disliked": null, + "video": null, + "mentions": [], + "emojis": [], + "tags": [], + "replies_count": 0, + "reblogs_count": 0, + "favourites_count": 0, + "favourited": false, + "reblogged": false, + "muted": false, + "bookmarked": false, + "pinned": false, + "card": null, + "reactions": [], + "poll": null, + "uri": "http://nextcloud.local/index.php/apps/social/@alice/17906371127479253498", + "url": "http://nextcloud.local/index.php/apps/social/@alice/17906371127479253498", + "reblog": null, + "media_attachments": [ + { + "id": "15", + "type": "image", + "media_type": "image/jpeg", + "url": "http://nextcloud.local/index.php/apps/social/media/b88735cc-b169-40e0-9fa5-85e3be451c1a.jpeg", + "preview_url": "http://nextcloud.local/index.php/apps/social/media/e46d0547-3b30-46cd-8b5b-394bb7005a49.jpeg", + "hls_url": null, + "remote_url": null, + "cache_error": null, + "meta": { + "original": { + "width": 1080, + "height": 1350, + "size": "1080x1350", + "aspect": 0.8 + }, + "small": { + "width": 640, + "height": 800, + "size": "640x800", + "aspect": 0.8 + }, + "focus": { + "x": 0, + "y": 0 + } + }, + "description": "Another fractal, taken in Berlin", + "blurhash": "LyLow4+,$SN?w6S]WUjI[ibCSIbW" + } + ], + "created_at": "2026-09-28T23:11:52.000Z", + "edited_at": null, + "noindex": false, + "account": { + "id": "6", + "nid": "6", + "username": "alice", + "acct": "alice", + "display_name": "alice", + "locked": false, + "bot": false, + "discoverable": false, + "indexable": false, + "group": false, + "created_at": "2026-09-28T23:06:02.000Z", + "note": "", + "url": "http://nextcloud.local/index.php/apps/social/@alice", + "avatar": "", + "avatar_static": "", + "header": "http://nextcloud.local/index.php/avatar/alice/128", + "header_static": "http://nextcloud.local/index.php/avatar/alice/128", + "followers_count": 2, + "following_count": 2, + "statuses_count": 13, + "last_status_at": null, + "emojis": [], + "fields": [], + "pronouns": "", + "support_link": "" + }, + "filtered": [] + }, + { + "id": "1790637112034926731", + "nid": "1790637112034926731", + "local": true, + "archived": false, + "view_count": null, + "tagged_people": [], + "interest": null, + "content": "

First draft, edited

", + "sensitive": false, + "spoiler_text": "", + "visibility": "public", + "language": "en", + "place": null, + "in_reply_to_id": null, + "in_reply_to_account_id": null, + "quote": null, + "quote_approval": { + "automatic": [ + "public" + ], + "manual": [], + "current_user": "automatic" + }, + "reply_approval": null, + "interaction_policy": null, + "dislikes_count": null, + "disliked": null, + "video": null, + "mentions": [], + "emojis": [], + "tags": [], + "replies_count": 0, + "reblogs_count": 0, + "favourites_count": 0, + "favourited": false, + "reblogged": false, + "muted": false, + "bookmarked": false, + "pinned": false, + "card": null, + "reactions": [], + "poll": null, + "uri": "http://nextcloud.local/index.php/apps/social/@alice/17906371127199851091", + "url": "http://nextcloud.local/index.php/apps/social/@alice/17906371127199851091", + "reblog": null, + "media_attachments": [], + "created_at": "2026-09-28T23:11:52.000Z", + "edited_at": "2026-09-28T23:11:52.000Z", + "noindex": false, + "account": { + "id": "6", + "nid": "6", + "username": "alice", + "acct": "alice", + "display_name": "alice", + "locked": false, + "bot": false, + "discoverable": false, + "indexable": false, + "group": false, + "created_at": "2026-09-28T23:06:02.000Z", + "note": "", + "url": "http://nextcloud.local/index.php/apps/social/@alice", + "avatar": "", + "avatar_static": "", + "header": "http://nextcloud.local/index.php/avatar/alice/128", + "header_static": "http://nextcloud.local/index.php/avatar/alice/128", + "followers_count": 2, + "following_count": 2, + "statuses_count": 13, + "last_status_at": null, + "emojis": [], + "fields": [], + "pronouns": "", + "support_link": "" + }, + "filtered": [] + }, + { + "id": "1790637105561356153", + "nid": "1790637105561356153", + "local": true, + "archived": false, + "view_count": null, + "tagged_people": [], + "interest": null, + "content": "

Nextcloud Social on GitHub: https://github.com/nextcloud/social

", + "sensitive": false, + "spoiler_text": "", + "visibility": "public", + "language": "en", + "place": null, + "in_reply_to_id": null, + "in_reply_to_account_id": null, + "quote": null, + "quote_approval": { + "automatic": [ + "public" + ], + "manual": [], + "current_user": "automatic" + }, + "reply_approval": null, + "interaction_policy": null, + "dislikes_count": null, + "disliked": null, + "video": null, + "mentions": [], + "emojis": [], + "tags": [], + "replies_count": 0, + "reblogs_count": 0, + "favourites_count": 0, + "favourited": false, + "reblogged": false, + "muted": false, + "bookmarked": false, + "pinned": false, + "card": null, + "reactions": [], + "poll": null, + "uri": "http://nextcloud.local/index.php/apps/social/@alice/17906371057334696925", + "url": "http://nextcloud.local/index.php/apps/social/@alice/17906371057334696925", + "reblog": null, + "media_attachments": [], + "created_at": "2026-09-28T23:11:45.000Z", + "edited_at": null, + "noindex": false, + "account": { + "id": "6", + "nid": "6", + "username": "alice", + "acct": "alice", + "display_name": "alice", + "locked": false, + "bot": false, + "discoverable": false, + "indexable": false, + "group": false, + "created_at": "2026-09-28T23:06:02.000Z", + "note": "", + "url": "http://nextcloud.local/index.php/apps/social/@alice", + "avatar": "", + "avatar_static": "", + "header": "http://nextcloud.local/index.php/avatar/alice/128", + "header_static": "http://nextcloud.local/index.php/avatar/alice/128", + "followers_count": 2, + "following_count": 2, + "statuses_count": 13, + "last_status_at": null, + "emojis": [], + "fields": [], + "pronouns": "", + "support_link": "" + }, + "filtered": [] + }, + { + "id": "1790637105549523409", + "nid": "1790637105549523409", + "local": true, + "archived": false, + "view_count": null, + "tagged_people": [], + "interest": null, + "content": "

A 15 second tone #audio

", + "sensitive": false, + "spoiler_text": "", + "visibility": "public", + "language": "en", + "place": null, + "in_reply_to_id": null, + "in_reply_to_account_id": null, + "quote": null, + "quote_approval": { + "automatic": [ + "public" + ], + "manual": [], + "current_user": "automatic" + }, + "reply_approval": null, + "interaction_policy": null, + "dislikes_count": null, + "disliked": null, + "video": null, + "mentions": [], + "emojis": [], + "tags": [ + { + "name": "audio", + "url": "http://nextcloud.local/index.php/apps/social/timeline/tags/audio" + } + ], + "replies_count": 0, + "reblogs_count": 0, + "favourites_count": 0, + "favourited": false, + "reblogged": false, + "muted": false, + "bookmarked": false, + "pinned": false, + "card": null, + "reactions": [], + "poll": null, + "uri": "http://nextcloud.local/index.php/apps/social/@alice/17906371052336860333", + "url": "http://nextcloud.local/index.php/apps/social/@alice/17906371052336860333", + "reblog": null, + "media_attachments": [ + { + "id": "14", + "type": "audio", + "media_type": "audio/mpeg", + "url": "http://nextcloud.local/index.php/apps/social/media/2ee64fb3-dda9-47f5-bac6-daa189149093.mpeg", + "preview_url": "http://nextcloud.local/index.php/apps/social/media/2ee64fb3-dda9-47f5-bac6-daa189149093.mpeg", + "hls_url": null, + "remote_url": null, + "cache_error": null, + "meta": { + "original": [], + "small": [], + "focus": { + "x": 0, + "y": 0 + } + }, + "description": "A 15 second 330 Hz tone", + "blurhash": null + } + ], + "created_at": "2026-09-28T23:11:45.000Z", + "edited_at": null, + "noindex": false, + "account": { + "id": "6", + "nid": "6", + "username": "alice", + "acct": "alice", + "display_name": "alice", + "locked": false, + "bot": false, + "discoverable": false, + "indexable": false, + "group": false, + "created_at": "2026-09-28T23:06:02.000Z", + "note": "", + "url": "http://nextcloud.local/index.php/apps/social/@alice", + "avatar": "", + "avatar_static": "", + "header": "http://nextcloud.local/index.php/avatar/alice/128", + "header_static": "http://nextcloud.local/index.php/avatar/alice/128", + "followers_count": 2, + "following_count": 2, + "statuses_count": 13, + "last_status_at": null, + "emojis": [], + "fields": [], + "pronouns": "", + "support_link": "" + }, + "filtered": [] + }, + { + "id": "1790637104958214061", + "nid": "1790637104958214061", + "local": true, + "archived": false, + "view_count": null, + "tagged_people": [], + "interest": null, + "content": "

Test video #video

00:00 Intro
00:10 Middle
00:20 End

", + "sensitive": false, + "spoiler_text": "", + "visibility": "public", + "language": "en", + "place": null, + "in_reply_to_id": null, + "in_reply_to_account_id": null, + "quote": null, + "quote_approval": { + "automatic": [ + "public" + ], + "manual": [], + "current_user": "automatic" + }, + "reply_approval": null, + "interaction_policy": null, + "dislikes_count": 0, + "disliked": false, + "video": { + "title": "Aloha test video" + }, + "mentions": [], + "emojis": [], + "tags": [ + { + "name": "video", + "url": "http://nextcloud.local/index.php/apps/social/timeline/tags/video" + } + ], + "replies_count": 0, + "reblogs_count": 0, + "favourites_count": 1, + "favourited": false, + "reblogged": false, + "muted": false, + "bookmarked": true, + "pinned": false, + "card": null, + "reactions": [], + "poll": null, + "uri": "http://nextcloud.local/index.php/apps/social/@alice/17906371045255950881", + "url": "http://nextcloud.local/index.php/apps/social/@alice/17906371045255950881", + "reblog": null, + "media_attachments": [ + { + "id": "12", + "type": "video", + "media_type": "video/mp4", + "url": "http://nextcloud.local/index.php/apps/social/media/09c96321-538a-4d33-a3e3-e326ba1c5a56.mp4", + "preview_url": "http://nextcloud.local/index.php/apps/social/media/09c96321-538a-4d33-a3e3-e326ba1c5a56.mp4", + "hls_url": null, + "remote_url": null, + "cache_error": null, + "meta": { + "original": [], + "small": [], + "focus": { + "x": 0, + "y": 0 + } + }, + "description": "A 30 second test pattern with a tone", + "blurhash": null + } + ], + "created_at": "2026-09-28T23:11:44.000Z", + "edited_at": null, + "noindex": false, + "account": { + "id": "6", + "nid": "6", + "username": "alice", + "acct": "alice", + "display_name": "alice", + "locked": false, + "bot": false, + "discoverable": false, + "indexable": false, + "group": false, + "created_at": "2026-09-28T23:06:02.000Z", + "note": "", + "url": "http://nextcloud.local/index.php/apps/social/@alice", + "avatar": "", + "avatar_static": "", + "header": "http://nextcloud.local/index.php/avatar/alice/128", + "header_static": "http://nextcloud.local/index.php/avatar/alice/128", + "followers_count": 2, + "following_count": 2, + "statuses_count": 13, + "last_status_at": null, + "emojis": [], + "fields": [], + "pronouns": "", + "support_link": "" + }, + "filtered": [] + }, + { + "id": "1790637104870044831", + "nid": "1790637104870044831", + "local": true, + "archived": false, + "view_count": null, + "tagged_people": [], + "interest": null, + "content": "

Vertical test #shorts

", + "sensitive": false, + "spoiler_text": "", + "visibility": "public", + "language": "en", + "place": null, + "in_reply_to_id": null, + "in_reply_to_account_id": null, + "quote": null, + "quote_approval": { + "automatic": [ + "public" + ], + "manual": [], + "current_user": "automatic" + }, + "reply_approval": null, + "interaction_policy": null, + "dislikes_count": 0, + "disliked": false, + "video": null, + "mentions": [], + "emojis": [], + "tags": [ + { + "name": "shorts", + "url": "http://nextcloud.local/index.php/apps/social/timeline/tags/shorts" + } + ], + "replies_count": 0, + "reblogs_count": 0, + "favourites_count": 0, + "favourited": false, + "reblogged": false, + "muted": false, + "bookmarked": false, + "pinned": false, + "card": null, + "reactions": [], + "poll": null, + "uri": "http://nextcloud.local/index.php/apps/social/@alice/17906371044200157676", + "url": "http://nextcloud.local/index.php/apps/social/@alice/17906371044200157676", + "reblog": null, + "media_attachments": [ + { + "id": "13", + "type": "video", + "media_type": "video/mp4", + "url": "http://nextcloud.local/index.php/apps/social/media/9ca843a2-645e-439f-901c-3fd1d58c22b6.mp4", + "preview_url": "http://nextcloud.local/index.php/apps/social/media/9ca843a2-645e-439f-901c-3fd1d58c22b6.mp4", + "hls_url": null, + "remote_url": null, + "cache_error": null, + "meta": { + "original": [], + "small": [], + "focus": { + "x": 0, + "y": 0 + } + }, + "description": "A 12 second vertical test pattern", + "blurhash": null + } + ], + "created_at": "2026-09-28T23:11:44.000Z", + "edited_at": null, + "noindex": false, + "account": { + "id": "6", + "nid": "6", + "username": "alice", + "acct": "alice", + "display_name": "alice", + "locked": false, + "bot": false, + "discoverable": false, + "indexable": false, + "group": false, + "created_at": "2026-09-28T23:06:02.000Z", + "note": "", + "url": "http://nextcloud.local/index.php/apps/social/@alice", + "avatar": "", + "avatar_static": "", + "header": "http://nextcloud.local/index.php/avatar/alice/128", + "header_static": "http://nextcloud.local/index.php/avatar/alice/128", + "followers_count": 2, + "following_count": 2, + "statuses_count": 13, + "last_status_at": null, + "emojis": [], + "fields": [], + "pronouns": "", + "support_link": "" + }, + "filtered": [] + }, + { + "id": "1790637103267719986", + "nid": "1790637103267719986", + "local": true, + "archived": false, + "view_count": null, + "tagged_people": [], + "interest": null, + "content": "

A picture without alt text, marked sensitive

", + "sensitive": true, + "spoiler_text": "", + "visibility": "public", + "language": "en", + "place": null, + "in_reply_to_id": null, + "in_reply_to_account_id": null, + "quote": null, + "quote_approval": { + "automatic": [ + "public" + ], + "manual": [], + "current_user": "automatic" + }, + "reply_approval": null, + "interaction_policy": null, + "dislikes_count": null, + "disliked": null, + "video": null, + "mentions": [], + "emojis": [], + "tags": [], + "replies_count": 0, + "reblogs_count": 0, + "favourites_count": 0, + "favourited": false, + "reblogged": false, + "muted": false, + "bookmarked": false, + "pinned": false, + "card": null, + "reactions": [], + "poll": null, + "uri": "http://nextcloud.local/index.php/apps/social/@alice/17906371033997341160", + "url": "http://nextcloud.local/index.php/apps/social/@alice/17906371033997341160", + "reblog": null, + "media_attachments": [ + { + "id": "11", + "type": "image", + "media_type": "image/png", + "url": "http://nextcloud.local/index.php/apps/social/media/347a8309-ea20-4900-8e4c-aff1e8b6a98b.png", + "preview_url": "http://nextcloud.local/index.php/apps/social/media/6bb97fab-b644-4c51-8b1b-8ea5921fdef0.png", + "hls_url": null, + "remote_url": null, + "cache_error": null, + "meta": { + "original": { + "width": 1080, + "height": 1080, + "size": "1080x1080", + "aspect": 1 + }, + "small": { + "width": 800, + "height": 800, + "size": "800x800", + "aspect": 1 + }, + "focus": { + "x": 0, + "y": 0 + } + }, + "description": null, + "blurhash": "LRF~:K*DD7L+S^o@$T+kR5wiM_IU" + } + ], + "created_at": "2026-09-28T23:11:43.000Z", + "edited_at": null, + "noindex": false, + "account": { + "id": "6", + "nid": "6", + "username": "alice", + "acct": "alice", + "display_name": "alice", + "locked": false, + "bot": false, + "discoverable": false, + "indexable": false, + "group": false, + "created_at": "2026-09-28T23:06:02.000Z", + "note": "", + "url": "http://nextcloud.local/index.php/apps/social/@alice", + "avatar": "", + "avatar_static": "", + "header": "http://nextcloud.local/index.php/avatar/alice/128", + "header_static": "http://nextcloud.local/index.php/avatar/alice/128", + "followers_count": 2, + "following_count": 2, + "statuses_count": 13, + "last_status_at": null, + "emojis": [], + "fields": [], + "pronouns": "", + "support_link": "" + }, + "filtered": [] + }, + { + "id": "1790637096330770789", + "nid": "1790637096330770789", + "local": true, + "archived": false, + "view_count": null, + "tagged_people": [], + "interest": null, + "content": "

Two test pictures #photography

", + "sensitive": false, + "spoiler_text": "", + "visibility": "public", + "language": "en", + "place": null, + "in_reply_to_id": null, + "in_reply_to_account_id": null, + "quote": null, + "quote_approval": { + "automatic": [ + "public" + ], + "manual": [], + "current_user": "automatic" + }, + "reply_approval": null, + "interaction_policy": null, + "dislikes_count": null, + "disliked": null, + "video": null, + "mentions": [], + "emojis": [], + "tags": [ + { + "name": "photography", + "url": "http://nextcloud.local/index.php/apps/social/timeline/tags/photography" + } + ], + "replies_count": 0, + "reblogs_count": 1, + "favourites_count": 0, + "favourited": false, + "reblogged": true, + "muted": false, + "bookmarked": false, + "pinned": false, + "card": null, + "reactions": [], + "poll": null, + "uri": "http://nextcloud.local/index.php/apps/social/@alice/17906370966999687509", + "url": "http://nextcloud.local/index.php/apps/social/@alice/17906370966999687509", + "reblog": null, + "media_attachments": [ + { + "id": "9", + "type": "image", + "media_type": "image/jpeg", + "url": "http://nextcloud.local/index.php/apps/social/media/48044adf-b771-4343-9607-c7fc93f16ad1.jpeg", + "preview_url": "http://nextcloud.local/index.php/apps/social/media/2c66bfd7-0029-47e1-b6ef-23dc7d422e2b.jpeg", + "hls_url": null, + "remote_url": null, + "cache_error": null, + "meta": { + "original": { + "width": 1600, + "height": 1200, + "size": "1600x1200", + "aspect": 1.3333333333333333 + }, + "small": { + "width": 800, + "height": 600, + "size": "800x600", + "aspect": 1.3333333333333333 + }, + "focus": { + "x": 0.3, + "y": -0.2 + } + }, + "description": "A test card pattern in bright colours", + "blurhash": "LVL4]9|Tnk_gsESdfjn,baayW;jZ" + }, + { + "id": "10", + "type": "image", + "media_type": "image/jpeg", + "url": "http://nextcloud.local/index.php/apps/social/media/1e936701-3c1a-49c2-a58a-bc7b0d5a9c27.jpeg", + "preview_url": "http://nextcloud.local/index.php/apps/social/media/43d878b8-d9fd-42fa-9f7e-38dea1357b60.jpeg", + "hls_url": null, + "remote_url": null, + "cache_error": null, + "meta": { + "original": { + "width": 1080, + "height": 1350, + "size": "1080x1350", + "aspect": 0.8 + }, + "small": { + "width": 640, + "height": 800, + "size": "640x800", + "aspect": 0.8 + }, + "focus": { + "x": 0, + "y": 0 + } + }, + "description": "A Mandelbrot fractal, portrait", + "blurhash": "LyLow4+,$SN?w6S]WUjI[ibCSIbW" + } + ], + "created_at": "2026-09-28T23:11:36.000Z", + "edited_at": null, + "noindex": false, + "account": { + "id": "6", + "nid": "6", + "username": "alice", + "acct": "alice", + "display_name": "alice", + "locked": false, + "bot": false, + "discoverable": false, + "indexable": false, + "group": false, + "created_at": "2026-09-28T23:06:02.000Z", + "note": "", + "url": "http://nextcloud.local/index.php/apps/social/@alice", + "avatar": "", + "avatar_static": "", + "header": "http://nextcloud.local/index.php/avatar/alice/128", + "header_static": "http://nextcloud.local/index.php/avatar/alice/128", + "followers_count": 2, + "following_count": 2, + "statuses_count": 13, + "last_status_at": null, + "emojis": [], + "fields": [], + "pronouns": "", + "support_link": "" + }, + "filtered": [] + }, + { + "id": "1790637087495743511", + "nid": "1790637087495743511", + "local": true, + "archived": false, + "view_count": null, + "tagged_people": [], + "interest": null, + "content": "

Which mode do you use most?

", + "sensitive": false, + "spoiler_text": "", + "visibility": "public", + "language": "en", + "place": null, + "in_reply_to_id": null, + "in_reply_to_account_id": null, + "quote": null, + "quote_approval": { + "automatic": [ + "public" + ], + "manual": [], + "current_user": "automatic" + }, + "reply_approval": null, + "interaction_policy": null, + "dislikes_count": null, + "disliked": null, + "video": null, + "mentions": [], + "emojis": [], + "tags": [], + "replies_count": 0, + "reblogs_count": 0, + "favourites_count": 0, + "favourited": false, + "reblogged": false, + "muted": false, + "bookmarked": false, + "pinned": false, + "card": null, + "reactions": [], + "poll": { + "id": "1790637087495743511", + "expires_at": "2026-09-29T23:11:27Z", + "expired": false, + "multiple": false, + "votes_count": 1, + "voters_count": 1, + "options": [ + { + "title": "Photos", + "votes_count": 1 + }, + { + "title": "Video", + "votes_count": 0 + }, + { + "title": "Shorts", + "votes_count": 0 + } + ], + "emojis": [], + "voted": true, + "own_votes": [ + 0 + ] + }, + "uri": "http://nextcloud.local/index.php/apps/social/@alice/17906370875255636478", + "url": "http://nextcloud.local/index.php/apps/social/@alice/17906370875255636478", + "reblog": null, + "media_attachments": [], + "created_at": "2026-09-28T23:11:27.000Z", + "edited_at": null, + "noindex": false, + "account": { + "id": "6", + "nid": "6", + "username": "alice", + "acct": "alice", + "display_name": "alice", + "locked": false, + "bot": false, + "discoverable": false, + "indexable": false, + "group": false, + "created_at": "2026-09-28T23:06:02.000Z", + "note": "", + "url": "http://nextcloud.local/index.php/apps/social/@alice", + "avatar": "", + "avatar_static": "", + "header": "http://nextcloud.local/index.php/avatar/alice/128", + "header_static": "http://nextcloud.local/index.php/avatar/alice/128", + "followers_count": 2, + "following_count": 2, + "statuses_count": 13, + "last_status_at": null, + "emojis": [], + "fields": [], + "pronouns": "", + "support_link": "" + }, + "oneOf": [ + { + "type": "Note", + "name": "Photos", + "replies": { + "type": "Collection", + "totalItems": 1 + } + }, + { + "type": "Note", + "name": "Video", + "replies": { + "type": "Collection", + "totalItems": 0 + } + }, + { + "type": "Note", + "name": "Shorts", + "replies": { + "type": "Collection", + "totalItems": 0 + } + } + ], + "endTime": "2026-09-29T23:11:27Z", + "votersCount": 1, + "filtered": [] + }, + { + "id": "1790637086680883443", + "nid": "1790637086680883443", + "local": true, + "archived": false, + "view_count": null, + "tagged_people": [], + "interest": null, + "content": "

An unlisted note for the timeline tests.

", + "sensitive": false, + "spoiler_text": "", + "visibility": "unlisted", + "language": "en", + "place": null, + "in_reply_to_id": null, + "in_reply_to_account_id": null, + "quote": null, + "quote_approval": { + "automatic": [ + "public" + ], + "manual": [], + "current_user": "automatic" + }, + "reply_approval": null, + "interaction_policy": null, + "dislikes_count": null, + "disliked": null, + "video": null, + "mentions": [], + "emojis": [], + "tags": [], + "replies_count": 0, + "reblogs_count": 0, + "favourites_count": 0, + "favourited": false, + "reblogged": false, + "muted": false, + "bookmarked": false, + "pinned": false, + "card": null, + "reactions": [], + "poll": null, + "uri": "http://nextcloud.local/index.php/apps/social/@alice/17906370862830940734", + "url": "http://nextcloud.local/index.php/apps/social/@alice/17906370862830940734", + "reblog": null, + "media_attachments": [], + "created_at": "2026-09-28T23:11:26.000Z", + "edited_at": null, + "noindex": false, + "account": { + "id": "6", + "nid": "6", + "username": "alice", + "acct": "alice", + "display_name": "alice", + "locked": false, + "bot": false, + "discoverable": false, + "indexable": false, + "group": false, + "created_at": "2026-09-28T23:06:02.000Z", + "note": "", + "url": "http://nextcloud.local/index.php/apps/social/@alice", + "avatar": "", + "avatar_static": "", + "header": "http://nextcloud.local/index.php/avatar/alice/128", + "header_static": "http://nextcloud.local/index.php/avatar/alice/128", + "followers_count": 2, + "following_count": 2, + "statuses_count": 13, + "last_status_at": null, + "emojis": [], + "fields": [], + "pronouns": "", + "support_link": "" + }, + "filtered": [] + }, + { + "id": "1790637086257046711", + "nid": "1790637086257046711", + "local": true, + "archived": false, + "view_count": null, + "tagged_people": [], + "interest": null, + "content": "

Only my followers see this.

", + "sensitive": false, + "spoiler_text": "", + "visibility": "private", + "language": "en", + "place": null, + "in_reply_to_id": null, + "in_reply_to_account_id": null, + "quote": null, + "quote_approval": { + "automatic": [], + "manual": [], + "current_user": "denied" + }, + "reply_approval": null, + "interaction_policy": null, + "dislikes_count": null, + "disliked": null, + "video": null, + "mentions": [], + "emojis": [], + "tags": [], + "replies_count": 0, + "reblogs_count": 0, + "favourites_count": 0, + "favourited": false, + "reblogged": false, + "muted": false, + "bookmarked": false, + "pinned": false, + "card": null, + "reactions": [], + "poll": null, + "uri": "http://nextcloud.local/index.php/apps/social/@alice/17906370861140078716", + "url": "http://nextcloud.local/index.php/apps/social/@alice/17906370861140078716", + "reblog": null, + "media_attachments": [], + "created_at": "2026-09-28T23:11:26.000Z", + "edited_at": null, + "noindex": false, + "account": { + "id": "6", + "nid": "6", + "username": "alice", + "acct": "alice", + "display_name": "alice", + "locked": false, + "bot": false, + "discoverable": false, + "indexable": false, + "group": false, + "created_at": "2026-09-28T23:06:02.000Z", + "note": "", + "url": "http://nextcloud.local/index.php/apps/social/@alice", + "avatar": "", + "avatar_static": "", + "header": "http://nextcloud.local/index.php/avatar/alice/128", + "header_static": "http://nextcloud.local/index.php/avatar/alice/128", + "followers_count": 2, + "following_count": 2, + "statuses_count": 13, + "last_status_at": null, + "emojis": [], + "fields": [], + "pronouns": "", + "support_link": "" + }, + "filtered": [] + }, + { + "id": "1790637086177868305", + "nid": "1790637086177868305", + "local": true, + "archived": false, + "view_count": null, + "tagged_people": [], + "interest": null, + "content": "

The butler did it.

", + "sensitive": true, + "spoiler_text": "Spoiler: season finale", + "visibility": "public", + "language": "en", + "place": null, + "in_reply_to_id": null, + "in_reply_to_account_id": null, + "quote": null, + "quote_approval": { + "automatic": [ + "public" + ], + "manual": [], + "current_user": "automatic" + }, + "reply_approval": null, + "interaction_policy": null, + "dislikes_count": null, + "disliked": null, + "video": null, + "mentions": [], + "emojis": [], + "tags": [], + "replies_count": 0, + "reblogs_count": 0, + "favourites_count": 0, + "favourited": false, + "reblogged": false, + "muted": false, + "bookmarked": false, + "pinned": false, + "card": null, + "reactions": [], + "poll": null, + "uri": "http://nextcloud.local/index.php/apps/social/@alice/17906370868446468411", + "url": "http://nextcloud.local/index.php/apps/social/@alice/17906370868446468411", + "reblog": null, + "media_attachments": [], + "created_at": "2026-09-28T23:11:26.000Z", + "edited_at": null, + "noindex": false, + "account": { + "id": "6", + "nid": "6", + "username": "alice", + "acct": "alice", + "display_name": "alice", + "locked": false, + "bot": false, + "discoverable": false, + "indexable": false, + "group": false, + "created_at": "2026-09-28T23:06:02.000Z", + "note": "", + "url": "http://nextcloud.local/index.php/apps/social/@alice", + "avatar": "", + "avatar_static": "", + "header": "http://nextcloud.local/index.php/avatar/alice/128", + "header_static": "http://nextcloud.local/index.php/avatar/alice/128", + "followers_count": 2, + "following_count": 2, + "statuses_count": 13, + "last_status_at": null, + "emojis": [], + "fields": [], + "pronouns": "", + "support_link": "" + }, + "filtered": [] + }, + { + "id": "1790637085797595891", + "nid": "1790637085797595891", + "local": true, + "archived": false, + "view_count": null, + "tagged_people": [], + "interest": null, + "content": "

Aloha from the dev instance! Say hi, @bob@nextcloud.local #aloha #android

", + "sensitive": false, + "spoiler_text": "", + "visibility": "public", + "language": "en", + "place": null, + "in_reply_to_id": null, + "in_reply_to_account_id": null, + "quote": null, + "quote_approval": { + "automatic": [ + "public" + ], + "manual": [], + "current_user": "automatic" + }, + "reply_approval": null, + "interaction_policy": null, + "dislikes_count": null, + "disliked": null, + "video": null, + "mentions": [], + "emojis": [], + "tags": [ + { + "name": "aloha", + "url": "http://nextcloud.local/index.php/apps/social/timeline/tags/aloha" + }, + { + "name": "android", + "url": "http://nextcloud.local/index.php/apps/social/timeline/tags/android" + } + ], + "replies_count": 1, + "reblogs_count": 0, + "favourites_count": 1, + "favourited": true, + "reblogged": false, + "muted": false, + "bookmarked": false, + "pinned": false, + "card": null, + "reactions": [ + { + "name": "🎉", + "count": 1, + "me": true + } + ], + "poll": null, + "uri": "http://nextcloud.local/index.php/apps/social/@alice/17906370853806247191", + "url": "http://nextcloud.local/index.php/apps/social/@alice/17906370853806247191", + "reblog": null, + "media_attachments": [], + "created_at": "2026-09-28T23:11:25.000Z", + "edited_at": null, + "noindex": false, + "account": { + "id": "6", + "nid": "6", + "username": "alice", + "acct": "alice", + "display_name": "alice", + "locked": false, + "bot": false, + "discoverable": false, + "indexable": false, + "group": false, + "created_at": "2026-09-28T23:06:02.000Z", + "note": "", + "url": "http://nextcloud.local/index.php/apps/social/@alice", + "avatar": "", + "avatar_static": "", + "header": "http://nextcloud.local/index.php/avatar/alice/128", + "header_static": "http://nextcloud.local/index.php/avatar/alice/128", + "followers_count": 2, + "following_count": 2, + "statuses_count": 13, + "last_status_at": null, + "emojis": [], + "fields": [], + "pronouns": "", + "support_link": "" + }, + "filtered": [] + } + ] +} diff --git a/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/timeline-home-jane-filtered.json b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/timeline-home-jane-filtered.json new file mode 100644 index 00000000..54e8b166 --- /dev/null +++ b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/timeline-home-jane-filtered.json @@ -0,0 +1,15 @@ +{ + "request": { + "method": "GET", + "path": "/index.php/apps/social/api/v1/timelines/home?limit=20" + }, + "status": 200, + "headers": { + "content-type": "application/json; charset=utf-8", + "cache-control": "no-cache, no-store, must-revalidate", + "x-ratelimit-limit": "60", + "x-ratelimit-remaining": "51", + "x-ratelimit-reset": "1790637300" + }, + "body": [] +} diff --git a/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/timeline-home-page.json b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/timeline-home-page.json new file mode 100644 index 00000000..d102885c --- /dev/null +++ b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/timeline-home-page.json @@ -0,0 +1,487 @@ +{ + "request": { + "method": "GET", + "path": "/index.php/apps/social/api/v1/timelines/home?limit=3" + }, + "status": 200, + "headers": { + "content-type": "application/json; charset=utf-8", + "cache-control": "no-cache, no-store, must-revalidate", + "link": "; rel=\"next\", ; rel=\"prev\"", + "x-ratelimit-limit": "60", + "x-ratelimit-remaining": "58", + "x-ratelimit-reset": "1790637300" + }, + "body": [ + { + "id": "1790637117344364158", + "nid": "1790637117344364158", + "local": true, + "archived": false, + "view_count": null, + "tagged_people": [], + "interest": null, + "content": "

Bob's own post #aloha

", + "sensitive": false, + "spoiler_text": "", + "visibility": "public", + "language": "en", + "place": null, + "in_reply_to_id": null, + "in_reply_to_account_id": null, + "quote": null, + "quote_approval": { + "automatic": [ + "public" + ], + "manual": [], + "current_user": "automatic" + }, + "reply_approval": null, + "interaction_policy": null, + "dislikes_count": null, + "disliked": null, + "video": null, + "mentions": [], + "emojis": [], + "tags": [ + { + "name": "aloha", + "url": "http://nextcloud.local/index.php/apps/social/timeline/tags/aloha" + } + ], + "replies_count": 0, + "reblogs_count": 0, + "favourites_count": 0, + "favourited": false, + "reblogged": false, + "muted": false, + "bookmarked": false, + "pinned": false, + "card": null, + "reactions": [], + "poll": null, + "uri": "http://nextcloud.local/index.php/apps/social/@bob/17906371177254273993", + "url": "http://nextcloud.local/index.php/apps/social/@bob/17906371177254273993", + "reblog": null, + "media_attachments": [], + "created_at": "2026-09-28T23:11:57.000Z", + "edited_at": null, + "noindex": false, + "account": { + "id": "7", + "nid": "7", + "username": "bob", + "acct": "bob", + "display_name": "bob", + "locked": false, + "bot": false, + "discoverable": false, + "indexable": false, + "group": false, + "created_at": "2026-09-28T23:06:03.000Z", + "note": "", + "url": "http://nextcloud.local/index.php/apps/social/@bob", + "avatar": "", + "avatar_static": "", + "header": "http://nextcloud.local/index.php/avatar/bob/128", + "header_static": "http://nextcloud.local/index.php/avatar/bob/128", + "followers_count": 1, + "following_count": 2, + "statuses_count": 3, + "last_status_at": null, + "emojis": [], + "fields": [], + "pronouns": "", + "support_link": "" + }, + "filtered": [] + }, + { + "id": "1790637116374990863", + "nid": "1790637116374990863", + "local": true, + "archived": false, + "view_count": null, + "tagged_people": [], + "interest": null, + "content": "

Quoting this one

", + "sensitive": false, + "spoiler_text": "", + "visibility": "public", + "language": "en", + "place": null, + "in_reply_to_id": null, + "in_reply_to_account_id": null, + "quote": { + "state": "accepted", + "quoted_status": { + "id": "1790637085797595891", + "nid": "1790637085797595891", + "local": true, + "archived": false, + "view_count": null, + "tagged_people": [], + "interest": null, + "content": "

Aloha from the dev instance! Say hi, @bob@nextcloud.local #aloha #android

", + "sensitive": false, + "spoiler_text": "", + "visibility": "public", + "language": "en", + "place": null, + "in_reply_to_id": null, + "in_reply_to_account_id": null, + "quote": null, + "quote_approval": { + "automatic": [ + "public" + ], + "manual": [], + "current_user": "automatic" + }, + "reply_approval": null, + "interaction_policy": null, + "dislikes_count": null, + "disliked": null, + "video": null, + "mentions": [], + "emojis": [], + "tags": [ + { + "name": "aloha", + "url": "http://nextcloud.local/index.php/apps/social/timeline/tags/aloha" + }, + { + "name": "android", + "url": "http://nextcloud.local/index.php/apps/social/timeline/tags/android" + } + ], + "replies_count": 1, + "reblogs_count": 0, + "favourites_count": 1, + "favourited": false, + "reblogged": false, + "muted": false, + "bookmarked": false, + "pinned": false, + "card": null, + "reactions": [], + "poll": null, + "uri": "http://nextcloud.local/index.php/apps/social/@alice/17906370853806247191", + "url": "http://nextcloud.local/index.php/apps/social/@alice/17906370853806247191", + "reblog": null, + "media_attachments": [], + "created_at": "2026-09-28T23:11:25.000Z", + "edited_at": null, + "noindex": false, + "account": { + "id": "6", + "nid": "6", + "username": "alice", + "acct": "alice", + "display_name": "alice", + "locked": false, + "bot": false, + "discoverable": false, + "indexable": false, + "group": false, + "created_at": "2026-09-28T23:06:02.000Z", + "note": "", + "url": "http://nextcloud.local/index.php/apps/social/@alice", + "avatar": "", + "avatar_static": "", + "header": "http://nextcloud.local/index.php/avatar/alice/128", + "header_static": "http://nextcloud.local/index.php/avatar/alice/128", + "followers_count": 2, + "following_count": 2, + "statuses_count": 13, + "last_status_at": null, + "emojis": [], + "fields": [], + "pronouns": "", + "support_link": "" + } + } + }, + "quote_approval": { + "automatic": [ + "public" + ], + "manual": [], + "current_user": "automatic" + }, + "reply_approval": null, + "interaction_policy": null, + "dislikes_count": null, + "disliked": null, + "video": null, + "mentions": [], + "emojis": [], + "tags": [], + "replies_count": 0, + "reblogs_count": 0, + "favourites_count": 0, + "favourited": false, + "reblogged": false, + "muted": false, + "bookmarked": false, + "pinned": false, + "card": null, + "reactions": [], + "poll": null, + "uri": "http://nextcloud.local/index.php/apps/social/@bob/17906371165948083557", + "url": "http://nextcloud.local/index.php/apps/social/@bob/17906371165948083557", + "reblog": null, + "media_attachments": [], + "created_at": "2026-09-28T23:11:56.000Z", + "edited_at": null, + "noindex": false, + "account": { + "id": "7", + "nid": "7", + "username": "bob", + "acct": "bob", + "display_name": "bob", + "locked": false, + "bot": false, + "discoverable": false, + "indexable": false, + "group": false, + "created_at": "2026-09-28T23:06:03.000Z", + "note": "", + "url": "http://nextcloud.local/index.php/apps/social/@bob", + "avatar": "", + "avatar_static": "", + "header": "http://nextcloud.local/index.php/avatar/bob/128", + "header_static": "http://nextcloud.local/index.php/avatar/bob/128", + "followers_count": 1, + "following_count": 2, + "statuses_count": 3, + "last_status_at": null, + "emojis": [], + "fields": [], + "pronouns": "", + "support_link": "" + }, + "filtered": [] + }, + { + "id": "1790637116232866223", + "nid": "1790637116232866223", + "local": true, + "archived": false, + "view_count": null, + "tagged_people": [], + "interest": null, + "content": "", + "sensitive": false, + "spoiler_text": "", + "visibility": "public", + "language": null, + "place": null, + "in_reply_to_id": null, + "in_reply_to_account_id": null, + "quote": null, + "quote_approval": { + "automatic": [ + "public" + ], + "manual": [], + "current_user": "automatic" + }, + "reply_approval": null, + "interaction_policy": null, + "dislikes_count": null, + "disliked": null, + "video": null, + "mentions": [], + "emojis": [], + "tags": [], + "replies_count": 0, + "reblogs_count": 0, + "favourites_count": 0, + "favourited": false, + "reblogged": false, + "muted": false, + "bookmarked": false, + "pinned": false, + "card": null, + "reactions": [], + "poll": null, + "uri": "http://nextcloud.local/index.php/apps/social/@bob/17906371167510741067", + "url": "http://nextcloud.local/index.php/apps/social/@bob/17906371167510741067", + "reblog": { + "id": "1790637096330770789", + "nid": "1790637096330770789", + "local": true, + "archived": false, + "view_count": null, + "tagged_people": [], + "interest": null, + "content": "

Two test pictures #photography

", + "sensitive": false, + "spoiler_text": "", + "visibility": "public", + "language": "en", + "place": null, + "in_reply_to_id": null, + "in_reply_to_account_id": null, + "quote": null, + "quote_approval": { + "automatic": [ + "public" + ], + "manual": [], + "current_user": "automatic" + }, + "reply_approval": null, + "interaction_policy": null, + "dislikes_count": null, + "disliked": null, + "video": null, + "mentions": [], + "emojis": [], + "tags": [], + "replies_count": 0, + "reblogs_count": 1, + "favourites_count": 0, + "favourited": false, + "reblogged": false, + "muted": false, + "bookmarked": false, + "pinned": false, + "card": null, + "reactions": [], + "poll": null, + "uri": "http://nextcloud.local/index.php/apps/social/@alice/17906370966999687509", + "url": "http://nextcloud.local/index.php/apps/social/@alice/17906370966999687509", + "reblog": null, + "media_attachments": [ + { + "id": "9", + "type": "image", + "media_type": "image/jpeg", + "url": "http://nextcloud.local/index.php/apps/social/media/48044adf-b771-4343-9607-c7fc93f16ad1.jpeg", + "preview_url": "http://nextcloud.local/index.php/apps/social/media/2c66bfd7-0029-47e1-b6ef-23dc7d422e2b.jpeg", + "hls_url": null, + "remote_url": null, + "cache_error": null, + "meta": { + "original": { + "width": 1600, + "height": 1200, + "size": "1600x1200", + "aspect": 1.3333333333333333 + }, + "small": { + "width": 800, + "height": 600, + "size": "800x600", + "aspect": 1.3333333333333333 + }, + "focus": { + "x": 0.3, + "y": -0.2 + } + }, + "description": "A test card pattern in bright colours", + "blurhash": "LVL4]9|Tnk_gsESdfjn,baayW;jZ" + }, + { + "id": "10", + "type": "image", + "media_type": "image/jpeg", + "url": "http://nextcloud.local/index.php/apps/social/media/1e936701-3c1a-49c2-a58a-bc7b0d5a9c27.jpeg", + "preview_url": "http://nextcloud.local/index.php/apps/social/media/43d878b8-d9fd-42fa-9f7e-38dea1357b60.jpeg", + "hls_url": null, + "remote_url": null, + "cache_error": null, + "meta": { + "original": { + "width": 1080, + "height": 1350, + "size": "1080x1350", + "aspect": 0.8 + }, + "small": { + "width": 640, + "height": 800, + "size": "640x800", + "aspect": 0.8 + }, + "focus": { + "x": 0, + "y": 0 + } + }, + "description": "A Mandelbrot fractal, portrait", + "blurhash": "LyLow4+,$SN?w6S]WUjI[ibCSIbW" + } + ], + "created_at": "2026-09-28T23:11:36.000Z", + "edited_at": null, + "noindex": false, + "account": { + "id": "0", + "nid": 0, + "username": "alice", + "acct": "alice", + "display_name": "alice", + "locked": false, + "bot": false, + "discoverable": false, + "indexable": false, + "group": false, + "created_at": "2026-09-28T23:06:02.000Z", + "note": "", + "url": "http://nextcloud.local/index.php/apps/social/@alice", + "avatar": "", + "avatar_static": "", + "header": "http://nextcloud.local/index.php/avatar/alice/128", + "header_static": "http://nextcloud.local/index.php/avatar/alice/128", + "followers_count": 2, + "following_count": 2, + "statuses_count": 13, + "last_status_at": null, + "emojis": [], + "fields": [], + "pronouns": "", + "support_link": "" + }, + "filtered": [] + }, + "media_attachments": [], + "created_at": "2026-09-28T23:11:56.000Z", + "edited_at": null, + "noindex": false, + "account": { + "id": "7", + "nid": "7", + "username": "bob", + "acct": "bob", + "display_name": "bob", + "locked": false, + "bot": false, + "discoverable": false, + "indexable": false, + "group": false, + "created_at": "2026-09-28T23:06:03.000Z", + "note": "", + "url": "http://nextcloud.local/index.php/apps/social/@bob", + "avatar": "", + "avatar_static": "", + "header": "http://nextcloud.local/index.php/avatar/bob/128", + "header_static": "http://nextcloud.local/index.php/avatar/bob/128", + "followers_count": 1, + "following_count": 2, + "statuses_count": 3, + "last_status_at": null, + "emojis": [], + "fields": [], + "pronouns": "", + "support_link": "" + }, + "filtered": [] + } + ] +} diff --git a/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/timeline-home.json b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/timeline-home.json new file mode 100644 index 00000000..3c45a165 --- /dev/null +++ b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/timeline-home.json @@ -0,0 +1,1733 @@ +{ + "request": { + "method": "GET", + "path": "/index.php/apps/social/api/v1/timelines/home?limit=20" + }, + "status": 200, + "headers": { + "content-type": "application/json; charset=utf-8", + "cache-control": "no-cache, no-store, must-revalidate", + "link": "; rel=\"prev\"", + "x-ratelimit-limit": "60", + "x-ratelimit-remaining": "59", + "x-ratelimit-reset": "1790637300" + }, + "body": [ + { + "id": "1790637117344364158", + "nid": "1790637117344364158", + "local": true, + "archived": false, + "view_count": null, + "tagged_people": [], + "interest": null, + "content": "

Bob's own post #aloha

", + "sensitive": false, + "spoiler_text": "", + "visibility": "public", + "language": "en", + "place": null, + "in_reply_to_id": null, + "in_reply_to_account_id": null, + "quote": null, + "quote_approval": { + "automatic": [ + "public" + ], + "manual": [], + "current_user": "automatic" + }, + "reply_approval": null, + "interaction_policy": null, + "dislikes_count": null, + "disliked": null, + "video": null, + "mentions": [], + "emojis": [], + "tags": [ + { + "name": "aloha", + "url": "http://nextcloud.local/index.php/apps/social/timeline/tags/aloha" + } + ], + "replies_count": 0, + "reblogs_count": 0, + "favourites_count": 0, + "favourited": false, + "reblogged": false, + "muted": false, + "bookmarked": false, + "pinned": false, + "card": null, + "reactions": [], + "poll": null, + "uri": "http://nextcloud.local/index.php/apps/social/@bob/17906371177254273993", + "url": "http://nextcloud.local/index.php/apps/social/@bob/17906371177254273993", + "reblog": null, + "media_attachments": [], + "created_at": "2026-09-28T23:11:57.000Z", + "edited_at": null, + "noindex": false, + "account": { + "id": "7", + "nid": "7", + "username": "bob", + "acct": "bob", + "display_name": "bob", + "locked": false, + "bot": false, + "discoverable": false, + "indexable": false, + "group": false, + "created_at": "2026-09-28T23:06:03.000Z", + "note": "", + "url": "http://nextcloud.local/index.php/apps/social/@bob", + "avatar": "", + "avatar_static": "", + "header": "http://nextcloud.local/index.php/avatar/bob/128", + "header_static": "http://nextcloud.local/index.php/avatar/bob/128", + "followers_count": 1, + "following_count": 2, + "statuses_count": 3, + "last_status_at": null, + "emojis": [], + "fields": [], + "pronouns": "", + "support_link": "" + }, + "filtered": [] + }, + { + "id": "1790637116374990863", + "nid": "1790637116374990863", + "local": true, + "archived": false, + "view_count": null, + "tagged_people": [], + "interest": null, + "content": "

Quoting this one

", + "sensitive": false, + "spoiler_text": "", + "visibility": "public", + "language": "en", + "place": null, + "in_reply_to_id": null, + "in_reply_to_account_id": null, + "quote": { + "state": "accepted", + "quoted_status": { + "id": "1790637085797595891", + "nid": "1790637085797595891", + "local": true, + "archived": false, + "view_count": null, + "tagged_people": [], + "interest": null, + "content": "

Aloha from the dev instance! Say hi, @bob@nextcloud.local #aloha #android

", + "sensitive": false, + "spoiler_text": "", + "visibility": "public", + "language": "en", + "place": null, + "in_reply_to_id": null, + "in_reply_to_account_id": null, + "quote": null, + "quote_approval": { + "automatic": [ + "public" + ], + "manual": [], + "current_user": "automatic" + }, + "reply_approval": null, + "interaction_policy": null, + "dislikes_count": null, + "disliked": null, + "video": null, + "mentions": [], + "emojis": [], + "tags": [ + { + "name": "aloha", + "url": "http://nextcloud.local/index.php/apps/social/timeline/tags/aloha" + }, + { + "name": "android", + "url": "http://nextcloud.local/index.php/apps/social/timeline/tags/android" + } + ], + "replies_count": 1, + "reblogs_count": 0, + "favourites_count": 1, + "favourited": false, + "reblogged": false, + "muted": false, + "bookmarked": false, + "pinned": false, + "card": null, + "reactions": [], + "poll": null, + "uri": "http://nextcloud.local/index.php/apps/social/@alice/17906370853806247191", + "url": "http://nextcloud.local/index.php/apps/social/@alice/17906370853806247191", + "reblog": null, + "media_attachments": [], + "created_at": "2026-09-28T23:11:25.000Z", + "edited_at": null, + "noindex": false, + "account": { + "id": "6", + "nid": "6", + "username": "alice", + "acct": "alice", + "display_name": "alice", + "locked": false, + "bot": false, + "discoverable": false, + "indexable": false, + "group": false, + "created_at": "2026-09-28T23:06:02.000Z", + "note": "", + "url": "http://nextcloud.local/index.php/apps/social/@alice", + "avatar": "", + "avatar_static": "", + "header": "http://nextcloud.local/index.php/avatar/alice/128", + "header_static": "http://nextcloud.local/index.php/avatar/alice/128", + "followers_count": 2, + "following_count": 2, + "statuses_count": 13, + "last_status_at": null, + "emojis": [], + "fields": [], + "pronouns": "", + "support_link": "" + } + } + }, + "quote_approval": { + "automatic": [ + "public" + ], + "manual": [], + "current_user": "automatic" + }, + "reply_approval": null, + "interaction_policy": null, + "dislikes_count": null, + "disliked": null, + "video": null, + "mentions": [], + "emojis": [], + "tags": [], + "replies_count": 0, + "reblogs_count": 0, + "favourites_count": 0, + "favourited": false, + "reblogged": false, + "muted": false, + "bookmarked": false, + "pinned": false, + "card": null, + "reactions": [], + "poll": null, + "uri": "http://nextcloud.local/index.php/apps/social/@bob/17906371165948083557", + "url": "http://nextcloud.local/index.php/apps/social/@bob/17906371165948083557", + "reblog": null, + "media_attachments": [], + "created_at": "2026-09-28T23:11:56.000Z", + "edited_at": null, + "noindex": false, + "account": { + "id": "7", + "nid": "7", + "username": "bob", + "acct": "bob", + "display_name": "bob", + "locked": false, + "bot": false, + "discoverable": false, + "indexable": false, + "group": false, + "created_at": "2026-09-28T23:06:03.000Z", + "note": "", + "url": "http://nextcloud.local/index.php/apps/social/@bob", + "avatar": "", + "avatar_static": "", + "header": "http://nextcloud.local/index.php/avatar/bob/128", + "header_static": "http://nextcloud.local/index.php/avatar/bob/128", + "followers_count": 1, + "following_count": 2, + "statuses_count": 3, + "last_status_at": null, + "emojis": [], + "fields": [], + "pronouns": "", + "support_link": "" + }, + "filtered": [] + }, + { + "id": "1790637115422878628", + "nid": "1790637115422878628", + "local": true, + "archived": false, + "view_count": null, + "tagged_people": [], + "interest": null, + "content": "

@bob@nextcloud.local and back again

", + "sensitive": false, + "spoiler_text": "", + "visibility": "public", + "language": "en", + "place": null, + "in_reply_to_id": "1790637115351395147", + "in_reply_to_account_id": "7", + "quote": null, + "quote_approval": { + "automatic": [ + "public" + ], + "manual": [], + "current_user": "automatic" + }, + "reply_approval": null, + "interaction_policy": null, + "dislikes_count": null, + "disliked": null, + "video": null, + "mentions": [], + "emojis": [], + "tags": [], + "replies_count": 0, + "reblogs_count": 0, + "favourites_count": 0, + "favourited": false, + "reblogged": false, + "muted": false, + "bookmarked": false, + "pinned": false, + "card": null, + "reactions": [], + "poll": null, + "uri": "http://nextcloud.local/index.php/apps/social/@alice/17906371159153077137", + "url": "http://nextcloud.local/index.php/apps/social/@alice/17906371159153077137", + "reblog": null, + "media_attachments": [], + "created_at": "2026-09-28T23:11:55.000Z", + "edited_at": null, + "noindex": false, + "account": { + "id": "6", + "nid": "6", + "username": "alice", + "acct": "alice", + "display_name": "alice", + "locked": false, + "bot": false, + "discoverable": false, + "indexable": false, + "group": false, + "created_at": "2026-09-28T23:06:02.000Z", + "note": "", + "url": "http://nextcloud.local/index.php/apps/social/@alice", + "avatar": "", + "avatar_static": "", + "header": "http://nextcloud.local/index.php/avatar/alice/128", + "header_static": "http://nextcloud.local/index.php/avatar/alice/128", + "followers_count": 2, + "following_count": 2, + "statuses_count": 13, + "last_status_at": null, + "emojis": [], + "fields": [], + "pronouns": "", + "support_link": "" + }, + "filtered": [] + }, + { + "id": "1790637115351395147", + "nid": "1790637115351395147", + "local": true, + "archived": false, + "view_count": null, + "tagged_people": [], + "interest": null, + "content": "

@alice@nextcloud.local hi back!

", + "sensitive": false, + "spoiler_text": "", + "visibility": "public", + "language": "en", + "place": null, + "in_reply_to_id": "1790637085797595891", + "in_reply_to_account_id": "6", + "quote": null, + "quote_approval": { + "automatic": [ + "public" + ], + "manual": [], + "current_user": "automatic" + }, + "reply_approval": null, + "interaction_policy": null, + "dislikes_count": null, + "disliked": null, + "video": null, + "mentions": [], + "emojis": [], + "tags": [], + "replies_count": 1, + "reblogs_count": 0, + "favourites_count": 0, + "favourited": false, + "reblogged": false, + "muted": false, + "bookmarked": false, + "pinned": false, + "card": null, + "reactions": [], + "poll": null, + "uri": "http://nextcloud.local/index.php/apps/social/@bob/17906371156396819743", + "url": "http://nextcloud.local/index.php/apps/social/@bob/17906371156396819743", + "reblog": null, + "media_attachments": [], + "created_at": "2026-09-28T23:11:55.000Z", + "edited_at": null, + "noindex": false, + "account": { + "id": "7", + "nid": "7", + "username": "bob", + "acct": "bob", + "display_name": "bob", + "locked": false, + "bot": false, + "discoverable": false, + "indexable": false, + "group": false, + "created_at": "2026-09-28T23:06:03.000Z", + "note": "", + "url": "http://nextcloud.local/index.php/apps/social/@bob", + "avatar": "", + "avatar_static": "", + "header": "http://nextcloud.local/index.php/avatar/bob/128", + "header_static": "http://nextcloud.local/index.php/avatar/bob/128", + "followers_count": 1, + "following_count": 2, + "statuses_count": 3, + "last_status_at": null, + "emojis": [], + "fields": [], + "pronouns": "", + "support_link": "" + }, + "filtered": [] + }, + { + "id": "1790637112216295042", + "nid": "1790637112216295042", + "local": true, + "archived": false, + "view_count": null, + "tagged_people": [], + "interest": null, + "content": "

Picture from Berlin

", + "sensitive": false, + "spoiler_text": "", + "visibility": "public", + "language": "en", + "place": { + "id": "1", + "name": "Berlin", + "country": "DE", + "lat": "", + "long": "" + }, + "in_reply_to_id": null, + "in_reply_to_account_id": null, + "quote": null, + "quote_approval": { + "automatic": [ + "public" + ], + "manual": [], + "current_user": "automatic" + }, + "reply_approval": null, + "interaction_policy": null, + "dislikes_count": null, + "disliked": null, + "video": null, + "mentions": [], + "emojis": [], + "tags": [], + "replies_count": 0, + "reblogs_count": 0, + "favourites_count": 0, + "favourited": false, + "reblogged": false, + "muted": false, + "bookmarked": false, + "pinned": false, + "card": null, + "reactions": [], + "poll": null, + "uri": "http://nextcloud.local/index.php/apps/social/@alice/17906371127479253498", + "url": "http://nextcloud.local/index.php/apps/social/@alice/17906371127479253498", + "reblog": null, + "media_attachments": [ + { + "id": "15", + "type": "image", + "media_type": "image/jpeg", + "url": "http://nextcloud.local/index.php/apps/social/media/b88735cc-b169-40e0-9fa5-85e3be451c1a.jpeg", + "preview_url": "http://nextcloud.local/index.php/apps/social/media/e46d0547-3b30-46cd-8b5b-394bb7005a49.jpeg", + "hls_url": null, + "remote_url": null, + "cache_error": null, + "meta": { + "original": { + "width": 1080, + "height": 1350, + "size": "1080x1350", + "aspect": 0.8 + }, + "small": { + "width": 640, + "height": 800, + "size": "640x800", + "aspect": 0.8 + }, + "focus": { + "x": 0, + "y": 0 + } + }, + "description": "Another fractal, taken in Berlin", + "blurhash": "LyLow4+,$SN?w6S]WUjI[ibCSIbW" + } + ], + "created_at": "2026-09-28T23:11:52.000Z", + "edited_at": null, + "noindex": false, + "account": { + "id": "6", + "nid": "6", + "username": "alice", + "acct": "alice", + "display_name": "alice", + "locked": false, + "bot": false, + "discoverable": false, + "indexable": false, + "group": false, + "created_at": "2026-09-28T23:06:02.000Z", + "note": "", + "url": "http://nextcloud.local/index.php/apps/social/@alice", + "avatar": "", + "avatar_static": "", + "header": "http://nextcloud.local/index.php/avatar/alice/128", + "header_static": "http://nextcloud.local/index.php/avatar/alice/128", + "followers_count": 2, + "following_count": 2, + "statuses_count": 13, + "last_status_at": null, + "emojis": [], + "fields": [], + "pronouns": "", + "support_link": "" + }, + "filtered": [] + }, + { + "id": "1790637112034926731", + "nid": "1790637112034926731", + "local": true, + "archived": false, + "view_count": null, + "tagged_people": [], + "interest": null, + "content": "

First draft, edited

", + "sensitive": false, + "spoiler_text": "", + "visibility": "public", + "language": "en", + "place": null, + "in_reply_to_id": null, + "in_reply_to_account_id": null, + "quote": null, + "quote_approval": { + "automatic": [ + "public" + ], + "manual": [], + "current_user": "automatic" + }, + "reply_approval": null, + "interaction_policy": null, + "dislikes_count": null, + "disliked": null, + "video": null, + "mentions": [], + "emojis": [], + "tags": [], + "replies_count": 0, + "reblogs_count": 0, + "favourites_count": 0, + "favourited": false, + "reblogged": false, + "muted": false, + "bookmarked": false, + "pinned": false, + "card": null, + "reactions": [], + "poll": null, + "uri": "http://nextcloud.local/index.php/apps/social/@alice/17906371127199851091", + "url": "http://nextcloud.local/index.php/apps/social/@alice/17906371127199851091", + "reblog": null, + "media_attachments": [], + "created_at": "2026-09-28T23:11:52.000Z", + "edited_at": "2026-09-28T23:11:52.000Z", + "noindex": false, + "account": { + "id": "6", + "nid": "6", + "username": "alice", + "acct": "alice", + "display_name": "alice", + "locked": false, + "bot": false, + "discoverable": false, + "indexable": false, + "group": false, + "created_at": "2026-09-28T23:06:02.000Z", + "note": "", + "url": "http://nextcloud.local/index.php/apps/social/@alice", + "avatar": "", + "avatar_static": "", + "header": "http://nextcloud.local/index.php/avatar/alice/128", + "header_static": "http://nextcloud.local/index.php/avatar/alice/128", + "followers_count": 2, + "following_count": 2, + "statuses_count": 13, + "last_status_at": null, + "emojis": [], + "fields": [], + "pronouns": "", + "support_link": "" + }, + "filtered": [] + }, + { + "id": "1790637105561356153", + "nid": "1790637105561356153", + "local": true, + "archived": false, + "view_count": null, + "tagged_people": [], + "interest": null, + "content": "

Nextcloud Social on GitHub: https://github.com/nextcloud/social

", + "sensitive": false, + "spoiler_text": "", + "visibility": "public", + "language": "en", + "place": null, + "in_reply_to_id": null, + "in_reply_to_account_id": null, + "quote": null, + "quote_approval": { + "automatic": [ + "public" + ], + "manual": [], + "current_user": "automatic" + }, + "reply_approval": null, + "interaction_policy": null, + "dislikes_count": null, + "disliked": null, + "video": null, + "mentions": [], + "emojis": [], + "tags": [], + "replies_count": 0, + "reblogs_count": 0, + "favourites_count": 0, + "favourited": false, + "reblogged": false, + "muted": false, + "bookmarked": false, + "pinned": false, + "card": null, + "reactions": [], + "poll": null, + "uri": "http://nextcloud.local/index.php/apps/social/@alice/17906371057334696925", + "url": "http://nextcloud.local/index.php/apps/social/@alice/17906371057334696925", + "reblog": null, + "media_attachments": [], + "created_at": "2026-09-28T23:11:45.000Z", + "edited_at": null, + "noindex": false, + "account": { + "id": "6", + "nid": "6", + "username": "alice", + "acct": "alice", + "display_name": "alice", + "locked": false, + "bot": false, + "discoverable": false, + "indexable": false, + "group": false, + "created_at": "2026-09-28T23:06:02.000Z", + "note": "", + "url": "http://nextcloud.local/index.php/apps/social/@alice", + "avatar": "", + "avatar_static": "", + "header": "http://nextcloud.local/index.php/avatar/alice/128", + "header_static": "http://nextcloud.local/index.php/avatar/alice/128", + "followers_count": 2, + "following_count": 2, + "statuses_count": 13, + "last_status_at": null, + "emojis": [], + "fields": [], + "pronouns": "", + "support_link": "" + }, + "filtered": [] + }, + { + "id": "1790637105549523409", + "nid": "1790637105549523409", + "local": true, + "archived": false, + "view_count": null, + "tagged_people": [], + "interest": null, + "content": "

A 15 second tone #audio

", + "sensitive": false, + "spoiler_text": "", + "visibility": "public", + "language": "en", + "place": null, + "in_reply_to_id": null, + "in_reply_to_account_id": null, + "quote": null, + "quote_approval": { + "automatic": [ + "public" + ], + "manual": [], + "current_user": "automatic" + }, + "reply_approval": null, + "interaction_policy": null, + "dislikes_count": null, + "disliked": null, + "video": null, + "mentions": [], + "emojis": [], + "tags": [ + { + "name": "audio", + "url": "http://nextcloud.local/index.php/apps/social/timeline/tags/audio" + } + ], + "replies_count": 0, + "reblogs_count": 0, + "favourites_count": 0, + "favourited": false, + "reblogged": false, + "muted": false, + "bookmarked": false, + "pinned": false, + "card": null, + "reactions": [], + "poll": null, + "uri": "http://nextcloud.local/index.php/apps/social/@alice/17906371052336860333", + "url": "http://nextcloud.local/index.php/apps/social/@alice/17906371052336860333", + "reblog": null, + "media_attachments": [ + { + "id": "14", + "type": "audio", + "media_type": "audio/mpeg", + "url": "http://nextcloud.local/index.php/apps/social/media/2ee64fb3-dda9-47f5-bac6-daa189149093.mpeg", + "preview_url": "http://nextcloud.local/index.php/apps/social/media/2ee64fb3-dda9-47f5-bac6-daa189149093.mpeg", + "hls_url": null, + "remote_url": null, + "cache_error": null, + "meta": { + "original": [], + "small": [], + "focus": { + "x": 0, + "y": 0 + } + }, + "description": "A 15 second 330 Hz tone", + "blurhash": null + } + ], + "created_at": "2026-09-28T23:11:45.000Z", + "edited_at": null, + "noindex": false, + "account": { + "id": "6", + "nid": "6", + "username": "alice", + "acct": "alice", + "display_name": "alice", + "locked": false, + "bot": false, + "discoverable": false, + "indexable": false, + "group": false, + "created_at": "2026-09-28T23:06:02.000Z", + "note": "", + "url": "http://nextcloud.local/index.php/apps/social/@alice", + "avatar": "", + "avatar_static": "", + "header": "http://nextcloud.local/index.php/avatar/alice/128", + "header_static": "http://nextcloud.local/index.php/avatar/alice/128", + "followers_count": 2, + "following_count": 2, + "statuses_count": 13, + "last_status_at": null, + "emojis": [], + "fields": [], + "pronouns": "", + "support_link": "" + }, + "filtered": [] + }, + { + "id": "1790637104958214061", + "nid": "1790637104958214061", + "local": true, + "archived": false, + "view_count": null, + "tagged_people": [], + "interest": null, + "content": "

Test video #video

00:00 Intro
00:10 Middle
00:20 End

", + "sensitive": false, + "spoiler_text": "", + "visibility": "public", + "language": "en", + "place": null, + "in_reply_to_id": null, + "in_reply_to_account_id": null, + "quote": null, + "quote_approval": { + "automatic": [ + "public" + ], + "manual": [], + "current_user": "automatic" + }, + "reply_approval": null, + "interaction_policy": null, + "dislikes_count": 0, + "disliked": false, + "video": { + "title": "Aloha test video" + }, + "mentions": [], + "emojis": [], + "tags": [ + { + "name": "video", + "url": "http://nextcloud.local/index.php/apps/social/timeline/tags/video" + } + ], + "replies_count": 0, + "reblogs_count": 0, + "favourites_count": 1, + "favourited": false, + "reblogged": false, + "muted": false, + "bookmarked": false, + "pinned": false, + "card": null, + "reactions": [], + "poll": null, + "uri": "http://nextcloud.local/index.php/apps/social/@alice/17906371045255950881", + "url": "http://nextcloud.local/index.php/apps/social/@alice/17906371045255950881", + "reblog": null, + "media_attachments": [ + { + "id": "12", + "type": "video", + "media_type": "video/mp4", + "url": "http://nextcloud.local/index.php/apps/social/media/09c96321-538a-4d33-a3e3-e326ba1c5a56.mp4", + "preview_url": "http://nextcloud.local/index.php/apps/social/media/09c96321-538a-4d33-a3e3-e326ba1c5a56.mp4", + "hls_url": null, + "remote_url": null, + "cache_error": null, + "meta": { + "original": [], + "small": [], + "focus": { + "x": 0, + "y": 0 + } + }, + "description": "A 30 second test pattern with a tone", + "blurhash": null + } + ], + "created_at": "2026-09-28T23:11:44.000Z", + "edited_at": null, + "noindex": false, + "account": { + "id": "6", + "nid": "6", + "username": "alice", + "acct": "alice", + "display_name": "alice", + "locked": false, + "bot": false, + "discoverable": false, + "indexable": false, + "group": false, + "created_at": "2026-09-28T23:06:02.000Z", + "note": "", + "url": "http://nextcloud.local/index.php/apps/social/@alice", + "avatar": "", + "avatar_static": "", + "header": "http://nextcloud.local/index.php/avatar/alice/128", + "header_static": "http://nextcloud.local/index.php/avatar/alice/128", + "followers_count": 2, + "following_count": 2, + "statuses_count": 13, + "last_status_at": null, + "emojis": [], + "fields": [], + "pronouns": "", + "support_link": "" + }, + "filtered": [] + }, + { + "id": "1790637104870044831", + "nid": "1790637104870044831", + "local": true, + "archived": false, + "view_count": null, + "tagged_people": [], + "interest": null, + "content": "

Vertical test #shorts

", + "sensitive": false, + "spoiler_text": "", + "visibility": "public", + "language": "en", + "place": null, + "in_reply_to_id": null, + "in_reply_to_account_id": null, + "quote": null, + "quote_approval": { + "automatic": [ + "public" + ], + "manual": [], + "current_user": "automatic" + }, + "reply_approval": null, + "interaction_policy": null, + "dislikes_count": 0, + "disliked": false, + "video": null, + "mentions": [], + "emojis": [], + "tags": [ + { + "name": "shorts", + "url": "http://nextcloud.local/index.php/apps/social/timeline/tags/shorts" + } + ], + "replies_count": 0, + "reblogs_count": 0, + "favourites_count": 0, + "favourited": false, + "reblogged": false, + "muted": false, + "bookmarked": false, + "pinned": false, + "card": null, + "reactions": [], + "poll": null, + "uri": "http://nextcloud.local/index.php/apps/social/@alice/17906371044200157676", + "url": "http://nextcloud.local/index.php/apps/social/@alice/17906371044200157676", + "reblog": null, + "media_attachments": [ + { + "id": "13", + "type": "video", + "media_type": "video/mp4", + "url": "http://nextcloud.local/index.php/apps/social/media/9ca843a2-645e-439f-901c-3fd1d58c22b6.mp4", + "preview_url": "http://nextcloud.local/index.php/apps/social/media/9ca843a2-645e-439f-901c-3fd1d58c22b6.mp4", + "hls_url": null, + "remote_url": null, + "cache_error": null, + "meta": { + "original": [], + "small": [], + "focus": { + "x": 0, + "y": 0 + } + }, + "description": "A 12 second vertical test pattern", + "blurhash": null + } + ], + "created_at": "2026-09-28T23:11:44.000Z", + "edited_at": null, + "noindex": false, + "account": { + "id": "6", + "nid": "6", + "username": "alice", + "acct": "alice", + "display_name": "alice", + "locked": false, + "bot": false, + "discoverable": false, + "indexable": false, + "group": false, + "created_at": "2026-09-28T23:06:02.000Z", + "note": "", + "url": "http://nextcloud.local/index.php/apps/social/@alice", + "avatar": "", + "avatar_static": "", + "header": "http://nextcloud.local/index.php/avatar/alice/128", + "header_static": "http://nextcloud.local/index.php/avatar/alice/128", + "followers_count": 2, + "following_count": 2, + "statuses_count": 13, + "last_status_at": null, + "emojis": [], + "fields": [], + "pronouns": "", + "support_link": "" + }, + "filtered": [] + }, + { + "id": "1790637103267719986", + "nid": "1790637103267719986", + "local": true, + "archived": false, + "view_count": null, + "tagged_people": [], + "interest": null, + "content": "

A picture without alt text, marked sensitive

", + "sensitive": true, + "spoiler_text": "", + "visibility": "public", + "language": "en", + "place": null, + "in_reply_to_id": null, + "in_reply_to_account_id": null, + "quote": null, + "quote_approval": { + "automatic": [ + "public" + ], + "manual": [], + "current_user": "automatic" + }, + "reply_approval": null, + "interaction_policy": null, + "dislikes_count": null, + "disliked": null, + "video": null, + "mentions": [], + "emojis": [], + "tags": [], + "replies_count": 0, + "reblogs_count": 0, + "favourites_count": 0, + "favourited": false, + "reblogged": false, + "muted": false, + "bookmarked": false, + "pinned": false, + "card": null, + "reactions": [], + "poll": null, + "uri": "http://nextcloud.local/index.php/apps/social/@alice/17906371033997341160", + "url": "http://nextcloud.local/index.php/apps/social/@alice/17906371033997341160", + "reblog": null, + "media_attachments": [ + { + "id": "11", + "type": "image", + "media_type": "image/png", + "url": "http://nextcloud.local/index.php/apps/social/media/347a8309-ea20-4900-8e4c-aff1e8b6a98b.png", + "preview_url": "http://nextcloud.local/index.php/apps/social/media/6bb97fab-b644-4c51-8b1b-8ea5921fdef0.png", + "hls_url": null, + "remote_url": null, + "cache_error": null, + "meta": { + "original": { + "width": 1080, + "height": 1080, + "size": "1080x1080", + "aspect": 1 + }, + "small": { + "width": 800, + "height": 800, + "size": "800x800", + "aspect": 1 + }, + "focus": { + "x": 0, + "y": 0 + } + }, + "description": null, + "blurhash": "LRF~:K*DD7L+S^o@$T+kR5wiM_IU" + } + ], + "created_at": "2026-09-28T23:11:43.000Z", + "edited_at": null, + "noindex": false, + "account": { + "id": "6", + "nid": "6", + "username": "alice", + "acct": "alice", + "display_name": "alice", + "locked": false, + "bot": false, + "discoverable": false, + "indexable": false, + "group": false, + "created_at": "2026-09-28T23:06:02.000Z", + "note": "", + "url": "http://nextcloud.local/index.php/apps/social/@alice", + "avatar": "", + "avatar_static": "", + "header": "http://nextcloud.local/index.php/avatar/alice/128", + "header_static": "http://nextcloud.local/index.php/avatar/alice/128", + "followers_count": 2, + "following_count": 2, + "statuses_count": 13, + "last_status_at": null, + "emojis": [], + "fields": [], + "pronouns": "", + "support_link": "" + }, + "filtered": [] + }, + { + "id": "1790637096330770789", + "nid": "1790637096330770789", + "local": true, + "archived": false, + "view_count": null, + "tagged_people": [], + "interest": null, + "content": "

Two test pictures #photography

", + "sensitive": false, + "spoiler_text": "", + "visibility": "public", + "language": "en", + "place": null, + "in_reply_to_id": null, + "in_reply_to_account_id": null, + "quote": null, + "quote_approval": { + "automatic": [ + "public" + ], + "manual": [], + "current_user": "automatic" + }, + "reply_approval": null, + "interaction_policy": null, + "dislikes_count": null, + "disliked": null, + "video": null, + "mentions": [], + "emojis": [], + "tags": [ + { + "name": "photography", + "url": "http://nextcloud.local/index.php/apps/social/timeline/tags/photography" + } + ], + "replies_count": 0, + "reblogs_count": 1, + "favourites_count": 0, + "favourited": false, + "reblogged": false, + "muted": false, + "bookmarked": false, + "pinned": false, + "card": null, + "reactions": [], + "poll": null, + "uri": "http://nextcloud.local/index.php/apps/social/@alice/17906370966999687509", + "url": "http://nextcloud.local/index.php/apps/social/@alice/17906370966999687509", + "reblog": null, + "media_attachments": [ + { + "id": "9", + "type": "image", + "media_type": "image/jpeg", + "url": "http://nextcloud.local/index.php/apps/social/media/48044adf-b771-4343-9607-c7fc93f16ad1.jpeg", + "preview_url": "http://nextcloud.local/index.php/apps/social/media/2c66bfd7-0029-47e1-b6ef-23dc7d422e2b.jpeg", + "hls_url": null, + "remote_url": null, + "cache_error": null, + "meta": { + "original": { + "width": 1600, + "height": 1200, + "size": "1600x1200", + "aspect": 1.3333333333333333 + }, + "small": { + "width": 800, + "height": 600, + "size": "800x600", + "aspect": 1.3333333333333333 + }, + "focus": { + "x": 0.3, + "y": -0.2 + } + }, + "description": "A test card pattern in bright colours", + "blurhash": "LVL4]9|Tnk_gsESdfjn,baayW;jZ" + }, + { + "id": "10", + "type": "image", + "media_type": "image/jpeg", + "url": "http://nextcloud.local/index.php/apps/social/media/1e936701-3c1a-49c2-a58a-bc7b0d5a9c27.jpeg", + "preview_url": "http://nextcloud.local/index.php/apps/social/media/43d878b8-d9fd-42fa-9f7e-38dea1357b60.jpeg", + "hls_url": null, + "remote_url": null, + "cache_error": null, + "meta": { + "original": { + "width": 1080, + "height": 1350, + "size": "1080x1350", + "aspect": 0.8 + }, + "small": { + "width": 640, + "height": 800, + "size": "640x800", + "aspect": 0.8 + }, + "focus": { + "x": 0, + "y": 0 + } + }, + "description": "A Mandelbrot fractal, portrait", + "blurhash": "LyLow4+,$SN?w6S]WUjI[ibCSIbW" + } + ], + "created_at": "2026-09-28T23:11:36.000Z", + "edited_at": null, + "noindex": false, + "account": { + "id": "6", + "nid": "6", + "username": "alice", + "acct": "alice", + "display_name": "alice", + "locked": false, + "bot": false, + "discoverable": false, + "indexable": false, + "group": false, + "created_at": "2026-09-28T23:06:02.000Z", + "note": "", + "url": "http://nextcloud.local/index.php/apps/social/@alice", + "avatar": "", + "avatar_static": "", + "header": "http://nextcloud.local/index.php/avatar/alice/128", + "header_static": "http://nextcloud.local/index.php/avatar/alice/128", + "followers_count": 2, + "following_count": 2, + "statuses_count": 13, + "last_status_at": null, + "emojis": [], + "fields": [], + "pronouns": "", + "support_link": "" + }, + "filtered": [] + }, + { + "id": "1790637087495743511", + "nid": "1790637087495743511", + "local": true, + "archived": false, + "view_count": null, + "tagged_people": [], + "interest": null, + "content": "

Which mode do you use most?

", + "sensitive": false, + "spoiler_text": "", + "visibility": "public", + "language": "en", + "place": null, + "in_reply_to_id": null, + "in_reply_to_account_id": null, + "quote": null, + "quote_approval": { + "automatic": [ + "public" + ], + "manual": [], + "current_user": "automatic" + }, + "reply_approval": null, + "interaction_policy": null, + "dislikes_count": null, + "disliked": null, + "video": null, + "mentions": [], + "emojis": [], + "tags": [], + "replies_count": 0, + "reblogs_count": 0, + "favourites_count": 0, + "favourited": false, + "reblogged": false, + "muted": false, + "bookmarked": false, + "pinned": false, + "card": null, + "reactions": [], + "poll": { + "id": "1790637087495743511", + "expires_at": "2026-09-29T23:11:27Z", + "expired": false, + "multiple": false, + "votes_count": 1, + "voters_count": 1, + "options": [ + { + "title": "Photos", + "votes_count": 1 + }, + { + "title": "Video", + "votes_count": 0 + }, + { + "title": "Shorts", + "votes_count": 0 + } + ], + "emojis": [], + "voted": false, + "own_votes": [] + }, + "uri": "http://nextcloud.local/index.php/apps/social/@alice/17906370875255636478", + "url": "http://nextcloud.local/index.php/apps/social/@alice/17906370875255636478", + "reblog": null, + "media_attachments": [], + "created_at": "2026-09-28T23:11:27.000Z", + "edited_at": null, + "noindex": false, + "account": { + "id": "6", + "nid": "6", + "username": "alice", + "acct": "alice", + "display_name": "alice", + "locked": false, + "bot": false, + "discoverable": false, + "indexable": false, + "group": false, + "created_at": "2026-09-28T23:06:02.000Z", + "note": "", + "url": "http://nextcloud.local/index.php/apps/social/@alice", + "avatar": "", + "avatar_static": "", + "header": "http://nextcloud.local/index.php/avatar/alice/128", + "header_static": "http://nextcloud.local/index.php/avatar/alice/128", + "followers_count": 2, + "following_count": 2, + "statuses_count": 13, + "last_status_at": null, + "emojis": [], + "fields": [], + "pronouns": "", + "support_link": "" + }, + "oneOf": [ + { + "type": "Note", + "name": "Photos", + "replies": { + "type": "Collection", + "totalItems": 1 + } + }, + { + "type": "Note", + "name": "Video", + "replies": { + "type": "Collection", + "totalItems": 0 + } + }, + { + "type": "Note", + "name": "Shorts", + "replies": { + "type": "Collection", + "totalItems": 0 + } + } + ], + "endTime": "2026-09-29T23:11:27Z", + "votersCount": 1, + "filtered": [] + }, + { + "id": "1790637086680883443", + "nid": "1790637086680883443", + "local": true, + "archived": false, + "view_count": null, + "tagged_people": [], + "interest": null, + "content": "

An unlisted note for the timeline tests.

", + "sensitive": false, + "spoiler_text": "", + "visibility": "unlisted", + "language": "en", + "place": null, + "in_reply_to_id": null, + "in_reply_to_account_id": null, + "quote": null, + "quote_approval": { + "automatic": [ + "public" + ], + "manual": [], + "current_user": "automatic" + }, + "reply_approval": null, + "interaction_policy": null, + "dislikes_count": null, + "disliked": null, + "video": null, + "mentions": [], + "emojis": [], + "tags": [], + "replies_count": 0, + "reblogs_count": 0, + "favourites_count": 0, + "favourited": false, + "reblogged": false, + "muted": false, + "bookmarked": false, + "pinned": false, + "card": null, + "reactions": [], + "poll": null, + "uri": "http://nextcloud.local/index.php/apps/social/@alice/17906370862830940734", + "url": "http://nextcloud.local/index.php/apps/social/@alice/17906370862830940734", + "reblog": null, + "media_attachments": [], + "created_at": "2026-09-28T23:11:26.000Z", + "edited_at": null, + "noindex": false, + "account": { + "id": "6", + "nid": "6", + "username": "alice", + "acct": "alice", + "display_name": "alice", + "locked": false, + "bot": false, + "discoverable": false, + "indexable": false, + "group": false, + "created_at": "2026-09-28T23:06:02.000Z", + "note": "", + "url": "http://nextcloud.local/index.php/apps/social/@alice", + "avatar": "", + "avatar_static": "", + "header": "http://nextcloud.local/index.php/avatar/alice/128", + "header_static": "http://nextcloud.local/index.php/avatar/alice/128", + "followers_count": 2, + "following_count": 2, + "statuses_count": 13, + "last_status_at": null, + "emojis": [], + "fields": [], + "pronouns": "", + "support_link": "" + }, + "filtered": [] + }, + { + "id": "1790637086257046711", + "nid": "1790637086257046711", + "local": true, + "archived": false, + "view_count": null, + "tagged_people": [], + "interest": null, + "content": "

Only my followers see this.

", + "sensitive": false, + "spoiler_text": "", + "visibility": "private", + "language": "en", + "place": null, + "in_reply_to_id": null, + "in_reply_to_account_id": null, + "quote": null, + "quote_approval": { + "automatic": [], + "manual": [], + "current_user": "automatic" + }, + "reply_approval": null, + "interaction_policy": null, + "dislikes_count": null, + "disliked": null, + "video": null, + "mentions": [], + "emojis": [], + "tags": [], + "replies_count": 0, + "reblogs_count": 0, + "favourites_count": 0, + "favourited": false, + "reblogged": false, + "muted": false, + "bookmarked": false, + "pinned": false, + "card": null, + "reactions": [], + "poll": null, + "uri": "http://nextcloud.local/index.php/apps/social/@alice/17906370861140078716", + "url": "http://nextcloud.local/index.php/apps/social/@alice/17906370861140078716", + "reblog": null, + "media_attachments": [], + "created_at": "2026-09-28T23:11:26.000Z", + "edited_at": null, + "noindex": false, + "account": { + "id": "6", + "nid": "6", + "username": "alice", + "acct": "alice", + "display_name": "alice", + "locked": false, + "bot": false, + "discoverable": false, + "indexable": false, + "group": false, + "created_at": "2026-09-28T23:06:02.000Z", + "note": "", + "url": "http://nextcloud.local/index.php/apps/social/@alice", + "avatar": "", + "avatar_static": "", + "header": "http://nextcloud.local/index.php/avatar/alice/128", + "header_static": "http://nextcloud.local/index.php/avatar/alice/128", + "followers_count": 2, + "following_count": 2, + "statuses_count": 13, + "last_status_at": null, + "emojis": [], + "fields": [], + "pronouns": "", + "support_link": "" + }, + "filtered": [] + }, + { + "id": "1790637086177868305", + "nid": "1790637086177868305", + "local": true, + "archived": false, + "view_count": null, + "tagged_people": [], + "interest": null, + "content": "

The butler did it.

", + "sensitive": true, + "spoiler_text": "Spoiler: season finale", + "visibility": "public", + "language": "en", + "place": null, + "in_reply_to_id": null, + "in_reply_to_account_id": null, + "quote": null, + "quote_approval": { + "automatic": [ + "public" + ], + "manual": [], + "current_user": "automatic" + }, + "reply_approval": null, + "interaction_policy": null, + "dislikes_count": null, + "disliked": null, + "video": null, + "mentions": [], + "emojis": [], + "tags": [], + "replies_count": 0, + "reblogs_count": 0, + "favourites_count": 0, + "favourited": false, + "reblogged": false, + "muted": false, + "bookmarked": false, + "pinned": false, + "card": null, + "reactions": [], + "poll": null, + "uri": "http://nextcloud.local/index.php/apps/social/@alice/17906370868446468411", + "url": "http://nextcloud.local/index.php/apps/social/@alice/17906370868446468411", + "reblog": null, + "media_attachments": [], + "created_at": "2026-09-28T23:11:26.000Z", + "edited_at": null, + "noindex": false, + "account": { + "id": "6", + "nid": "6", + "username": "alice", + "acct": "alice", + "display_name": "alice", + "locked": false, + "bot": false, + "discoverable": false, + "indexable": false, + "group": false, + "created_at": "2026-09-28T23:06:02.000Z", + "note": "", + "url": "http://nextcloud.local/index.php/apps/social/@alice", + "avatar": "", + "avatar_static": "", + "header": "http://nextcloud.local/index.php/avatar/alice/128", + "header_static": "http://nextcloud.local/index.php/avatar/alice/128", + "followers_count": 2, + "following_count": 2, + "statuses_count": 13, + "last_status_at": null, + "emojis": [], + "fields": [], + "pronouns": "", + "support_link": "" + }, + "filtered": [] + }, + { + "id": "1790637085797595891", + "nid": "1790637085797595891", + "local": true, + "archived": false, + "view_count": null, + "tagged_people": [], + "interest": null, + "content": "

Aloha from the dev instance! Say hi, @bob@nextcloud.local #aloha #android

", + "sensitive": false, + "spoiler_text": "", + "visibility": "public", + "language": "en", + "place": null, + "in_reply_to_id": null, + "in_reply_to_account_id": null, + "quote": null, + "quote_approval": { + "automatic": [ + "public" + ], + "manual": [], + "current_user": "automatic" + }, + "reply_approval": null, + "interaction_policy": null, + "dislikes_count": null, + "disliked": null, + "video": null, + "mentions": [], + "emojis": [], + "tags": [ + { + "name": "aloha", + "url": "http://nextcloud.local/index.php/apps/social/timeline/tags/aloha" + }, + { + "name": "android", + "url": "http://nextcloud.local/index.php/apps/social/timeline/tags/android" + } + ], + "replies_count": 1, + "reblogs_count": 0, + "favourites_count": 1, + "favourited": false, + "reblogged": false, + "muted": false, + "bookmarked": false, + "pinned": false, + "card": null, + "reactions": [ + { + "name": "🎉", + "count": 1, + "me": false + } + ], + "poll": null, + "uri": "http://nextcloud.local/index.php/apps/social/@alice/17906370853806247191", + "url": "http://nextcloud.local/index.php/apps/social/@alice/17906370853806247191", + "reblog": null, + "media_attachments": [], + "created_at": "2026-09-28T23:11:25.000Z", + "edited_at": null, + "noindex": false, + "account": { + "id": "6", + "nid": "6", + "username": "alice", + "acct": "alice", + "display_name": "alice", + "locked": false, + "bot": false, + "discoverable": false, + "indexable": false, + "group": false, + "created_at": "2026-09-28T23:06:02.000Z", + "note": "", + "url": "http://nextcloud.local/index.php/apps/social/@alice", + "avatar": "", + "avatar_static": "", + "header": "http://nextcloud.local/index.php/avatar/alice/128", + "header_static": "http://nextcloud.local/index.php/avatar/alice/128", + "followers_count": 2, + "following_count": 2, + "statuses_count": 13, + "last_status_at": null, + "emojis": [], + "fields": [], + "pronouns": "", + "support_link": "" + }, + "filtered": [] + } + ] +} diff --git a/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/timeline-interests.json b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/timeline-interests.json new file mode 100644 index 00000000..81982bb4 --- /dev/null +++ b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/timeline-interests.json @@ -0,0 +1,15 @@ +{ + "request": { + "method": "GET", + "path": "/index.php/apps/social/api/v1/timelines/interests?limit=10" + }, + "status": 200, + "headers": { + "content-type": "application/json; charset=utf-8", + "cache-control": "no-cache, no-store, must-revalidate", + "x-ratelimit-limit": "300", + "x-ratelimit-remaining": "299", + "x-ratelimit-reset": "1790637300" + }, + "body": [] +} diff --git a/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/timeline-list-jane.json b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/timeline-list-jane.json new file mode 100644 index 00000000..bda590fe --- /dev/null +++ b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/timeline-list-jane.json @@ -0,0 +1,15 @@ +{ + "request": { + "method": "GET", + "path": "/index.php/apps/social/api/v1/timelines/list/1?limit=20" + }, + "status": 200, + "headers": { + "content-type": "application/json; charset=utf-8", + "cache-control": "no-cache, no-store, must-revalidate", + "x-ratelimit-limit": "300", + "x-ratelimit-remaining": "252", + "x-ratelimit-reset": "1790637300" + }, + "body": [] +} diff --git a/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/timeline-public-local.json b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/timeline-public-local.json new file mode 100644 index 00000000..921fa193 --- /dev/null +++ b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/timeline-public-local.json @@ -0,0 +1,1577 @@ +{ + "request": { + "method": "GET", + "path": "/index.php/apps/social/api/v1/timelines/public?local=true&limit=20" + }, + "status": 200, + "headers": { + "content-type": "application/json; charset=utf-8", + "cache-control": "no-cache, no-store, must-revalidate", + "link": "; rel=\"prev\"", + "x-ratelimit-limit": "60", + "x-ratelimit-remaining": "56", + "x-ratelimit-reset": "1790637300" + }, + "body": [ + { + "id": "1790637117344364158", + "nid": "1790637117344364158", + "local": true, + "archived": false, + "view_count": null, + "tagged_people": [], + "interest": null, + "content": "

Bob's own post #aloha

", + "sensitive": false, + "spoiler_text": "", + "visibility": "public", + "language": "en", + "place": null, + "in_reply_to_id": null, + "in_reply_to_account_id": null, + "quote": null, + "quote_approval": { + "automatic": [ + "public" + ], + "manual": [], + "current_user": "automatic" + }, + "reply_approval": null, + "interaction_policy": null, + "dislikes_count": null, + "disliked": null, + "video": null, + "mentions": [], + "emojis": [], + "tags": [ + { + "name": "aloha", + "url": "http://nextcloud.local/index.php/apps/social/timeline/tags/aloha" + } + ], + "replies_count": 0, + "reblogs_count": 0, + "favourites_count": 0, + "favourited": false, + "reblogged": false, + "muted": false, + "bookmarked": false, + "pinned": false, + "card": null, + "reactions": [], + "poll": null, + "uri": "http://nextcloud.local/index.php/apps/social/@bob/17906371177254273993", + "url": "http://nextcloud.local/index.php/apps/social/@bob/17906371177254273993", + "reblog": null, + "media_attachments": [], + "created_at": "2026-09-28T23:11:57.000Z", + "edited_at": null, + "noindex": false, + "account": { + "id": "7", + "nid": "7", + "username": "bob", + "acct": "bob", + "display_name": "bob", + "locked": false, + "bot": false, + "discoverable": false, + "indexable": false, + "group": false, + "created_at": "2026-09-28T23:06:03.000Z", + "note": "", + "url": "http://nextcloud.local/index.php/apps/social/@bob", + "avatar": "", + "avatar_static": "", + "header": "http://nextcloud.local/index.php/avatar/bob/128", + "header_static": "http://nextcloud.local/index.php/avatar/bob/128", + "followers_count": 1, + "following_count": 2, + "statuses_count": 3, + "last_status_at": null, + "emojis": [], + "fields": [], + "pronouns": "", + "support_link": "" + }, + "filtered": [] + }, + { + "id": "1790637116374990863", + "nid": "1790637116374990863", + "local": true, + "archived": false, + "view_count": null, + "tagged_people": [], + "interest": null, + "content": "

Quoting this one

", + "sensitive": false, + "spoiler_text": "", + "visibility": "public", + "language": "en", + "place": null, + "in_reply_to_id": null, + "in_reply_to_account_id": null, + "quote": { + "state": "accepted", + "quoted_status": { + "id": "1790637085797595891", + "nid": "1790637085797595891", + "local": true, + "archived": false, + "view_count": null, + "tagged_people": [], + "interest": null, + "content": "

Aloha from the dev instance! Say hi, @bob@nextcloud.local #aloha #android

", + "sensitive": false, + "spoiler_text": "", + "visibility": "public", + "language": "en", + "place": null, + "in_reply_to_id": null, + "in_reply_to_account_id": null, + "quote": null, + "quote_approval": { + "automatic": [ + "public" + ], + "manual": [], + "current_user": "automatic" + }, + "reply_approval": null, + "interaction_policy": null, + "dislikes_count": null, + "disliked": null, + "video": null, + "mentions": [], + "emojis": [], + "tags": [ + { + "name": "aloha", + "url": "http://nextcloud.local/index.php/apps/social/timeline/tags/aloha" + }, + { + "name": "android", + "url": "http://nextcloud.local/index.php/apps/social/timeline/tags/android" + } + ], + "replies_count": 1, + "reblogs_count": 0, + "favourites_count": 1, + "favourited": false, + "reblogged": false, + "muted": false, + "bookmarked": false, + "pinned": false, + "card": null, + "reactions": [], + "poll": null, + "uri": "http://nextcloud.local/index.php/apps/social/@alice/17906370853806247191", + "url": "http://nextcloud.local/index.php/apps/social/@alice/17906370853806247191", + "reblog": null, + "media_attachments": [], + "created_at": "2026-09-28T23:11:25.000Z", + "edited_at": null, + "noindex": false, + "account": { + "id": "6", + "nid": "6", + "username": "alice", + "acct": "alice", + "display_name": "alice", + "locked": false, + "bot": false, + "discoverable": false, + "indexable": false, + "group": false, + "created_at": "2026-09-28T23:06:02.000Z", + "note": "", + "url": "http://nextcloud.local/index.php/apps/social/@alice", + "avatar": "", + "avatar_static": "", + "header": "http://nextcloud.local/index.php/avatar/alice/128", + "header_static": "http://nextcloud.local/index.php/avatar/alice/128", + "followers_count": 2, + "following_count": 2, + "statuses_count": 13, + "last_status_at": null, + "emojis": [], + "fields": [], + "pronouns": "", + "support_link": "" + } + } + }, + "quote_approval": { + "automatic": [ + "public" + ], + "manual": [], + "current_user": "automatic" + }, + "reply_approval": null, + "interaction_policy": null, + "dislikes_count": null, + "disliked": null, + "video": null, + "mentions": [], + "emojis": [], + "tags": [], + "replies_count": 0, + "reblogs_count": 0, + "favourites_count": 0, + "favourited": false, + "reblogged": false, + "muted": false, + "bookmarked": false, + "pinned": false, + "card": null, + "reactions": [], + "poll": null, + "uri": "http://nextcloud.local/index.php/apps/social/@bob/17906371165948083557", + "url": "http://nextcloud.local/index.php/apps/social/@bob/17906371165948083557", + "reblog": null, + "media_attachments": [], + "created_at": "2026-09-28T23:11:56.000Z", + "edited_at": null, + "noindex": false, + "account": { + "id": "7", + "nid": "7", + "username": "bob", + "acct": "bob", + "display_name": "bob", + "locked": false, + "bot": false, + "discoverable": false, + "indexable": false, + "group": false, + "created_at": "2026-09-28T23:06:03.000Z", + "note": "", + "url": "http://nextcloud.local/index.php/apps/social/@bob", + "avatar": "", + "avatar_static": "", + "header": "http://nextcloud.local/index.php/avatar/bob/128", + "header_static": "http://nextcloud.local/index.php/avatar/bob/128", + "followers_count": 1, + "following_count": 2, + "statuses_count": 3, + "last_status_at": null, + "emojis": [], + "fields": [], + "pronouns": "", + "support_link": "" + }, + "filtered": [] + }, + { + "id": "1790637115422878628", + "nid": "1790637115422878628", + "local": true, + "archived": false, + "view_count": null, + "tagged_people": [], + "interest": null, + "content": "

@bob@nextcloud.local and back again

", + "sensitive": false, + "spoiler_text": "", + "visibility": "public", + "language": "en", + "place": null, + "in_reply_to_id": "1790637115351395147", + "in_reply_to_account_id": "7", + "quote": null, + "quote_approval": { + "automatic": [ + "public" + ], + "manual": [], + "current_user": "automatic" + }, + "reply_approval": null, + "interaction_policy": null, + "dislikes_count": null, + "disliked": null, + "video": null, + "mentions": [], + "emojis": [], + "tags": [], + "replies_count": 0, + "reblogs_count": 0, + "favourites_count": 0, + "favourited": false, + "reblogged": false, + "muted": false, + "bookmarked": false, + "pinned": false, + "card": null, + "reactions": [], + "poll": null, + "uri": "http://nextcloud.local/index.php/apps/social/@alice/17906371159153077137", + "url": "http://nextcloud.local/index.php/apps/social/@alice/17906371159153077137", + "reblog": null, + "media_attachments": [], + "created_at": "2026-09-28T23:11:55.000Z", + "edited_at": null, + "noindex": false, + "account": { + "id": "6", + "nid": "6", + "username": "alice", + "acct": "alice", + "display_name": "alice", + "locked": false, + "bot": false, + "discoverable": false, + "indexable": false, + "group": false, + "created_at": "2026-09-28T23:06:02.000Z", + "note": "", + "url": "http://nextcloud.local/index.php/apps/social/@alice", + "avatar": "", + "avatar_static": "", + "header": "http://nextcloud.local/index.php/avatar/alice/128", + "header_static": "http://nextcloud.local/index.php/avatar/alice/128", + "followers_count": 2, + "following_count": 2, + "statuses_count": 13, + "last_status_at": null, + "emojis": [], + "fields": [], + "pronouns": "", + "support_link": "" + }, + "filtered": [] + }, + { + "id": "1790637115351395147", + "nid": "1790637115351395147", + "local": true, + "archived": false, + "view_count": null, + "tagged_people": [], + "interest": null, + "content": "

@alice@nextcloud.local hi back!

", + "sensitive": false, + "spoiler_text": "", + "visibility": "public", + "language": "en", + "place": null, + "in_reply_to_id": "1790637085797595891", + "in_reply_to_account_id": "6", + "quote": null, + "quote_approval": { + "automatic": [ + "public" + ], + "manual": [], + "current_user": "automatic" + }, + "reply_approval": null, + "interaction_policy": null, + "dislikes_count": null, + "disliked": null, + "video": null, + "mentions": [], + "emojis": [], + "tags": [], + "replies_count": 1, + "reblogs_count": 0, + "favourites_count": 0, + "favourited": false, + "reblogged": false, + "muted": false, + "bookmarked": false, + "pinned": false, + "card": null, + "reactions": [], + "poll": null, + "uri": "http://nextcloud.local/index.php/apps/social/@bob/17906371156396819743", + "url": "http://nextcloud.local/index.php/apps/social/@bob/17906371156396819743", + "reblog": null, + "media_attachments": [], + "created_at": "2026-09-28T23:11:55.000Z", + "edited_at": null, + "noindex": false, + "account": { + "id": "7", + "nid": "7", + "username": "bob", + "acct": "bob", + "display_name": "bob", + "locked": false, + "bot": false, + "discoverable": false, + "indexable": false, + "group": false, + "created_at": "2026-09-28T23:06:03.000Z", + "note": "", + "url": "http://nextcloud.local/index.php/apps/social/@bob", + "avatar": "", + "avatar_static": "", + "header": "http://nextcloud.local/index.php/avatar/bob/128", + "header_static": "http://nextcloud.local/index.php/avatar/bob/128", + "followers_count": 1, + "following_count": 2, + "statuses_count": 3, + "last_status_at": null, + "emojis": [], + "fields": [], + "pronouns": "", + "support_link": "" + }, + "filtered": [] + }, + { + "id": "1790637112216295042", + "nid": "1790637112216295042", + "local": true, + "archived": false, + "view_count": null, + "tagged_people": [], + "interest": null, + "content": "

Picture from Berlin

", + "sensitive": false, + "spoiler_text": "", + "visibility": "public", + "language": "en", + "place": { + "id": "1", + "name": "Berlin", + "country": "DE", + "lat": "", + "long": "" + }, + "in_reply_to_id": null, + "in_reply_to_account_id": null, + "quote": null, + "quote_approval": { + "automatic": [ + "public" + ], + "manual": [], + "current_user": "automatic" + }, + "reply_approval": null, + "interaction_policy": null, + "dislikes_count": null, + "disliked": null, + "video": null, + "mentions": [], + "emojis": [], + "tags": [], + "replies_count": 0, + "reblogs_count": 0, + "favourites_count": 0, + "favourited": false, + "reblogged": false, + "muted": false, + "bookmarked": false, + "pinned": false, + "card": null, + "reactions": [], + "poll": null, + "uri": "http://nextcloud.local/index.php/apps/social/@alice/17906371127479253498", + "url": "http://nextcloud.local/index.php/apps/social/@alice/17906371127479253498", + "reblog": null, + "media_attachments": [ + { + "id": "15", + "type": "image", + "media_type": "image/jpeg", + "url": "http://nextcloud.local/index.php/apps/social/media/b88735cc-b169-40e0-9fa5-85e3be451c1a.jpeg", + "preview_url": "http://nextcloud.local/index.php/apps/social/media/e46d0547-3b30-46cd-8b5b-394bb7005a49.jpeg", + "hls_url": null, + "remote_url": null, + "cache_error": null, + "meta": { + "original": { + "width": 1080, + "height": 1350, + "size": "1080x1350", + "aspect": 0.8 + }, + "small": { + "width": 640, + "height": 800, + "size": "640x800", + "aspect": 0.8 + }, + "focus": { + "x": 0, + "y": 0 + } + }, + "description": "Another fractal, taken in Berlin", + "blurhash": "LyLow4+,$SN?w6S]WUjI[ibCSIbW" + } + ], + "created_at": "2026-09-28T23:11:52.000Z", + "edited_at": null, + "noindex": false, + "account": { + "id": "6", + "nid": "6", + "username": "alice", + "acct": "alice", + "display_name": "alice", + "locked": false, + "bot": false, + "discoverable": false, + "indexable": false, + "group": false, + "created_at": "2026-09-28T23:06:02.000Z", + "note": "", + "url": "http://nextcloud.local/index.php/apps/social/@alice", + "avatar": "", + "avatar_static": "", + "header": "http://nextcloud.local/index.php/avatar/alice/128", + "header_static": "http://nextcloud.local/index.php/avatar/alice/128", + "followers_count": 2, + "following_count": 2, + "statuses_count": 13, + "last_status_at": null, + "emojis": [], + "fields": [], + "pronouns": "", + "support_link": "" + }, + "filtered": [] + }, + { + "id": "1790637112034926731", + "nid": "1790637112034926731", + "local": true, + "archived": false, + "view_count": null, + "tagged_people": [], + "interest": null, + "content": "

First draft, edited

", + "sensitive": false, + "spoiler_text": "", + "visibility": "public", + "language": "en", + "place": null, + "in_reply_to_id": null, + "in_reply_to_account_id": null, + "quote": null, + "quote_approval": { + "automatic": [ + "public" + ], + "manual": [], + "current_user": "automatic" + }, + "reply_approval": null, + "interaction_policy": null, + "dislikes_count": null, + "disliked": null, + "video": null, + "mentions": [], + "emojis": [], + "tags": [], + "replies_count": 0, + "reblogs_count": 0, + "favourites_count": 0, + "favourited": false, + "reblogged": false, + "muted": false, + "bookmarked": false, + "pinned": false, + "card": null, + "reactions": [], + "poll": null, + "uri": "http://nextcloud.local/index.php/apps/social/@alice/17906371127199851091", + "url": "http://nextcloud.local/index.php/apps/social/@alice/17906371127199851091", + "reblog": null, + "media_attachments": [], + "created_at": "2026-09-28T23:11:52.000Z", + "edited_at": "2026-09-28T23:11:52.000Z", + "noindex": false, + "account": { + "id": "6", + "nid": "6", + "username": "alice", + "acct": "alice", + "display_name": "alice", + "locked": false, + "bot": false, + "discoverable": false, + "indexable": false, + "group": false, + "created_at": "2026-09-28T23:06:02.000Z", + "note": "", + "url": "http://nextcloud.local/index.php/apps/social/@alice", + "avatar": "", + "avatar_static": "", + "header": "http://nextcloud.local/index.php/avatar/alice/128", + "header_static": "http://nextcloud.local/index.php/avatar/alice/128", + "followers_count": 2, + "following_count": 2, + "statuses_count": 13, + "last_status_at": null, + "emojis": [], + "fields": [], + "pronouns": "", + "support_link": "" + }, + "filtered": [] + }, + { + "id": "1790637105561356153", + "nid": "1790637105561356153", + "local": true, + "archived": false, + "view_count": null, + "tagged_people": [], + "interest": null, + "content": "

Nextcloud Social on GitHub: https://github.com/nextcloud/social

", + "sensitive": false, + "spoiler_text": "", + "visibility": "public", + "language": "en", + "place": null, + "in_reply_to_id": null, + "in_reply_to_account_id": null, + "quote": null, + "quote_approval": { + "automatic": [ + "public" + ], + "manual": [], + "current_user": "automatic" + }, + "reply_approval": null, + "interaction_policy": null, + "dislikes_count": null, + "disliked": null, + "video": null, + "mentions": [], + "emojis": [], + "tags": [], + "replies_count": 0, + "reblogs_count": 0, + "favourites_count": 0, + "favourited": false, + "reblogged": false, + "muted": false, + "bookmarked": false, + "pinned": false, + "card": null, + "reactions": [], + "poll": null, + "uri": "http://nextcloud.local/index.php/apps/social/@alice/17906371057334696925", + "url": "http://nextcloud.local/index.php/apps/social/@alice/17906371057334696925", + "reblog": null, + "media_attachments": [], + "created_at": "2026-09-28T23:11:45.000Z", + "edited_at": null, + "noindex": false, + "account": { + "id": "6", + "nid": "6", + "username": "alice", + "acct": "alice", + "display_name": "alice", + "locked": false, + "bot": false, + "discoverable": false, + "indexable": false, + "group": false, + "created_at": "2026-09-28T23:06:02.000Z", + "note": "", + "url": "http://nextcloud.local/index.php/apps/social/@alice", + "avatar": "", + "avatar_static": "", + "header": "http://nextcloud.local/index.php/avatar/alice/128", + "header_static": "http://nextcloud.local/index.php/avatar/alice/128", + "followers_count": 2, + "following_count": 2, + "statuses_count": 13, + "last_status_at": null, + "emojis": [], + "fields": [], + "pronouns": "", + "support_link": "" + }, + "filtered": [] + }, + { + "id": "1790637105549523409", + "nid": "1790637105549523409", + "local": true, + "archived": false, + "view_count": null, + "tagged_people": [], + "interest": null, + "content": "

A 15 second tone #audio

", + "sensitive": false, + "spoiler_text": "", + "visibility": "public", + "language": "en", + "place": null, + "in_reply_to_id": null, + "in_reply_to_account_id": null, + "quote": null, + "quote_approval": { + "automatic": [ + "public" + ], + "manual": [], + "current_user": "automatic" + }, + "reply_approval": null, + "interaction_policy": null, + "dislikes_count": null, + "disliked": null, + "video": null, + "mentions": [], + "emojis": [], + "tags": [ + { + "name": "audio", + "url": "http://nextcloud.local/index.php/apps/social/timeline/tags/audio" + } + ], + "replies_count": 0, + "reblogs_count": 0, + "favourites_count": 0, + "favourited": false, + "reblogged": false, + "muted": false, + "bookmarked": false, + "pinned": false, + "card": null, + "reactions": [], + "poll": null, + "uri": "http://nextcloud.local/index.php/apps/social/@alice/17906371052336860333", + "url": "http://nextcloud.local/index.php/apps/social/@alice/17906371052336860333", + "reblog": null, + "media_attachments": [ + { + "id": "14", + "type": "audio", + "media_type": "audio/mpeg", + "url": "http://nextcloud.local/index.php/apps/social/media/2ee64fb3-dda9-47f5-bac6-daa189149093.mpeg", + "preview_url": "http://nextcloud.local/index.php/apps/social/media/2ee64fb3-dda9-47f5-bac6-daa189149093.mpeg", + "hls_url": null, + "remote_url": null, + "cache_error": null, + "meta": { + "original": [], + "small": [], + "focus": { + "x": 0, + "y": 0 + } + }, + "description": "A 15 second 330 Hz tone", + "blurhash": null + } + ], + "created_at": "2026-09-28T23:11:45.000Z", + "edited_at": null, + "noindex": false, + "account": { + "id": "6", + "nid": "6", + "username": "alice", + "acct": "alice", + "display_name": "alice", + "locked": false, + "bot": false, + "discoverable": false, + "indexable": false, + "group": false, + "created_at": "2026-09-28T23:06:02.000Z", + "note": "", + "url": "http://nextcloud.local/index.php/apps/social/@alice", + "avatar": "", + "avatar_static": "", + "header": "http://nextcloud.local/index.php/avatar/alice/128", + "header_static": "http://nextcloud.local/index.php/avatar/alice/128", + "followers_count": 2, + "following_count": 2, + "statuses_count": 13, + "last_status_at": null, + "emojis": [], + "fields": [], + "pronouns": "", + "support_link": "" + }, + "filtered": [] + }, + { + "id": "1790637104958214061", + "nid": "1790637104958214061", + "local": true, + "archived": false, + "view_count": null, + "tagged_people": [], + "interest": null, + "content": "

Test video #video

00:00 Intro
00:10 Middle
00:20 End

", + "sensitive": false, + "spoiler_text": "", + "visibility": "public", + "language": "en", + "place": null, + "in_reply_to_id": null, + "in_reply_to_account_id": null, + "quote": null, + "quote_approval": { + "automatic": [ + "public" + ], + "manual": [], + "current_user": "automatic" + }, + "reply_approval": null, + "interaction_policy": null, + "dislikes_count": 0, + "disliked": false, + "video": { + "title": "Aloha test video" + }, + "mentions": [], + "emojis": [], + "tags": [ + { + "name": "video", + "url": "http://nextcloud.local/index.php/apps/social/timeline/tags/video" + } + ], + "replies_count": 0, + "reblogs_count": 0, + "favourites_count": 1, + "favourited": false, + "reblogged": false, + "muted": false, + "bookmarked": false, + "pinned": false, + "card": null, + "reactions": [], + "poll": null, + "uri": "http://nextcloud.local/index.php/apps/social/@alice/17906371045255950881", + "url": "http://nextcloud.local/index.php/apps/social/@alice/17906371045255950881", + "reblog": null, + "media_attachments": [ + { + "id": "12", + "type": "video", + "media_type": "video/mp4", + "url": "http://nextcloud.local/index.php/apps/social/media/09c96321-538a-4d33-a3e3-e326ba1c5a56.mp4", + "preview_url": "http://nextcloud.local/index.php/apps/social/media/09c96321-538a-4d33-a3e3-e326ba1c5a56.mp4", + "hls_url": null, + "remote_url": null, + "cache_error": null, + "meta": { + "original": [], + "small": [], + "focus": { + "x": 0, + "y": 0 + } + }, + "description": "A 30 second test pattern with a tone", + "blurhash": null + } + ], + "created_at": "2026-09-28T23:11:44.000Z", + "edited_at": null, + "noindex": false, + "account": { + "id": "6", + "nid": "6", + "username": "alice", + "acct": "alice", + "display_name": "alice", + "locked": false, + "bot": false, + "discoverable": false, + "indexable": false, + "group": false, + "created_at": "2026-09-28T23:06:02.000Z", + "note": "", + "url": "http://nextcloud.local/index.php/apps/social/@alice", + "avatar": "", + "avatar_static": "", + "header": "http://nextcloud.local/index.php/avatar/alice/128", + "header_static": "http://nextcloud.local/index.php/avatar/alice/128", + "followers_count": 2, + "following_count": 2, + "statuses_count": 13, + "last_status_at": null, + "emojis": [], + "fields": [], + "pronouns": "", + "support_link": "" + }, + "filtered": [] + }, + { + "id": "1790637104870044831", + "nid": "1790637104870044831", + "local": true, + "archived": false, + "view_count": null, + "tagged_people": [], + "interest": null, + "content": "

Vertical test #shorts

", + "sensitive": false, + "spoiler_text": "", + "visibility": "public", + "language": "en", + "place": null, + "in_reply_to_id": null, + "in_reply_to_account_id": null, + "quote": null, + "quote_approval": { + "automatic": [ + "public" + ], + "manual": [], + "current_user": "automatic" + }, + "reply_approval": null, + "interaction_policy": null, + "dislikes_count": 0, + "disliked": false, + "video": null, + "mentions": [], + "emojis": [], + "tags": [ + { + "name": "shorts", + "url": "http://nextcloud.local/index.php/apps/social/timeline/tags/shorts" + } + ], + "replies_count": 0, + "reblogs_count": 0, + "favourites_count": 0, + "favourited": false, + "reblogged": false, + "muted": false, + "bookmarked": false, + "pinned": false, + "card": null, + "reactions": [], + "poll": null, + "uri": "http://nextcloud.local/index.php/apps/social/@alice/17906371044200157676", + "url": "http://nextcloud.local/index.php/apps/social/@alice/17906371044200157676", + "reblog": null, + "media_attachments": [ + { + "id": "13", + "type": "video", + "media_type": "video/mp4", + "url": "http://nextcloud.local/index.php/apps/social/media/9ca843a2-645e-439f-901c-3fd1d58c22b6.mp4", + "preview_url": "http://nextcloud.local/index.php/apps/social/media/9ca843a2-645e-439f-901c-3fd1d58c22b6.mp4", + "hls_url": null, + "remote_url": null, + "cache_error": null, + "meta": { + "original": [], + "small": [], + "focus": { + "x": 0, + "y": 0 + } + }, + "description": "A 12 second vertical test pattern", + "blurhash": null + } + ], + "created_at": "2026-09-28T23:11:44.000Z", + "edited_at": null, + "noindex": false, + "account": { + "id": "6", + "nid": "6", + "username": "alice", + "acct": "alice", + "display_name": "alice", + "locked": false, + "bot": false, + "discoverable": false, + "indexable": false, + "group": false, + "created_at": "2026-09-28T23:06:02.000Z", + "note": "", + "url": "http://nextcloud.local/index.php/apps/social/@alice", + "avatar": "", + "avatar_static": "", + "header": "http://nextcloud.local/index.php/avatar/alice/128", + "header_static": "http://nextcloud.local/index.php/avatar/alice/128", + "followers_count": 2, + "following_count": 2, + "statuses_count": 13, + "last_status_at": null, + "emojis": [], + "fields": [], + "pronouns": "", + "support_link": "" + }, + "filtered": [] + }, + { + "id": "1790637103267719986", + "nid": "1790637103267719986", + "local": true, + "archived": false, + "view_count": null, + "tagged_people": [], + "interest": null, + "content": "

A picture without alt text, marked sensitive

", + "sensitive": true, + "spoiler_text": "", + "visibility": "public", + "language": "en", + "place": null, + "in_reply_to_id": null, + "in_reply_to_account_id": null, + "quote": null, + "quote_approval": { + "automatic": [ + "public" + ], + "manual": [], + "current_user": "automatic" + }, + "reply_approval": null, + "interaction_policy": null, + "dislikes_count": null, + "disliked": null, + "video": null, + "mentions": [], + "emojis": [], + "tags": [], + "replies_count": 0, + "reblogs_count": 0, + "favourites_count": 0, + "favourited": false, + "reblogged": false, + "muted": false, + "bookmarked": false, + "pinned": false, + "card": null, + "reactions": [], + "poll": null, + "uri": "http://nextcloud.local/index.php/apps/social/@alice/17906371033997341160", + "url": "http://nextcloud.local/index.php/apps/social/@alice/17906371033997341160", + "reblog": null, + "media_attachments": [ + { + "id": "11", + "type": "image", + "media_type": "image/png", + "url": "http://nextcloud.local/index.php/apps/social/media/347a8309-ea20-4900-8e4c-aff1e8b6a98b.png", + "preview_url": "http://nextcloud.local/index.php/apps/social/media/6bb97fab-b644-4c51-8b1b-8ea5921fdef0.png", + "hls_url": null, + "remote_url": null, + "cache_error": null, + "meta": { + "original": { + "width": 1080, + "height": 1080, + "size": "1080x1080", + "aspect": 1 + }, + "small": { + "width": 800, + "height": 800, + "size": "800x800", + "aspect": 1 + }, + "focus": { + "x": 0, + "y": 0 + } + }, + "description": null, + "blurhash": "LRF~:K*DD7L+S^o@$T+kR5wiM_IU" + } + ], + "created_at": "2026-09-28T23:11:43.000Z", + "edited_at": null, + "noindex": false, + "account": { + "id": "6", + "nid": "6", + "username": "alice", + "acct": "alice", + "display_name": "alice", + "locked": false, + "bot": false, + "discoverable": false, + "indexable": false, + "group": false, + "created_at": "2026-09-28T23:06:02.000Z", + "note": "", + "url": "http://nextcloud.local/index.php/apps/social/@alice", + "avatar": "", + "avatar_static": "", + "header": "http://nextcloud.local/index.php/avatar/alice/128", + "header_static": "http://nextcloud.local/index.php/avatar/alice/128", + "followers_count": 2, + "following_count": 2, + "statuses_count": 13, + "last_status_at": null, + "emojis": [], + "fields": [], + "pronouns": "", + "support_link": "" + }, + "filtered": [] + }, + { + "id": "1790637096330770789", + "nid": "1790637096330770789", + "local": true, + "archived": false, + "view_count": null, + "tagged_people": [], + "interest": null, + "content": "

Two test pictures #photography

", + "sensitive": false, + "spoiler_text": "", + "visibility": "public", + "language": "en", + "place": null, + "in_reply_to_id": null, + "in_reply_to_account_id": null, + "quote": null, + "quote_approval": { + "automatic": [ + "public" + ], + "manual": [], + "current_user": "automatic" + }, + "reply_approval": null, + "interaction_policy": null, + "dislikes_count": null, + "disliked": null, + "video": null, + "mentions": [], + "emojis": [], + "tags": [ + { + "name": "photography", + "url": "http://nextcloud.local/index.php/apps/social/timeline/tags/photography" + } + ], + "replies_count": 0, + "reblogs_count": 1, + "favourites_count": 0, + "favourited": false, + "reblogged": false, + "muted": false, + "bookmarked": false, + "pinned": false, + "card": null, + "reactions": [], + "poll": null, + "uri": "http://nextcloud.local/index.php/apps/social/@alice/17906370966999687509", + "url": "http://nextcloud.local/index.php/apps/social/@alice/17906370966999687509", + "reblog": null, + "media_attachments": [ + { + "id": "9", + "type": "image", + "media_type": "image/jpeg", + "url": "http://nextcloud.local/index.php/apps/social/media/48044adf-b771-4343-9607-c7fc93f16ad1.jpeg", + "preview_url": "http://nextcloud.local/index.php/apps/social/media/2c66bfd7-0029-47e1-b6ef-23dc7d422e2b.jpeg", + "hls_url": null, + "remote_url": null, + "cache_error": null, + "meta": { + "original": { + "width": 1600, + "height": 1200, + "size": "1600x1200", + "aspect": 1.3333333333333333 + }, + "small": { + "width": 800, + "height": 600, + "size": "800x600", + "aspect": 1.3333333333333333 + }, + "focus": { + "x": 0.3, + "y": -0.2 + } + }, + "description": "A test card pattern in bright colours", + "blurhash": "LVL4]9|Tnk_gsESdfjn,baayW;jZ" + }, + { + "id": "10", + "type": "image", + "media_type": "image/jpeg", + "url": "http://nextcloud.local/index.php/apps/social/media/1e936701-3c1a-49c2-a58a-bc7b0d5a9c27.jpeg", + "preview_url": "http://nextcloud.local/index.php/apps/social/media/43d878b8-d9fd-42fa-9f7e-38dea1357b60.jpeg", + "hls_url": null, + "remote_url": null, + "cache_error": null, + "meta": { + "original": { + "width": 1080, + "height": 1350, + "size": "1080x1350", + "aspect": 0.8 + }, + "small": { + "width": 640, + "height": 800, + "size": "640x800", + "aspect": 0.8 + }, + "focus": { + "x": 0, + "y": 0 + } + }, + "description": "A Mandelbrot fractal, portrait", + "blurhash": "LyLow4+,$SN?w6S]WUjI[ibCSIbW" + } + ], + "created_at": "2026-09-28T23:11:36.000Z", + "edited_at": null, + "noindex": false, + "account": { + "id": "6", + "nid": "6", + "username": "alice", + "acct": "alice", + "display_name": "alice", + "locked": false, + "bot": false, + "discoverable": false, + "indexable": false, + "group": false, + "created_at": "2026-09-28T23:06:02.000Z", + "note": "", + "url": "http://nextcloud.local/index.php/apps/social/@alice", + "avatar": "", + "avatar_static": "", + "header": "http://nextcloud.local/index.php/avatar/alice/128", + "header_static": "http://nextcloud.local/index.php/avatar/alice/128", + "followers_count": 2, + "following_count": 2, + "statuses_count": 13, + "last_status_at": null, + "emojis": [], + "fields": [], + "pronouns": "", + "support_link": "" + }, + "filtered": [] + }, + { + "id": "1790637087495743511", + "nid": "1790637087495743511", + "local": true, + "archived": false, + "view_count": null, + "tagged_people": [], + "interest": null, + "content": "

Which mode do you use most?

", + "sensitive": false, + "spoiler_text": "", + "visibility": "public", + "language": "en", + "place": null, + "in_reply_to_id": null, + "in_reply_to_account_id": null, + "quote": null, + "quote_approval": { + "automatic": [ + "public" + ], + "manual": [], + "current_user": "automatic" + }, + "reply_approval": null, + "interaction_policy": null, + "dislikes_count": null, + "disliked": null, + "video": null, + "mentions": [], + "emojis": [], + "tags": [], + "replies_count": 0, + "reblogs_count": 0, + "favourites_count": 0, + "favourited": false, + "reblogged": false, + "muted": false, + "bookmarked": false, + "pinned": false, + "card": null, + "reactions": [], + "poll": { + "id": "1790637087495743511", + "expires_at": "2026-09-29T23:11:27Z", + "expired": false, + "multiple": false, + "votes_count": 1, + "voters_count": 1, + "options": [ + { + "title": "Photos", + "votes_count": 1 + }, + { + "title": "Video", + "votes_count": 0 + }, + { + "title": "Shorts", + "votes_count": 0 + } + ], + "emojis": [], + "voted": false, + "own_votes": [] + }, + "uri": "http://nextcloud.local/index.php/apps/social/@alice/17906370875255636478", + "url": "http://nextcloud.local/index.php/apps/social/@alice/17906370875255636478", + "reblog": null, + "media_attachments": [], + "created_at": "2026-09-28T23:11:27.000Z", + "edited_at": null, + "noindex": false, + "account": { + "id": "6", + "nid": "6", + "username": "alice", + "acct": "alice", + "display_name": "alice", + "locked": false, + "bot": false, + "discoverable": false, + "indexable": false, + "group": false, + "created_at": "2026-09-28T23:06:02.000Z", + "note": "", + "url": "http://nextcloud.local/index.php/apps/social/@alice", + "avatar": "", + "avatar_static": "", + "header": "http://nextcloud.local/index.php/avatar/alice/128", + "header_static": "http://nextcloud.local/index.php/avatar/alice/128", + "followers_count": 2, + "following_count": 2, + "statuses_count": 13, + "last_status_at": null, + "emojis": [], + "fields": [], + "pronouns": "", + "support_link": "" + }, + "oneOf": [ + { + "type": "Note", + "name": "Photos", + "replies": { + "type": "Collection", + "totalItems": 1 + } + }, + { + "type": "Note", + "name": "Video", + "replies": { + "type": "Collection", + "totalItems": 0 + } + }, + { + "type": "Note", + "name": "Shorts", + "replies": { + "type": "Collection", + "totalItems": 0 + } + } + ], + "endTime": "2026-09-29T23:11:27Z", + "votersCount": 1, + "filtered": [] + }, + { + "id": "1790637086177868305", + "nid": "1790637086177868305", + "local": true, + "archived": false, + "view_count": null, + "tagged_people": [], + "interest": null, + "content": "

The butler did it.

", + "sensitive": true, + "spoiler_text": "Spoiler: season finale", + "visibility": "public", + "language": "en", + "place": null, + "in_reply_to_id": null, + "in_reply_to_account_id": null, + "quote": null, + "quote_approval": { + "automatic": [ + "public" + ], + "manual": [], + "current_user": "automatic" + }, + "reply_approval": null, + "interaction_policy": null, + "dislikes_count": null, + "disliked": null, + "video": null, + "mentions": [], + "emojis": [], + "tags": [], + "replies_count": 0, + "reblogs_count": 0, + "favourites_count": 0, + "favourited": false, + "reblogged": false, + "muted": false, + "bookmarked": false, + "pinned": false, + "card": null, + "reactions": [], + "poll": null, + "uri": "http://nextcloud.local/index.php/apps/social/@alice/17906370868446468411", + "url": "http://nextcloud.local/index.php/apps/social/@alice/17906370868446468411", + "reblog": null, + "media_attachments": [], + "created_at": "2026-09-28T23:11:26.000Z", + "edited_at": null, + "noindex": false, + "account": { + "id": "6", + "nid": "6", + "username": "alice", + "acct": "alice", + "display_name": "alice", + "locked": false, + "bot": false, + "discoverable": false, + "indexable": false, + "group": false, + "created_at": "2026-09-28T23:06:02.000Z", + "note": "", + "url": "http://nextcloud.local/index.php/apps/social/@alice", + "avatar": "", + "avatar_static": "", + "header": "http://nextcloud.local/index.php/avatar/alice/128", + "header_static": "http://nextcloud.local/index.php/avatar/alice/128", + "followers_count": 2, + "following_count": 2, + "statuses_count": 13, + "last_status_at": null, + "emojis": [], + "fields": [], + "pronouns": "", + "support_link": "" + }, + "filtered": [] + }, + { + "id": "1790637085797595891", + "nid": "1790637085797595891", + "local": true, + "archived": false, + "view_count": null, + "tagged_people": [], + "interest": null, + "content": "

Aloha from the dev instance! Say hi, @bob@nextcloud.local #aloha #android

", + "sensitive": false, + "spoiler_text": "", + "visibility": "public", + "language": "en", + "place": null, + "in_reply_to_id": null, + "in_reply_to_account_id": null, + "quote": null, + "quote_approval": { + "automatic": [ + "public" + ], + "manual": [], + "current_user": "automatic" + }, + "reply_approval": null, + "interaction_policy": null, + "dislikes_count": null, + "disliked": null, + "video": null, + "mentions": [], + "emojis": [], + "tags": [ + { + "name": "aloha", + "url": "http://nextcloud.local/index.php/apps/social/timeline/tags/aloha" + }, + { + "name": "android", + "url": "http://nextcloud.local/index.php/apps/social/timeline/tags/android" + } + ], + "replies_count": 1, + "reblogs_count": 0, + "favourites_count": 1, + "favourited": false, + "reblogged": false, + "muted": false, + "bookmarked": false, + "pinned": false, + "card": null, + "reactions": [ + { + "name": "🎉", + "count": 1, + "me": false + } + ], + "poll": null, + "uri": "http://nextcloud.local/index.php/apps/social/@alice/17906370853806247191", + "url": "http://nextcloud.local/index.php/apps/social/@alice/17906370853806247191", + "reblog": null, + "media_attachments": [], + "created_at": "2026-09-28T23:11:25.000Z", + "edited_at": null, + "noindex": false, + "account": { + "id": "6", + "nid": "6", + "username": "alice", + "acct": "alice", + "display_name": "alice", + "locked": false, + "bot": false, + "discoverable": false, + "indexable": false, + "group": false, + "created_at": "2026-09-28T23:06:02.000Z", + "note": "", + "url": "http://nextcloud.local/index.php/apps/social/@alice", + "avatar": "", + "avatar_static": "", + "header": "http://nextcloud.local/index.php/avatar/alice/128", + "header_static": "http://nextcloud.local/index.php/avatar/alice/128", + "followers_count": 2, + "following_count": 2, + "statuses_count": 13, + "last_status_at": null, + "emojis": [], + "fields": [], + "pronouns": "", + "support_link": "" + }, + "filtered": [] + } + ] +} diff --git a/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/timeline-public-only-media.json b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/timeline-public-only-media.json new file mode 100644 index 00000000..3fa854fb --- /dev/null +++ b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/timeline-public-only-media.json @@ -0,0 +1,705 @@ +{ + "request": { + "method": "GET", + "path": "/index.php/apps/social/api/v1/timelines/public?only_media=true&limit=20" + }, + "status": 200, + "headers": { + "content-type": "application/json; charset=utf-8", + "cache-control": "no-cache, no-store, must-revalidate", + "link": "; rel=\"prev\"", + "x-ratelimit-limit": "60", + "x-ratelimit-remaining": "55", + "x-ratelimit-reset": "1790637300" + }, + "body": [ + { + "id": "1790637112216295042", + "nid": "1790637112216295042", + "local": true, + "archived": false, + "view_count": null, + "tagged_people": [], + "interest": null, + "content": "

Picture from Berlin

", + "sensitive": false, + "spoiler_text": "", + "visibility": "public", + "language": "en", + "place": { + "id": "1", + "name": "Berlin", + "country": "DE", + "lat": "", + "long": "" + }, + "in_reply_to_id": null, + "in_reply_to_account_id": null, + "quote": null, + "quote_approval": { + "automatic": [ + "public" + ], + "manual": [], + "current_user": "automatic" + }, + "reply_approval": null, + "interaction_policy": null, + "dislikes_count": null, + "disliked": null, + "video": null, + "mentions": [], + "emojis": [], + "tags": [], + "replies_count": 0, + "reblogs_count": 0, + "favourites_count": 0, + "favourited": false, + "reblogged": false, + "muted": false, + "bookmarked": false, + "pinned": false, + "card": null, + "reactions": [], + "poll": null, + "uri": "http://nextcloud.local/index.php/apps/social/@alice/17906371127479253498", + "url": "http://nextcloud.local/index.php/apps/social/@alice/17906371127479253498", + "reblog": null, + "media_attachments": [ + { + "id": "15", + "type": "image", + "media_type": "image/jpeg", + "url": "http://nextcloud.local/index.php/apps/social/media/b88735cc-b169-40e0-9fa5-85e3be451c1a.jpeg", + "preview_url": "http://nextcloud.local/index.php/apps/social/media/e46d0547-3b30-46cd-8b5b-394bb7005a49.jpeg", + "hls_url": null, + "remote_url": null, + "cache_error": null, + "meta": { + "original": { + "width": 1080, + "height": 1350, + "size": "1080x1350", + "aspect": 0.8 + }, + "small": { + "width": 640, + "height": 800, + "size": "640x800", + "aspect": 0.8 + }, + "focus": { + "x": 0, + "y": 0 + } + }, + "description": "Another fractal, taken in Berlin", + "blurhash": "LyLow4+,$SN?w6S]WUjI[ibCSIbW" + } + ], + "created_at": "2026-09-28T23:11:52.000Z", + "edited_at": null, + "noindex": false, + "account": { + "id": "6", + "nid": "6", + "username": "alice", + "acct": "alice", + "display_name": "alice", + "locked": false, + "bot": false, + "discoverable": false, + "indexable": false, + "group": false, + "created_at": "2026-09-28T23:06:02.000Z", + "note": "", + "url": "http://nextcloud.local/index.php/apps/social/@alice", + "avatar": "", + "avatar_static": "", + "header": "http://nextcloud.local/index.php/avatar/alice/128", + "header_static": "http://nextcloud.local/index.php/avatar/alice/128", + "followers_count": 2, + "following_count": 2, + "statuses_count": 13, + "last_status_at": null, + "emojis": [], + "fields": [], + "pronouns": "", + "support_link": "" + }, + "filtered": [] + }, + { + "id": "1790637105549523409", + "nid": "1790637105549523409", + "local": true, + "archived": false, + "view_count": null, + "tagged_people": [], + "interest": null, + "content": "

A 15 second tone #audio

", + "sensitive": false, + "spoiler_text": "", + "visibility": "public", + "language": "en", + "place": null, + "in_reply_to_id": null, + "in_reply_to_account_id": null, + "quote": null, + "quote_approval": { + "automatic": [ + "public" + ], + "manual": [], + "current_user": "automatic" + }, + "reply_approval": null, + "interaction_policy": null, + "dislikes_count": null, + "disliked": null, + "video": null, + "mentions": [], + "emojis": [], + "tags": [ + { + "name": "audio", + "url": "http://nextcloud.local/index.php/apps/social/timeline/tags/audio" + } + ], + "replies_count": 0, + "reblogs_count": 0, + "favourites_count": 0, + "favourited": false, + "reblogged": false, + "muted": false, + "bookmarked": false, + "pinned": false, + "card": null, + "reactions": [], + "poll": null, + "uri": "http://nextcloud.local/index.php/apps/social/@alice/17906371052336860333", + "url": "http://nextcloud.local/index.php/apps/social/@alice/17906371052336860333", + "reblog": null, + "media_attachments": [ + { + "id": "14", + "type": "audio", + "media_type": "audio/mpeg", + "url": "http://nextcloud.local/index.php/apps/social/media/2ee64fb3-dda9-47f5-bac6-daa189149093.mpeg", + "preview_url": "http://nextcloud.local/index.php/apps/social/media/2ee64fb3-dda9-47f5-bac6-daa189149093.mpeg", + "hls_url": null, + "remote_url": null, + "cache_error": null, + "meta": { + "original": [], + "small": [], + "focus": { + "x": 0, + "y": 0 + } + }, + "description": "A 15 second 330 Hz tone", + "blurhash": null + } + ], + "created_at": "2026-09-28T23:11:45.000Z", + "edited_at": null, + "noindex": false, + "account": { + "id": "6", + "nid": "6", + "username": "alice", + "acct": "alice", + "display_name": "alice", + "locked": false, + "bot": false, + "discoverable": false, + "indexable": false, + "group": false, + "created_at": "2026-09-28T23:06:02.000Z", + "note": "", + "url": "http://nextcloud.local/index.php/apps/social/@alice", + "avatar": "", + "avatar_static": "", + "header": "http://nextcloud.local/index.php/avatar/alice/128", + "header_static": "http://nextcloud.local/index.php/avatar/alice/128", + "followers_count": 2, + "following_count": 2, + "statuses_count": 13, + "last_status_at": null, + "emojis": [], + "fields": [], + "pronouns": "", + "support_link": "" + }, + "filtered": [] + }, + { + "id": "1790637104958214061", + "nid": "1790637104958214061", + "local": true, + "archived": false, + "view_count": null, + "tagged_people": [], + "interest": null, + "content": "

Test video #video

00:00 Intro
00:10 Middle
00:20 End

", + "sensitive": false, + "spoiler_text": "", + "visibility": "public", + "language": "en", + "place": null, + "in_reply_to_id": null, + "in_reply_to_account_id": null, + "quote": null, + "quote_approval": { + "automatic": [ + "public" + ], + "manual": [], + "current_user": "automatic" + }, + "reply_approval": null, + "interaction_policy": null, + "dislikes_count": 0, + "disliked": false, + "video": { + "title": "Aloha test video" + }, + "mentions": [], + "emojis": [], + "tags": [ + { + "name": "video", + "url": "http://nextcloud.local/index.php/apps/social/timeline/tags/video" + } + ], + "replies_count": 0, + "reblogs_count": 0, + "favourites_count": 1, + "favourited": false, + "reblogged": false, + "muted": false, + "bookmarked": false, + "pinned": false, + "card": null, + "reactions": [], + "poll": null, + "uri": "http://nextcloud.local/index.php/apps/social/@alice/17906371045255950881", + "url": "http://nextcloud.local/index.php/apps/social/@alice/17906371045255950881", + "reblog": null, + "media_attachments": [ + { + "id": "12", + "type": "video", + "media_type": "video/mp4", + "url": "http://nextcloud.local/index.php/apps/social/media/09c96321-538a-4d33-a3e3-e326ba1c5a56.mp4", + "preview_url": "http://nextcloud.local/index.php/apps/social/media/09c96321-538a-4d33-a3e3-e326ba1c5a56.mp4", + "hls_url": null, + "remote_url": null, + "cache_error": null, + "meta": { + "original": [], + "small": [], + "focus": { + "x": 0, + "y": 0 + } + }, + "description": "A 30 second test pattern with a tone", + "blurhash": null + } + ], + "created_at": "2026-09-28T23:11:44.000Z", + "edited_at": null, + "noindex": false, + "account": { + "id": "6", + "nid": "6", + "username": "alice", + "acct": "alice", + "display_name": "alice", + "locked": false, + "bot": false, + "discoverable": false, + "indexable": false, + "group": false, + "created_at": "2026-09-28T23:06:02.000Z", + "note": "", + "url": "http://nextcloud.local/index.php/apps/social/@alice", + "avatar": "", + "avatar_static": "", + "header": "http://nextcloud.local/index.php/avatar/alice/128", + "header_static": "http://nextcloud.local/index.php/avatar/alice/128", + "followers_count": 2, + "following_count": 2, + "statuses_count": 13, + "last_status_at": null, + "emojis": [], + "fields": [], + "pronouns": "", + "support_link": "" + }, + "filtered": [] + }, + { + "id": "1790637104870044831", + "nid": "1790637104870044831", + "local": true, + "archived": false, + "view_count": null, + "tagged_people": [], + "interest": null, + "content": "

Vertical test #shorts

", + "sensitive": false, + "spoiler_text": "", + "visibility": "public", + "language": "en", + "place": null, + "in_reply_to_id": null, + "in_reply_to_account_id": null, + "quote": null, + "quote_approval": { + "automatic": [ + "public" + ], + "manual": [], + "current_user": "automatic" + }, + "reply_approval": null, + "interaction_policy": null, + "dislikes_count": 0, + "disliked": false, + "video": null, + "mentions": [], + "emojis": [], + "tags": [ + { + "name": "shorts", + "url": "http://nextcloud.local/index.php/apps/social/timeline/tags/shorts" + } + ], + "replies_count": 0, + "reblogs_count": 0, + "favourites_count": 0, + "favourited": false, + "reblogged": false, + "muted": false, + "bookmarked": false, + "pinned": false, + "card": null, + "reactions": [], + "poll": null, + "uri": "http://nextcloud.local/index.php/apps/social/@alice/17906371044200157676", + "url": "http://nextcloud.local/index.php/apps/social/@alice/17906371044200157676", + "reblog": null, + "media_attachments": [ + { + "id": "13", + "type": "video", + "media_type": "video/mp4", + "url": "http://nextcloud.local/index.php/apps/social/media/9ca843a2-645e-439f-901c-3fd1d58c22b6.mp4", + "preview_url": "http://nextcloud.local/index.php/apps/social/media/9ca843a2-645e-439f-901c-3fd1d58c22b6.mp4", + "hls_url": null, + "remote_url": null, + "cache_error": null, + "meta": { + "original": [], + "small": [], + "focus": { + "x": 0, + "y": 0 + } + }, + "description": "A 12 second vertical test pattern", + "blurhash": null + } + ], + "created_at": "2026-09-28T23:11:44.000Z", + "edited_at": null, + "noindex": false, + "account": { + "id": "6", + "nid": "6", + "username": "alice", + "acct": "alice", + "display_name": "alice", + "locked": false, + "bot": false, + "discoverable": false, + "indexable": false, + "group": false, + "created_at": "2026-09-28T23:06:02.000Z", + "note": "", + "url": "http://nextcloud.local/index.php/apps/social/@alice", + "avatar": "", + "avatar_static": "", + "header": "http://nextcloud.local/index.php/avatar/alice/128", + "header_static": "http://nextcloud.local/index.php/avatar/alice/128", + "followers_count": 2, + "following_count": 2, + "statuses_count": 13, + "last_status_at": null, + "emojis": [], + "fields": [], + "pronouns": "", + "support_link": "" + }, + "filtered": [] + }, + { + "id": "1790637103267719986", + "nid": "1790637103267719986", + "local": true, + "archived": false, + "view_count": null, + "tagged_people": [], + "interest": null, + "content": "

A picture without alt text, marked sensitive

", + "sensitive": true, + "spoiler_text": "", + "visibility": "public", + "language": "en", + "place": null, + "in_reply_to_id": null, + "in_reply_to_account_id": null, + "quote": null, + "quote_approval": { + "automatic": [ + "public" + ], + "manual": [], + "current_user": "automatic" + }, + "reply_approval": null, + "interaction_policy": null, + "dislikes_count": null, + "disliked": null, + "video": null, + "mentions": [], + "emojis": [], + "tags": [], + "replies_count": 0, + "reblogs_count": 0, + "favourites_count": 0, + "favourited": false, + "reblogged": false, + "muted": false, + "bookmarked": false, + "pinned": false, + "card": null, + "reactions": [], + "poll": null, + "uri": "http://nextcloud.local/index.php/apps/social/@alice/17906371033997341160", + "url": "http://nextcloud.local/index.php/apps/social/@alice/17906371033997341160", + "reblog": null, + "media_attachments": [ + { + "id": "11", + "type": "image", + "media_type": "image/png", + "url": "http://nextcloud.local/index.php/apps/social/media/347a8309-ea20-4900-8e4c-aff1e8b6a98b.png", + "preview_url": "http://nextcloud.local/index.php/apps/social/media/6bb97fab-b644-4c51-8b1b-8ea5921fdef0.png", + "hls_url": null, + "remote_url": null, + "cache_error": null, + "meta": { + "original": { + "width": 1080, + "height": 1080, + "size": "1080x1080", + "aspect": 1 + }, + "small": { + "width": 800, + "height": 800, + "size": "800x800", + "aspect": 1 + }, + "focus": { + "x": 0, + "y": 0 + } + }, + "description": null, + "blurhash": "LRF~:K*DD7L+S^o@$T+kR5wiM_IU" + } + ], + "created_at": "2026-09-28T23:11:43.000Z", + "edited_at": null, + "noindex": false, + "account": { + "id": "6", + "nid": "6", + "username": "alice", + "acct": "alice", + "display_name": "alice", + "locked": false, + "bot": false, + "discoverable": false, + "indexable": false, + "group": false, + "created_at": "2026-09-28T23:06:02.000Z", + "note": "", + "url": "http://nextcloud.local/index.php/apps/social/@alice", + "avatar": "", + "avatar_static": "", + "header": "http://nextcloud.local/index.php/avatar/alice/128", + "header_static": "http://nextcloud.local/index.php/avatar/alice/128", + "followers_count": 2, + "following_count": 2, + "statuses_count": 13, + "last_status_at": null, + "emojis": [], + "fields": [], + "pronouns": "", + "support_link": "" + }, + "filtered": [] + }, + { + "id": "1790637096330770789", + "nid": "1790637096330770789", + "local": true, + "archived": false, + "view_count": null, + "tagged_people": [], + "interest": null, + "content": "

Two test pictures #photography

", + "sensitive": false, + "spoiler_text": "", + "visibility": "public", + "language": "en", + "place": null, + "in_reply_to_id": null, + "in_reply_to_account_id": null, + "quote": null, + "quote_approval": { + "automatic": [ + "public" + ], + "manual": [], + "current_user": "automatic" + }, + "reply_approval": null, + "interaction_policy": null, + "dislikes_count": null, + "disliked": null, + "video": null, + "mentions": [], + "emojis": [], + "tags": [ + { + "name": "photography", + "url": "http://nextcloud.local/index.php/apps/social/timeline/tags/photography" + } + ], + "replies_count": 0, + "reblogs_count": 1, + "favourites_count": 0, + "favourited": false, + "reblogged": false, + "muted": false, + "bookmarked": false, + "pinned": false, + "card": null, + "reactions": [], + "poll": null, + "uri": "http://nextcloud.local/index.php/apps/social/@alice/17906370966999687509", + "url": "http://nextcloud.local/index.php/apps/social/@alice/17906370966999687509", + "reblog": null, + "media_attachments": [ + { + "id": "9", + "type": "image", + "media_type": "image/jpeg", + "url": "http://nextcloud.local/index.php/apps/social/media/48044adf-b771-4343-9607-c7fc93f16ad1.jpeg", + "preview_url": "http://nextcloud.local/index.php/apps/social/media/2c66bfd7-0029-47e1-b6ef-23dc7d422e2b.jpeg", + "hls_url": null, + "remote_url": null, + "cache_error": null, + "meta": { + "original": { + "width": 1600, + "height": 1200, + "size": "1600x1200", + "aspect": 1.3333333333333333 + }, + "small": { + "width": 800, + "height": 600, + "size": "800x600", + "aspect": 1.3333333333333333 + }, + "focus": { + "x": 0.3, + "y": -0.2 + } + }, + "description": "A test card pattern in bright colours", + "blurhash": "LVL4]9|Tnk_gsESdfjn,baayW;jZ" + }, + { + "id": "10", + "type": "image", + "media_type": "image/jpeg", + "url": "http://nextcloud.local/index.php/apps/social/media/1e936701-3c1a-49c2-a58a-bc7b0d5a9c27.jpeg", + "preview_url": "http://nextcloud.local/index.php/apps/social/media/43d878b8-d9fd-42fa-9f7e-38dea1357b60.jpeg", + "hls_url": null, + "remote_url": null, + "cache_error": null, + "meta": { + "original": { + "width": 1080, + "height": 1350, + "size": "1080x1350", + "aspect": 0.8 + }, + "small": { + "width": 640, + "height": 800, + "size": "640x800", + "aspect": 0.8 + }, + "focus": { + "x": 0, + "y": 0 + } + }, + "description": "A Mandelbrot fractal, portrait", + "blurhash": "LyLow4+,$SN?w6S]WUjI[ibCSIbW" + } + ], + "created_at": "2026-09-28T23:11:36.000Z", + "edited_at": null, + "noindex": false, + "account": { + "id": "6", + "nid": "6", + "username": "alice", + "acct": "alice", + "display_name": "alice", + "locked": false, + "bot": false, + "discoverable": false, + "indexable": false, + "group": false, + "created_at": "2026-09-28T23:06:02.000Z", + "note": "", + "url": "http://nextcloud.local/index.php/apps/social/@alice", + "avatar": "", + "avatar_static": "", + "header": "http://nextcloud.local/index.php/avatar/alice/128", + "header_static": "http://nextcloud.local/index.php/avatar/alice/128", + "followers_count": 2, + "following_count": 2, + "statuses_count": 13, + "last_status_at": null, + "emojis": [], + "fields": [], + "pronouns": "", + "support_link": "" + }, + "filtered": [] + } + ] +} diff --git a/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/timeline-public-only-news.json b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/timeline-public-only-news.json new file mode 100644 index 00000000..752bed34 --- /dev/null +++ b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/timeline-public-only-news.json @@ -0,0 +1,96 @@ +{ + "request": { + "method": "GET", + "path": "/index.php/apps/social/api/v1/timelines/public?only_news=true&limit=20" + }, + "status": 200, + "headers": { + "content-type": "application/json; charset=utf-8", + "cache-control": "no-cache, no-store, must-revalidate", + "link": "; rel=\"prev\"", + "x-ratelimit-limit": "60", + "x-ratelimit-remaining": "53", + "x-ratelimit-reset": "1790637300" + }, + "body": [ + { + "id": "1790637105561356153", + "nid": "1790637105561356153", + "local": true, + "archived": false, + "view_count": null, + "tagged_people": [], + "interest": null, + "content": "

Nextcloud Social on GitHub: https://github.com/nextcloud/social

", + "sensitive": false, + "spoiler_text": "", + "visibility": "public", + "language": "en", + "place": null, + "in_reply_to_id": null, + "in_reply_to_account_id": null, + "quote": null, + "quote_approval": { + "automatic": [ + "public" + ], + "manual": [], + "current_user": "automatic" + }, + "reply_approval": null, + "interaction_policy": null, + "dislikes_count": null, + "disliked": null, + "video": null, + "mentions": [], + "emojis": [], + "tags": [], + "replies_count": 0, + "reblogs_count": 0, + "favourites_count": 0, + "favourited": false, + "reblogged": false, + "muted": false, + "bookmarked": false, + "pinned": false, + "card": null, + "reactions": [], + "poll": null, + "uri": "http://nextcloud.local/index.php/apps/social/@alice/17906371057334696925", + "url": "http://nextcloud.local/index.php/apps/social/@alice/17906371057334696925", + "reblog": null, + "media_attachments": [], + "created_at": "2026-09-28T23:11:45.000Z", + "edited_at": null, + "noindex": false, + "account": { + "id": "6", + "nid": "6", + "username": "alice", + "acct": "alice", + "display_name": "alice", + "locked": false, + "bot": false, + "discoverable": false, + "indexable": false, + "group": false, + "created_at": "2026-09-28T23:06:02.000Z", + "note": "", + "url": "http://nextcloud.local/index.php/apps/social/@alice", + "avatar": "", + "avatar_static": "", + "header": "http://nextcloud.local/index.php/avatar/alice/128", + "header_static": "http://nextcloud.local/index.php/avatar/alice/128", + "followers_count": 2, + "following_count": 2, + "statuses_count": 13, + "last_status_at": null, + "emojis": [], + "fields": [], + "pronouns": "", + "support_link": "" + }, + "filtered": [] + } + ] +} diff --git a/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/timeline-public-only-video.json b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/timeline-public-only-video.json new file mode 100644 index 00000000..37462a06 --- /dev/null +++ b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/timeline-public-only-video.json @@ -0,0 +1,229 @@ +{ + "request": { + "method": "GET", + "path": "/index.php/apps/social/api/v1/timelines/public?only_video=true&limit=20" + }, + "status": 200, + "headers": { + "content-type": "application/json; charset=utf-8", + "cache-control": "no-cache, no-store, must-revalidate", + "link": "; rel=\"prev\"", + "x-ratelimit-limit": "60", + "x-ratelimit-remaining": "54", + "x-ratelimit-reset": "1790637300" + }, + "body": [ + { + "id": "1790637104958214061", + "nid": "1790637104958214061", + "local": true, + "archived": false, + "view_count": null, + "tagged_people": [], + "interest": null, + "content": "

Test video #video

00:00 Intro
00:10 Middle
00:20 End

", + "sensitive": false, + "spoiler_text": "", + "visibility": "public", + "language": "en", + "place": null, + "in_reply_to_id": null, + "in_reply_to_account_id": null, + "quote": null, + "quote_approval": { + "automatic": [ + "public" + ], + "manual": [], + "current_user": "automatic" + }, + "reply_approval": null, + "interaction_policy": null, + "dislikes_count": 0, + "disliked": false, + "video": { + "title": "Aloha test video" + }, + "mentions": [], + "emojis": [], + "tags": [ + { + "name": "video", + "url": "http://nextcloud.local/index.php/apps/social/timeline/tags/video" + } + ], + "replies_count": 0, + "reblogs_count": 0, + "favourites_count": 1, + "favourited": false, + "reblogged": false, + "muted": false, + "bookmarked": false, + "pinned": false, + "card": null, + "reactions": [], + "poll": null, + "uri": "http://nextcloud.local/index.php/apps/social/@alice/17906371045255950881", + "url": "http://nextcloud.local/index.php/apps/social/@alice/17906371045255950881", + "reblog": null, + "media_attachments": [ + { + "id": "12", + "type": "video", + "media_type": "video/mp4", + "url": "http://nextcloud.local/index.php/apps/social/media/09c96321-538a-4d33-a3e3-e326ba1c5a56.mp4", + "preview_url": "http://nextcloud.local/index.php/apps/social/media/09c96321-538a-4d33-a3e3-e326ba1c5a56.mp4", + "hls_url": null, + "remote_url": null, + "cache_error": null, + "meta": { + "original": [], + "small": [], + "focus": { + "x": 0, + "y": 0 + } + }, + "description": "A 30 second test pattern with a tone", + "blurhash": null + } + ], + "created_at": "2026-09-28T23:11:44.000Z", + "edited_at": null, + "noindex": false, + "account": { + "id": "6", + "nid": "6", + "username": "alice", + "acct": "alice", + "display_name": "alice", + "locked": false, + "bot": false, + "discoverable": false, + "indexable": false, + "group": false, + "created_at": "2026-09-28T23:06:02.000Z", + "note": "", + "url": "http://nextcloud.local/index.php/apps/social/@alice", + "avatar": "", + "avatar_static": "", + "header": "http://nextcloud.local/index.php/avatar/alice/128", + "header_static": "http://nextcloud.local/index.php/avatar/alice/128", + "followers_count": 2, + "following_count": 2, + "statuses_count": 13, + "last_status_at": null, + "emojis": [], + "fields": [], + "pronouns": "", + "support_link": "" + }, + "filtered": [] + }, + { + "id": "1790637104870044831", + "nid": "1790637104870044831", + "local": true, + "archived": false, + "view_count": null, + "tagged_people": [], + "interest": null, + "content": "

Vertical test #shorts

", + "sensitive": false, + "spoiler_text": "", + "visibility": "public", + "language": "en", + "place": null, + "in_reply_to_id": null, + "in_reply_to_account_id": null, + "quote": null, + "quote_approval": { + "automatic": [ + "public" + ], + "manual": [], + "current_user": "automatic" + }, + "reply_approval": null, + "interaction_policy": null, + "dislikes_count": 0, + "disliked": false, + "video": null, + "mentions": [], + "emojis": [], + "tags": [ + { + "name": "shorts", + "url": "http://nextcloud.local/index.php/apps/social/timeline/tags/shorts" + } + ], + "replies_count": 0, + "reblogs_count": 0, + "favourites_count": 0, + "favourited": false, + "reblogged": false, + "muted": false, + "bookmarked": false, + "pinned": false, + "card": null, + "reactions": [], + "poll": null, + "uri": "http://nextcloud.local/index.php/apps/social/@alice/17906371044200157676", + "url": "http://nextcloud.local/index.php/apps/social/@alice/17906371044200157676", + "reblog": null, + "media_attachments": [ + { + "id": "13", + "type": "video", + "media_type": "video/mp4", + "url": "http://nextcloud.local/index.php/apps/social/media/9ca843a2-645e-439f-901c-3fd1d58c22b6.mp4", + "preview_url": "http://nextcloud.local/index.php/apps/social/media/9ca843a2-645e-439f-901c-3fd1d58c22b6.mp4", + "hls_url": null, + "remote_url": null, + "cache_error": null, + "meta": { + "original": [], + "small": [], + "focus": { + "x": 0, + "y": 0 + } + }, + "description": "A 12 second vertical test pattern", + "blurhash": null + } + ], + "created_at": "2026-09-28T23:11:44.000Z", + "edited_at": null, + "noindex": false, + "account": { + "id": "6", + "nid": "6", + "username": "alice", + "acct": "alice", + "display_name": "alice", + "locked": false, + "bot": false, + "discoverable": false, + "indexable": false, + "group": false, + "created_at": "2026-09-28T23:06:02.000Z", + "note": "", + "url": "http://nextcloud.local/index.php/apps/social/@alice", + "avatar": "", + "avatar_static": "", + "header": "http://nextcloud.local/index.php/avatar/alice/128", + "header_static": "http://nextcloud.local/index.php/avatar/alice/128", + "followers_count": 2, + "following_count": 2, + "statuses_count": 13, + "last_status_at": null, + "emojis": [], + "fields": [], + "pronouns": "", + "support_link": "" + }, + "filtered": [] + } + ] +} diff --git a/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/timeline-public.json b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/timeline-public.json new file mode 100644 index 00000000..7a5ce19e --- /dev/null +++ b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/timeline-public.json @@ -0,0 +1,1577 @@ +{ + "request": { + "method": "GET", + "path": "/index.php/apps/social/api/v1/timelines/public?limit=20" + }, + "status": 200, + "headers": { + "content-type": "application/json; charset=utf-8", + "cache-control": "no-cache, no-store, must-revalidate", + "link": "; rel=\"prev\"", + "x-ratelimit-limit": "60", + "x-ratelimit-remaining": "57", + "x-ratelimit-reset": "1790637300" + }, + "body": [ + { + "id": "1790637117344364158", + "nid": "1790637117344364158", + "local": true, + "archived": false, + "view_count": null, + "tagged_people": [], + "interest": null, + "content": "

Bob's own post #aloha

", + "sensitive": false, + "spoiler_text": "", + "visibility": "public", + "language": "en", + "place": null, + "in_reply_to_id": null, + "in_reply_to_account_id": null, + "quote": null, + "quote_approval": { + "automatic": [ + "public" + ], + "manual": [], + "current_user": "automatic" + }, + "reply_approval": null, + "interaction_policy": null, + "dislikes_count": null, + "disliked": null, + "video": null, + "mentions": [], + "emojis": [], + "tags": [ + { + "name": "aloha", + "url": "http://nextcloud.local/index.php/apps/social/timeline/tags/aloha" + } + ], + "replies_count": 0, + "reblogs_count": 0, + "favourites_count": 0, + "favourited": false, + "reblogged": false, + "muted": false, + "bookmarked": false, + "pinned": false, + "card": null, + "reactions": [], + "poll": null, + "uri": "http://nextcloud.local/index.php/apps/social/@bob/17906371177254273993", + "url": "http://nextcloud.local/index.php/apps/social/@bob/17906371177254273993", + "reblog": null, + "media_attachments": [], + "created_at": "2026-09-28T23:11:57.000Z", + "edited_at": null, + "noindex": false, + "account": { + "id": "7", + "nid": "7", + "username": "bob", + "acct": "bob", + "display_name": "bob", + "locked": false, + "bot": false, + "discoverable": false, + "indexable": false, + "group": false, + "created_at": "2026-09-28T23:06:03.000Z", + "note": "", + "url": "http://nextcloud.local/index.php/apps/social/@bob", + "avatar": "", + "avatar_static": "", + "header": "http://nextcloud.local/index.php/avatar/bob/128", + "header_static": "http://nextcloud.local/index.php/avatar/bob/128", + "followers_count": 1, + "following_count": 2, + "statuses_count": 3, + "last_status_at": null, + "emojis": [], + "fields": [], + "pronouns": "", + "support_link": "" + }, + "filtered": [] + }, + { + "id": "1790637116374990863", + "nid": "1790637116374990863", + "local": true, + "archived": false, + "view_count": null, + "tagged_people": [], + "interest": null, + "content": "

Quoting this one

", + "sensitive": false, + "spoiler_text": "", + "visibility": "public", + "language": "en", + "place": null, + "in_reply_to_id": null, + "in_reply_to_account_id": null, + "quote": { + "state": "accepted", + "quoted_status": { + "id": "1790637085797595891", + "nid": "1790637085797595891", + "local": true, + "archived": false, + "view_count": null, + "tagged_people": [], + "interest": null, + "content": "

Aloha from the dev instance! Say hi, @bob@nextcloud.local #aloha #android

", + "sensitive": false, + "spoiler_text": "", + "visibility": "public", + "language": "en", + "place": null, + "in_reply_to_id": null, + "in_reply_to_account_id": null, + "quote": null, + "quote_approval": { + "automatic": [ + "public" + ], + "manual": [], + "current_user": "automatic" + }, + "reply_approval": null, + "interaction_policy": null, + "dislikes_count": null, + "disliked": null, + "video": null, + "mentions": [], + "emojis": [], + "tags": [ + { + "name": "aloha", + "url": "http://nextcloud.local/index.php/apps/social/timeline/tags/aloha" + }, + { + "name": "android", + "url": "http://nextcloud.local/index.php/apps/social/timeline/tags/android" + } + ], + "replies_count": 1, + "reblogs_count": 0, + "favourites_count": 1, + "favourited": false, + "reblogged": false, + "muted": false, + "bookmarked": false, + "pinned": false, + "card": null, + "reactions": [], + "poll": null, + "uri": "http://nextcloud.local/index.php/apps/social/@alice/17906370853806247191", + "url": "http://nextcloud.local/index.php/apps/social/@alice/17906370853806247191", + "reblog": null, + "media_attachments": [], + "created_at": "2026-09-28T23:11:25.000Z", + "edited_at": null, + "noindex": false, + "account": { + "id": "6", + "nid": "6", + "username": "alice", + "acct": "alice", + "display_name": "alice", + "locked": false, + "bot": false, + "discoverable": false, + "indexable": false, + "group": false, + "created_at": "2026-09-28T23:06:02.000Z", + "note": "", + "url": "http://nextcloud.local/index.php/apps/social/@alice", + "avatar": "", + "avatar_static": "", + "header": "http://nextcloud.local/index.php/avatar/alice/128", + "header_static": "http://nextcloud.local/index.php/avatar/alice/128", + "followers_count": 2, + "following_count": 2, + "statuses_count": 13, + "last_status_at": null, + "emojis": [], + "fields": [], + "pronouns": "", + "support_link": "" + } + } + }, + "quote_approval": { + "automatic": [ + "public" + ], + "manual": [], + "current_user": "automatic" + }, + "reply_approval": null, + "interaction_policy": null, + "dislikes_count": null, + "disliked": null, + "video": null, + "mentions": [], + "emojis": [], + "tags": [], + "replies_count": 0, + "reblogs_count": 0, + "favourites_count": 0, + "favourited": false, + "reblogged": false, + "muted": false, + "bookmarked": false, + "pinned": false, + "card": null, + "reactions": [], + "poll": null, + "uri": "http://nextcloud.local/index.php/apps/social/@bob/17906371165948083557", + "url": "http://nextcloud.local/index.php/apps/social/@bob/17906371165948083557", + "reblog": null, + "media_attachments": [], + "created_at": "2026-09-28T23:11:56.000Z", + "edited_at": null, + "noindex": false, + "account": { + "id": "7", + "nid": "7", + "username": "bob", + "acct": "bob", + "display_name": "bob", + "locked": false, + "bot": false, + "discoverable": false, + "indexable": false, + "group": false, + "created_at": "2026-09-28T23:06:03.000Z", + "note": "", + "url": "http://nextcloud.local/index.php/apps/social/@bob", + "avatar": "", + "avatar_static": "", + "header": "http://nextcloud.local/index.php/avatar/bob/128", + "header_static": "http://nextcloud.local/index.php/avatar/bob/128", + "followers_count": 1, + "following_count": 2, + "statuses_count": 3, + "last_status_at": null, + "emojis": [], + "fields": [], + "pronouns": "", + "support_link": "" + }, + "filtered": [] + }, + { + "id": "1790637115422878628", + "nid": "1790637115422878628", + "local": true, + "archived": false, + "view_count": null, + "tagged_people": [], + "interest": null, + "content": "

@bob@nextcloud.local and back again

", + "sensitive": false, + "spoiler_text": "", + "visibility": "public", + "language": "en", + "place": null, + "in_reply_to_id": "1790637115351395147", + "in_reply_to_account_id": "7", + "quote": null, + "quote_approval": { + "automatic": [ + "public" + ], + "manual": [], + "current_user": "automatic" + }, + "reply_approval": null, + "interaction_policy": null, + "dislikes_count": null, + "disliked": null, + "video": null, + "mentions": [], + "emojis": [], + "tags": [], + "replies_count": 0, + "reblogs_count": 0, + "favourites_count": 0, + "favourited": false, + "reblogged": false, + "muted": false, + "bookmarked": false, + "pinned": false, + "card": null, + "reactions": [], + "poll": null, + "uri": "http://nextcloud.local/index.php/apps/social/@alice/17906371159153077137", + "url": "http://nextcloud.local/index.php/apps/social/@alice/17906371159153077137", + "reblog": null, + "media_attachments": [], + "created_at": "2026-09-28T23:11:55.000Z", + "edited_at": null, + "noindex": false, + "account": { + "id": "6", + "nid": "6", + "username": "alice", + "acct": "alice", + "display_name": "alice", + "locked": false, + "bot": false, + "discoverable": false, + "indexable": false, + "group": false, + "created_at": "2026-09-28T23:06:02.000Z", + "note": "", + "url": "http://nextcloud.local/index.php/apps/social/@alice", + "avatar": "", + "avatar_static": "", + "header": "http://nextcloud.local/index.php/avatar/alice/128", + "header_static": "http://nextcloud.local/index.php/avatar/alice/128", + "followers_count": 2, + "following_count": 2, + "statuses_count": 13, + "last_status_at": null, + "emojis": [], + "fields": [], + "pronouns": "", + "support_link": "" + }, + "filtered": [] + }, + { + "id": "1790637115351395147", + "nid": "1790637115351395147", + "local": true, + "archived": false, + "view_count": null, + "tagged_people": [], + "interest": null, + "content": "

@alice@nextcloud.local hi back!

", + "sensitive": false, + "spoiler_text": "", + "visibility": "public", + "language": "en", + "place": null, + "in_reply_to_id": "1790637085797595891", + "in_reply_to_account_id": "6", + "quote": null, + "quote_approval": { + "automatic": [ + "public" + ], + "manual": [], + "current_user": "automatic" + }, + "reply_approval": null, + "interaction_policy": null, + "dislikes_count": null, + "disliked": null, + "video": null, + "mentions": [], + "emojis": [], + "tags": [], + "replies_count": 1, + "reblogs_count": 0, + "favourites_count": 0, + "favourited": false, + "reblogged": false, + "muted": false, + "bookmarked": false, + "pinned": false, + "card": null, + "reactions": [], + "poll": null, + "uri": "http://nextcloud.local/index.php/apps/social/@bob/17906371156396819743", + "url": "http://nextcloud.local/index.php/apps/social/@bob/17906371156396819743", + "reblog": null, + "media_attachments": [], + "created_at": "2026-09-28T23:11:55.000Z", + "edited_at": null, + "noindex": false, + "account": { + "id": "7", + "nid": "7", + "username": "bob", + "acct": "bob", + "display_name": "bob", + "locked": false, + "bot": false, + "discoverable": false, + "indexable": false, + "group": false, + "created_at": "2026-09-28T23:06:03.000Z", + "note": "", + "url": "http://nextcloud.local/index.php/apps/social/@bob", + "avatar": "", + "avatar_static": "", + "header": "http://nextcloud.local/index.php/avatar/bob/128", + "header_static": "http://nextcloud.local/index.php/avatar/bob/128", + "followers_count": 1, + "following_count": 2, + "statuses_count": 3, + "last_status_at": null, + "emojis": [], + "fields": [], + "pronouns": "", + "support_link": "" + }, + "filtered": [] + }, + { + "id": "1790637112216295042", + "nid": "1790637112216295042", + "local": true, + "archived": false, + "view_count": null, + "tagged_people": [], + "interest": null, + "content": "

Picture from Berlin

", + "sensitive": false, + "spoiler_text": "", + "visibility": "public", + "language": "en", + "place": { + "id": "1", + "name": "Berlin", + "country": "DE", + "lat": "", + "long": "" + }, + "in_reply_to_id": null, + "in_reply_to_account_id": null, + "quote": null, + "quote_approval": { + "automatic": [ + "public" + ], + "manual": [], + "current_user": "automatic" + }, + "reply_approval": null, + "interaction_policy": null, + "dislikes_count": null, + "disliked": null, + "video": null, + "mentions": [], + "emojis": [], + "tags": [], + "replies_count": 0, + "reblogs_count": 0, + "favourites_count": 0, + "favourited": false, + "reblogged": false, + "muted": false, + "bookmarked": false, + "pinned": false, + "card": null, + "reactions": [], + "poll": null, + "uri": "http://nextcloud.local/index.php/apps/social/@alice/17906371127479253498", + "url": "http://nextcloud.local/index.php/apps/social/@alice/17906371127479253498", + "reblog": null, + "media_attachments": [ + { + "id": "15", + "type": "image", + "media_type": "image/jpeg", + "url": "http://nextcloud.local/index.php/apps/social/media/b88735cc-b169-40e0-9fa5-85e3be451c1a.jpeg", + "preview_url": "http://nextcloud.local/index.php/apps/social/media/e46d0547-3b30-46cd-8b5b-394bb7005a49.jpeg", + "hls_url": null, + "remote_url": null, + "cache_error": null, + "meta": { + "original": { + "width": 1080, + "height": 1350, + "size": "1080x1350", + "aspect": 0.8 + }, + "small": { + "width": 640, + "height": 800, + "size": "640x800", + "aspect": 0.8 + }, + "focus": { + "x": 0, + "y": 0 + } + }, + "description": "Another fractal, taken in Berlin", + "blurhash": "LyLow4+,$SN?w6S]WUjI[ibCSIbW" + } + ], + "created_at": "2026-09-28T23:11:52.000Z", + "edited_at": null, + "noindex": false, + "account": { + "id": "6", + "nid": "6", + "username": "alice", + "acct": "alice", + "display_name": "alice", + "locked": false, + "bot": false, + "discoverable": false, + "indexable": false, + "group": false, + "created_at": "2026-09-28T23:06:02.000Z", + "note": "", + "url": "http://nextcloud.local/index.php/apps/social/@alice", + "avatar": "", + "avatar_static": "", + "header": "http://nextcloud.local/index.php/avatar/alice/128", + "header_static": "http://nextcloud.local/index.php/avatar/alice/128", + "followers_count": 2, + "following_count": 2, + "statuses_count": 13, + "last_status_at": null, + "emojis": [], + "fields": [], + "pronouns": "", + "support_link": "" + }, + "filtered": [] + }, + { + "id": "1790637112034926731", + "nid": "1790637112034926731", + "local": true, + "archived": false, + "view_count": null, + "tagged_people": [], + "interest": null, + "content": "

First draft, edited

", + "sensitive": false, + "spoiler_text": "", + "visibility": "public", + "language": "en", + "place": null, + "in_reply_to_id": null, + "in_reply_to_account_id": null, + "quote": null, + "quote_approval": { + "automatic": [ + "public" + ], + "manual": [], + "current_user": "automatic" + }, + "reply_approval": null, + "interaction_policy": null, + "dislikes_count": null, + "disliked": null, + "video": null, + "mentions": [], + "emojis": [], + "tags": [], + "replies_count": 0, + "reblogs_count": 0, + "favourites_count": 0, + "favourited": false, + "reblogged": false, + "muted": false, + "bookmarked": false, + "pinned": false, + "card": null, + "reactions": [], + "poll": null, + "uri": "http://nextcloud.local/index.php/apps/social/@alice/17906371127199851091", + "url": "http://nextcloud.local/index.php/apps/social/@alice/17906371127199851091", + "reblog": null, + "media_attachments": [], + "created_at": "2026-09-28T23:11:52.000Z", + "edited_at": "2026-09-28T23:11:52.000Z", + "noindex": false, + "account": { + "id": "6", + "nid": "6", + "username": "alice", + "acct": "alice", + "display_name": "alice", + "locked": false, + "bot": false, + "discoverable": false, + "indexable": false, + "group": false, + "created_at": "2026-09-28T23:06:02.000Z", + "note": "", + "url": "http://nextcloud.local/index.php/apps/social/@alice", + "avatar": "", + "avatar_static": "", + "header": "http://nextcloud.local/index.php/avatar/alice/128", + "header_static": "http://nextcloud.local/index.php/avatar/alice/128", + "followers_count": 2, + "following_count": 2, + "statuses_count": 13, + "last_status_at": null, + "emojis": [], + "fields": [], + "pronouns": "", + "support_link": "" + }, + "filtered": [] + }, + { + "id": "1790637105561356153", + "nid": "1790637105561356153", + "local": true, + "archived": false, + "view_count": null, + "tagged_people": [], + "interest": null, + "content": "

Nextcloud Social on GitHub: https://github.com/nextcloud/social

", + "sensitive": false, + "spoiler_text": "", + "visibility": "public", + "language": "en", + "place": null, + "in_reply_to_id": null, + "in_reply_to_account_id": null, + "quote": null, + "quote_approval": { + "automatic": [ + "public" + ], + "manual": [], + "current_user": "automatic" + }, + "reply_approval": null, + "interaction_policy": null, + "dislikes_count": null, + "disliked": null, + "video": null, + "mentions": [], + "emojis": [], + "tags": [], + "replies_count": 0, + "reblogs_count": 0, + "favourites_count": 0, + "favourited": false, + "reblogged": false, + "muted": false, + "bookmarked": false, + "pinned": false, + "card": null, + "reactions": [], + "poll": null, + "uri": "http://nextcloud.local/index.php/apps/social/@alice/17906371057334696925", + "url": "http://nextcloud.local/index.php/apps/social/@alice/17906371057334696925", + "reblog": null, + "media_attachments": [], + "created_at": "2026-09-28T23:11:45.000Z", + "edited_at": null, + "noindex": false, + "account": { + "id": "6", + "nid": "6", + "username": "alice", + "acct": "alice", + "display_name": "alice", + "locked": false, + "bot": false, + "discoverable": false, + "indexable": false, + "group": false, + "created_at": "2026-09-28T23:06:02.000Z", + "note": "", + "url": "http://nextcloud.local/index.php/apps/social/@alice", + "avatar": "", + "avatar_static": "", + "header": "http://nextcloud.local/index.php/avatar/alice/128", + "header_static": "http://nextcloud.local/index.php/avatar/alice/128", + "followers_count": 2, + "following_count": 2, + "statuses_count": 13, + "last_status_at": null, + "emojis": [], + "fields": [], + "pronouns": "", + "support_link": "" + }, + "filtered": [] + }, + { + "id": "1790637105549523409", + "nid": "1790637105549523409", + "local": true, + "archived": false, + "view_count": null, + "tagged_people": [], + "interest": null, + "content": "

A 15 second tone #audio

", + "sensitive": false, + "spoiler_text": "", + "visibility": "public", + "language": "en", + "place": null, + "in_reply_to_id": null, + "in_reply_to_account_id": null, + "quote": null, + "quote_approval": { + "automatic": [ + "public" + ], + "manual": [], + "current_user": "automatic" + }, + "reply_approval": null, + "interaction_policy": null, + "dislikes_count": null, + "disliked": null, + "video": null, + "mentions": [], + "emojis": [], + "tags": [ + { + "name": "audio", + "url": "http://nextcloud.local/index.php/apps/social/timeline/tags/audio" + } + ], + "replies_count": 0, + "reblogs_count": 0, + "favourites_count": 0, + "favourited": false, + "reblogged": false, + "muted": false, + "bookmarked": false, + "pinned": false, + "card": null, + "reactions": [], + "poll": null, + "uri": "http://nextcloud.local/index.php/apps/social/@alice/17906371052336860333", + "url": "http://nextcloud.local/index.php/apps/social/@alice/17906371052336860333", + "reblog": null, + "media_attachments": [ + { + "id": "14", + "type": "audio", + "media_type": "audio/mpeg", + "url": "http://nextcloud.local/index.php/apps/social/media/2ee64fb3-dda9-47f5-bac6-daa189149093.mpeg", + "preview_url": "http://nextcloud.local/index.php/apps/social/media/2ee64fb3-dda9-47f5-bac6-daa189149093.mpeg", + "hls_url": null, + "remote_url": null, + "cache_error": null, + "meta": { + "original": [], + "small": [], + "focus": { + "x": 0, + "y": 0 + } + }, + "description": "A 15 second 330 Hz tone", + "blurhash": null + } + ], + "created_at": "2026-09-28T23:11:45.000Z", + "edited_at": null, + "noindex": false, + "account": { + "id": "6", + "nid": "6", + "username": "alice", + "acct": "alice", + "display_name": "alice", + "locked": false, + "bot": false, + "discoverable": false, + "indexable": false, + "group": false, + "created_at": "2026-09-28T23:06:02.000Z", + "note": "", + "url": "http://nextcloud.local/index.php/apps/social/@alice", + "avatar": "", + "avatar_static": "", + "header": "http://nextcloud.local/index.php/avatar/alice/128", + "header_static": "http://nextcloud.local/index.php/avatar/alice/128", + "followers_count": 2, + "following_count": 2, + "statuses_count": 13, + "last_status_at": null, + "emojis": [], + "fields": [], + "pronouns": "", + "support_link": "" + }, + "filtered": [] + }, + { + "id": "1790637104958214061", + "nid": "1790637104958214061", + "local": true, + "archived": false, + "view_count": null, + "tagged_people": [], + "interest": null, + "content": "

Test video #video

00:00 Intro
00:10 Middle
00:20 End

", + "sensitive": false, + "spoiler_text": "", + "visibility": "public", + "language": "en", + "place": null, + "in_reply_to_id": null, + "in_reply_to_account_id": null, + "quote": null, + "quote_approval": { + "automatic": [ + "public" + ], + "manual": [], + "current_user": "automatic" + }, + "reply_approval": null, + "interaction_policy": null, + "dislikes_count": 0, + "disliked": false, + "video": { + "title": "Aloha test video" + }, + "mentions": [], + "emojis": [], + "tags": [ + { + "name": "video", + "url": "http://nextcloud.local/index.php/apps/social/timeline/tags/video" + } + ], + "replies_count": 0, + "reblogs_count": 0, + "favourites_count": 1, + "favourited": false, + "reblogged": false, + "muted": false, + "bookmarked": false, + "pinned": false, + "card": null, + "reactions": [], + "poll": null, + "uri": "http://nextcloud.local/index.php/apps/social/@alice/17906371045255950881", + "url": "http://nextcloud.local/index.php/apps/social/@alice/17906371045255950881", + "reblog": null, + "media_attachments": [ + { + "id": "12", + "type": "video", + "media_type": "video/mp4", + "url": "http://nextcloud.local/index.php/apps/social/media/09c96321-538a-4d33-a3e3-e326ba1c5a56.mp4", + "preview_url": "http://nextcloud.local/index.php/apps/social/media/09c96321-538a-4d33-a3e3-e326ba1c5a56.mp4", + "hls_url": null, + "remote_url": null, + "cache_error": null, + "meta": { + "original": [], + "small": [], + "focus": { + "x": 0, + "y": 0 + } + }, + "description": "A 30 second test pattern with a tone", + "blurhash": null + } + ], + "created_at": "2026-09-28T23:11:44.000Z", + "edited_at": null, + "noindex": false, + "account": { + "id": "6", + "nid": "6", + "username": "alice", + "acct": "alice", + "display_name": "alice", + "locked": false, + "bot": false, + "discoverable": false, + "indexable": false, + "group": false, + "created_at": "2026-09-28T23:06:02.000Z", + "note": "", + "url": "http://nextcloud.local/index.php/apps/social/@alice", + "avatar": "", + "avatar_static": "", + "header": "http://nextcloud.local/index.php/avatar/alice/128", + "header_static": "http://nextcloud.local/index.php/avatar/alice/128", + "followers_count": 2, + "following_count": 2, + "statuses_count": 13, + "last_status_at": null, + "emojis": [], + "fields": [], + "pronouns": "", + "support_link": "" + }, + "filtered": [] + }, + { + "id": "1790637104870044831", + "nid": "1790637104870044831", + "local": true, + "archived": false, + "view_count": null, + "tagged_people": [], + "interest": null, + "content": "

Vertical test #shorts

", + "sensitive": false, + "spoiler_text": "", + "visibility": "public", + "language": "en", + "place": null, + "in_reply_to_id": null, + "in_reply_to_account_id": null, + "quote": null, + "quote_approval": { + "automatic": [ + "public" + ], + "manual": [], + "current_user": "automatic" + }, + "reply_approval": null, + "interaction_policy": null, + "dislikes_count": 0, + "disliked": false, + "video": null, + "mentions": [], + "emojis": [], + "tags": [ + { + "name": "shorts", + "url": "http://nextcloud.local/index.php/apps/social/timeline/tags/shorts" + } + ], + "replies_count": 0, + "reblogs_count": 0, + "favourites_count": 0, + "favourited": false, + "reblogged": false, + "muted": false, + "bookmarked": false, + "pinned": false, + "card": null, + "reactions": [], + "poll": null, + "uri": "http://nextcloud.local/index.php/apps/social/@alice/17906371044200157676", + "url": "http://nextcloud.local/index.php/apps/social/@alice/17906371044200157676", + "reblog": null, + "media_attachments": [ + { + "id": "13", + "type": "video", + "media_type": "video/mp4", + "url": "http://nextcloud.local/index.php/apps/social/media/9ca843a2-645e-439f-901c-3fd1d58c22b6.mp4", + "preview_url": "http://nextcloud.local/index.php/apps/social/media/9ca843a2-645e-439f-901c-3fd1d58c22b6.mp4", + "hls_url": null, + "remote_url": null, + "cache_error": null, + "meta": { + "original": [], + "small": [], + "focus": { + "x": 0, + "y": 0 + } + }, + "description": "A 12 second vertical test pattern", + "blurhash": null + } + ], + "created_at": "2026-09-28T23:11:44.000Z", + "edited_at": null, + "noindex": false, + "account": { + "id": "6", + "nid": "6", + "username": "alice", + "acct": "alice", + "display_name": "alice", + "locked": false, + "bot": false, + "discoverable": false, + "indexable": false, + "group": false, + "created_at": "2026-09-28T23:06:02.000Z", + "note": "", + "url": "http://nextcloud.local/index.php/apps/social/@alice", + "avatar": "", + "avatar_static": "", + "header": "http://nextcloud.local/index.php/avatar/alice/128", + "header_static": "http://nextcloud.local/index.php/avatar/alice/128", + "followers_count": 2, + "following_count": 2, + "statuses_count": 13, + "last_status_at": null, + "emojis": [], + "fields": [], + "pronouns": "", + "support_link": "" + }, + "filtered": [] + }, + { + "id": "1790637103267719986", + "nid": "1790637103267719986", + "local": true, + "archived": false, + "view_count": null, + "tagged_people": [], + "interest": null, + "content": "

A picture without alt text, marked sensitive

", + "sensitive": true, + "spoiler_text": "", + "visibility": "public", + "language": "en", + "place": null, + "in_reply_to_id": null, + "in_reply_to_account_id": null, + "quote": null, + "quote_approval": { + "automatic": [ + "public" + ], + "manual": [], + "current_user": "automatic" + }, + "reply_approval": null, + "interaction_policy": null, + "dislikes_count": null, + "disliked": null, + "video": null, + "mentions": [], + "emojis": [], + "tags": [], + "replies_count": 0, + "reblogs_count": 0, + "favourites_count": 0, + "favourited": false, + "reblogged": false, + "muted": false, + "bookmarked": false, + "pinned": false, + "card": null, + "reactions": [], + "poll": null, + "uri": "http://nextcloud.local/index.php/apps/social/@alice/17906371033997341160", + "url": "http://nextcloud.local/index.php/apps/social/@alice/17906371033997341160", + "reblog": null, + "media_attachments": [ + { + "id": "11", + "type": "image", + "media_type": "image/png", + "url": "http://nextcloud.local/index.php/apps/social/media/347a8309-ea20-4900-8e4c-aff1e8b6a98b.png", + "preview_url": "http://nextcloud.local/index.php/apps/social/media/6bb97fab-b644-4c51-8b1b-8ea5921fdef0.png", + "hls_url": null, + "remote_url": null, + "cache_error": null, + "meta": { + "original": { + "width": 1080, + "height": 1080, + "size": "1080x1080", + "aspect": 1 + }, + "small": { + "width": 800, + "height": 800, + "size": "800x800", + "aspect": 1 + }, + "focus": { + "x": 0, + "y": 0 + } + }, + "description": null, + "blurhash": "LRF~:K*DD7L+S^o@$T+kR5wiM_IU" + } + ], + "created_at": "2026-09-28T23:11:43.000Z", + "edited_at": null, + "noindex": false, + "account": { + "id": "6", + "nid": "6", + "username": "alice", + "acct": "alice", + "display_name": "alice", + "locked": false, + "bot": false, + "discoverable": false, + "indexable": false, + "group": false, + "created_at": "2026-09-28T23:06:02.000Z", + "note": "", + "url": "http://nextcloud.local/index.php/apps/social/@alice", + "avatar": "", + "avatar_static": "", + "header": "http://nextcloud.local/index.php/avatar/alice/128", + "header_static": "http://nextcloud.local/index.php/avatar/alice/128", + "followers_count": 2, + "following_count": 2, + "statuses_count": 13, + "last_status_at": null, + "emojis": [], + "fields": [], + "pronouns": "", + "support_link": "" + }, + "filtered": [] + }, + { + "id": "1790637096330770789", + "nid": "1790637096330770789", + "local": true, + "archived": false, + "view_count": null, + "tagged_people": [], + "interest": null, + "content": "

Two test pictures #photography

", + "sensitive": false, + "spoiler_text": "", + "visibility": "public", + "language": "en", + "place": null, + "in_reply_to_id": null, + "in_reply_to_account_id": null, + "quote": null, + "quote_approval": { + "automatic": [ + "public" + ], + "manual": [], + "current_user": "automatic" + }, + "reply_approval": null, + "interaction_policy": null, + "dislikes_count": null, + "disliked": null, + "video": null, + "mentions": [], + "emojis": [], + "tags": [ + { + "name": "photography", + "url": "http://nextcloud.local/index.php/apps/social/timeline/tags/photography" + } + ], + "replies_count": 0, + "reblogs_count": 1, + "favourites_count": 0, + "favourited": false, + "reblogged": false, + "muted": false, + "bookmarked": false, + "pinned": false, + "card": null, + "reactions": [], + "poll": null, + "uri": "http://nextcloud.local/index.php/apps/social/@alice/17906370966999687509", + "url": "http://nextcloud.local/index.php/apps/social/@alice/17906370966999687509", + "reblog": null, + "media_attachments": [ + { + "id": "9", + "type": "image", + "media_type": "image/jpeg", + "url": "http://nextcloud.local/index.php/apps/social/media/48044adf-b771-4343-9607-c7fc93f16ad1.jpeg", + "preview_url": "http://nextcloud.local/index.php/apps/social/media/2c66bfd7-0029-47e1-b6ef-23dc7d422e2b.jpeg", + "hls_url": null, + "remote_url": null, + "cache_error": null, + "meta": { + "original": { + "width": 1600, + "height": 1200, + "size": "1600x1200", + "aspect": 1.3333333333333333 + }, + "small": { + "width": 800, + "height": 600, + "size": "800x600", + "aspect": 1.3333333333333333 + }, + "focus": { + "x": 0.3, + "y": -0.2 + } + }, + "description": "A test card pattern in bright colours", + "blurhash": "LVL4]9|Tnk_gsESdfjn,baayW;jZ" + }, + { + "id": "10", + "type": "image", + "media_type": "image/jpeg", + "url": "http://nextcloud.local/index.php/apps/social/media/1e936701-3c1a-49c2-a58a-bc7b0d5a9c27.jpeg", + "preview_url": "http://nextcloud.local/index.php/apps/social/media/43d878b8-d9fd-42fa-9f7e-38dea1357b60.jpeg", + "hls_url": null, + "remote_url": null, + "cache_error": null, + "meta": { + "original": { + "width": 1080, + "height": 1350, + "size": "1080x1350", + "aspect": 0.8 + }, + "small": { + "width": 640, + "height": 800, + "size": "640x800", + "aspect": 0.8 + }, + "focus": { + "x": 0, + "y": 0 + } + }, + "description": "A Mandelbrot fractal, portrait", + "blurhash": "LyLow4+,$SN?w6S]WUjI[ibCSIbW" + } + ], + "created_at": "2026-09-28T23:11:36.000Z", + "edited_at": null, + "noindex": false, + "account": { + "id": "6", + "nid": "6", + "username": "alice", + "acct": "alice", + "display_name": "alice", + "locked": false, + "bot": false, + "discoverable": false, + "indexable": false, + "group": false, + "created_at": "2026-09-28T23:06:02.000Z", + "note": "", + "url": "http://nextcloud.local/index.php/apps/social/@alice", + "avatar": "", + "avatar_static": "", + "header": "http://nextcloud.local/index.php/avatar/alice/128", + "header_static": "http://nextcloud.local/index.php/avatar/alice/128", + "followers_count": 2, + "following_count": 2, + "statuses_count": 13, + "last_status_at": null, + "emojis": [], + "fields": [], + "pronouns": "", + "support_link": "" + }, + "filtered": [] + }, + { + "id": "1790637087495743511", + "nid": "1790637087495743511", + "local": true, + "archived": false, + "view_count": null, + "tagged_people": [], + "interest": null, + "content": "

Which mode do you use most?

", + "sensitive": false, + "spoiler_text": "", + "visibility": "public", + "language": "en", + "place": null, + "in_reply_to_id": null, + "in_reply_to_account_id": null, + "quote": null, + "quote_approval": { + "automatic": [ + "public" + ], + "manual": [], + "current_user": "automatic" + }, + "reply_approval": null, + "interaction_policy": null, + "dislikes_count": null, + "disliked": null, + "video": null, + "mentions": [], + "emojis": [], + "tags": [], + "replies_count": 0, + "reblogs_count": 0, + "favourites_count": 0, + "favourited": false, + "reblogged": false, + "muted": false, + "bookmarked": false, + "pinned": false, + "card": null, + "reactions": [], + "poll": { + "id": "1790637087495743511", + "expires_at": "2026-09-29T23:11:27Z", + "expired": false, + "multiple": false, + "votes_count": 1, + "voters_count": 1, + "options": [ + { + "title": "Photos", + "votes_count": 1 + }, + { + "title": "Video", + "votes_count": 0 + }, + { + "title": "Shorts", + "votes_count": 0 + } + ], + "emojis": [], + "voted": false, + "own_votes": [] + }, + "uri": "http://nextcloud.local/index.php/apps/social/@alice/17906370875255636478", + "url": "http://nextcloud.local/index.php/apps/social/@alice/17906370875255636478", + "reblog": null, + "media_attachments": [], + "created_at": "2026-09-28T23:11:27.000Z", + "edited_at": null, + "noindex": false, + "account": { + "id": "6", + "nid": "6", + "username": "alice", + "acct": "alice", + "display_name": "alice", + "locked": false, + "bot": false, + "discoverable": false, + "indexable": false, + "group": false, + "created_at": "2026-09-28T23:06:02.000Z", + "note": "", + "url": "http://nextcloud.local/index.php/apps/social/@alice", + "avatar": "", + "avatar_static": "", + "header": "http://nextcloud.local/index.php/avatar/alice/128", + "header_static": "http://nextcloud.local/index.php/avatar/alice/128", + "followers_count": 2, + "following_count": 2, + "statuses_count": 13, + "last_status_at": null, + "emojis": [], + "fields": [], + "pronouns": "", + "support_link": "" + }, + "oneOf": [ + { + "type": "Note", + "name": "Photos", + "replies": { + "type": "Collection", + "totalItems": 1 + } + }, + { + "type": "Note", + "name": "Video", + "replies": { + "type": "Collection", + "totalItems": 0 + } + }, + { + "type": "Note", + "name": "Shorts", + "replies": { + "type": "Collection", + "totalItems": 0 + } + } + ], + "endTime": "2026-09-29T23:11:27Z", + "votersCount": 1, + "filtered": [] + }, + { + "id": "1790637086177868305", + "nid": "1790637086177868305", + "local": true, + "archived": false, + "view_count": null, + "tagged_people": [], + "interest": null, + "content": "

The butler did it.

", + "sensitive": true, + "spoiler_text": "Spoiler: season finale", + "visibility": "public", + "language": "en", + "place": null, + "in_reply_to_id": null, + "in_reply_to_account_id": null, + "quote": null, + "quote_approval": { + "automatic": [ + "public" + ], + "manual": [], + "current_user": "automatic" + }, + "reply_approval": null, + "interaction_policy": null, + "dislikes_count": null, + "disliked": null, + "video": null, + "mentions": [], + "emojis": [], + "tags": [], + "replies_count": 0, + "reblogs_count": 0, + "favourites_count": 0, + "favourited": false, + "reblogged": false, + "muted": false, + "bookmarked": false, + "pinned": false, + "card": null, + "reactions": [], + "poll": null, + "uri": "http://nextcloud.local/index.php/apps/social/@alice/17906370868446468411", + "url": "http://nextcloud.local/index.php/apps/social/@alice/17906370868446468411", + "reblog": null, + "media_attachments": [], + "created_at": "2026-09-28T23:11:26.000Z", + "edited_at": null, + "noindex": false, + "account": { + "id": "6", + "nid": "6", + "username": "alice", + "acct": "alice", + "display_name": "alice", + "locked": false, + "bot": false, + "discoverable": false, + "indexable": false, + "group": false, + "created_at": "2026-09-28T23:06:02.000Z", + "note": "", + "url": "http://nextcloud.local/index.php/apps/social/@alice", + "avatar": "", + "avatar_static": "", + "header": "http://nextcloud.local/index.php/avatar/alice/128", + "header_static": "http://nextcloud.local/index.php/avatar/alice/128", + "followers_count": 2, + "following_count": 2, + "statuses_count": 13, + "last_status_at": null, + "emojis": [], + "fields": [], + "pronouns": "", + "support_link": "" + }, + "filtered": [] + }, + { + "id": "1790637085797595891", + "nid": "1790637085797595891", + "local": true, + "archived": false, + "view_count": null, + "tagged_people": [], + "interest": null, + "content": "

Aloha from the dev instance! Say hi, @bob@nextcloud.local #aloha #android

", + "sensitive": false, + "spoiler_text": "", + "visibility": "public", + "language": "en", + "place": null, + "in_reply_to_id": null, + "in_reply_to_account_id": null, + "quote": null, + "quote_approval": { + "automatic": [ + "public" + ], + "manual": [], + "current_user": "automatic" + }, + "reply_approval": null, + "interaction_policy": null, + "dislikes_count": null, + "disliked": null, + "video": null, + "mentions": [], + "emojis": [], + "tags": [ + { + "name": "aloha", + "url": "http://nextcloud.local/index.php/apps/social/timeline/tags/aloha" + }, + { + "name": "android", + "url": "http://nextcloud.local/index.php/apps/social/timeline/tags/android" + } + ], + "replies_count": 1, + "reblogs_count": 0, + "favourites_count": 1, + "favourited": false, + "reblogged": false, + "muted": false, + "bookmarked": false, + "pinned": false, + "card": null, + "reactions": [ + { + "name": "🎉", + "count": 1, + "me": false + } + ], + "poll": null, + "uri": "http://nextcloud.local/index.php/apps/social/@alice/17906370853806247191", + "url": "http://nextcloud.local/index.php/apps/social/@alice/17906370853806247191", + "reblog": null, + "media_attachments": [], + "created_at": "2026-09-28T23:11:25.000Z", + "edited_at": null, + "noindex": false, + "account": { + "id": "6", + "nid": "6", + "username": "alice", + "acct": "alice", + "display_name": "alice", + "locked": false, + "bot": false, + "discoverable": false, + "indexable": false, + "group": false, + "created_at": "2026-09-28T23:06:02.000Z", + "note": "", + "url": "http://nextcloud.local/index.php/apps/social/@alice", + "avatar": "", + "avatar_static": "", + "header": "http://nextcloud.local/index.php/avatar/alice/128", + "header_static": "http://nextcloud.local/index.php/avatar/alice/128", + "followers_count": 2, + "following_count": 2, + "statuses_count": 13, + "last_status_at": null, + "emojis": [], + "fields": [], + "pronouns": "", + "support_link": "" + }, + "filtered": [] + } + ] +} diff --git a/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/timeline-tag-aloha.json b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/timeline-tag-aloha.json new file mode 100644 index 00000000..610f6038 --- /dev/null +++ b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/timeline-tag-aloha.json @@ -0,0 +1,195 @@ +{ + "request": { + "method": "GET", + "path": "/index.php/apps/social/api/v1/timelines/tag/aloha?limit=20" + }, + "status": 200, + "headers": { + "content-type": "application/json; charset=utf-8", + "cache-control": "no-cache, no-store, must-revalidate", + "link": "; rel=\"prev\"", + "x-ratelimit-limit": "300", + "x-ratelimit-remaining": "253", + "x-ratelimit-reset": "1790637300" + }, + "body": [ + { + "id": "1790637117344364158", + "nid": "1790637117344364158", + "local": true, + "archived": false, + "view_count": null, + "tagged_people": [], + "interest": null, + "content": "

Bob's own post #aloha

", + "sensitive": false, + "spoiler_text": "", + "visibility": "public", + "language": "en", + "place": null, + "in_reply_to_id": null, + "in_reply_to_account_id": null, + "quote": null, + "quote_approval": { + "automatic": [ + "public" + ], + "manual": [], + "current_user": "automatic" + }, + "reply_approval": null, + "interaction_policy": null, + "dislikes_count": null, + "disliked": null, + "video": null, + "mentions": [], + "emojis": [], + "tags": [ + { + "name": "aloha", + "url": "http://nextcloud.local/index.php/apps/social/timeline/tags/aloha" + } + ], + "replies_count": 0, + "reblogs_count": 0, + "favourites_count": 0, + "favourited": false, + "reblogged": false, + "muted": false, + "bookmarked": false, + "pinned": false, + "card": null, + "reactions": [], + "poll": null, + "uri": "http://nextcloud.local/index.php/apps/social/@bob/17906371177254273993", + "url": "http://nextcloud.local/index.php/apps/social/@bob/17906371177254273993", + "reblog": null, + "media_attachments": [], + "created_at": "2026-09-28T23:11:57.000Z", + "edited_at": null, + "noindex": false, + "account": { + "id": "7", + "nid": "7", + "username": "bob", + "acct": "bob", + "display_name": "bob", + "locked": false, + "bot": false, + "discoverable": false, + "indexable": false, + "group": false, + "created_at": "2026-09-28T23:06:03.000Z", + "note": "", + "url": "http://nextcloud.local/index.php/apps/social/@bob", + "avatar": "", + "avatar_static": "", + "header": "http://nextcloud.local/index.php/avatar/bob/128", + "header_static": "http://nextcloud.local/index.php/avatar/bob/128", + "followers_count": 1, + "following_count": 2, + "statuses_count": 3, + "last_status_at": null, + "emojis": [], + "fields": [], + "pronouns": "", + "support_link": "" + }, + "filtered": [] + }, + { + "id": "1790637085797595891", + "nid": "1790637085797595891", + "local": true, + "archived": false, + "view_count": null, + "tagged_people": [], + "interest": null, + "content": "

Aloha from the dev instance! Say hi, @bob@nextcloud.local #aloha #android

", + "sensitive": false, + "spoiler_text": "", + "visibility": "public", + "language": "en", + "place": null, + "in_reply_to_id": null, + "in_reply_to_account_id": null, + "quote": null, + "quote_approval": { + "automatic": [ + "public" + ], + "manual": [], + "current_user": "automatic" + }, + "reply_approval": null, + "interaction_policy": null, + "dislikes_count": null, + "disliked": null, + "video": null, + "mentions": [], + "emojis": [], + "tags": [ + { + "name": "aloha", + "url": "http://nextcloud.local/index.php/apps/social/timeline/tags/aloha" + }, + { + "name": "android", + "url": "http://nextcloud.local/index.php/apps/social/timeline/tags/android" + } + ], + "replies_count": 1, + "reblogs_count": 0, + "favourites_count": 1, + "favourited": false, + "reblogged": false, + "muted": false, + "bookmarked": false, + "pinned": false, + "card": null, + "reactions": [ + { + "name": "🎉", + "count": 1, + "me": false + } + ], + "poll": null, + "uri": "http://nextcloud.local/index.php/apps/social/@alice/17906370853806247191", + "url": "http://nextcloud.local/index.php/apps/social/@alice/17906370853806247191", + "reblog": null, + "media_attachments": [], + "created_at": "2026-09-28T23:11:25.000Z", + "edited_at": null, + "noindex": false, + "account": { + "id": "6", + "nid": "6", + "username": "alice", + "acct": "alice", + "display_name": "alice", + "locked": false, + "bot": false, + "discoverable": false, + "indexable": false, + "group": false, + "created_at": "2026-09-28T23:06:02.000Z", + "note": "", + "url": "http://nextcloud.local/index.php/apps/social/@alice", + "avatar": "", + "avatar_static": "", + "header": "http://nextcloud.local/index.php/avatar/alice/128", + "header_static": "http://nextcloud.local/index.php/avatar/alice/128", + "followers_count": 2, + "following_count": 2, + "statuses_count": 13, + "last_status_at": null, + "emojis": [], + "fields": [], + "pronouns": "", + "support_link": "" + }, + "filtered": [] + } + ] +} diff --git a/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/trends-links.json b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/trends-links.json new file mode 100644 index 00000000..c8434f5d --- /dev/null +++ b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/trends-links.json @@ -0,0 +1,15 @@ +{ + "request": { + "method": "GET", + "path": "/index.php/apps/social/api/v1/trends/links" + }, + "status": 200, + "headers": { + "content-type": "application/json; charset=utf-8", + "cache-control": "no-cache, no-store, must-revalidate", + "x-ratelimit-limit": "300", + "x-ratelimit-remaining": "203", + "x-ratelimit-reset": "1790637300" + }, + "body": [] +} diff --git a/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/trends-statuses.json b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/trends-statuses.json new file mode 100644 index 00000000..9db54eca --- /dev/null +++ b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/trends-statuses.json @@ -0,0 +1,353 @@ +{ + "request": { + "method": "GET", + "path": "/index.php/apps/social/api/v1/trends/statuses" + }, + "status": 200, + "headers": { + "content-type": "application/json; charset=utf-8", + "cache-control": "no-cache, no-store, must-revalidate", + "x-ratelimit-limit": "300", + "x-ratelimit-remaining": "204", + "x-ratelimit-reset": "1790637300" + }, + "body": [ + { + "id": "1790637085797595891", + "nid": "1790637085797595891", + "local": true, + "archived": false, + "view_count": null, + "tagged_people": [], + "interest": null, + "content": "

Aloha from the dev instance! Say hi, @bob@nextcloud.local #aloha #android

", + "sensitive": false, + "spoiler_text": "", + "visibility": "public", + "language": "en", + "place": null, + "in_reply_to_id": null, + "in_reply_to_account_id": null, + "quote": null, + "quote_approval": { + "automatic": [ + "public" + ], + "manual": [], + "current_user": "automatic" + }, + "reply_approval": null, + "interaction_policy": null, + "dislikes_count": null, + "disliked": null, + "video": null, + "mentions": [], + "emojis": [], + "tags": [ + { + "name": "aloha", + "url": "http://nextcloud.local/index.php/apps/social/timeline/tags/aloha" + }, + { + "name": "android", + "url": "http://nextcloud.local/index.php/apps/social/timeline/tags/android" + } + ], + "replies_count": 1, + "reblogs_count": 0, + "favourites_count": 1, + "favourited": false, + "reblogged": false, + "muted": false, + "bookmarked": false, + "pinned": false, + "card": null, + "reactions": [], + "poll": null, + "uri": "http://nextcloud.local/index.php/apps/social/@alice/17906370853806247191", + "url": "http://nextcloud.local/index.php/apps/social/@alice/17906370853806247191", + "reblog": null, + "media_attachments": [], + "created_at": "2026-09-28T23:11:25.000Z", + "edited_at": null, + "noindex": false, + "account": { + "id": "6", + "nid": "6", + "username": "alice", + "acct": "alice", + "display_name": "alice", + "locked": false, + "bot": false, + "discoverable": false, + "indexable": false, + "group": false, + "created_at": "2026-09-28T23:06:02.000Z", + "note": "", + "url": "http://nextcloud.local/index.php/apps/social/@alice", + "avatar": "", + "avatar_static": "", + "header": "http://nextcloud.local/index.php/avatar/alice/128", + "header_static": "http://nextcloud.local/index.php/avatar/alice/128", + "followers_count": 2, + "following_count": 2, + "statuses_count": 13, + "last_status_at": null, + "emojis": [], + "fields": [], + "pronouns": "", + "support_link": "" + } + }, + { + "id": "1790637104958214061", + "nid": "1790637104958214061", + "local": true, + "archived": false, + "view_count": null, + "tagged_people": [], + "interest": null, + "content": "

Test video #video

00:00 Intro
00:10 Middle
00:20 End

", + "sensitive": false, + "spoiler_text": "", + "visibility": "public", + "language": "en", + "place": null, + "in_reply_to_id": null, + "in_reply_to_account_id": null, + "quote": null, + "quote_approval": { + "automatic": [ + "public" + ], + "manual": [], + "current_user": "automatic" + }, + "reply_approval": null, + "interaction_policy": null, + "dislikes_count": 0, + "disliked": false, + "video": { + "title": "Aloha test video" + }, + "mentions": [], + "emojis": [], + "tags": [ + { + "name": "video", + "url": "http://nextcloud.local/index.php/apps/social/timeline/tags/video" + } + ], + "replies_count": 0, + "reblogs_count": 0, + "favourites_count": 1, + "favourited": false, + "reblogged": false, + "muted": false, + "bookmarked": false, + "pinned": false, + "card": null, + "reactions": [], + "poll": null, + "uri": "http://nextcloud.local/index.php/apps/social/@alice/17906371045255950881", + "url": "http://nextcloud.local/index.php/apps/social/@alice/17906371045255950881", + "reblog": null, + "media_attachments": [ + { + "id": "12", + "type": "video", + "media_type": "video/mp4", + "url": "http://nextcloud.local/index.php/apps/social/media/09c96321-538a-4d33-a3e3-e326ba1c5a56.mp4", + "preview_url": "http://nextcloud.local/index.php/apps/social/media/09c96321-538a-4d33-a3e3-e326ba1c5a56.mp4", + "hls_url": null, + "remote_url": null, + "cache_error": null, + "meta": { + "original": [], + "small": [], + "focus": { + "x": 0, + "y": 0 + } + }, + "description": "A 30 second test pattern with a tone", + "blurhash": null + } + ], + "created_at": "2026-09-28T23:11:44.000Z", + "edited_at": null, + "noindex": false, + "account": { + "id": "6", + "nid": "6", + "username": "alice", + "acct": "alice", + "display_name": "alice", + "locked": false, + "bot": false, + "discoverable": false, + "indexable": false, + "group": false, + "created_at": "2026-09-28T23:06:02.000Z", + "note": "", + "url": "http://nextcloud.local/index.php/apps/social/@alice", + "avatar": "", + "avatar_static": "", + "header": "http://nextcloud.local/index.php/avatar/alice/128", + "header_static": "http://nextcloud.local/index.php/avatar/alice/128", + "followers_count": 2, + "following_count": 2, + "statuses_count": 13, + "last_status_at": null, + "emojis": [], + "fields": [], + "pronouns": "", + "support_link": "" + } + }, + { + "id": "1790637096330770789", + "nid": "1790637096330770789", + "local": true, + "archived": false, + "view_count": null, + "tagged_people": [], + "interest": null, + "content": "

Two test pictures #photography

", + "sensitive": false, + "spoiler_text": "", + "visibility": "public", + "language": "en", + "place": null, + "in_reply_to_id": null, + "in_reply_to_account_id": null, + "quote": null, + "quote_approval": { + "automatic": [ + "public" + ], + "manual": [], + "current_user": "automatic" + }, + "reply_approval": null, + "interaction_policy": null, + "dislikes_count": null, + "disliked": null, + "video": null, + "mentions": [], + "emojis": [], + "tags": [ + { + "name": "photography", + "url": "http://nextcloud.local/index.php/apps/social/timeline/tags/photography" + } + ], + "replies_count": 0, + "reblogs_count": 1, + "favourites_count": 0, + "favourited": false, + "reblogged": false, + "muted": false, + "bookmarked": false, + "pinned": false, + "card": null, + "reactions": [], + "poll": null, + "uri": "http://nextcloud.local/index.php/apps/social/@alice/17906370966999687509", + "url": "http://nextcloud.local/index.php/apps/social/@alice/17906370966999687509", + "reblog": null, + "media_attachments": [ + { + "id": "9", + "type": "image", + "media_type": "image/jpeg", + "url": "http://nextcloud.local/index.php/apps/social/media/48044adf-b771-4343-9607-c7fc93f16ad1.jpeg", + "preview_url": "http://nextcloud.local/index.php/apps/social/media/2c66bfd7-0029-47e1-b6ef-23dc7d422e2b.jpeg", + "hls_url": null, + "remote_url": null, + "cache_error": null, + "meta": { + "original": { + "width": 1600, + "height": 1200, + "size": "1600x1200", + "aspect": 1.3333333333333333 + }, + "small": { + "width": 800, + "height": 600, + "size": "800x600", + "aspect": 1.3333333333333333 + }, + "focus": { + "x": 0.3, + "y": -0.2 + } + }, + "description": "A test card pattern in bright colours", + "blurhash": "LVL4]9|Tnk_gsESdfjn,baayW;jZ" + }, + { + "id": "10", + "type": "image", + "media_type": "image/jpeg", + "url": "http://nextcloud.local/index.php/apps/social/media/1e936701-3c1a-49c2-a58a-bc7b0d5a9c27.jpeg", + "preview_url": "http://nextcloud.local/index.php/apps/social/media/43d878b8-d9fd-42fa-9f7e-38dea1357b60.jpeg", + "hls_url": null, + "remote_url": null, + "cache_error": null, + "meta": { + "original": { + "width": 1080, + "height": 1350, + "size": "1080x1350", + "aspect": 0.8 + }, + "small": { + "width": 640, + "height": 800, + "size": "640x800", + "aspect": 0.8 + }, + "focus": { + "x": 0, + "y": 0 + } + }, + "description": "A Mandelbrot fractal, portrait", + "blurhash": "LyLow4+,$SN?w6S]WUjI[ibCSIbW" + } + ], + "created_at": "2026-09-28T23:11:36.000Z", + "edited_at": null, + "noindex": false, + "account": { + "id": "6", + "nid": "6", + "username": "alice", + "acct": "alice", + "display_name": "alice", + "locked": false, + "bot": false, + "discoverable": false, + "indexable": false, + "group": false, + "created_at": "2026-09-28T23:06:02.000Z", + "note": "", + "url": "http://nextcloud.local/index.php/apps/social/@alice", + "avatar": "", + "avatar_static": "", + "header": "http://nextcloud.local/index.php/avatar/alice/128", + "header_static": "http://nextcloud.local/index.php/avatar/alice/128", + "followers_count": 2, + "following_count": 2, + "statuses_count": 13, + "last_status_at": null, + "emojis": [], + "fields": [], + "pronouns": "", + "support_link": "" + } + } + ] +} diff --git a/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/trends-tags.json b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/trends-tags.json new file mode 100644 index 00000000..a93d70c0 --- /dev/null +++ b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/api/trends-tags.json @@ -0,0 +1,16 @@ +{ + "request": { + "method": "GET", + "path": "/index.php/apps/social/api/v1/trends/tags" + }, + "status": 200, + "headers": { + "content-type": "application/json; charset=utf-8", + "cache-control": "private, no-cache", + "etag": "\"a58caa41a87fcece526fa814095e68a3c87ef0d7\"", + "x-ratelimit-limit": "300", + "x-ratelimit-remaining": "205", + "x-ratelimit-reset": "1790637300" + }, + "body": [] +} diff --git a/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/errors/401-bad-token.json b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/errors/401-bad-token.json new file mode 100644 index 00000000..9c0f44b9 --- /dev/null +++ b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/errors/401-bad-token.json @@ -0,0 +1,18 @@ +{ + "request": { + "method": "GET", + "path": "/index.php/apps/social/api/v1/accounts/verify_credentials" + }, + "status": 401, + "headers": { + "content-type": "application/json; charset=utf-8", + "cache-control": "no-cache, no-store, must-revalidate", + "www-authenticate": "Bearer error=\"invalid_token\"", + "x-ratelimit-limit": "300", + "x-ratelimit-remaining": "179", + "x-ratelimit-reset": "1790637300" + }, + "body": { + "error": "the access_token was revoked" + } +} diff --git a/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/errors/401-no-token.json b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/errors/401-no-token.json new file mode 100644 index 00000000..f42784c3 --- /dev/null +++ b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/errors/401-no-token.json @@ -0,0 +1,18 @@ +{ + "request": { + "method": "GET", + "path": "/index.php/apps/social/api/v1/timelines/home" + }, + "status": 401, + "headers": { + "content-type": "application/json; charset=utf-8", + "cache-control": "no-cache, no-store, must-revalidate", + "www-authenticate": "Bearer error=\"invalid_token\"", + "x-ratelimit-limit": "60", + "x-ratelimit-remaining": "50", + "x-ratelimit-reset": "1790637300" + }, + "body": { + "error": "the access_token was revoked" + } +} diff --git a/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/errors/403-account-signup.json b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/errors/403-account-signup.json new file mode 100644 index 00000000..a5379fe6 --- /dev/null +++ b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/errors/403-account-signup.json @@ -0,0 +1,25 @@ +{ + "request": { + "method": "POST", + "path": "/index.php/apps/social/api/v1/accounts" + }, + "status": 403, + "headers": { + "content-type": "application/json; charset=utf-8", + "cache-control": "no-cache, no-store, must-revalidate", + "x-ratelimit-limit": "300", + "x-ratelimit-remaining": "177", + "x-ratelimit-reset": "1790637300" + }, + "body": { + "error": "Account registration is not handled by this application", + "details": { + "base": [ + { + "error": "ERR_BLOCKED", + "description": "An account on this server is a Nextcloud account, created by an administrator. Once it exists, this application gives it a fediverse identity; there is nothing to sign up for here." + } + ] + } + } +} diff --git a/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/errors/404-push-subscription.json b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/errors/404-push-subscription.json new file mode 100644 index 00000000..3a5e003b --- /dev/null +++ b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/errors/404-push-subscription.json @@ -0,0 +1,12 @@ +{ + "request": { + "method": "GET", + "path": "/index.php/apps/social/api/v1/push/subscription" + }, + "status": 404, + "headers": { + "content-type": "text/html; charset=UTF-8", + "cache-control": "no-cache, no-store, must-revalidate" + }, + "body": "\n\n\t\n\t\t\n\t\t\n\t\t\tNextcloud\t\t\n\t\t\n\t\t\n\t\t\t\t\n\t\t\t\t\n\t\t\n\t\t\n\t\t\n\t\t\n\t\t\n\n\n\t\t\n\n\n\n\t\t\t\n\t\n\t\t\n\t\t
\n\t\t\t\n\t\t\t\n\t\t\t\n\t
\n\t\t
\n\t\t\t
\n\t\t\t\t\t\t\t\t\t
\n\t\t\t\t\t\t
\n\t\t\t\t\t\t\t
\n\t\t\t\t\t\t
\n\t\t\t\t\t
\n\t\t\t\t\t\t\t\t
\n\t\t\t\t\t

\n\t\t\t\t\t\tNextcloud\t\t\t\t\t

\n\t\t\t\t\t\t\n\t\t\t\t
\n\t\t\t
\n\t\t
\n\t\t\t\t
\n\t\t\t

\n\t\t\t\tNextcloud – a safe home for all your data\t\t\t

\n\t\t
\n\t\n\n" +} diff --git a/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/errors/404-status.json b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/errors/404-status.json new file mode 100644 index 00000000..5191a2d2 --- /dev/null +++ b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/errors/404-status.json @@ -0,0 +1,17 @@ +{ + "request": { + "method": "GET", + "path": "/index.php/apps/social/api/v1/statuses/1" + }, + "status": 404, + "headers": { + "content-type": "application/json; charset=utf-8", + "cache-control": "no-cache, no-store, must-revalidate", + "x-ratelimit-limit": "300", + "x-ratelimit-remaining": "178", + "x-ratelimit-reset": "1790637300" + }, + "body": { + "error": "stream not found" + } +} diff --git a/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/errors/404-streaming.json b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/errors/404-streaming.json new file mode 100644 index 00000000..0d8b6f85 --- /dev/null +++ b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/errors/404-streaming.json @@ -0,0 +1,12 @@ +{ + "request": { + "method": "GET", + "path": "/index.php/apps/social/api/v1/streaming/health" + }, + "status": 404, + "headers": { + "content-type": "text/html; charset=UTF-8", + "cache-control": "no-cache, no-store, must-revalidate" + }, + "body": "\n\n\t\n\t\t\n\t\t\n\t\t\tNextcloud\t\t\n\t\t\n\t\t\n\t\t\t\t\n\t\t\t\t\n\t\t\n\t\t\n\t\t\n\t\t\n\t\t\n\n\n\t\t\n\n\n\n\t\t\t\n\t\n\t\t\n\t\t
\n\t\t\t\n\t\t\t\n\t\t\t\n\t
\n\t\t
\n\t\t\t
\n\t\t\t\t\t\t\t\t\t
\n\t\t\t\t\t\t
\n\t\t\t\t\t\t\t
\n\t\t\t\t\t\t
\n\t\t\t\t\t
\n\t\t\t\t\t\t\t\t
\n\t\t\t\t\t

\n\t\t\t\t\t\tNextcloud\t\t\t\t\t

\n\t\t\t\t\t\t\n\t\t\t\t
\n\t\t\t
\n\t\t
\n\t\t\t\t
\n\t\t\t

\n\t\t\t\tNextcloud – a safe home for all your data\t\t\t

\n\t\t
\n\t\n\n" +} diff --git a/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/errors/422-bad-visibility.json b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/errors/422-bad-visibility.json new file mode 100644 index 00000000..fdf3921b --- /dev/null +++ b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/errors/422-bad-visibility.json @@ -0,0 +1,17 @@ +{ + "request": { + "method": "POST", + "path": "/index.php/apps/social/api/v1/statuses" + }, + "status": 422, + "headers": { + "content-type": "application/json; charset=utf-8", + "cache-control": "no-cache, no-store, must-revalidate", + "x-ratelimit-limit": "30", + "x-ratelimit-remaining": "27", + "x-ratelimit-reset": "1790637300" + }, + "body": { + "error": "unknown visibility: bogus" + } +} diff --git a/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/errors/422-over-length.json b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/errors/422-over-length.json new file mode 100644 index 00000000..c84ff5ed --- /dev/null +++ b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/errors/422-over-length.json @@ -0,0 +1,17 @@ +{ + "request": { + "method": "POST", + "path": "/index.php/apps/social/api/v1/statuses" + }, + "status": 422, + "headers": { + "content-type": "application/json; charset=utf-8", + "cache-control": "no-cache, no-store, must-revalidate", + "x-ratelimit-limit": "30", + "x-ratelimit-remaining": "26", + "x-ratelimit-reset": "1790637300" + }, + "body": { + "error": "a post may not be longer than 5000 characters" + } +} diff --git a/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/errors/422-unknown-timeline.json b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/errors/422-unknown-timeline.json new file mode 100644 index 00000000..c97c53d5 --- /dev/null +++ b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/errors/422-unknown-timeline.json @@ -0,0 +1,17 @@ +{ + "request": { + "method": "GET", + "path": "/index.php/apps/social/api/v1/timelines/bogus" + }, + "status": 422, + "headers": { + "content-type": "application/json; charset=utf-8", + "cache-control": "no-cache, no-store, must-revalidate", + "x-ratelimit-limit": "60", + "x-ratelimit-remaining": "49", + "x-ratelimit-reset": "1790637300" + }, + "body": { + "error": "unknown timeline" + } +} diff --git a/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/errors/500-limit-zero.json b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/errors/500-limit-zero.json new file mode 100644 index 00000000..eb0ce039 --- /dev/null +++ b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/errors/500-limit-zero.json @@ -0,0 +1,15 @@ +{ + "request": { + "method": "GET", + "path": "/index.php/apps/social/api/v1/timelines/public?limit=0" + }, + "status": 400, + "headers": { + "content-type": "text/html; charset=UTF-8", + "cache-control": "no-cache, no-store, must-revalidate", + "x-ratelimit-limit": "60", + "x-ratelimit-remaining": "48", + "x-ratelimit-reset": "1790637300" + }, + "body": "" +} diff --git a/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/index.txt b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/index.txt new file mode 100644 index 00000000..82610f69 --- /dev/null +++ b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/index.txt @@ -0,0 +1,154 @@ +api/accounts-alice-featured-tags.json +api/accounts-alice-followers.json +api/accounts-alice-following.json +api/accounts-alice-highlights.json +api/accounts-alice-statuses-media.json +api/accounts-alice-statuses-pinned.json +api/accounts-alice-statuses.json +api/accounts-alice.json +api/accounts-jane-locked.json +api/accounts-lookup-bob.json +api/accounts-relationships.json +api/accounts-search.json +api/accounts-verify-credentials.json +api/announcements.json +api/annual-reports.json +api/apps-register.json +api/apps-verify-credentials.json +api/blocks.json +api/bookmarks-bob.json +api/collection-items.json +api/collection.json +api/collections-account-alice.json +api/collections-alice.json +api/conversations-bob.json +api/conversations-unread-count.json +api/custom-emojis.json +api/directory.json +api/discover-posts.json +api/favourites-bob.json +api/filters-v2-jane.json +api/follow-requests-jane.json +api/followed-tags.json +api/gifs.json +api/instance-privacy-policy.json +api/instance-rules.json +api/instance-translation-languages.json +api/interests.json +api/list-accounts-jane.json +api/lists-jane.json +api/markers.json +api/mutes.json +api/notifications-policy-v2.json +api/notifications-requests.json +api/notifications-v1-jane.json +api/notifications-v1-unread-count.json +api/notifications-v1.json +api/notifications-v2-unread-count.json +api/notifications-v2.json +api/oauth-token.json +api/oauth-userinfo.json +api/pixelfed-compose-settings.json +api/places-search.json +api/poll.json +api/preferences.json +api/scheduled-statuses.json +api/search-account.json +api/search-aloha.json +api/search-hashtags.json +api/starter-packs.json +api/status-audio.json +api/status-bob.json +api/status-cw.json +api/status-direct.json +api/status-edited-history.json +api/status-edited-source.json +api/status-edited.json +api/status-link-card.json +api/status-link.json +api/status-photos-reblogged-by.json +api/status-photos.json +api/status-place.json +api/status-poll.json +api/status-private.json +api/status-public-card-none.json +api/status-public-context.json +api/status-public-delivery.json +api/status-public-favourited-by.json +api/status-public-quotes.json +api/status-public-reactions.json +api/status-public.json +api/status-quote.json +api/status-reply.json +api/status-reply_2.json +api/status-sensitive_no_alt.json +api/status-short.json +api/status-unlisted.json +api/status-video.json +api/stories-account-alice.json +api/stories-carousel-bob.json +api/stories-self-alice.json +api/stories-v12-carousel-bob.json +api/suggestions-v2.json +api/timeline-home-bob-sees-boost.json +api/timeline-home-jane-filtered.json +api/timeline-home-page.json +api/timeline-home.json +api/timeline-interests.json +api/timeline-list-jane.json +api/timeline-public-local.json +api/timeline-public-only-media.json +api/timeline-public-only-news.json +api/timeline-public-only-video.json +api/timeline-public.json +api/timeline-tag-aloha.json +api/trends-links.json +api/trends-statuses.json +api/trends-tags.json +errors/401-bad-token.json +errors/401-no-token.json +errors/403-account-signup.json +errors/404-push-subscription.json +errors/404-status.json +errors/404-streaming.json +errors/422-bad-visibility.json +errors/422-over-length.json +errors/422-unknown-timeline.json +errors/500-limit-zero.json +plain/host-meta.json +plain/instance-v1-app.json +plain/instance-v1-root.json +plain/instance-v2-app.json +plain/instance-v2-root.json +plain/nodeinfo-2.0.json +plain/nodeinfo-2.1.json +plain/nodeinfo-links.json +plain/oauth-authorization-server-app.json +plain/oauth-authorization-server-root.json +plain/status.json +plain/timeline-public-page-app.json +plain/timeline-public-page-root.json +plain/webfinger-alice.json +root/host-meta.json +root/instance-v1-app.json +root/instance-v1-root.json +root/instance-v2-app.json +root/instance-v2-root.json +root/nodeinfo-2.0.json +root/nodeinfo-2.1.json +root/nodeinfo-links.json +root/oauth-authorization-server-app.json +root/oauth-authorization-server-root.json +root/ocs-capabilities.json +root/peertube-config.json +root/pixelfed-config.json +root/status.json +root/timeline-public-page-app.json +root/timeline-public-page-root.json +root/webfinger-alice.json +writes/favourite-returns-status.json +writes/idempotent-post-1.json +writes/idempotent-post-2.json +writes/media-upload-v2.json +writes/videos-continue-bob.json +writes/watched-bob.json diff --git a/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/plain/host-meta.json b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/plain/host-meta.json new file mode 100644 index 00000000..2d78d80b --- /dev/null +++ b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/plain/host-meta.json @@ -0,0 +1,12 @@ +{ + "request": { + "method": "GET", + "path": "/.well-known/host-meta" + }, + "status": 200, + "headers": { + "content-type": "application/xrd+xml", + "cache-control": "no-cache, no-store, must-revalidate" + }, + "body": "\n\n \n\n" +} diff --git a/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/plain/instance-v1-app.json b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/plain/instance-v1-app.json new file mode 100644 index 00000000..9e4dcddc --- /dev/null +++ b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/plain/instance-v1-app.json @@ -0,0 +1,95 @@ +{ + "request": { + "method": "GET", + "path": "/index.php/apps/social/api/v1/instance" + }, + "status": 200, + "headers": { + "content-type": "application/json; charset=utf-8", + "cache-control": "private, no-cache", + "etag": "\"dcb95d804d5119f428e890555ddc6853d6df819a\"", + "x-ratelimit-limit": "300", + "x-ratelimit-remaining": "278", + "x-ratelimit-reset": "1790637300" + }, + "body": { + "uri": "nextcloud.local", + "title": "Nextcloud Social", + "version": "4.3.0 (compatible; Nextcloud Social 0.26.97)", + "short_description": "a safe home for your data", + "description": "a safe home for your data", + "email": "", + "urls": {}, + "stats": { + "user_count": 10188, + "status_count": 18, + "domain_count": 0 + }, + "thumbnail": "http://nextcloud.local/apps-extra/social/img/social.svg", + "languages": [ + "en" + ], + "registrations": false, + "approval_required": false, + "invites_enabled": false, + "configuration": { + "statuses": { + "max_characters": 5000, + "max_media_attachments": 10, + "characters_reserved_per_url": 23 + }, + "media_attachments": { + "supported_mime_types": [ + "image/jpeg", + "image/gif", + "image/png", + "image/webp", + "image/avif", + "image/heic", + "video/mp4", + "video/webm", + "video/quicktime", + "audio/mpeg", + "audio/mp4", + "audio/ogg", + "audio/opus", + "audio/wav", + "audio/x-wav", + "audio/flac", + "audio/aac", + "application/pdf", + "text/plain", + "text/markdown", + "text/csv", + "application/zip", + "application/epub+zip", + "application/vnd.oasis.opendocument.text", + "application/vnd.oasis.opendocument.spreadsheet", + "application/vnd.oasis.opendocument.presentation", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "application/vnd.openxmlformats-officedocument.presentationml.presentation" + ], + "image_size_limit": 10485760, + "video_size_limit": 2147483648, + "image_matrix_limit": 50000000, + "video_frame_rate_limit": 0, + "video_matrix_limit": 0 + }, + "polls": { + "max_options": 4, + "max_characters_per_option": 100, + "min_expiration": 300, + "max_expiration": 2629746 + }, + "accounts": { + "max_featured_tags": 10 + }, + "translation": { + "enabled": false + } + }, + "rules": [], + "contact_account": null + } +} diff --git a/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/plain/instance-v1-root.json b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/plain/instance-v1-root.json new file mode 100644 index 00000000..17808432 --- /dev/null +++ b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/plain/instance-v1-root.json @@ -0,0 +1,11 @@ +{ + "request": { + "method": "GET", + "path": "/api/v1/instance" + }, + "status": 404, + "headers": { + "content-type": "text/html; charset=iso-8859-1" + }, + "body": "\n\n404 Not Found\n\n

Not Found

\n

The requested URL was not found on this server.

\n
\n
Apache/2.4.67 (Debian) Server at nextcloud.local Port 80
\n\n" +} diff --git a/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/plain/instance-v2-app.json b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/plain/instance-v2-app.json new file mode 100644 index 00000000..bbed0abd --- /dev/null +++ b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/plain/instance-v2-app.json @@ -0,0 +1,104 @@ +{ + "request": { + "method": "GET", + "path": "/index.php/apps/social/api/v2/instance" + }, + "status": 200, + "headers": { + "content-type": "application/json; charset=utf-8", + "cache-control": "private, no-cache", + "etag": "\"bc87b269207006b1028c880cf1471e1cbcf45d14\"", + "x-ratelimit-limit": "300", + "x-ratelimit-remaining": "277", + "x-ratelimit-reset": "1790637300" + }, + "body": { + "domain": "nextcloud.local", + "title": "Nextcloud Social", + "version": "4.3.0 (compatible; Nextcloud Social 0.26.97)", + "api_versions": { + "mastodon": 3 + }, + "source_url": "https://github.com/nextcloud/social", + "description": "a safe home for your data", + "usage": { + "users": { + "active_month": 10188 + } + }, + "thumbnail": { + "url": "http://nextcloud.local/apps-extra/social/img/social.svg" + }, + "languages": [ + "en" + ], + "configuration": { + "statuses": { + "max_characters": 5000, + "max_media_attachments": 10, + "characters_reserved_per_url": 23 + }, + "media_attachments": { + "supported_mime_types": [ + "image/jpeg", + "image/gif", + "image/png", + "image/webp", + "image/avif", + "image/heic", + "video/mp4", + "video/webm", + "video/quicktime", + "audio/mpeg", + "audio/mp4", + "audio/ogg", + "audio/opus", + "audio/wav", + "audio/x-wav", + "audio/flac", + "audio/aac", + "application/pdf", + "text/plain", + "text/markdown", + "text/csv", + "application/zip", + "application/epub+zip", + "application/vnd.oasis.opendocument.text", + "application/vnd.oasis.opendocument.spreadsheet", + "application/vnd.oasis.opendocument.presentation", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "application/vnd.openxmlformats-officedocument.presentationml.presentation" + ], + "image_size_limit": 10485760, + "video_size_limit": 2147483648, + "image_matrix_limit": 50000000, + "video_frame_rate_limit": 0, + "video_matrix_limit": 0 + }, + "polls": { + "max_options": 4, + "max_characters_per_option": 100, + "min_expiration": 300, + "max_expiration": 2629746 + }, + "accounts": { + "max_featured_tags": 10 + }, + "translation": { + "enabled": false + }, + "urls": {} + }, + "registrations": { + "enabled": false, + "approval_required": false, + "message": null + }, + "contact": { + "email": "", + "account": null + }, + "rules": [] + } +} diff --git a/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/plain/instance-v2-root.json b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/plain/instance-v2-root.json new file mode 100644 index 00000000..79b8aa06 --- /dev/null +++ b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/plain/instance-v2-root.json @@ -0,0 +1,11 @@ +{ + "request": { + "method": "GET", + "path": "/api/v2/instance" + }, + "status": 404, + "headers": { + "content-type": "text/html; charset=iso-8859-1" + }, + "body": "\n\n404 Not Found\n\n

Not Found

\n

The requested URL was not found on this server.

\n
\n
Apache/2.4.67 (Debian) Server at nextcloud.local Port 80
\n\n" +} diff --git a/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/plain/nodeinfo-2.0.json b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/plain/nodeinfo-2.0.json new file mode 100644 index 00000000..559dae2c --- /dev/null +++ b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/plain/nodeinfo-2.0.json @@ -0,0 +1,39 @@ +{ + "request": { + "method": "GET", + "path": "/index.php/apps/social/.well-known/nodeinfo/2.0" + }, + "status": 200, + "headers": { + "content-type": "application/json; charset=utf-8", + "cache-control": "no-cache, no-store, must-revalidate", + "x-ratelimit-limit": "300", + "x-ratelimit-remaining": "280", + "x-ratelimit-reset": "1790637300" + }, + "body": { + "version": "2.0", + "software": { + "name": "nextcloud-social", + "version": "0.26.97" + }, + "protocols": [ + "activitypub" + ], + "services": { + "inbound": [], + "outbound": [] + }, + "usage": { + "users": { + "total": 10188 + }, + "localPosts": 18 + }, + "openRegistrations": false, + "metadata": { + "nodeName": "Nextcloud Social", + "nodeDescription": "a safe home for your data" + } + } +} diff --git a/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/plain/nodeinfo-2.1.json b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/plain/nodeinfo-2.1.json new file mode 100644 index 00000000..414caa70 --- /dev/null +++ b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/plain/nodeinfo-2.1.json @@ -0,0 +1,41 @@ +{ + "request": { + "method": "GET", + "path": "/index.php/apps/social/.well-known/nodeinfo/2.1" + }, + "status": 200, + "headers": { + "content-type": "application/json; charset=utf-8", + "cache-control": "no-cache, no-store, must-revalidate", + "x-ratelimit-limit": "300", + "x-ratelimit-remaining": "281", + "x-ratelimit-reset": "1790637300" + }, + "body": { + "version": "2.1", + "software": { + "name": "nextcloud-social", + "version": "0.26.97", + "repository": "https://github.com/nextcloud/social", + "homepage": "https://github.com/nextcloud/social" + }, + "protocols": [ + "activitypub" + ], + "services": { + "inbound": [], + "outbound": [] + }, + "usage": { + "users": { + "total": 10188 + }, + "localPosts": 18 + }, + "openRegistrations": false, + "metadata": { + "nodeName": "Nextcloud Social", + "nodeDescription": "a safe home for your data" + } + } +} diff --git a/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/plain/nodeinfo-links.json b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/plain/nodeinfo-links.json new file mode 100644 index 00000000..adf2ae03 --- /dev/null +++ b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/plain/nodeinfo-links.json @@ -0,0 +1,23 @@ +{ + "request": { + "method": "GET", + "path": "/.well-known/nodeinfo" + }, + "status": 200, + "headers": { + "content-type": "application/json; charset=utf-8", + "cache-control": "no-cache, no-store, must-revalidate" + }, + "body": { + "links": [ + { + "rel": "http://nodeinfo.diaspora.software/ns/schema/2.0", + "href": "http://nextcloud.local/index.php/apps/social/.well-known/nodeinfo/2.0" + }, + { + "rel": "http://nodeinfo.diaspora.software/ns/schema/2.1", + "href": "http://nextcloud.local/index.php/apps/social/.well-known/nodeinfo/2.1" + } + ] + } +} diff --git a/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/plain/oauth-authorization-server-app.json b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/plain/oauth-authorization-server-app.json new file mode 100644 index 00000000..c85fd1cb --- /dev/null +++ b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/plain/oauth-authorization-server-app.json @@ -0,0 +1,69 @@ +{ + "request": { + "method": "GET", + "path": "/index.php/apps/social/.well-known/oauth-authorization-server" + }, + "status": 200, + "headers": { + "content-type": "application/json; charset=utf-8", + "cache-control": "no-cache, no-store, must-revalidate", + "x-ratelimit-limit": "300", + "x-ratelimit-remaining": "279", + "x-ratelimit-reset": "1790637300" + }, + "body": { + "issuer": "http://nextcloud.local/index.php/apps/social/", + "authorization_endpoint": "http://nextcloud.local/index.php/apps/social/oauth/authorize", + "token_endpoint": "http://nextcloud.local/index.php/apps/social/oauth/token", + "revocation_endpoint": "http://nextcloud.local/index.php/apps/social/oauth/revoke", + "userinfo_endpoint": "http://nextcloud.local/index.php/apps/social/oauth/userinfo", + "app_registration_endpoint": "http://nextcloud.local/index.php/apps/social/api/v1/apps", + "scopes_supported": [ + "read", + "write", + "follow", + "read:accounts", + "read:blocks", + "read:bookmarks", + "read:collections", + "read:favourites", + "read:filters", + "read:follows", + "read:lists", + "read:mutes", + "read:notifications", + "read:search", + "read:statuses", + "read:stories", + "write:accounts", + "write:blocks", + "write:bookmarks", + "write:collections", + "write:conversations", + "write:favourites", + "write:filters", + "write:follows", + "write:lists", + "write:media", + "write:mutes", + "write:notifications", + "write:reports", + "write:statuses", + "write:stories" + ], + "response_types_supported": [ + "code" + ], + "grant_types_supported": [ + "authorization_code" + ], + "token_endpoint_auth_methods_supported": [ + "client_secret_post", + "client_secret_basic" + ], + "code_challenge_methods_supported": [ + "S256" + ], + "service_documentation": "https://github.com/nextcloud/social" + } +} diff --git a/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/plain/oauth-authorization-server-root.json b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/plain/oauth-authorization-server-root.json new file mode 100644 index 00000000..ab4656ad --- /dev/null +++ b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/plain/oauth-authorization-server-root.json @@ -0,0 +1,14 @@ +{ + "request": { + "method": "GET", + "path": "/.well-known/oauth-authorization-server" + }, + "status": 404, + "headers": { + "content-type": "application/json; charset=utf-8", + "cache-control": "no-cache, no-store, must-revalidate" + }, + "body": { + "message": "oauth-authorization-server not supported" + } +} diff --git a/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/plain/status.json b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/plain/status.json new file mode 100644 index 00000000..3580212a --- /dev/null +++ b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/plain/status.json @@ -0,0 +1,20 @@ +{ + "request": { + "method": "GET", + "path": "/status.php" + }, + "status": 200, + "headers": { + "content-type": "application/json" + }, + "body": { + "installed": true, + "maintenance": false, + "needsDbUpgrade": false, + "version": "36.0.0.0", + "versionstring": "36.0.0 dev", + "edition": "", + "productname": "Nextcloud", + "extendedSupport": false + } +} diff --git a/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/plain/timeline-public-page-app.json b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/plain/timeline-public-page-app.json new file mode 100644 index 00000000..5e420c55 --- /dev/null +++ b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/plain/timeline-public-page-app.json @@ -0,0 +1,269 @@ +{ + "request": { + "method": "GET", + "path": "/index.php/apps/social/api/v1/timelines/public?limit=2" + }, + "status": 200, + "headers": { + "content-type": "application/json; charset=utf-8", + "cache-control": "no-cache, no-store, must-revalidate", + "link": "; rel=\"next\", ; rel=\"prev\"", + "x-ratelimit-limit": "60", + "x-ratelimit-remaining": "59", + "x-ratelimit-reset": "1790637240" + }, + "body": [ + { + "id": "1790637117344364158", + "nid": "1790637117344364158", + "local": true, + "archived": false, + "view_count": null, + "tagged_people": [], + "interest": null, + "content": "

Bob's own post #aloha

", + "sensitive": false, + "spoiler_text": "", + "visibility": "public", + "language": "en", + "place": null, + "in_reply_to_id": null, + "in_reply_to_account_id": null, + "quote": null, + "quote_approval": { + "automatic": [ + "public" + ], + "manual": [], + "current_user": "automatic" + }, + "reply_approval": null, + "interaction_policy": null, + "dislikes_count": null, + "disliked": null, + "video": null, + "mentions": [], + "emojis": [], + "tags": [ + { + "name": "aloha", + "url": "http://nextcloud.local/index.php/apps/social/timeline/tags/aloha" + } + ], + "replies_count": 0, + "reblogs_count": 0, + "favourites_count": 0, + "favourited": false, + "reblogged": false, + "muted": false, + "bookmarked": false, + "pinned": false, + "card": null, + "reactions": [], + "poll": null, + "uri": "http://nextcloud.local/index.php/apps/social/@bob/17906371177254273993", + "url": "http://nextcloud.local/index.php/apps/social/@bob/17906371177254273993", + "reblog": null, + "media_attachments": [], + "created_at": "2026-09-28T23:11:57.000Z", + "edited_at": null, + "noindex": false, + "account": { + "id": "7", + "nid": "7", + "username": "bob", + "acct": "bob", + "display_name": "bob", + "locked": false, + "bot": false, + "discoverable": false, + "indexable": false, + "group": false, + "created_at": "2026-09-28T23:06:03.000Z", + "note": "", + "url": "http://nextcloud.local/index.php/apps/social/@bob", + "avatar": "", + "avatar_static": "", + "header": "http://nextcloud.local/index.php/avatar/bob/128", + "header_static": "http://nextcloud.local/index.php/avatar/bob/128", + "followers_count": 1, + "following_count": 2, + "statuses_count": 3, + "last_status_at": null, + "emojis": [], + "fields": [], + "pronouns": "", + "support_link": "" + }, + "filtered": [] + }, + { + "id": "1790637116374990863", + "nid": "1790637116374990863", + "local": true, + "archived": false, + "view_count": null, + "tagged_people": [], + "interest": null, + "content": "

Quoting this one

", + "sensitive": false, + "spoiler_text": "", + "visibility": "public", + "language": "en", + "place": null, + "in_reply_to_id": null, + "in_reply_to_account_id": null, + "quote": { + "state": "accepted", + "quoted_status": { + "id": "1790637085797595891", + "nid": "1790637085797595891", + "local": true, + "archived": false, + "view_count": null, + "tagged_people": [], + "interest": null, + "content": "

Aloha from the dev instance! Say hi, @bob@nextcloud.local #aloha #android

", + "sensitive": false, + "spoiler_text": "", + "visibility": "public", + "language": "en", + "place": null, + "in_reply_to_id": null, + "in_reply_to_account_id": null, + "quote": null, + "quote_approval": { + "automatic": [ + "public" + ], + "manual": [], + "current_user": "automatic" + }, + "reply_approval": null, + "interaction_policy": null, + "dislikes_count": null, + "disliked": null, + "video": null, + "mentions": [], + "emojis": [], + "tags": [ + { + "name": "aloha", + "url": "http://nextcloud.local/index.php/apps/social/timeline/tags/aloha" + }, + { + "name": "android", + "url": "http://nextcloud.local/index.php/apps/social/timeline/tags/android" + } + ], + "replies_count": 1, + "reblogs_count": 0, + "favourites_count": 1, + "favourited": false, + "reblogged": false, + "muted": false, + "bookmarked": false, + "pinned": false, + "card": null, + "reactions": [], + "poll": null, + "uri": "http://nextcloud.local/index.php/apps/social/@alice/17906370853806247191", + "url": "http://nextcloud.local/index.php/apps/social/@alice/17906370853806247191", + "reblog": null, + "media_attachments": [], + "created_at": "2026-09-28T23:11:25.000Z", + "edited_at": null, + "noindex": false, + "account": { + "id": "6", + "nid": "6", + "username": "alice", + "acct": "alice", + "display_name": "alice", + "locked": false, + "bot": false, + "discoverable": false, + "indexable": false, + "group": false, + "created_at": "2026-09-28T23:06:02.000Z", + "note": "", + "url": "http://nextcloud.local/index.php/apps/social/@alice", + "avatar": "", + "avatar_static": "", + "header": "http://nextcloud.local/index.php/avatar/alice/128", + "header_static": "http://nextcloud.local/index.php/avatar/alice/128", + "followers_count": 2, + "following_count": 2, + "statuses_count": 13, + "last_status_at": null, + "emojis": [], + "fields": [], + "pronouns": "", + "support_link": "" + } + } + }, + "quote_approval": { + "automatic": [ + "public" + ], + "manual": [], + "current_user": "automatic" + }, + "reply_approval": null, + "interaction_policy": null, + "dislikes_count": null, + "disliked": null, + "video": null, + "mentions": [], + "emojis": [], + "tags": [], + "replies_count": 0, + "reblogs_count": 0, + "favourites_count": 0, + "favourited": false, + "reblogged": false, + "muted": false, + "bookmarked": false, + "pinned": false, + "card": null, + "reactions": [], + "poll": null, + "uri": "http://nextcloud.local/index.php/apps/social/@bob/17906371165948083557", + "url": "http://nextcloud.local/index.php/apps/social/@bob/17906371165948083557", + "reblog": null, + "media_attachments": [], + "created_at": "2026-09-28T23:11:56.000Z", + "edited_at": null, + "noindex": false, + "account": { + "id": "7", + "nid": "7", + "username": "bob", + "acct": "bob", + "display_name": "bob", + "locked": false, + "bot": false, + "discoverable": false, + "indexable": false, + "group": false, + "created_at": "2026-09-28T23:06:03.000Z", + "note": "", + "url": "http://nextcloud.local/index.php/apps/social/@bob", + "avatar": "", + "avatar_static": "", + "header": "http://nextcloud.local/index.php/avatar/bob/128", + "header_static": "http://nextcloud.local/index.php/avatar/bob/128", + "followers_count": 1, + "following_count": 2, + "statuses_count": 3, + "last_status_at": null, + "emojis": [], + "fields": [], + "pronouns": "", + "support_link": "" + }, + "filtered": [] + } + ] +} diff --git a/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/plain/timeline-public-page-root.json b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/plain/timeline-public-page-root.json new file mode 100644 index 00000000..dd220300 --- /dev/null +++ b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/plain/timeline-public-page-root.json @@ -0,0 +1,11 @@ +{ + "request": { + "method": "GET", + "path": "/api/v1/timelines/public?limit=2" + }, + "status": 404, + "headers": { + "content-type": "text/html; charset=iso-8859-1" + }, + "body": "\n\n404 Not Found\n\n

Not Found

\n

The requested URL was not found on this server.

\n
\n
Apache/2.4.67 (Debian) Server at nextcloud.local Port 80
\n\n" +} diff --git a/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/plain/webfinger-alice.json b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/plain/webfinger-alice.json new file mode 100644 index 00000000..5392d441 --- /dev/null +++ b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/plain/webfinger-alice.json @@ -0,0 +1,34 @@ +{ + "request": { + "method": "GET", + "path": "/.well-known/webfinger?resource=acct:alice@nextcloud.local" + }, + "status": 200, + "headers": { + "content-type": "application/json; charset=utf-8", + "cache-control": "no-cache, no-store, must-revalidate" + }, + "body": { + "subject": "acct:alice@nextcloud.local", + "aliases": [ + "http://nextcloud.local/index.php/apps/social/@alice", + "http://nextcloud.local/index.php/u/alice" + ], + "links": [ + { + "rel": "self", + "type": "application/activity+json", + "href": "http://nextcloud.local/index.php/apps/social/@alice" + }, + { + "rel": "http://webfinger.net/rel/profile-page", + "type": "text/html", + "href": "http://nextcloud.local/index.php/apps/social/@alice" + }, + { + "rel": "http://ostatus.org/schema/1.0/subscribe", + "template": "http://nextcloud.local/index.php/apps/social/ostatus/follow/?uri={uri}" + } + ] + } +} diff --git a/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/root/host-meta.json b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/root/host-meta.json new file mode 100644 index 00000000..2d78d80b --- /dev/null +++ b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/root/host-meta.json @@ -0,0 +1,12 @@ +{ + "request": { + "method": "GET", + "path": "/.well-known/host-meta" + }, + "status": 200, + "headers": { + "content-type": "application/xrd+xml", + "cache-control": "no-cache, no-store, must-revalidate" + }, + "body": "\n\n \n\n" +} diff --git a/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/root/instance-v1-app.json b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/root/instance-v1-app.json new file mode 100644 index 00000000..2bc27373 --- /dev/null +++ b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/root/instance-v1-app.json @@ -0,0 +1,95 @@ +{ + "request": { + "method": "GET", + "path": "/index.php/apps/social/api/v1/instance" + }, + "status": 200, + "headers": { + "content-type": "application/json; charset=utf-8", + "cache-control": "private, no-cache", + "etag": "\"dcb95d804d5119f428e890555ddc6853d6df819a\"", + "x-ratelimit-limit": "300", + "x-ratelimit-remaining": "268", + "x-ratelimit-reset": "1790637300" + }, + "body": { + "uri": "nextcloud.local", + "title": "Nextcloud Social", + "version": "4.3.0 (compatible; Nextcloud Social 0.26.97)", + "short_description": "a safe home for your data", + "description": "a safe home for your data", + "email": "", + "urls": {}, + "stats": { + "user_count": 10188, + "status_count": 18, + "domain_count": 0 + }, + "thumbnail": "http://nextcloud.local/apps-extra/social/img/social.svg", + "languages": [ + "en" + ], + "registrations": false, + "approval_required": false, + "invites_enabled": false, + "configuration": { + "statuses": { + "max_characters": 5000, + "max_media_attachments": 10, + "characters_reserved_per_url": 23 + }, + "media_attachments": { + "supported_mime_types": [ + "image/jpeg", + "image/gif", + "image/png", + "image/webp", + "image/avif", + "image/heic", + "video/mp4", + "video/webm", + "video/quicktime", + "audio/mpeg", + "audio/mp4", + "audio/ogg", + "audio/opus", + "audio/wav", + "audio/x-wav", + "audio/flac", + "audio/aac", + "application/pdf", + "text/plain", + "text/markdown", + "text/csv", + "application/zip", + "application/epub+zip", + "application/vnd.oasis.opendocument.text", + "application/vnd.oasis.opendocument.spreadsheet", + "application/vnd.oasis.opendocument.presentation", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "application/vnd.openxmlformats-officedocument.presentationml.presentation" + ], + "image_size_limit": 10485760, + "video_size_limit": 2147483648, + "image_matrix_limit": 50000000, + "video_frame_rate_limit": 0, + "video_matrix_limit": 0 + }, + "polls": { + "max_options": 4, + "max_characters_per_option": 100, + "min_expiration": 300, + "max_expiration": 2629746 + }, + "accounts": { + "max_featured_tags": 10 + }, + "translation": { + "enabled": false + } + }, + "rules": [], + "contact_account": null + } +} diff --git a/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/root/instance-v1-root.json b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/root/instance-v1-root.json new file mode 100644 index 00000000..f8b026f4 --- /dev/null +++ b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/root/instance-v1-root.json @@ -0,0 +1,95 @@ +{ + "request": { + "method": "GET", + "path": "/api/v1/instance" + }, + "status": 200, + "headers": { + "content-type": "application/json; charset=utf-8", + "cache-control": "private, no-cache", + "etag": "\"dcb95d804d5119f428e890555ddc6853d6df819a\"", + "x-ratelimit-limit": "300", + "x-ratelimit-remaining": "270", + "x-ratelimit-reset": "1790637300" + }, + "body": { + "uri": "nextcloud.local", + "title": "Nextcloud Social", + "version": "4.3.0 (compatible; Nextcloud Social 0.26.97)", + "short_description": "a safe home for your data", + "description": "a safe home for your data", + "email": "", + "urls": {}, + "stats": { + "user_count": 10188, + "status_count": 18, + "domain_count": 0 + }, + "thumbnail": "http://nextcloud.local/apps-extra/social/img/social.svg", + "languages": [ + "en" + ], + "registrations": false, + "approval_required": false, + "invites_enabled": false, + "configuration": { + "statuses": { + "max_characters": 5000, + "max_media_attachments": 10, + "characters_reserved_per_url": 23 + }, + "media_attachments": { + "supported_mime_types": [ + "image/jpeg", + "image/gif", + "image/png", + "image/webp", + "image/avif", + "image/heic", + "video/mp4", + "video/webm", + "video/quicktime", + "audio/mpeg", + "audio/mp4", + "audio/ogg", + "audio/opus", + "audio/wav", + "audio/x-wav", + "audio/flac", + "audio/aac", + "application/pdf", + "text/plain", + "text/markdown", + "text/csv", + "application/zip", + "application/epub+zip", + "application/vnd.oasis.opendocument.text", + "application/vnd.oasis.opendocument.spreadsheet", + "application/vnd.oasis.opendocument.presentation", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "application/vnd.openxmlformats-officedocument.presentationml.presentation" + ], + "image_size_limit": 10485760, + "video_size_limit": 2147483648, + "image_matrix_limit": 50000000, + "video_frame_rate_limit": 0, + "video_matrix_limit": 0 + }, + "polls": { + "max_options": 4, + "max_characters_per_option": 100, + "min_expiration": 300, + "max_expiration": 2629746 + }, + "accounts": { + "max_featured_tags": 10 + }, + "translation": { + "enabled": false + } + }, + "rules": [], + "contact_account": null + } +} diff --git a/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/root/instance-v2-app.json b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/root/instance-v2-app.json new file mode 100644 index 00000000..110c4b90 --- /dev/null +++ b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/root/instance-v2-app.json @@ -0,0 +1,104 @@ +{ + "request": { + "method": "GET", + "path": "/index.php/apps/social/api/v2/instance" + }, + "status": 200, + "headers": { + "content-type": "application/json; charset=utf-8", + "cache-control": "private, no-cache", + "etag": "\"bc87b269207006b1028c880cf1471e1cbcf45d14\"", + "x-ratelimit-limit": "300", + "x-ratelimit-remaining": "267", + "x-ratelimit-reset": "1790637300" + }, + "body": { + "domain": "nextcloud.local", + "title": "Nextcloud Social", + "version": "4.3.0 (compatible; Nextcloud Social 0.26.97)", + "api_versions": { + "mastodon": 3 + }, + "source_url": "https://github.com/nextcloud/social", + "description": "a safe home for your data", + "usage": { + "users": { + "active_month": 10188 + } + }, + "thumbnail": { + "url": "http://nextcloud.local/apps-extra/social/img/social.svg" + }, + "languages": [ + "en" + ], + "configuration": { + "statuses": { + "max_characters": 5000, + "max_media_attachments": 10, + "characters_reserved_per_url": 23 + }, + "media_attachments": { + "supported_mime_types": [ + "image/jpeg", + "image/gif", + "image/png", + "image/webp", + "image/avif", + "image/heic", + "video/mp4", + "video/webm", + "video/quicktime", + "audio/mpeg", + "audio/mp4", + "audio/ogg", + "audio/opus", + "audio/wav", + "audio/x-wav", + "audio/flac", + "audio/aac", + "application/pdf", + "text/plain", + "text/markdown", + "text/csv", + "application/zip", + "application/epub+zip", + "application/vnd.oasis.opendocument.text", + "application/vnd.oasis.opendocument.spreadsheet", + "application/vnd.oasis.opendocument.presentation", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "application/vnd.openxmlformats-officedocument.presentationml.presentation" + ], + "image_size_limit": 10485760, + "video_size_limit": 2147483648, + "image_matrix_limit": 50000000, + "video_frame_rate_limit": 0, + "video_matrix_limit": 0 + }, + "polls": { + "max_options": 4, + "max_characters_per_option": 100, + "min_expiration": 300, + "max_expiration": 2629746 + }, + "accounts": { + "max_featured_tags": 10 + }, + "translation": { + "enabled": false + }, + "urls": {} + }, + "registrations": { + "enabled": false, + "approval_required": false, + "message": null + }, + "contact": { + "email": "", + "account": null + }, + "rules": [] + } +} diff --git a/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/root/instance-v2-root.json b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/root/instance-v2-root.json new file mode 100644 index 00000000..7776a889 --- /dev/null +++ b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/root/instance-v2-root.json @@ -0,0 +1,104 @@ +{ + "request": { + "method": "GET", + "path": "/api/v2/instance" + }, + "status": 200, + "headers": { + "content-type": "application/json; charset=utf-8", + "cache-control": "private, no-cache", + "etag": "\"bc87b269207006b1028c880cf1471e1cbcf45d14\"", + "x-ratelimit-limit": "300", + "x-ratelimit-remaining": "269", + "x-ratelimit-reset": "1790637300" + }, + "body": { + "domain": "nextcloud.local", + "title": "Nextcloud Social", + "version": "4.3.0 (compatible; Nextcloud Social 0.26.97)", + "api_versions": { + "mastodon": 3 + }, + "source_url": "https://github.com/nextcloud/social", + "description": "a safe home for your data", + "usage": { + "users": { + "active_month": 10188 + } + }, + "thumbnail": { + "url": "http://nextcloud.local/apps-extra/social/img/social.svg" + }, + "languages": [ + "en" + ], + "configuration": { + "statuses": { + "max_characters": 5000, + "max_media_attachments": 10, + "characters_reserved_per_url": 23 + }, + "media_attachments": { + "supported_mime_types": [ + "image/jpeg", + "image/gif", + "image/png", + "image/webp", + "image/avif", + "image/heic", + "video/mp4", + "video/webm", + "video/quicktime", + "audio/mpeg", + "audio/mp4", + "audio/ogg", + "audio/opus", + "audio/wav", + "audio/x-wav", + "audio/flac", + "audio/aac", + "application/pdf", + "text/plain", + "text/markdown", + "text/csv", + "application/zip", + "application/epub+zip", + "application/vnd.oasis.opendocument.text", + "application/vnd.oasis.opendocument.spreadsheet", + "application/vnd.oasis.opendocument.presentation", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "application/vnd.openxmlformats-officedocument.presentationml.presentation" + ], + "image_size_limit": 10485760, + "video_size_limit": 2147483648, + "image_matrix_limit": 50000000, + "video_frame_rate_limit": 0, + "video_matrix_limit": 0 + }, + "polls": { + "max_options": 4, + "max_characters_per_option": 100, + "min_expiration": 300, + "max_expiration": 2629746 + }, + "accounts": { + "max_featured_tags": 10 + }, + "translation": { + "enabled": false + }, + "urls": {} + }, + "registrations": { + "enabled": false, + "approval_required": false, + "message": null + }, + "contact": { + "email": "", + "account": null + }, + "rules": [] + } +} diff --git a/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/root/nodeinfo-2.0.json b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/root/nodeinfo-2.0.json new file mode 100644 index 00000000..d9714de1 --- /dev/null +++ b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/root/nodeinfo-2.0.json @@ -0,0 +1,39 @@ +{ + "request": { + "method": "GET", + "path": "/index.php/apps/social/.well-known/nodeinfo/2.0" + }, + "status": 200, + "headers": { + "content-type": "application/json; charset=utf-8", + "cache-control": "no-cache, no-store, must-revalidate", + "x-ratelimit-limit": "300", + "x-ratelimit-remaining": "273", + "x-ratelimit-reset": "1790637300" + }, + "body": { + "version": "2.0", + "software": { + "name": "nextcloud-social", + "version": "0.26.97" + }, + "protocols": [ + "activitypub" + ], + "services": { + "inbound": [], + "outbound": [] + }, + "usage": { + "users": { + "total": 10188 + }, + "localPosts": 18 + }, + "openRegistrations": false, + "metadata": { + "nodeName": "Nextcloud Social", + "nodeDescription": "a safe home for your data" + } + } +} diff --git a/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/root/nodeinfo-2.1.json b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/root/nodeinfo-2.1.json new file mode 100644 index 00000000..20ce487a --- /dev/null +++ b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/root/nodeinfo-2.1.json @@ -0,0 +1,41 @@ +{ + "request": { + "method": "GET", + "path": "/index.php/apps/social/.well-known/nodeinfo/2.1" + }, + "status": 200, + "headers": { + "content-type": "application/json; charset=utf-8", + "cache-control": "no-cache, no-store, must-revalidate", + "x-ratelimit-limit": "300", + "x-ratelimit-remaining": "274", + "x-ratelimit-reset": "1790637300" + }, + "body": { + "version": "2.1", + "software": { + "name": "nextcloud-social", + "version": "0.26.97", + "repository": "https://github.com/nextcloud/social", + "homepage": "https://github.com/nextcloud/social" + }, + "protocols": [ + "activitypub" + ], + "services": { + "inbound": [], + "outbound": [] + }, + "usage": { + "users": { + "total": 10188 + }, + "localPosts": 18 + }, + "openRegistrations": false, + "metadata": { + "nodeName": "Nextcloud Social", + "nodeDescription": "a safe home for your data" + } + } +} diff --git a/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/root/nodeinfo-links.json b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/root/nodeinfo-links.json new file mode 100644 index 00000000..adf2ae03 --- /dev/null +++ b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/root/nodeinfo-links.json @@ -0,0 +1,23 @@ +{ + "request": { + "method": "GET", + "path": "/.well-known/nodeinfo" + }, + "status": 200, + "headers": { + "content-type": "application/json; charset=utf-8", + "cache-control": "no-cache, no-store, must-revalidate" + }, + "body": { + "links": [ + { + "rel": "http://nodeinfo.diaspora.software/ns/schema/2.0", + "href": "http://nextcloud.local/index.php/apps/social/.well-known/nodeinfo/2.0" + }, + { + "rel": "http://nodeinfo.diaspora.software/ns/schema/2.1", + "href": "http://nextcloud.local/index.php/apps/social/.well-known/nodeinfo/2.1" + } + ] + } +} diff --git a/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/root/oauth-authorization-server-app.json b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/root/oauth-authorization-server-app.json new file mode 100644 index 00000000..c7ca6a13 --- /dev/null +++ b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/root/oauth-authorization-server-app.json @@ -0,0 +1,69 @@ +{ + "request": { + "method": "GET", + "path": "/index.php/apps/social/.well-known/oauth-authorization-server" + }, + "status": 200, + "headers": { + "content-type": "application/json; charset=utf-8", + "cache-control": "no-cache, no-store, must-revalidate", + "x-ratelimit-limit": "300", + "x-ratelimit-remaining": "271", + "x-ratelimit-reset": "1790637300" + }, + "body": { + "issuer": "http://nextcloud.local/index.php/apps/social/", + "authorization_endpoint": "http://nextcloud.local/index.php/apps/social/oauth/authorize", + "token_endpoint": "http://nextcloud.local/index.php/apps/social/oauth/token", + "revocation_endpoint": "http://nextcloud.local/index.php/apps/social/oauth/revoke", + "userinfo_endpoint": "http://nextcloud.local/index.php/apps/social/oauth/userinfo", + "app_registration_endpoint": "http://nextcloud.local/index.php/apps/social/api/v1/apps", + "scopes_supported": [ + "read", + "write", + "follow", + "read:accounts", + "read:blocks", + "read:bookmarks", + "read:collections", + "read:favourites", + "read:filters", + "read:follows", + "read:lists", + "read:mutes", + "read:notifications", + "read:search", + "read:statuses", + "read:stories", + "write:accounts", + "write:blocks", + "write:bookmarks", + "write:collections", + "write:conversations", + "write:favourites", + "write:filters", + "write:follows", + "write:lists", + "write:media", + "write:mutes", + "write:notifications", + "write:reports", + "write:statuses", + "write:stories" + ], + "response_types_supported": [ + "code" + ], + "grant_types_supported": [ + "authorization_code" + ], + "token_endpoint_auth_methods_supported": [ + "client_secret_post", + "client_secret_basic" + ], + "code_challenge_methods_supported": [ + "S256" + ], + "service_documentation": "https://github.com/nextcloud/social" + } +} diff --git a/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/root/oauth-authorization-server-root.json b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/root/oauth-authorization-server-root.json new file mode 100644 index 00000000..18b181ca --- /dev/null +++ b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/root/oauth-authorization-server-root.json @@ -0,0 +1,69 @@ +{ + "request": { + "method": "GET", + "path": "/.well-known/oauth-authorization-server" + }, + "status": 200, + "headers": { + "content-type": "application/json; charset=utf-8", + "cache-control": "no-cache, no-store, must-revalidate", + "x-ratelimit-limit": "300", + "x-ratelimit-remaining": "272", + "x-ratelimit-reset": "1790637300" + }, + "body": { + "issuer": "http://nextcloud.local/index.php/apps/social/", + "authorization_endpoint": "http://nextcloud.local/index.php/apps/social/oauth/authorize", + "token_endpoint": "http://nextcloud.local/index.php/apps/social/oauth/token", + "revocation_endpoint": "http://nextcloud.local/index.php/apps/social/oauth/revoke", + "userinfo_endpoint": "http://nextcloud.local/index.php/apps/social/oauth/userinfo", + "app_registration_endpoint": "http://nextcloud.local/index.php/apps/social/api/v1/apps", + "scopes_supported": [ + "read", + "write", + "follow", + "read:accounts", + "read:blocks", + "read:bookmarks", + "read:collections", + "read:favourites", + "read:filters", + "read:follows", + "read:lists", + "read:mutes", + "read:notifications", + "read:search", + "read:statuses", + "read:stories", + "write:accounts", + "write:blocks", + "write:bookmarks", + "write:collections", + "write:conversations", + "write:favourites", + "write:filters", + "write:follows", + "write:lists", + "write:media", + "write:mutes", + "write:notifications", + "write:reports", + "write:statuses", + "write:stories" + ], + "response_types_supported": [ + "code" + ], + "grant_types_supported": [ + "authorization_code" + ], + "token_endpoint_auth_methods_supported": [ + "client_secret_post", + "client_secret_basic" + ], + "code_challenge_methods_supported": [ + "S256" + ], + "service_documentation": "https://github.com/nextcloud/social" + } +} diff --git a/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/root/ocs-capabilities.json b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/root/ocs-capabilities.json new file mode 100644 index 00000000..ae3d3876 --- /dev/null +++ b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/root/ocs-capabilities.json @@ -0,0 +1,380 @@ +{ + "request": { + "method": "GET", + "path": "/ocs/v2.php/cloud/capabilities?format=json" + }, + "status": 200, + "headers": { + "content-type": "application/json; charset=utf-8", + "cache-control": "no-cache, no-store, must-revalidate", + "etag": "\"88ba2bd81e2033a14cac986c398f056b\"" + }, + "body": { + "ocs": { + "meta": { + "status": "ok", + "statuscode": 200, + "message": "OK" + }, + "data": { + "version": { + "major": 36, + "minor": 0, + "micro": 0, + "string": "36.0.0 dev", + "edition": "", + "extendedSupport": false + }, + "capabilities": { + "bruteforce": { + "delay": 0, + "allow-listed": false + }, + "spreed": { + "features": [ + "audio", + "video", + "chat-v2", + "conversation-v4", + "guest-signaling", + "empty-group-room", + "guest-display-names", + "multi-room-users", + "favorites", + "last-room-activity", + "no-ping", + "system-messages", + "delete-messages", + "mention-flag", + "in-call-flags", + "conversation-call-flags", + "notification-levels", + "invite-groups-and-mails", + "locked-one-to-one-rooms", + "read-only-rooms", + "listable-rooms", + "chat-read-marker", + "chat-unread", + "webinary-lobby", + "start-call-flag", + "chat-replies", + "circles-support", + "force-mute", + "sip-support", + "sip-support-nopin", + "chat-read-status", + "phonebook-search", + "raise-hand", + "room-description", + "rich-object-sharing", + "temp-user-avatar-api", + "geo-location-sharing", + "voice-message-sharing", + "signaling-v3", + "publishing-permissions", + "clear-history", + "direct-mention-flag", + "notification-calls", + "conversation-permissions", + "rich-object-list-media", + "rich-object-delete", + "unified-search", + "chat-permission", + "react-permission", + "silent-send", + "silent-call", + "send-call-notification", + "talk-polls", + "breakout-rooms-v1", + "recording-v1", + "avatar", + "chat-get-context", + "single-conversation-status", + "chat-keep-notifications", + "typing-privacy", + "remind-me-later", + "bots-v1", + "markdown-messages", + "media-caption", + "session-state", + "note-to-self", + "recording-consent", + "sip-support-dialout", + "delete-messages-unlimited", + "edit-messages", + "silent-send-state", + "chat-read-last", + "federation-v1", + "federation-v2", + "ban-v1", + "chat-reference-id", + "mention-permissions", + "edit-messages-note-to-self", + "archived-conversations-v2", + "talk-polls-drafts", + "download-call-participants", + "email-csv-import", + "conversation-creation-password", + "call-notification-state-api", + "schedule-meeting", + "edit-draft-poll", + "conversation-creation-all", + "important-conversations", + "unbind-conversation", + "sip-direct-dialin", + "dashboard-event-rooms", + "mutual-calendar-events", + "upcoming-reminders", + "sensitive-conversations", + "threads", + "pinned-messages", + "federated-shared-items", + "scheduled-messages", + "conversation-presets", + "private-reply", + "conversation-tags", + "preserve-conversation", + "bot-features-api", + "classified-conversations", + "announcement-preset", + "promote-demote-owner", + "message-expiration", + "reactions", + "recording-chunked-upload" + ], + "features-local": [ + "favorites", + "chat-read-status", + "listable-rooms", + "phonebook-search", + "temp-user-avatar-api", + "unified-search", + "avatar", + "remind-me-later", + "note-to-self", + "archived-conversations-v2", + "chat-summary-api", + "call-notification-state-api", + "schedule-meeting", + "conversation-creation-all", + "important-conversations", + "sip-direct-dialin", + "dashboard-event-rooms", + "mutual-calendar-events", + "upcoming-reminders", + "sensitive-conversations", + "scheduled-messages", + "conversation-presets", + "conversation-tags", + "recording-chunked-upload", + "bot-features-api", + "classified-conversations", + "announcement-preset" + ], + "config": { + "attachments": { + "allowed": false, + "conversation-subfolders": true + }, + "call": { + "enabled": true, + "breakout-rooms": true, + "recording": false, + "recording-consent": 0, + "supported-reactions": [ + "❤️", + "🎉", + "👏", + "👋", + "👍", + "👎", + "🔥", + "😂", + "🤩", + "🤔", + "😲", + "😥" + ], + "can-upload-background": false, + "sip-enabled": false, + "sip-dialout-enabled": false, + "default-phone-region": "", + "can-enable-sip": false, + "start-without-media": false, + "max-duration": 0, + "blur-virtual-background": false, + "end-to-end-encryption": false, + "live-transcription": false, + "live-translation": false, + "play-sounds": true, + "grid-limit": 19, + "grid-limit-enforced": false, + "predefined-backgrounds": [ + "1_office.jpg", + "2_home.jpg", + "3_abstract.jpg", + "4_beach.jpg", + "5_park.jpg", + "6_theater.jpg", + "7_library.jpg", + "8_space_station.jpg" + ], + "predefined-backgrounds-v2": [ + "/apps-extra/spreed/img/backgrounds/1_office.jpg", + "/apps-extra/spreed/img/backgrounds/2_home.jpg", + "/apps-extra/spreed/img/backgrounds/3_abstract.jpg", + "/apps-extra/spreed/img/backgrounds/4_beach.jpg", + "/apps-extra/spreed/img/backgrounds/5_park.jpg", + "/apps-extra/spreed/img/backgrounds/6_theater.jpg", + "/apps-extra/spreed/img/backgrounds/7_library.jpg", + "/apps-extra/spreed/img/backgrounds/8_space_station.jpg" + ], + "live-transcription-target-language-id": "" + }, + "chat": { + "max-length": 32000, + "read-privacy": 0, + "has-translation-providers": false, + "has-translation-task-providers": false, + "typing-privacy": 0, + "summary-threshold": 100, + "style": "split", + "matterbridge-enabled": false + }, + "conversations": { + "can-create": false, + "force-passwords": false, + "list-style": "two-lines", + "sort-order": "activity", + "group-mode": "none", + "description-length": 2000, + "retention-event": 28, + "retention-phone": 7, + "retention-instant-meetings": 1, + "retention-classified": 3600 + }, + "federation": { + "enabled": false, + "incoming-enabled": false, + "outgoing-enabled": false, + "only-trusted-servers": true + }, + "previews": { + "max-gif-size": 3145728 + }, + "signaling": { + "session-ping-limit": 200, + "mode": "internal", + "hello-v2-token-key": "-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEA4L/dK1zqVzTDWoatP9daA+hwerO\nOAvc+OKVwnODUwwkAK6z7boc9eCbznQXszm/Kj4dwPR1zY1zcIp7CNhVZA==\n-----END PUBLIC KEY-----\n" + }, + "experiments": { + "enabled": 0 + }, + "feature-hints": { + "current": 34, + "hidden": 0 + }, + "permissions": { + "max-default": 510, + "max-custom": 511, + "default": 502 + } + }, + "config-local": { + "attachments": [ + "allowed", + "folder", + "conversation-subfolders" + ], + "call": [ + "predefined-backgrounds", + "predefined-backgrounds-v2", + "can-upload-background", + "start-without-media", + "blur-virtual-background", + "live-transcription-target-language-id", + "play-sounds", + "grid-limit", + "grid-limit-enforced", + "external-call-service" + ], + "chat": [ + "read-privacy", + "has-translation-providers", + "has-translation-task-providers", + "typing-privacy", + "summary-threshold", + "style", + "matterbridge-enabled" + ], + "conversations": [ + "can-create", + "list-style", + "sort-order", + "group-mode", + "description-length" + ], + "federation": [ + "enabled", + "incoming-enabled", + "outgoing-enabled", + "only-trusted-servers" + ], + "previews": [ + "max-gif-size" + ], + "signaling": [ + "session-ping-limit", + "mode", + "hello-v2-token-key" + ], + "experiments": [ + "enabled" + ], + "feature-hints": [ + "current", + "hidden" + ], + "permissions": [] + }, + "version": "26.0.0-dev.0" + }, + "theming": { + "name": "Nextcloud", + "productName": "Nextcloud", + "url": "https://nextcloud.com", + "imprintUrl": "", + "privacyUrl": "", + "slogan": "a safe home for all your data", + "color": "#00679e", + "color-text": "#ffffff", + "color-element": "#00679e", + "color-element-bright": "#00679e", + "color-element-dark": "#00679e", + "logo": "http://nextcloud.local/core/img/logo/logo.svg?v=14", + "background": "http://nextcloud.local/apps/theming/img/background/jo-myoung-hee-fluid.webp", + "background-text": "#ffffff", + "background-plain": false, + "background-default": true, + "logoheader": "http://nextcloud.local/core/img/logo/logo.svg?v=14", + "favicon": "http://nextcloud.local/core/img/logo/logo.svg?v=14", + "primaryColor": "#00679e", + "backgroundColor": "#00679e", + "defaultPrimaryColor": "#00679e", + "defaultBackgroundColor": "#00679e", + "inverted": false, + "cacheBuster": "c9b9d054", + "enabledThemes": [], + "toastTimeout": 7000, + "toastTimeoutValues": [ + 7000, + 15000, + 30000, + -1 + ] + } + } + } + } + } +} diff --git a/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/root/peertube-config.json b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/root/peertube-config.json new file mode 100644 index 00000000..7b629985 --- /dev/null +++ b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/root/peertube-config.json @@ -0,0 +1,174 @@ +{ + "request": { + "method": "GET", + "path": "/index.php/apps/social/api/v1/config" + }, + "status": 200, + "headers": { + "content-type": "application/json; charset=utf-8", + "cache-control": "no-cache, no-store, must-revalidate", + "x-ratelimit-limit": "60", + "x-ratelimit-remaining": "59", + "x-ratelimit-reset": "1790637240" + }, + "body": { + "instance": { + "name": "Nextcloud Social", + "shortDescription": "", + "defaultClientRoute": "/videos/recently-added", + "isNSFW": false, + "defaultNSFWPolicy": "blur", + "customizations": { + "javascript": "", + "css": "" + }, + "avatars": [], + "banners": [] + }, + "search": { + "remoteUri": { + "users": true, + "anonymous": false + } + }, + "plugin": { + "registered": [] + }, + "theme": { + "registered": [], + "default": "default" + }, + "email": { + "enabled": false + }, + "contactForm": { + "enabled": false + }, + "serverVersion": "6.0.0", + "serverCommit": "", + "software": { + "name": "nextcloud-social", + "version": "0.26.97" + }, + "signup": { + "allowed": false, + "allowedForCurrentIP": false, + "requiresEmailVerification": false, + "requiresApproval": false + }, + "transcoding": { + "hls": { + "enabled": false + }, + "web_videos": { + "enabled": true + }, + "enabledResolutions": [] + }, + "import": { + "videos": { + "http": { + "enabled": false + }, + "torrent": { + "enabled": false + } + }, + "videoChannelSynchronization": { + "enabled": false + }, + "users": { + "enabled": true + } + }, + "export": { + "users": { + "enabled": false + } + }, + "autoBlacklist": { + "videos": { + "ofUsers": { + "enabled": false + } + } + }, + "avatar": { + "file": { + "size": { + "max": 2097152 + } + }, + "extensions": [ + ".png", + ".jpg", + ".jpeg" + ] + }, + "video": { + "image": { + "extensions": [ + ".png", + ".jpg", + ".jpeg" + ], + "size": { + "max": 10485760 + } + }, + "file": { + "extensions": [ + ".mp4", + ".webm", + ".mov", + ".mkv", + ".ogv" + ] + } + }, + "videoCaption": { + "file": { + "size": { + "max": 2097152 + }, + "extensions": [ + ".vtt", + ".srt" + ] + } + }, + "user": { + "videoQuota": -1, + "videoQuotaDaily": -1 + }, + "trending": { + "videos": { + "intervalDays": 7 + } + }, + "tracker": { + "enabled": false + }, + "followings": { + "instance": { + "autoFollowIndex": { + "indexUrl": "" + } + } + }, + "federation": { + "enabled": true + }, + "homepage": { + "enabled": false + }, + "views": { + "videos": { + "watchingInterval": { + "anonymous": 5000, + "users": 5000 + } + } + } + } +} diff --git a/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/root/pixelfed-config.json b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/root/pixelfed-config.json new file mode 100644 index 00000000..2a94a7d7 --- /dev/null +++ b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/root/pixelfed-config.json @@ -0,0 +1,65 @@ +{ + "request": { + "method": "GET", + "path": "/index.php/apps/social/api/v2/config" + }, + "status": 200, + "headers": { + "content-type": "application/json; charset=utf-8", + "cache-control": "no-cache, no-store, must-revalidate", + "x-ratelimit-limit": "300", + "x-ratelimit-remaining": "266", + "x-ratelimit-reset": "1790637300" + }, + "body": { + "open_registration": false, + "uploader": { + "max_photo_size": 10240, + "max_caption_length": 5000, + "max_altext_length": 1500, + "album_limit": 10, + "image_quality": 100, + "optimize_image": false, + "optimize_video": false, + "media_types": "image/jpeg,image/gif,image/png,image/webp,image/avif,image/heic,video/mp4,video/webm,video/quicktime,audio/mpeg,audio/mp4,audio/ogg,audio/opus,audio/wav,audio/x-wav,audio/flac,audio/aac,application/pdf,text/plain,text/markdown,text/csv,application/zip,application/epub+zip,application/vnd.oasis.opendocument.text,application/vnd.oasis.opendocument.spreadsheet,application/vnd.oasis.opendocument.presentation,application/vnd.openxmlformats-officedocument.wordprocessingml.document,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,application/vnd.openxmlformats-officedocument.presentationml.presentation", + "max_collection_length": 100 + }, + "site": { + "name": "Nextcloud Social", + "domain": "nextcloud.local", + "url": "http://nextcloud.local/index.php", + "description": "a safe home for your data" + }, + "account": { + "max_bio_length": 500, + "max_name_length": 255 + }, + "features": { + "albums": true, + "collections": true, + "stories": true, + "places": true, + "hashtags": true, + "video": true, + "polls": true, + "bookmarks": true, + "mobile_apis": true, + "circles": false, + "direct_messages": true, + "live_streaming": false, + "push_notifications": false, + "stories_reactions": true + }, + "limits": { + "max_album_length": 10, + "max_caption_length": 5000, + "max_altext_length": 1500, + "story_length": 40, + "story_lifetime": 86400 + }, + "activitypub": { + "enabled": true, + "remote_follow": true + } + } +} diff --git a/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/root/status.json b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/root/status.json new file mode 100644 index 00000000..3580212a --- /dev/null +++ b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/root/status.json @@ -0,0 +1,20 @@ +{ + "request": { + "method": "GET", + "path": "/status.php" + }, + "status": 200, + "headers": { + "content-type": "application/json" + }, + "body": { + "installed": true, + "maintenance": false, + "needsDbUpgrade": false, + "version": "36.0.0.0", + "versionstring": "36.0.0 dev", + "edition": "", + "productname": "Nextcloud", + "extendedSupport": false + } +} diff --git a/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/root/timeline-public-page-app.json b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/root/timeline-public-page-app.json new file mode 100644 index 00000000..dc0146c1 --- /dev/null +++ b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/root/timeline-public-page-app.json @@ -0,0 +1,269 @@ +{ + "request": { + "method": "GET", + "path": "/index.php/apps/social/api/v1/timelines/public?limit=2" + }, + "status": 200, + "headers": { + "content-type": "application/json; charset=utf-8", + "cache-control": "no-cache, no-store, must-revalidate", + "link": "; rel=\"next\", ; rel=\"prev\"", + "x-ratelimit-limit": "60", + "x-ratelimit-remaining": "57", + "x-ratelimit-reset": "1790637240" + }, + "body": [ + { + "id": "1790637117344364158", + "nid": "1790637117344364158", + "local": true, + "archived": false, + "view_count": null, + "tagged_people": [], + "interest": null, + "content": "

Bob's own post #aloha

", + "sensitive": false, + "spoiler_text": "", + "visibility": "public", + "language": "en", + "place": null, + "in_reply_to_id": null, + "in_reply_to_account_id": null, + "quote": null, + "quote_approval": { + "automatic": [ + "public" + ], + "manual": [], + "current_user": "automatic" + }, + "reply_approval": null, + "interaction_policy": null, + "dislikes_count": null, + "disliked": null, + "video": null, + "mentions": [], + "emojis": [], + "tags": [ + { + "name": "aloha", + "url": "http://nextcloud.local/index.php/apps/social/timeline/tags/aloha" + } + ], + "replies_count": 0, + "reblogs_count": 0, + "favourites_count": 0, + "favourited": false, + "reblogged": false, + "muted": false, + "bookmarked": false, + "pinned": false, + "card": null, + "reactions": [], + "poll": null, + "uri": "http://nextcloud.local/index.php/apps/social/@bob/17906371177254273993", + "url": "http://nextcloud.local/index.php/apps/social/@bob/17906371177254273993", + "reblog": null, + "media_attachments": [], + "created_at": "2026-09-28T23:11:57.000Z", + "edited_at": null, + "noindex": false, + "account": { + "id": "7", + "nid": "7", + "username": "bob", + "acct": "bob", + "display_name": "bob", + "locked": false, + "bot": false, + "discoverable": false, + "indexable": false, + "group": false, + "created_at": "2026-09-28T23:06:03.000Z", + "note": "", + "url": "http://nextcloud.local/index.php/apps/social/@bob", + "avatar": "", + "avatar_static": "", + "header": "http://nextcloud.local/index.php/avatar/bob/128", + "header_static": "http://nextcloud.local/index.php/avatar/bob/128", + "followers_count": 1, + "following_count": 2, + "statuses_count": 3, + "last_status_at": null, + "emojis": [], + "fields": [], + "pronouns": "", + "support_link": "" + }, + "filtered": [] + }, + { + "id": "1790637116374990863", + "nid": "1790637116374990863", + "local": true, + "archived": false, + "view_count": null, + "tagged_people": [], + "interest": null, + "content": "

Quoting this one

", + "sensitive": false, + "spoiler_text": "", + "visibility": "public", + "language": "en", + "place": null, + "in_reply_to_id": null, + "in_reply_to_account_id": null, + "quote": { + "state": "accepted", + "quoted_status": { + "id": "1790637085797595891", + "nid": "1790637085797595891", + "local": true, + "archived": false, + "view_count": null, + "tagged_people": [], + "interest": null, + "content": "

Aloha from the dev instance! Say hi, @bob@nextcloud.local #aloha #android

", + "sensitive": false, + "spoiler_text": "", + "visibility": "public", + "language": "en", + "place": null, + "in_reply_to_id": null, + "in_reply_to_account_id": null, + "quote": null, + "quote_approval": { + "automatic": [ + "public" + ], + "manual": [], + "current_user": "automatic" + }, + "reply_approval": null, + "interaction_policy": null, + "dislikes_count": null, + "disliked": null, + "video": null, + "mentions": [], + "emojis": [], + "tags": [ + { + "name": "aloha", + "url": "http://nextcloud.local/index.php/apps/social/timeline/tags/aloha" + }, + { + "name": "android", + "url": "http://nextcloud.local/index.php/apps/social/timeline/tags/android" + } + ], + "replies_count": 1, + "reblogs_count": 0, + "favourites_count": 1, + "favourited": false, + "reblogged": false, + "muted": false, + "bookmarked": false, + "pinned": false, + "card": null, + "reactions": [], + "poll": null, + "uri": "http://nextcloud.local/index.php/apps/social/@alice/17906370853806247191", + "url": "http://nextcloud.local/index.php/apps/social/@alice/17906370853806247191", + "reblog": null, + "media_attachments": [], + "created_at": "2026-09-28T23:11:25.000Z", + "edited_at": null, + "noindex": false, + "account": { + "id": "6", + "nid": "6", + "username": "alice", + "acct": "alice", + "display_name": "alice", + "locked": false, + "bot": false, + "discoverable": false, + "indexable": false, + "group": false, + "created_at": "2026-09-28T23:06:02.000Z", + "note": "", + "url": "http://nextcloud.local/index.php/apps/social/@alice", + "avatar": "", + "avatar_static": "", + "header": "http://nextcloud.local/index.php/avatar/alice/128", + "header_static": "http://nextcloud.local/index.php/avatar/alice/128", + "followers_count": 2, + "following_count": 2, + "statuses_count": 13, + "last_status_at": null, + "emojis": [], + "fields": [], + "pronouns": "", + "support_link": "" + } + } + }, + "quote_approval": { + "automatic": [ + "public" + ], + "manual": [], + "current_user": "automatic" + }, + "reply_approval": null, + "interaction_policy": null, + "dislikes_count": null, + "disliked": null, + "video": null, + "mentions": [], + "emojis": [], + "tags": [], + "replies_count": 0, + "reblogs_count": 0, + "favourites_count": 0, + "favourited": false, + "reblogged": false, + "muted": false, + "bookmarked": false, + "pinned": false, + "card": null, + "reactions": [], + "poll": null, + "uri": "http://nextcloud.local/index.php/apps/social/@bob/17906371165948083557", + "url": "http://nextcloud.local/index.php/apps/social/@bob/17906371165948083557", + "reblog": null, + "media_attachments": [], + "created_at": "2026-09-28T23:11:56.000Z", + "edited_at": null, + "noindex": false, + "account": { + "id": "7", + "nid": "7", + "username": "bob", + "acct": "bob", + "display_name": "bob", + "locked": false, + "bot": false, + "discoverable": false, + "indexable": false, + "group": false, + "created_at": "2026-09-28T23:06:03.000Z", + "note": "", + "url": "http://nextcloud.local/index.php/apps/social/@bob", + "avatar": "", + "avatar_static": "", + "header": "http://nextcloud.local/index.php/avatar/bob/128", + "header_static": "http://nextcloud.local/index.php/avatar/bob/128", + "followers_count": 1, + "following_count": 2, + "statuses_count": 3, + "last_status_at": null, + "emojis": [], + "fields": [], + "pronouns": "", + "support_link": "" + }, + "filtered": [] + } + ] +} diff --git a/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/root/timeline-public-page-root.json b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/root/timeline-public-page-root.json new file mode 100644 index 00000000..1bc2198b --- /dev/null +++ b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/root/timeline-public-page-root.json @@ -0,0 +1,269 @@ +{ + "request": { + "method": "GET", + "path": "/api/v1/timelines/public?limit=2" + }, + "status": 200, + "headers": { + "content-type": "application/json; charset=utf-8", + "cache-control": "no-cache, no-store, must-revalidate", + "link": "; rel=\"next\", ; rel=\"prev\"", + "x-ratelimit-limit": "60", + "x-ratelimit-remaining": "58", + "x-ratelimit-reset": "1790637240" + }, + "body": [ + { + "id": "1790637117344364158", + "nid": "1790637117344364158", + "local": true, + "archived": false, + "view_count": null, + "tagged_people": [], + "interest": null, + "content": "

Bob's own post #aloha

", + "sensitive": false, + "spoiler_text": "", + "visibility": "public", + "language": "en", + "place": null, + "in_reply_to_id": null, + "in_reply_to_account_id": null, + "quote": null, + "quote_approval": { + "automatic": [ + "public" + ], + "manual": [], + "current_user": "automatic" + }, + "reply_approval": null, + "interaction_policy": null, + "dislikes_count": null, + "disliked": null, + "video": null, + "mentions": [], + "emojis": [], + "tags": [ + { + "name": "aloha", + "url": "http://nextcloud.local/index.php/apps/social/timeline/tags/aloha" + } + ], + "replies_count": 0, + "reblogs_count": 0, + "favourites_count": 0, + "favourited": false, + "reblogged": false, + "muted": false, + "bookmarked": false, + "pinned": false, + "card": null, + "reactions": [], + "poll": null, + "uri": "http://nextcloud.local/index.php/apps/social/@bob/17906371177254273993", + "url": "http://nextcloud.local/index.php/apps/social/@bob/17906371177254273993", + "reblog": null, + "media_attachments": [], + "created_at": "2026-09-28T23:11:57.000Z", + "edited_at": null, + "noindex": false, + "account": { + "id": "7", + "nid": "7", + "username": "bob", + "acct": "bob", + "display_name": "bob", + "locked": false, + "bot": false, + "discoverable": false, + "indexable": false, + "group": false, + "created_at": "2026-09-28T23:06:03.000Z", + "note": "", + "url": "http://nextcloud.local/index.php/apps/social/@bob", + "avatar": "", + "avatar_static": "", + "header": "http://nextcloud.local/index.php/avatar/bob/128", + "header_static": "http://nextcloud.local/index.php/avatar/bob/128", + "followers_count": 1, + "following_count": 2, + "statuses_count": 3, + "last_status_at": null, + "emojis": [], + "fields": [], + "pronouns": "", + "support_link": "" + }, + "filtered": [] + }, + { + "id": "1790637116374990863", + "nid": "1790637116374990863", + "local": true, + "archived": false, + "view_count": null, + "tagged_people": [], + "interest": null, + "content": "

Quoting this one

", + "sensitive": false, + "spoiler_text": "", + "visibility": "public", + "language": "en", + "place": null, + "in_reply_to_id": null, + "in_reply_to_account_id": null, + "quote": { + "state": "accepted", + "quoted_status": { + "id": "1790637085797595891", + "nid": "1790637085797595891", + "local": true, + "archived": false, + "view_count": null, + "tagged_people": [], + "interest": null, + "content": "

Aloha from the dev instance! Say hi, @bob@nextcloud.local #aloha #android

", + "sensitive": false, + "spoiler_text": "", + "visibility": "public", + "language": "en", + "place": null, + "in_reply_to_id": null, + "in_reply_to_account_id": null, + "quote": null, + "quote_approval": { + "automatic": [ + "public" + ], + "manual": [], + "current_user": "automatic" + }, + "reply_approval": null, + "interaction_policy": null, + "dislikes_count": null, + "disliked": null, + "video": null, + "mentions": [], + "emojis": [], + "tags": [ + { + "name": "aloha", + "url": "http://nextcloud.local/index.php/apps/social/timeline/tags/aloha" + }, + { + "name": "android", + "url": "http://nextcloud.local/index.php/apps/social/timeline/tags/android" + } + ], + "replies_count": 1, + "reblogs_count": 0, + "favourites_count": 1, + "favourited": false, + "reblogged": false, + "muted": false, + "bookmarked": false, + "pinned": false, + "card": null, + "reactions": [], + "poll": null, + "uri": "http://nextcloud.local/index.php/apps/social/@alice/17906370853806247191", + "url": "http://nextcloud.local/index.php/apps/social/@alice/17906370853806247191", + "reblog": null, + "media_attachments": [], + "created_at": "2026-09-28T23:11:25.000Z", + "edited_at": null, + "noindex": false, + "account": { + "id": "6", + "nid": "6", + "username": "alice", + "acct": "alice", + "display_name": "alice", + "locked": false, + "bot": false, + "discoverable": false, + "indexable": false, + "group": false, + "created_at": "2026-09-28T23:06:02.000Z", + "note": "", + "url": "http://nextcloud.local/index.php/apps/social/@alice", + "avatar": "", + "avatar_static": "", + "header": "http://nextcloud.local/index.php/avatar/alice/128", + "header_static": "http://nextcloud.local/index.php/avatar/alice/128", + "followers_count": 2, + "following_count": 2, + "statuses_count": 13, + "last_status_at": null, + "emojis": [], + "fields": [], + "pronouns": "", + "support_link": "" + } + } + }, + "quote_approval": { + "automatic": [ + "public" + ], + "manual": [], + "current_user": "automatic" + }, + "reply_approval": null, + "interaction_policy": null, + "dislikes_count": null, + "disliked": null, + "video": null, + "mentions": [], + "emojis": [], + "tags": [], + "replies_count": 0, + "reblogs_count": 0, + "favourites_count": 0, + "favourited": false, + "reblogged": false, + "muted": false, + "bookmarked": false, + "pinned": false, + "card": null, + "reactions": [], + "poll": null, + "uri": "http://nextcloud.local/index.php/apps/social/@bob/17906371165948083557", + "url": "http://nextcloud.local/index.php/apps/social/@bob/17906371165948083557", + "reblog": null, + "media_attachments": [], + "created_at": "2026-09-28T23:11:56.000Z", + "edited_at": null, + "noindex": false, + "account": { + "id": "7", + "nid": "7", + "username": "bob", + "acct": "bob", + "display_name": "bob", + "locked": false, + "bot": false, + "discoverable": false, + "indexable": false, + "group": false, + "created_at": "2026-09-28T23:06:03.000Z", + "note": "", + "url": "http://nextcloud.local/index.php/apps/social/@bob", + "avatar": "", + "avatar_static": "", + "header": "http://nextcloud.local/index.php/avatar/bob/128", + "header_static": "http://nextcloud.local/index.php/avatar/bob/128", + "followers_count": 1, + "following_count": 2, + "statuses_count": 3, + "last_status_at": null, + "emojis": [], + "fields": [], + "pronouns": "", + "support_link": "" + }, + "filtered": [] + } + ] +} diff --git a/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/root/webfinger-alice.json b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/root/webfinger-alice.json new file mode 100644 index 00000000..5392d441 --- /dev/null +++ b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/root/webfinger-alice.json @@ -0,0 +1,34 @@ +{ + "request": { + "method": "GET", + "path": "/.well-known/webfinger?resource=acct:alice@nextcloud.local" + }, + "status": 200, + "headers": { + "content-type": "application/json; charset=utf-8", + "cache-control": "no-cache, no-store, must-revalidate" + }, + "body": { + "subject": "acct:alice@nextcloud.local", + "aliases": [ + "http://nextcloud.local/index.php/apps/social/@alice", + "http://nextcloud.local/index.php/u/alice" + ], + "links": [ + { + "rel": "self", + "type": "application/activity+json", + "href": "http://nextcloud.local/index.php/apps/social/@alice" + }, + { + "rel": "http://webfinger.net/rel/profile-page", + "type": "text/html", + "href": "http://nextcloud.local/index.php/apps/social/@alice" + }, + { + "rel": "http://ostatus.org/schema/1.0/subscribe", + "template": "http://nextcloud.local/index.php/apps/social/ostatus/follow/?uri={uri}" + } + ] + } +} diff --git a/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/writes/favourite-returns-status.json b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/writes/favourite-returns-status.json new file mode 100644 index 00000000..e8d8b684 --- /dev/null +++ b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/writes/favourite-returns-status.json @@ -0,0 +1,97 @@ +{ + "request": { + "method": "POST", + "path": "/index.php/apps/social/api/v1/statuses/1790637117344364158/favourite" + }, + "status": 200, + "headers": { + "content-type": "application/json; charset=utf-8", + "cache-control": "no-cache, no-store, must-revalidate", + "x-ratelimit-limit": "300", + "x-ratelimit-remaining": "180", + "x-ratelimit-reset": "1790637300" + }, + "body": { + "id": "1790637117344364158", + "nid": "1790637117344364158", + "local": true, + "archived": false, + "view_count": null, + "tagged_people": [], + "interest": null, + "content": "

Bob's own post #aloha

", + "sensitive": false, + "spoiler_text": "", + "visibility": "public", + "language": "en", + "place": null, + "in_reply_to_id": null, + "in_reply_to_account_id": null, + "quote": null, + "quote_approval": { + "automatic": [ + "public" + ], + "manual": [], + "current_user": "automatic" + }, + "reply_approval": null, + "interaction_policy": null, + "dislikes_count": null, + "disliked": null, + "video": null, + "mentions": [], + "emojis": [], + "tags": [ + { + "name": "aloha", + "url": "http://nextcloud.local/index.php/apps/social/timeline/tags/aloha" + } + ], + "replies_count": 0, + "reblogs_count": 0, + "favourites_count": 1, + "favourited": true, + "reblogged": false, + "muted": false, + "bookmarked": false, + "pinned": false, + "card": null, + "reactions": [], + "poll": null, + "uri": "http://nextcloud.local/index.php/apps/social/@bob/17906371177254273993", + "url": "http://nextcloud.local/index.php/apps/social/@bob/17906371177254273993", + "reblog": null, + "media_attachments": [], + "created_at": "2026-09-28T23:11:57.000Z", + "edited_at": null, + "noindex": false, + "account": { + "id": "7", + "nid": "7", + "username": "bob", + "acct": "bob", + "display_name": "bob", + "locked": false, + "bot": false, + "discoverable": false, + "indexable": false, + "group": false, + "created_at": "2026-09-28T23:06:03.000Z", + "note": "", + "url": "http://nextcloud.local/index.php/apps/social/@bob", + "avatar": "", + "avatar_static": "", + "header": "http://nextcloud.local/index.php/avatar/bob/128", + "header_static": "http://nextcloud.local/index.php/avatar/bob/128", + "followers_count": 1, + "following_count": 2, + "statuses_count": 4, + "last_status_at": null, + "emojis": [], + "fields": [], + "pronouns": "", + "support_link": "" + } + } +} diff --git a/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/writes/idempotent-post-1.json b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/writes/idempotent-post-1.json new file mode 100644 index 00000000..9c275ceb --- /dev/null +++ b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/writes/idempotent-post-1.json @@ -0,0 +1,92 @@ +{ + "request": { + "method": "POST", + "path": "/index.php/apps/social/api/v1/statuses" + }, + "status": 200, + "headers": { + "content-type": "application/json; charset=utf-8", + "cache-control": "no-cache, no-store, must-revalidate", + "x-ratelimit-limit": "30", + "x-ratelimit-remaining": "29", + "x-ratelimit-reset": "1790637300" + }, + "body": { + "id": "1790637255203793303", + "nid": "1790637255203793303", + "local": true, + "archived": false, + "view_count": null, + "tagged_people": [], + "interest": null, + "content": "

Idempotency check

", + "sensitive": false, + "spoiler_text": "", + "visibility": "unlisted", + "language": "en", + "place": null, + "in_reply_to_id": null, + "in_reply_to_account_id": null, + "quote": null, + "quote_approval": { + "automatic": [ + "public" + ], + "manual": [], + "current_user": "automatic" + }, + "reply_approval": null, + "interaction_policy": null, + "dislikes_count": null, + "disliked": null, + "video": null, + "mentions": [], + "emojis": [], + "tags": [], + "replies_count": 0, + "reblogs_count": 0, + "favourites_count": 0, + "favourited": false, + "reblogged": false, + "muted": false, + "bookmarked": false, + "pinned": false, + "card": null, + "reactions": [], + "poll": null, + "uri": "http://nextcloud.local/index.php/apps/social/@bob/17906372550124710982", + "url": "http://nextcloud.local/index.php/apps/social/@bob/17906372550124710982", + "reblog": null, + "media_attachments": [], + "created_at": "2026-09-28T23:14:15.000Z", + "edited_at": null, + "noindex": false, + "account": { + "id": "7", + "nid": "7", + "username": "bob", + "acct": "bob", + "display_name": "bob", + "locked": false, + "bot": false, + "discoverable": false, + "indexable": false, + "group": false, + "created_at": "2026-09-28T23:06:03.000Z", + "note": "", + "url": "http://nextcloud.local/index.php/apps/social/@bob", + "avatar": "", + "avatar_static": "", + "header": "http://nextcloud.local/index.php/avatar/bob/128", + "header_static": "http://nextcloud.local/index.php/avatar/bob/128", + "followers_count": 1, + "following_count": 2, + "statuses_count": 4, + "last_status_at": null, + "emojis": [], + "fields": [], + "pronouns": "", + "support_link": "" + } + } +} diff --git a/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/writes/idempotent-post-2.json b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/writes/idempotent-post-2.json new file mode 100644 index 00000000..ad9df757 --- /dev/null +++ b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/writes/idempotent-post-2.json @@ -0,0 +1,92 @@ +{ + "request": { + "method": "POST", + "path": "/index.php/apps/social/api/v1/statuses" + }, + "status": 200, + "headers": { + "content-type": "application/json; charset=utf-8", + "cache-control": "no-cache, no-store, must-revalidate", + "x-ratelimit-limit": "30", + "x-ratelimit-remaining": "28", + "x-ratelimit-reset": "1790637300" + }, + "body": { + "id": "1790637255203793303", + "nid": "1790637255203793303", + "local": true, + "archived": false, + "view_count": null, + "tagged_people": [], + "interest": null, + "content": "

Idempotency check

", + "sensitive": false, + "spoiler_text": "", + "visibility": "unlisted", + "language": "en", + "place": null, + "in_reply_to_id": null, + "in_reply_to_account_id": null, + "quote": null, + "quote_approval": { + "automatic": [ + "public" + ], + "manual": [], + "current_user": "automatic" + }, + "reply_approval": null, + "interaction_policy": null, + "dislikes_count": null, + "disliked": null, + "video": null, + "mentions": [], + "emojis": [], + "tags": [], + "replies_count": 0, + "reblogs_count": 0, + "favourites_count": 0, + "favourited": false, + "reblogged": false, + "muted": false, + "bookmarked": false, + "pinned": false, + "card": null, + "reactions": [], + "poll": null, + "uri": "http://nextcloud.local/index.php/apps/social/@bob/17906372550124710982", + "url": "http://nextcloud.local/index.php/apps/social/@bob/17906372550124710982", + "reblog": null, + "media_attachments": [], + "created_at": "2026-09-28T23:14:15.000Z", + "edited_at": null, + "noindex": false, + "account": { + "id": "7", + "nid": "7", + "username": "bob", + "acct": "bob", + "display_name": "bob", + "locked": false, + "bot": false, + "discoverable": false, + "indexable": false, + "group": false, + "created_at": "2026-09-28T23:06:03.000Z", + "note": "", + "url": "http://nextcloud.local/index.php/apps/social/@bob", + "avatar": "", + "avatar_static": "", + "header": "http://nextcloud.local/index.php/avatar/bob/128", + "header_static": "http://nextcloud.local/index.php/avatar/bob/128", + "followers_count": 1, + "following_count": 2, + "statuses_count": 4, + "last_status_at": null, + "emojis": [], + "fields": [], + "pronouns": "", + "support_link": "" + } + } +} diff --git a/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/writes/media-upload-v2.json b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/writes/media-upload-v2.json new file mode 100644 index 00000000..08568af4 --- /dev/null +++ b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/writes/media-upload-v2.json @@ -0,0 +1,44 @@ +{ + "request": { + "method": "POST", + "path": "/index.php/apps/social/api/v2/media" + }, + "status": 200, + "headers": { + "content-type": "application/json; charset=utf-8", + "cache-control": "no-cache, no-store, must-revalidate", + "x-ratelimit-limit": "30", + "x-ratelimit-remaining": "29", + "x-ratelimit-reset": "1790637300" + }, + "body": { + "id": "17", + "type": "image", + "media_type": "image/png", + "url": "http://nextcloud.local/index.php/apps/social/media/44b73778-38d5-4951-a9dc-025f2e6b0351.png", + "preview_url": "http://nextcloud.local/index.php/apps/social/media/556039d3-6c5e-48a3-8e65-eb8247ec2ea4.png", + "hls_url": null, + "remote_url": null, + "cache_error": null, + "meta": { + "original": { + "width": 1080, + "height": 1080, + "size": "1080x1080", + "aspect": 1 + }, + "small": { + "width": 800, + "height": 800, + "size": "800x800", + "aspect": 1 + }, + "focus": { + "x": -0.5, + "y": 0.5 + } + }, + "description": "Fixture upload", + "blurhash": "LRF~:K*DD7L+S^o@$T+kR5wiM_IU" + } +} diff --git a/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/writes/videos-continue-bob.json b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/writes/videos-continue-bob.json new file mode 100644 index 00000000..965478ae --- /dev/null +++ b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/writes/videos-continue-bob.json @@ -0,0 +1,95 @@ +{ + "request": { + "method": "GET", + "path": "/index.php/apps/social/api/v1/videos/continue" + }, + "status": 200, + "headers": { + "content-type": "application/json; charset=utf-8", + "cache-control": "no-cache, no-store, must-revalidate", + "x-ratelimit-limit": "300", + "x-ratelimit-remaining": "181", + "x-ratelimit-reset": "1790637300" + }, + "body": [ + { + "id": "1790637104958214061", + "nid": "1790637104958214061", + "local": true, + "archived": false, + "view_count": null, + "tagged_people": [], + "interest": null, + "content": "

Test video #video

00:00 Intro
00:10 Middle
00:20 End

", + "sensitive": false, + "spoiler_text": "", + "visibility": "public", + "language": "en", + "place": null, + "in_reply_to_id": null, + "in_reply_to_account_id": null, + "quote": null, + "quote_approval": { + "automatic": [ + "public" + ], + "manual": [], + "current_user": "automatic" + }, + "reply_approval": null, + "interaction_policy": null, + "dislikes_count": 0, + "disliked": false, + "video": { + "title": "Aloha test video" + }, + "mentions": [], + "emojis": [], + "tags": [ + { + "name": "video", + "url": "http://nextcloud.local/index.php/apps/social/timeline/tags/video" + } + ], + "replies_count": 0, + "reblogs_count": 0, + "favourites_count": 1, + "favourited": false, + "reblogged": false, + "muted": false, + "bookmarked": false, + "pinned": false, + "card": null, + "reactions": [], + "poll": null, + "uri": "http://nextcloud.local/index.php/apps/social/@alice/17906371045255950881", + "url": "http://nextcloud.local/index.php/apps/social/@alice/17906371045255950881", + "reblog": null, + "media_attachments": [ + { + "id": "12", + "type": "video", + "media_type": "video/mp4", + "url": "http://nextcloud.local/index.php/apps/social/media/09c96321-538a-4d33-a3e3-e326ba1c5a56.mp4", + "preview_url": "http://nextcloud.local/index.php/apps/social/media/09c96321-538a-4d33-a3e3-e326ba1c5a56.mp4", + "hls_url": null, + "remote_url": null, + "cache_error": null, + "meta": { + "original": [], + "small": [], + "focus": { + "x": 0, + "y": 0 + } + }, + "description": "A 30 second test pattern with a tone", + "blurhash": null + } + ], + "created_at": "2026-09-28T23:11:44.000Z", + "edited_at": null, + "noindex": false + } + ] +} diff --git a/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/writes/watched-bob.json b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/writes/watched-bob.json new file mode 100644 index 00000000..242cb787 --- /dev/null +++ b/core/testing/src/main/resources/fixtures/nextcloud-social-0.26.97/writes/watched-bob.json @@ -0,0 +1,15 @@ +{ + "request": { + "method": "POST", + "path": "/index.php/apps/social/api/v1/statuses/1790637104958214061/watched" + }, + "status": 200, + "headers": { + "content-type": "application/json; charset=utf-8", + "cache-control": "no-cache, no-store, must-revalidate", + "x-ratelimit-limit": "600", + "x-ratelimit-remaining": "599", + "x-ratelimit-reset": "1790637300" + }, + "body": [] +} diff --git a/core/testing/src/test/kotlin/social/aloha/core/testing/AccountMaintenanceTest.kt b/core/testing/src/test/kotlin/social/aloha/core/testing/AccountMaintenanceTest.kt new file mode 100644 index 00000000..20cb3307 --- /dev/null +++ b/core/testing/src/test/kotlin/social/aloha/core/testing/AccountMaintenanceTest.kt @@ -0,0 +1,108 @@ +// SPDX-FileCopyrightText: 2026 Aloha Social contributors +// SPDX-License-Identifier: MIT + +package social.aloha.core.testing + +import androidx.datastore.preferences.core.emptyPreferences +import androidx.room.Room +import androidx.test.core.app.ApplicationProvider +import java.time.Clock +import java.time.Instant +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import okhttp3.OkHttpClient +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import social.aloha.core.data.AccountMaintenance +import social.aloha.core.data.AccountRepository +import social.aloha.core.data.NewAccount +import social.aloha.core.database.AccountsDatabase +import social.aloha.core.datastore.AppPreferences +import social.aloha.core.datastore.TokenVault +import social.aloha.core.model.AccessToken +import social.aloha.core.model.ServerCapabilities +import social.aloha.core.network.RateLimiter +import social.aloha.core.network.capabilities.CapabilityDetector +import social.aloha.core.network.probe.ReprobeGate +import social.aloha.core.network.probe.ServerProbe + +@RunWith(RobolectricTestRunner::class) +class AccountMaintenanceTest { + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + private val mock = MockSocialServer(MockServerConfiguration.NextcloudWithoutRewrite).start() + private val database = Room.inMemoryDatabaseBuilder( + ApplicationProvider.getApplicationContext(), + AccountsDatabase::class.java, + ).build() + private val accounts = AccountRepository( + database.accountDao(), + TokenVault(InMemoryDataStore(ByteArray(0)), FakeSecretCipher(), Dispatchers.IO), + AppPreferences(InMemoryDataStore(emptyPreferences())), + Clock.systemUTC(), + scope, + ) + private val http = OkHttpClient() + private val limiter = RateLimiter(nowMillis = Clock.systemUTC()::millis) + private val maintenance = AccountMaintenance( + accounts, + ServerProbe(http, limiter, Dispatchers.IO), + CapabilityDetector(http, limiter, Dispatchers.IO) { Instant.now() }, + ReprobeGate(nowMillis = Clock.systemUTC()::millis), + Clock.systemUTC(), + ) + + @After + fun close() { + scope.cancel() + database.close() + mock.close() + } + + private suspend fun signedInAt(apiBase: String, detectedAt: Instant) = accounts.signedIn( + NewAccount( + host = mock.origin.host, + serverAccountId = "6", + handle = "alice", + displayName = "alice", + avatarUrl = null, + headerUrl = null, + capabilities = ServerCapabilities.minimal(apiBase).copy(detectedAt = detectedAt), + profilePending = false, + ), + AccessToken(MockCredentials.ACCESS_TOKEN, ""), + ) + + @Test + fun `stale capabilities are detected again`() = runBlocking { + signedInAt(mock.apiBase.toString(), Instant.EPOCH) + maintenance.checkAll() + val capabilities = accounts.all().single().capabilities + assertTrue(capabilities.isNextcloudSocial) + assertTrue(capabilities.detectedAt.isAfter(Instant.EPOCH)) + } + + @Test + fun `an API base that stopped answering is found again and stored, however fresh`() = runBlocking { + // the account was added while the rewrite rules were installed; the administrator removed them + signedInAt(mock.origin.toString(), Instant.now()) + maintenance.checkAll() + assertEquals(mock.apiBase.toString(), accounts.all().single().apiBase) + } + + @Test + fun `fresh capabilities on an answering base are left alone`() = runBlocking { + signedInAt(mock.apiBase.toString(), Instant.now()) + val before = mock.requests.size + maintenance.checkAll() + // only the instance was asked for + assertEquals(before + 1, mock.requests.size) + } +} diff --git a/core/testing/src/test/kotlin/social/aloha/core/testing/AccountRecoveryTest.kt b/core/testing/src/test/kotlin/social/aloha/core/testing/AccountRecoveryTest.kt new file mode 100644 index 00000000..5afd20b3 --- /dev/null +++ b/core/testing/src/test/kotlin/social/aloha/core/testing/AccountRecoveryTest.kt @@ -0,0 +1,180 @@ +// SPDX-FileCopyrightText: 2026 Aloha Social contributors +// SPDX-License-Identifier: MIT + +package social.aloha.core.testing + +import androidx.datastore.preferences.core.emptyPreferences +import androidx.room.Room +import androidx.test.core.app.ApplicationProvider +import java.time.Clock +import java.time.Instant +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.OkHttpClient +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import social.aloha.core.data.AccountRepository +import social.aloha.core.data.Authorization +import social.aloha.core.data.ClientFactory +import social.aloha.core.data.NewAccount +import social.aloha.core.data.SignInCoordinator +import social.aloha.core.data.SignInResult +import social.aloha.core.database.AccountsDatabase +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.ServerCapabilities +import social.aloha.core.network.ApiError +import social.aloha.core.network.ApiResult +import social.aloha.core.network.RateLimiter +import social.aloha.core.network.capabilities.CapabilityDetector +import social.aloha.core.network.endpoints.AccountEndpoints +import social.aloha.core.network.oauth.OAuthClient +import social.aloha.core.network.oauth.OAuthIdentity + +@RunWith(RobolectricTestRunner::class) +class AccountRecoveryTest { + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + private val database = Room.inMemoryDatabaseBuilder( + ApplicationProvider.getApplicationContext(), + AccountsDatabase::class.java, + ).build() + private val cipher = FakeSecretCipher() + private val vaultBytes = InMemoryDataStore(ByteArray(0)) + private val vault = TokenVault(vaultBytes, cipher, Dispatchers.IO) + private val accounts = AccountRepository( + database.accountDao(), + vault, + AppPreferences(InMemoryDataStore(emptyPreferences())), + Clock.systemUTC(), + scope, + ) + private val http = OkHttpClient() + private val limiter = RateLimiter(nowMillis = Clock.systemUTC()::millis) + private val clients = ClientFactory(http, limiter, Dispatchers.IO, accounts) + private var mock: MockSocialServer? = null + + @After + fun close() { + scope.cancel() + database.close() + mock?.close() + } + + private fun newAccount(apiBase: String, id: String = "6") = NewAccount( + host = "cloud.example", + serverAccountId = id, + handle = "alice", + displayName = "Alice", + avatarUrl = null, + headerUrl = null, + capabilities = ServerCapabilities.minimal(apiBase), + profilePending = false, + ) + + @Test + fun `a revoked token marks the account for a new sign-in and keeps it`() = runBlocking { + val server = MockSocialServer(MockServerConfiguration.NextcloudWithoutRewrite).start().also { mock = it } + val account = accounts.signedIn( + newAccount(server.apiBase.toString()), + AccessToken(MockCredentials.REVOKED_TOKEN, ""), + ) + val client = clients.forAccount(account) ?: error("client") + + val result = client.execute(AccountEndpoints.verifyCredentials()) + + assertTrue((result as ApiResult.Failure).error is ApiError.Unauthorised) + val after = accounts.accounts.first { list -> list.singleOrNull()?.needsReauth == true }.single() + assertTrue(after.needsReauth) + assertEquals(account.id, after.id) + assertEquals(MockCredentials.REVOKED_TOKEN, accounts.token(account.id)?.value) + } + + /** The app as the next process sees it: same database and vault bytes, nothing held in memory. */ + private fun restarted(): AccountRepository = AccountRepository( + database.accountDao(), + TokenVault(vaultBytes, cipher, Dispatchers.IO), + AppPreferences(InMemoryDataStore(emptyPreferences())), + Clock.systemUTC(), + scope, + ) + + @Test + fun `a vault that cannot be read leaves every account needing a new sign-in, none deleted`() = runBlocking { + accounts.signedIn(newAccount("https://cloud.example/", "1"), AccessToken("t1", "")) + accounts.signedIn(newAccount("https://cloud.example/", "2"), AccessToken("t2", "")) + cipher.lose = true + + val all = restarted().accounts.first { list -> list.all { it.needsReauth } } + + assertEquals(2, all.size) + } + + @Test + fun `accounts restored from a backup without their tokens need a new sign-in`() = runBlocking { + val account = accounts.signedIn(newAccount("https://cloud.example/"), AccessToken("t", "")) + vault.remove(VaultKey.AccessToken(account.id)) + + assertTrue(restarted().accounts.first { list -> list.all { it.needsReauth } }.single().needsReauth) + } + + @Test + fun `signing in again to the same person updates the account instead of adding one`() = runBlocking { + val first = accounts.signedIn(newAccount("https://cloud.example/"), AccessToken("old", "")) + accounts.markNeedsReauth(first.id) + val second = accounts.signedIn(newAccount("https://cloud.example/"), AccessToken("new", "")) + + assertEquals(first.id, second.id) + assertFalse(accounts.all().single().needsReauth) + assertEquals("new", accounts.token(first.id)?.value) + } + + @Test + fun `removing the active account forgets its secrets and activates the next`() = runBlocking { + val first = accounts.signedIn(newAccount("https://cloud.example/", "1"), AccessToken("t1", "")) + val second = accounts.signedIn(newAccount("https://cloud.example/", "2"), AccessToken("t2", "")) + accounts.remove(second.id) + + assertNull(accounts.token(second.id)) + assertEquals(first.id, accounts.all().single().id) + assertEquals(first.id, accounts.activeAccount.first { it?.id == first.id }?.id) + } + + @Test + fun `a new account the server cannot describe yet signs in from userinfo with its profile pending`() = runBlocking { + val server = MockSocialServer(MockServerConfiguration.NextcloudWithoutRewrite).start().also { mock = it } + server.pin("GET", "/api/v1/accounts/verify_credentials", status = 500, body = "{}") + val coordinator = SignInCoordinator( + accounts, + vault, + OAuthClient(http, limiter, Dispatchers.IO), + CapabilityDetector(http, limiter, Dispatchers.IO) { Instant.now() }, + { OAuthIdentity.SCHEME_REDIRECT }, + clients, + ) + val found = testServerFinder(http, limiter).found(server.origin.toString()) + val state = ( + coordinator.beginAuthorization( + found, + ) as Authorization.Started + ).url.toHttpUrl().queryParameter("state") + + val result = coordinator.complete("alohasocial://oauth-callback/?code=c&state=$state") + + val account = (result as SignInResult.SignedIn).account + assertTrue(account.profilePending) + assertEquals("alice", account.handle) + } +} diff --git a/core/testing/src/test/kotlin/social/aloha/core/testing/CapabilityDetectionTest.kt b/core/testing/src/test/kotlin/social/aloha/core/testing/CapabilityDetectionTest.kt new file mode 100644 index 00000000..e8b935e8 --- /dev/null +++ b/core/testing/src/test/kotlin/social/aloha/core/testing/CapabilityDetectionTest.kt @@ -0,0 +1,81 @@ +// SPDX-FileCopyrightText: 2026 Aloha Social contributors +// SPDX-License-Identifier: MIT + +package social.aloha.core.testing + +import java.time.Clock +import java.time.Instant +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.test.runTest +import okhttp3.OkHttpClient +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertInstanceOf +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import social.aloha.core.model.AccessToken +import social.aloha.core.model.ServerCapabilities +import social.aloha.core.network.RateLimiter +import social.aloha.core.network.capabilities.CapabilityDetector +import social.aloha.core.network.probe.ProbeResult +import social.aloha.core.network.probe.ServerAddress +import social.aloha.core.network.probe.ServerProbe + +class CapabilityDetectionTest { + private var server: MockSocialServer? = null + private val http = OkHttpClient() + private val limiter = RateLimiter(nowMillis = Clock.systemUTC()::millis) + private val detectedAt = Instant.parse("2026-09-29T12:00:00Z") + + @AfterEach + fun stop() { + server?.close() + } + + private suspend fun detect(configuration: MockServerConfiguration): ServerCapabilities { + val mock = MockSocialServer(configuration).start().also { server = it } + val address = ServerAddress.parse(mock.origin.toString(), allowCleartext = true) ?: error("address") + val outcome = assertInstanceOf( + ProbeResult.Found::class.java, + ServerProbe(http, limiter, Dispatchers.IO).discover(address), + ).outcome + return CapabilityDetector(http, limiter, Dispatchers.IO) { detectedAt } + .detect(outcome.apiBase, AccessToken(MockCredentials.ACCESS_TOKEN, ""), outcome.instance, outcome.nodeInfo) + } + + @Test + fun `Nextcloud Social's extensions are found and its transport gaps honoured`() = runTest { + val capabilities = detect(MockServerConfiguration.NextcloudWithoutRewrite) + assertTrue(capabilities.isNextcloudSocial) + assertTrue(capabilities.onlyVideoFilter && capabilities.onlyNewsFilter && capabilities.onlyMediaFilter) + assertTrue(capabilities.stories && capabilities.collections && capabilities.watchPositions) + assertTrue(capabilities.groupedNotifications && capabilities.filtersV2 && capabilities.editHistory) + assertTrue(capabilities.preferencesWrite) + assertEquals(null, capabilities.streamingUrl) + assertEquals(null, capabilities.webPushVapidKey) + assertEquals(detectedAt, capabilities.detectedAt) + } + + @Test + fun `the Nextcloud theme is read from the Nextcloud root above the app path`() = runTest { + val theme = detect(MockServerConfiguration.NextcloudWithoutRewrite).theme + assertTrue(theme?.hasColour == true) + } + + @Test + fun `Mastodon gets none of the extensions and News is hidden`() = runTest { + val capabilities = detect(MockServerConfiguration.Mastodon) + assertFalse(capabilities.isNextcloudSocial) + assertFalse(capabilities.onlyVideoFilter || capabilities.onlyNewsFilter) + assertFalse(capabilities.stories || capabilities.collections || capabilities.watchPositions) + assertFalse(capabilities.supports(social.aloha.core.model.FeedMode.News)) + } + + @Test + fun `a server that 404s the extension routes is treated as not having them`() = runTest { + val capabilities = detect(MockServerConfiguration.CoreOnly) + assertFalse(capabilities.stories || capabilities.collections || capabilities.watchPositions) + assertFalse(capabilities.groupedNotifications) + } +} diff --git a/core/testing/src/test/kotlin/social/aloha/core/testing/CorpusDecodingTest.kt b/core/testing/src/test/kotlin/social/aloha/core/testing/CorpusDecodingTest.kt new file mode 100644 index 00000000..1b8e5290 --- /dev/null +++ b/core/testing/src/test/kotlin/social/aloha/core/testing/CorpusDecodingTest.kt @@ -0,0 +1,262 @@ +// SPDX-FileCopyrightText: 2026 Aloha Social contributors +// SPDX-License-Identifier: MIT + +package social.aloha.core.testing + +import java.io.File +import java.time.Clock +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking +import okhttp3.OkHttpClient +import org.junit.jupiter.api.AfterAll +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeAll +import org.junit.jupiter.api.DynamicTest +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestFactory +import org.junit.jupiter.api.TestInstance +import social.aloha.core.model.TimelineFilters +import social.aloha.core.model.TimelineSource +import social.aloha.core.network.ApiClient +import social.aloha.core.network.ApiError +import social.aloha.core.network.ApiRequest +import social.aloha.core.network.ApiResult +import social.aloha.core.network.Credentials +import social.aloha.core.network.DecodingFailure +import social.aloha.core.network.RateLimiter +import social.aloha.core.network.endpoints.AccountEndpoints +import social.aloha.core.network.endpoints.AnnualReportEndpoints +import social.aloha.core.network.endpoints.BlockEndpoints +import social.aloha.core.network.endpoints.CollectionEndpoints +import social.aloha.core.network.endpoints.ComposeEndpoints +import social.aloha.core.network.endpoints.DirectoryOrder +import social.aloha.core.network.endpoints.DiscoverMedia +import social.aloha.core.network.endpoints.DiscoveryEndpoints +import social.aloha.core.network.endpoints.FeaturedTagEndpoints +import social.aloha.core.network.endpoints.FilterEndpoints +import social.aloha.core.network.endpoints.FollowRequestEndpoints +import social.aloha.core.network.endpoints.GifEndpoints +import social.aloha.core.network.endpoints.InstanceEndpoints +import social.aloha.core.network.endpoints.InterestEndpoints +import social.aloha.core.network.endpoints.ListEndpoints +import social.aloha.core.network.endpoints.MarkerEndpoints +import social.aloha.core.network.endpoints.MediaEndpoints +import social.aloha.core.network.endpoints.NotificationEndpoints +import social.aloha.core.network.endpoints.PlaceEndpoints +import social.aloha.core.network.endpoints.PollEndpoints +import social.aloha.core.network.endpoints.ProfileEndpoints +import social.aloha.core.network.endpoints.SafetyEndpoints +import social.aloha.core.network.endpoints.SearchEndpoints +import social.aloha.core.network.endpoints.StatusAction +import social.aloha.core.network.endpoints.StatusEndpoints +import social.aloha.core.network.endpoints.StatusExtraEndpoints +import social.aloha.core.network.endpoints.StatusPost +import social.aloha.core.network.endpoints.StoryEndpoints +import social.aloha.core.network.endpoints.TagEndpoints +import social.aloha.core.network.endpoints.TimelineEndpoints +import social.aloha.core.network.endpoints.VideoEndpoints + +/** + * Every body the dev instance answered, decoded through the endpoint factory that asks for it: the + * request succeeds and lossy decoding drops nothing. A fixture that is neither mapped here nor listed + * as not decodable fails [every fixture is decoded or explained], so a new capture cannot slip by. + */ +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class CorpusDecodingTest { + private lateinit var server: MockSocialServer + private lateinit var client: ApiClient + private val dropped = mutableListOf>() + private val upload = File.createTempFile("upload", ".jpg").apply { writeBytes(byteArrayOf(1, 2, 3)) } + + private val status = "1790637085797595891" + private val video = "1790637104958214061" + private val link = "1790637105561356153" + private val photos = "1790637096330770789" + private val edited = "1790637112034926731" + + private val decoded: Map> by lazy { + mapOf( + "api/accounts-alice-featured-tags.json" to ProfileEndpoints.featuredTags("6"), + "api/accounts-alice-followers.json" to AccountEndpoints.followers("6"), + "api/accounts-alice-following.json" to AccountEndpoints.following("6"), + "api/accounts-alice-highlights.json" to ProfileEndpoints.highlights("6"), + "api/accounts-alice-statuses-media.json" to + TimelineEndpoints.timeline(TimelineSource.Account("6", includeReplies = true, onlyMedia = true)), + "api/accounts-alice-statuses-pinned.json" to ProfileEndpoints.pinnedStatuses("6"), + "api/accounts-alice-statuses.json" to + TimelineEndpoints.timeline(TimelineSource.Account("6", includeReplies = true, onlyMedia = false)), + "api/accounts-alice.json" to AccountEndpoints.account("6"), + "api/accounts-jane-locked.json" to AccountEndpoints.account("8"), + "api/accounts-lookup-bob.json" to AccountEndpoints.lookup("bob"), + "api/accounts-relationships.json" to AccountEndpoints.relationships(listOf("7", "8", "5")), + "api/accounts-search.json" to AccountEndpoints.search("ja"), + "api/accounts-verify-credentials.json" to AccountEndpoints.verifyCredentials(), + "api/announcements.json" to SafetyEndpoints.announcements(), + "api/annual-reports.json" to AnnualReportEndpoints.all(), + "api/blocks.json" to BlockEndpoints.blocks(), + "api/bookmarks-bob.json" to TimelineEndpoints.timeline(TimelineSource.Bookmarks), + "api/collection-items.json" to CollectionEndpoints.items("1"), + "api/collection.json" to CollectionEndpoints.collection("1"), + "api/collections-account-alice.json" to CollectionEndpoints.forAccount("6"), + "api/collections-alice.json" to CollectionEndpoints.all(), + "api/conversations-bob.json" to TimelineEndpoints.conversations(), + "api/conversations-unread-count.json" to TimelineEndpoints.conversationsUnreadCount(), + "api/custom-emojis.json" to InstanceEndpoints.customEmojis(), + "api/directory.json" to SearchEndpoints.directory(DirectoryOrder.Active), + "api/discover-posts.json" to DiscoveryEndpoints.discoverPosts(DiscoverMedia.Images), + "api/favourites-bob.json" to TimelineEndpoints.timeline(TimelineSource.Favourites), + "api/filters-v2-jane.json" to FilterEndpoints.all(), + "api/follow-requests-jane.json" to FollowRequestEndpoints.all(), + "api/followed-tags.json" to TagEndpoints.followed(), + "api/gifs.json" to GifEndpoints.library("cat"), + "api/instance-rules.json" to InstanceEndpoints.rules(), + "api/instance-translation-languages.json" to InstanceEndpoints.translationLanguages(), + "api/interests.json" to InterestEndpoints.state(), + "api/list-accounts-jane.json" to ListEndpoints.accounts("1"), + "api/lists-jane.json" to ListEndpoints.all(), + "api/markers.json" to MarkerEndpoints.read(), + "api/mutes.json" to BlockEndpoints.mutes(), + "api/notifications-policy-v2.json" to NotificationEndpoints.policy(v2 = true), + "api/notifications-requests.json" to NotificationEndpoints.requests(), + "api/notifications-v1-jane.json" to NotificationEndpoints.flat(), + "api/notifications-v1-unread-count.json" to NotificationEndpoints.unreadCount(grouped = false), + "api/notifications-v1.json" to NotificationEndpoints.flat(), + "api/notifications-v2-unread-count.json" to NotificationEndpoints.unreadCount(grouped = true), + "api/notifications-v2.json" to NotificationEndpoints.grouped(), + "api/places-search.json" to PlaceEndpoints.search("Ber"), + "api/poll.json" to PollEndpoints.poll("1790637087495743511"), + "api/preferences.json" to InstanceEndpoints.preferences(), + "api/scheduled-statuses.json" to ComposeEndpoints.scheduled(), + "api/search-account.json" to SearchEndpoints.search("bob", type = "accounts"), + "api/search-aloha.json" to SearchEndpoints.search("aloha"), + "api/search-hashtags.json" to SearchEndpoints.search("alo", type = "hashtags"), + "api/starter-packs.json" to DiscoveryEndpoints.starterPacks(), + "api/status-audio.json" to StatusEndpoints.status("1790637105549523409"), + "api/status-bob.json" to StatusEndpoints.status("1790637117344364158"), + "api/status-cw.json" to StatusEndpoints.status("1790637086177868305"), + "api/status-direct.json" to StatusEndpoints.status("1790637086268689343"), + "api/status-edited-history.json" to StatusEndpoints.history(edited), + "api/status-edited-source.json" to StatusEndpoints.source(edited), + "api/status-edited.json" to StatusEndpoints.status(edited), + "api/status-link-card.json" to StatusEndpoints.card(link), + "api/status-link.json" to StatusEndpoints.status(link), + "api/status-photos-reblogged-by.json" to StatusEndpoints.rebloggedBy(photos), + "api/status-photos.json" to StatusEndpoints.status(photos), + "api/status-place.json" to StatusEndpoints.status("1790637112216295042"), + "api/status-poll.json" to StatusEndpoints.status("1790637087495743511"), + "api/status-private.json" to StatusEndpoints.status("1790637086257046711"), + "api/status-public-card-none.json" to StatusEndpoints.card(status), + "api/status-public-context.json" to StatusEndpoints.context(status), + "api/status-public-delivery.json" to StatusExtraEndpoints.delivery(status), + "api/status-public-favourited-by.json" to StatusEndpoints.favouritedBy(status), + "api/status-public-quotes.json" to StatusExtraEndpoints.quotes(status), + "api/status-public-reactions.json" to StatusExtraEndpoints.reactions(status), + "api/status-public.json" to StatusEndpoints.status(status), + "api/status-quote.json" to StatusEndpoints.status("1790637116374990863"), + "api/status-reply.json" to StatusEndpoints.status("1790637115351395147"), + "api/status-reply_2.json" to StatusEndpoints.status("1790637115422878628"), + "api/status-sensitive_no_alt.json" to StatusEndpoints.status("1790637103267719986"), + "api/status-short.json" to StatusEndpoints.status("1790637104870044831"), + "api/status-unlisted.json" to StatusEndpoints.status("1790637086680883443"), + "api/status-video.json" to StatusEndpoints.status(video), + "api/stories-account-alice.json" to StoryEndpoints.forAccount("6"), + "api/stories-carousel-bob.json" to StoryEndpoints.carousel(), + "api/stories-self-alice.json" to StoryEndpoints.own(), + "api/stories-v12-carousel-bob.json" to StoryEndpoints.carouselV2(), + "api/suggestions-v2.json" to SearchEndpoints.suggestions(), + "api/timeline-home-bob-sees-boost.json" to TimelineEndpoints.timeline(TimelineSource.Home), + "api/timeline-home-jane-filtered.json" to TimelineEndpoints.timeline(TimelineSource.Home), + "api/timeline-home-page.json" to TimelineEndpoints.timeline(TimelineSource.Home, limit = 3), + "api/timeline-home.json" to TimelineEndpoints.timeline(TimelineSource.Home), + "api/timeline-interests.json" to InterestEndpoints.timeline(limit = 10), + "api/timeline-list-jane.json" to TimelineEndpoints.timeline(TimelineSource.List("1")), + "api/timeline-public-local.json" to TimelineEndpoints.timeline(TimelineSource.Local), + "api/timeline-public-only-media.json" to + TimelineEndpoints.timeline(TimelineSource.Federated, TimelineFilters(onlyMedia = true)), + "api/timeline-public-only-news.json" to + TimelineEndpoints.timeline(TimelineSource.Federated, TimelineFilters(onlyNews = true)), + "api/timeline-public-only-video.json" to + TimelineEndpoints.timeline(TimelineSource.Federated, TimelineFilters(onlyVideo = true)), + "api/timeline-public.json" to TimelineEndpoints.timeline(TimelineSource.Federated), + "api/timeline-tag-aloha.json" to TimelineEndpoints.timeline(TimelineSource.Hashtag("aloha")), + "api/trends-links.json" to SearchEndpoints.trendingLinks(), + "api/trends-statuses.json" to DiscoveryEndpoints.trendingStatuses(), + "api/trends-tags.json" to SearchEndpoints.trendingTags(), + "writes/favourite-returns-status.json" to + StatusEndpoints.action("1790637117344364158", StatusAction.Favourite), + "writes/idempotent-post-1.json" to ComposeEndpoints.post(StatusPost("aloha")), + "writes/idempotent-post-2.json" to ComposeEndpoints.post(StatusPost("aloha")), + "writes/media-upload-v2.json" to + MediaEndpoints.upload(upload, "photo.jpg", "image/jpeg", description = null), + "writes/videos-continue-bob.json" to VideoEndpoints.continueWatching(), + "writes/watched-bob.json" to + VideoEndpoints.reportWatched(video, positionSeconds = 30.0, durationSeconds = 60.0), + ) + } + + /** Fixtures a factory is not the reader of, each with the reason. */ + private val notDecoded = mapOf( + "api/apps-register.json" to "read by the OAuth client, tested with it", + "api/apps-verify-credentials.json" to "no factory: the app does not check its own registration", + "api/oauth-token.json" to "read by the OAuth client, tested with it", + "api/oauth-userinfo.json" to "read by the OAuth client, tested with it", + "api/instance-privacy-policy.json" to "a 404 on this instance, checked below", + "api/pixelfed-compose-settings.json" to + "Pixelfed composer settings; the composer reads the server limits instead", + ) + + @BeforeAll + fun start() { + server = MockSocialServer(MockServerConfiguration.NextcloudWithoutRewrite).start() + client = ApiClient( + server.apiBase, + { Credentials(MockCredentials.ACCESS_TOKEN) }, + OkHttpClient(), + RateLimiter(nowMillis = Clock.systemUTC()::millis), + Dispatchers.IO, + failureListener = { path, failures -> + synchronized(dropped) { failures.forEach { dropped += path to it } } + }, + ) + } + + @AfterAll + fun stop() { + server.close() + upload.delete() + } + + @TestFactory + fun `every captured body decodes through its factory and drops nothing`(): List = + decoded.map { (name, request) -> + DynamicTest.dynamicTest(name) { + server.pinFixture(name) + dropped.clear() + val result = runBlocking { client.execute(request) } + assertTrue(result is ApiResult.Success, "$name: $result") + assertEquals(emptyList>(), dropped, name) + } + } + + @Test + fun `every fixture is decoded or explained`() { + val captured = FixtureCorpus.nextcloudSocial.fixtures.map { it.name }.filter { + it.startsWith("api/") || + it.startsWith("writes/") + } + val unexplained = captured - decoded.keys - notDecoded.keys + assertEquals(emptySet(), unexplained.toSet()) + } + + @Test + fun `this instance serves no privacy policy, and says so with a 404`() { + server.pinFixture("api/instance-privacy-policy.json") + assertEquals( + ApiError.NotFound, + runBlocking { + client.execute(InstanceEndpoints.privacyPolicy()) + }.let { (it as ApiResult.Failure).error }, + ) + } +} diff --git a/core/testing/src/test/kotlin/social/aloha/core/testing/LiveInstanceTest.kt b/core/testing/src/test/kotlin/social/aloha/core/testing/LiveInstanceTest.kt new file mode 100644 index 00000000..60ddf22f --- /dev/null +++ b/core/testing/src/test/kotlin/social/aloha/core/testing/LiveInstanceTest.kt @@ -0,0 +1,172 @@ +// SPDX-FileCopyrightText: 2026 Aloha Social contributors +// SPDX-License-Identifier: MIT + +package social.aloha.core.testing + +import androidx.datastore.preferences.core.emptyPreferences +import androidx.room.Room +import androidx.test.core.app.ApplicationProvider +import java.time.Clock +import java.time.Instant +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.runBlocking +import okhttp3.Credentials +import okhttp3.FormBody +import okhttp3.HttpUrl +import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.OkHttpClient +import okhttp3.Request +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Assume.assumeTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import social.aloha.core.data.AccountRepository +import social.aloha.core.data.Authorization +import social.aloha.core.data.ClientFactory +import social.aloha.core.data.SignInCoordinator +import social.aloha.core.data.SignInResult +import social.aloha.core.database.AccountsDatabase +import social.aloha.core.datastore.AppPreferences +import social.aloha.core.datastore.TokenVault +import social.aloha.core.model.AccessToken +import social.aloha.core.network.ApiError +import social.aloha.core.network.ApiResult +import social.aloha.core.network.RateLimiter +import social.aloha.core.network.capabilities.CapabilityDetector +import social.aloha.core.network.endpoints.AccountEndpoints +import social.aloha.core.network.oauth.OAuthClient +import social.aloha.core.network.oauth.OAuthEndpoints +import social.aloha.core.network.oauth.OAuthIdentity +import social.aloha.core.network.oauth.authorizationUrl +import social.aloha.core.network.probe.ProbeOutcome +import social.aloha.core.network.probe.ProbeResult +import social.aloha.core.network.probe.ServerAddress +import social.aloha.core.network.probe.ServerProbe + +/** + * Sign-in against a real Nextcloud Social, end to end, with the real client code. Runs only when + * `ALOHA_LIVE_SERVER` names the server (e.g. `http://nextcloud.local`) and `ALOHA_LIVE_USER` and + * `ALOHA_LIVE_PASSWORD` a person on it; CI never sets them. The browser's consent step is replaced + * by the same form posted with the person's app password and `OCS-APIRequest: true`, which a dev + * instance accepts; redirects are not followed, so the callback URI is read from `Location`. + */ +@RunWith(RobolectricTestRunner::class) +class LiveInstanceTest { + private val server: String? = System.getenv("ALOHA_LIVE_SERVER") + private val user = System.getenv("ALOHA_LIVE_USER").orEmpty() + private val password = System.getenv("ALOHA_LIVE_PASSWORD").orEmpty() + + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + private val database = Room.inMemoryDatabaseBuilder( + ApplicationProvider.getApplicationContext(), + AccountsDatabase::class.java, + ).build() + private val vault = TokenVault(InMemoryDataStore(ByteArray(0)), FakeSecretCipher(), Dispatchers.IO) + private val accounts = AccountRepository( + database.accountDao(), + vault, + AppPreferences(InMemoryDataStore(emptyPreferences())), + Clock.systemUTC(), + scope, + ) + private val http = OkHttpClient() + private val limiter = RateLimiter(nowMillis = Clock.systemUTC()::millis) + private val oauth = OAuthClient(http, limiter, Dispatchers.IO) + private val clients = ClientFactory(http, limiter, Dispatchers.IO, accounts) + private val coordinator = SignInCoordinator( + accounts, + vault, + oauth, + CapabilityDetector(http, limiter, Dispatchers.IO) { Instant.now() }, + { OAuthIdentity.SCHEME_REDIRECT }, + clients, + ) + + @After + fun close() { + scope.cancel() + database.close() + } + + private fun probe(): ProbeOutcome { + assumeTrue("set ALOHA_LIVE_SERVER to run against a real server", server != null) + val address = ServerAddress.parse(server.orEmpty(), allowCleartext = true) ?: error("address") + return ( + runBlocking { + ServerProbe(http, limiter, Dispatchers.IO).discover(address) + } as ProbeResult.Found + ).outcome + } + + private suspend fun begin(): HttpUrl = ( + coordinator.beginAuthorization( + testServerFinder(http, limiter).found(server.orEmpty()), + ) as Authorization.Started + ) + .url.toHttpUrl() + + /** What the browser would do: the consent form, answered as the person, with the redirect captured. */ + private fun consent(authorize: HttpUrl): String { + val form = FormBody.Builder().apply { + authorize.queryParameterNames.forEach { name -> add(name, authorize.queryParameter(name).orEmpty()) } + }.build() + val request = Request.Builder().url(authorize.newBuilder().query(null).build()).post(form) + .header("Authorization", Credentials.basic(user, password)) + .header("OCS-APIRequest", "true") + .build() + http.newBuilder().followRedirects(false).build().newCall(request).execute().use { response -> + return response.header("Location") + ?: error("no redirect: ${response.code} ${response.body.string().take(300)}") + } + } + + @Test + fun `sign-in succeeds, the token works, and the account knows what the server can do`() = runBlocking { + probe() + val authorize = begin() + val callback = consent(authorize) + assertTrue(callback, callback.startsWith(OAuthIdentity.SCHEME_REDIRECT)) + + val account = (coordinator.complete(callback) as SignInResult.SignedIn).account + println("LIVE apiBase=${account.apiBase} handle=${account.handle} caps=${account.capabilities}") + assertTrue(account.capabilities.isNextcloudSocial) + assertTrue(account.capabilities.onlyVideoFilter) + val me = clients.forAccount(account)?.execute(AccountEndpoints.verifyCredentials()) + assertTrue("$me", me is ApiResult.Success) + } + + @Test + fun `a code is single-use, plain PKCE is refused, and a revoked token becomes a new sign-in`() = runBlocking { + val outcome = probe() + val authorize = begin() + val callback = consent(authorize) + val code = + "https://x.invalid/?${callback.substringAfter('?')}".toHttpUrl().queryParameter("code") ?: error("code") + val base = outcome.apiBase + val registration = accounts.registration(base.host) ?: error("registration") + val endpoints = OAuthEndpoints.from(outcome.authorizationServer ?: oauth.metadata(base), base) + val pending = authorizationUrl(endpoints, base, registration.clientId, OAuthIdentity.SCHEME_REDIRECT).second + .copy(verifier = "x".repeat(64)) + + val wrongVerifier = oauth.exchange(endpoints.token, registration, pending, code) + println("LIVE exchange with a wrong verifier: $wrongVerifier") + assertFalse(wrongVerifier is ApiResult.Success) + + val plain = authorize.newBuilder().setQueryParameter("code_challenge_method", "plain").build() + val plainAnswer = runCatching { consent(plain) } + println("LIVE plain PKCE: $plainAnswer") + assertTrue(plainAnswer.isFailure || !plainAnswer.getOrThrow().contains("code=")) + + val revoked = AccessToken("not-a-real-token", "") + val answer = clients.create(base) { social.aloha.core.network.Credentials(revoked.value) } + .execute(AccountEndpoints.verifyCredentials()) + assertEquals(ApiError.Unauthorised::class, (answer as ApiResult.Failure).error::class) + } +} diff --git a/core/testing/src/test/kotlin/social/aloha/core/testing/MockSocialServerTest.kt b/core/testing/src/test/kotlin/social/aloha/core/testing/MockSocialServerTest.kt new file mode 100644 index 00000000..0c6e0ef4 --- /dev/null +++ b/core/testing/src/test/kotlin/social/aloha/core/testing/MockSocialServerTest.kt @@ -0,0 +1,152 @@ +// SPDX-FileCopyrightText: 2026 Aloha Social contributors +// SPDX-License-Identifier: MIT + +package social.aloha.core.testing + +import java.io.File +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.jsonObject +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import okhttp3.Response +import org.junit.jupiter.api.AfterEach +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 MockSocialServerTest { + private val http = OkHttpClient() + private var server: MockSocialServer? = null + + @AfterEach + fun stop() { + server?.close() + } + + private fun start(configuration: MockServerConfiguration) = MockSocialServer(configuration).start().also { + server = + it + } + + private fun MockSocialServer.get(path: String, token: String? = MockCredentials.ACCESS_TOKEN): Response = + http.newCall( + Request.Builder().url(origin.resolve(path) ?: error(path)).apply { + token?.let { header("Authorization", "Bearer $it") } + }.build(), + ).execute() + + @Test + fun `every corpus index lists exactly the files on disk`() { + listOf(FixtureCorpus.nextcloudSocial, FixtureCorpus.mastodonSocial).forEach { corpus -> + val folder = File("src/main/resources/fixtures/${corpus.folder}") + val onDisk = folder.walkTopDown().filter { it.isFile && it.extension == "json" } + .map { it.relativeTo(folder).invariantSeparatorsPath }.sorted().toList() + assertEquals(onDisk, corpus.fixtures.map { it.name }.sorted(), corpus.folder) + } + } + + @Test + fun `without the rewrite rules the API answers only under the app path`() { + val mock = start(MockServerConfiguration.NextcloudWithoutRewrite) + mock.get("/api/v2/instance").use { assertEquals(404, it.code) } + mock.get("/index.php/apps/social/api/v2/instance").use { assertEquals(200, it.code) } + assertEquals("/index.php/apps/social/", mock.apiBase.encodedPath) + } + + @Test + fun `with the rewrite rules the API answers at the root as well`() { + val mock = start(MockServerConfiguration.NextcloudWithRewrite) + mock.get("/api/v2/instance").use { assertEquals(200, it.code) } + mock.get("/.well-known/oauth-authorization-server").use { assertEquals(200, it.code) } + } + + @Test + fun `captured origins are rewritten to the mock's own, in bodies and Link headers`() { + val mock = start(MockServerConfiguration.NextcloudWithoutRewrite) + mock.get("/index.php/apps/social/api/v1/timelines/public?limit=20").use { response -> + val body = response.body.string() + assertFalse(body.contains("http://nextcloud.local")) + assertTrue(response.header("Link").orEmpty().startsWith("<${mock.origin.toString().trimEnd('/')}")) + } + } + + @Test + fun `the most specific fixture wins and a pin overrides it`() { + val mock = start(MockServerConfiguration.NextcloudWithoutRewrite) + val onlyVideo = mock.get("/index.php/apps/social/api/v1/timelines/public?only_video=true&limit=20").use { + it.body.string() + } + assertEquals( + FixtureCorpus.nextcloudSocial.named("api/timeline-public-only-video.json").body.length, + onlyVideo.length, + ) + mock.pin("GET", "/api/v1/timelines/home", "api/timeline-home-jane-filtered.json") + val home = mock.get("/index.php/apps/social/api/v1/timelines/home?limit=20").use { it.body.string() } + assertTrue(home.isNotEmpty()) + } + + @Test + fun `a viewer route refuses a missing and a revoked token`() { + val mock = start(MockServerConfiguration.NextcloudWithoutRewrite) + mock.get("/index.php/apps/social/api/v1/accounts/verify_credentials", token = null).use { + assertEquals(401, it.code) + } + mock.get("/index.php/apps/social/api/v1/accounts/verify_credentials", MockCredentials.REVOKED_TOKEN).use { + assertEquals(401, it.code) + assertTrue(it.body.string().contains("revoked")) + } + } + + @Test + fun `the core-only server answers the core and 404s the extensions`() { + val mock = start(MockServerConfiguration.CoreOnly) + mock.get("/index.php/apps/social/api/v1/timelines/public?limit=20").use { assertEquals(200, it.code) } + mock.get("/index.php/apps/social/api/v1/stories/carousel").use { assertEquals(404, it.code) } + mock.get("/index.php/apps/social/api/v2/config").use { assertEquals(404, it.code) } + } + + @Test + fun `the rate-limited server refuses the first read of a path, then answers`() { + val mock = start(MockServerConfiguration.RateLimited) + mock.get("/api/v2/instance").use { + assertEquals(429, it.code) + assertEquals("1", it.header("Retry-After")) + } + mock.get("/api/v2/instance").use { assertEquals(200, it.code) } + } + + @Test + fun `the malformed server breaks the third timeline entry only`() { + val body = start(MockServerConfiguration.MalformedEntities) + .get("/index.php/apps/social/api/v1/timelines/public?limit=20").use { it.body.string() } + val array = Json.parseToJsonElement(body) as JsonArray + assertEquals(JsonNull, array[2].jsonObject["id"]) + assertTrue(array.filterIndexed { index, _ -> index != 2 }.all { it.jsonObject["id"] != JsonNull }) + } + + @Test + fun `the Mastodon server signs in with a registration, a token and an account`() { + val mock = start(MockServerConfiguration.Mastodon) + val register = http.newCall( + Request.Builder().url(mock.origin.resolve("/api/v1/apps") ?: error("")).post("".toRequestBody()).build(), + ).execute() + register.use { assertTrue(it.body.string().contains(MockCredentials.CLIENT_ID)) } + mock.get("/api/v1/accounts/verify_credentials").use { assertEquals(200, it.code) } + mock.get("/api/v1/stories/carousel").use { assertEquals(404, it.code) } + } + + @Test + fun `the connected server runs Login Flow v2`() { + val mock = start(MockServerConfiguration.NextcloudConnected) + val poll = http.newCall( + Request.Builder().url( + mock.origin.resolve("/index.php/login/v2/poll") ?: error(""), + ).post("token=x".toRequestBody()).build(), + ).execute() + poll.use { assertTrue(it.body.string().contains(MockCredentials.APP_PASSWORD)) } + } +} diff --git a/core/testing/src/test/kotlin/social/aloha/core/testing/ProbeAgainstServerShapesTest.kt b/core/testing/src/test/kotlin/social/aloha/core/testing/ProbeAgainstServerShapesTest.kt new file mode 100644 index 00000000..a0c2262e --- /dev/null +++ b/core/testing/src/test/kotlin/social/aloha/core/testing/ProbeAgainstServerShapesTest.kt @@ -0,0 +1,106 @@ +// SPDX-FileCopyrightText: 2026 Aloha Social contributors +// SPDX-License-Identifier: MIT + +package social.aloha.core.testing + +import java.time.Clock +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.test.runTest +import mockwebserver3.Dispatcher +import mockwebserver3.MockResponse +import mockwebserver3.MockWebServer +import mockwebserver3.RecordedRequest +import okhttp3.OkHttpClient +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertInstanceOf +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import social.aloha.core.network.RateLimiter +import social.aloha.core.network.probe.CandidateKind +import social.aloha.core.network.probe.ProbeResult +import social.aloha.core.network.probe.ServerAddress +import social.aloha.core.network.probe.ServerProbe + +class ProbeAgainstServerShapesTest { + private var server: MockSocialServer? = null + private val probe = ServerProbe(OkHttpClient(), RateLimiter(nowMillis = Clock.systemUTC()::millis), Dispatchers.IO) + + @AfterEach + fun stop() { + server?.close() + } + + private suspend fun discover(configuration: MockServerConfiguration): ProbeResult { + val mock = MockSocialServer(configuration).start().also { server = it } + val address = ServerAddress.parse(mock.origin.toString(), allowCleartext = true) ?: error("address") + return probe.discover(address) + } + + private fun ProbeResult.found() = assertInstanceOf(ProbeResult.Found::class.java, this).outcome + + @Test + fun `a Nextcloud with the rewrite rules is found through its RFC 8414 issuer, on the app path`() = runTest { + val outcome = discover(MockServerConfiguration.NextcloudWithRewrite).found() + assertEquals(CandidateKind.AuthorizationServerIssuer, outcome.winner.kind) + assertEquals("/index.php/apps/social/", outcome.apiBase.encodedPath) + assertEquals("nextcloud.local", outcome.instance.domain) + assertTrue(outcome.nodeInfo?.isNextcloudSocial == true) + } + + @Test + fun `a Nextcloud WITHOUT the rewrite rules still resolves, to the app path`() = runTest { + val outcome = discover(MockServerConfiguration.NextcloudWithoutRewrite).found() + assertEquals(CandidateKind.AppPath, outcome.winner.kind) + assertEquals("/index.php/apps/social/", outcome.apiBase.encodedPath) + assertTrue(outcome.nodeInfo?.isNextcloudSocial == true) + } + + @Test + fun `stock Mastodon resolves to the root and is not taken for Nextcloud Social`() = runTest { + val outcome = discover(MockServerConfiguration.Mastodon).found() + assertEquals("/", outcome.apiBase.encodedPath) + assertEquals("mastodon", outcome.nodeInfo?.softwareName) + } + + @Test + fun `a core-only server resolves like one without the rewrite rules`() = runTest { + assertEquals("/index.php/apps/social/", discover(MockServerConfiguration.CoreOnly).found().apiBase.encodedPath) + } + + @Test + fun `a rate-limited server is still found once its Retry-After has passed`() = runTest { + assertEquals("/", discover(MockServerConfiguration.RateLimited).found().apiBase.encodedPath.take(1)) + } + + @Test + fun `a server that answers nothing useful reports every candidate it tried`() = runTest { + val nothing = MockWebServer().apply { + dispatcher = object : Dispatcher() { + override fun dispatch(request: RecordedRequest) = MockResponse.Builder().code(404).build() + } + start() + } + nothing.use { + val address = + ServerAddress.parse(it.url("/typed/path").toString(), allowCleartext = true) ?: error("address") + val result = assertInstanceOf(ProbeResult.NothingAnswered::class.java, probe.discover(address)) + assertEquals(listOf(0, 1, 2, 3, 4, 5), result.attempted.map { candidate -> candidate.rank }) + } + } + + @Test + fun `a server nobody answers for is unreachable, not missing its API`() = runTest { + // port 1 is closed: every candidate fails to connect, so there was no answer to judge + val address = ServerAddress.parse("http://127.0.0.1:1/", allowCleartext = true) ?: error("address") + assertInstanceOf(ProbeResult.Unreachable::class.java, probe.discover(address)) + } + + @Test + fun `a hand-entered API address is probed alone`() = runTest { + val mock = MockSocialServer(MockServerConfiguration.NextcloudWithoutRewrite).start().also { server = it } + val outcome = probe.discover(mock.origin.resolve("/index.php/apps/social") ?: error("url")).found() + assertEquals(CandidateKind.Manual, outcome.winner.kind) + assertEquals(listOf(1), outcome.attempted.map { it.rank }) + } +} diff --git a/core/testing/src/test/kotlin/social/aloha/core/testing/ServerFactsTest.kt b/core/testing/src/test/kotlin/social/aloha/core/testing/ServerFactsTest.kt new file mode 100644 index 00000000..e68e7022 --- /dev/null +++ b/core/testing/src/test/kotlin/social/aloha/core/testing/ServerFactsTest.kt @@ -0,0 +1,124 @@ +// SPDX-FileCopyrightText: 2026 Aloha Social contributors +// SPDX-License-Identifier: MIT + +package social.aloha.core.testing + +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +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 + +/** + * What Nextcloud Social 0.26.97 was seen to do, pinned to the captured corpus. The client is built + * around each of these; when a re-capture after a server update changes one, the matching test + * fails and the client code that works around it is due for review. + */ +class ServerFactsTest { + private val corpus = FixtureCorpus.nextcloudSocial + + private fun body(name: String): JsonElement = Json.parseToJsonElement(corpus.named(name).body) + + private fun JsonElement.field(name: String): JsonElement? = (this as? JsonObject)?.get(name) + + @Test + fun `reblog is always null, so a boost is not visible as a boost`() { + val statuses = body("api/timeline-home-bob-sees-boost.json").jsonArray + assertTrue(statuses.all { it.field("reblog") == null || it.field("reblog") == JsonNull }) + } + + @Test + fun `an account's avatar is empty and its header is the Nextcloud avatar, except in verify_credentials`() { + val alice = body("api/accounts-alice.json") + assertEquals("", alice.field("avatar")?.jsonPrimitive?.content) + assertTrue(alice.field("header")?.jsonPrimitive?.content.orEmpty().contains("/index.php/avatar/alice/")) + val me = body("api/accounts-verify-credentials.json") + assertEquals(me.field("avatar"), me.field("header")) + } + + @Test + fun `a tagged place is never stored`() { + assertEquals(JsonNull, body("api/status-place.json").field("place") ?: JsonNull) + } + + @Test + fun `the status entity carries no reactions while the reactions route does`() { + assertEquals(JsonArray(emptyList()), body("api/status-public.json").field("reactions")) + assertTrue(body("api/status-public-reactions.json").jsonArray.isNotEmpty()) + } + + @Test + fun `without ffmpeg a video has no dimensions, no ladder and its own URL as the preview`() { + val attachment = + body("api/status-video.json").field("media_attachments")?.jsonArray?.first() ?: error("no attachment") + assertEquals(JsonNull, attachment.field("hls_url") ?: JsonNull) + assertEquals(attachment.field("url"), attachment.field("preview_url")) + val original = attachment.field("meta")?.field("original") + assertTrue(original == null || (original is JsonArray && original.isEmpty())) + } + + @Test + fun `polling routes send no ETag and forbid storing`() { + val headers = corpus.named("api/timeline-home.json").headers + assertFalse(headers.keys.any { it.equals("etag", ignoreCase = true) }) + assertTrue(headers["cache-control"].orEmpty().contains("no-store")) + } + + @Test + fun `Link headers point at the app path even through the root rules`() { + val link = corpus.named("root/timeline-public-page-root.json").headers["link"].orEmpty() + assertTrue(link.contains("/index.php/apps/social/api/v1/timelines/public"), link) + } + + @Test + fun `limit=0 is refused with a 400`() { + assertEquals(400, corpus.named("errors/500-limit-zero.json").status) + } + + @Test + fun `the same idempotency key returns the same status`() { + assertEquals( + body("writes/idempotent-post-1.json").field("id"), + body("writes/idempotent-post-2.json").field("id"), + ) + } + + @Test + fun `status ids are decimal strings beyond 2 to the 53rd`() { + val id = body("api/status-public.json").field("id")?.jsonPrimitive ?: error("no id") + assertTrue(id.isString) + assertTrue(id.content.length > 16 && id.content.all(Char::isDigit)) + } + + @Test + fun `a revoked token answers 401 with the server's own words`() { + val error = corpus.named("errors/401-bad-token.json") + assertEquals(401, error.status) + assertEquals( + "the access_token was revoked", + body("errors/401-bad-token.json").jsonObject["error"]?.jsonPrimitive?.content, + ) + } + + @Test + fun `there is no streaming and no Web Push key`() { + val instance = body("plain/instance-v1-app.json") + assertEquals(JsonObject(emptyMap()), instance.field("urls")) + assertEquals(404, corpus.named("errors/404-streaming.json").status) + assertEquals(404, corpus.named("errors/404-push-subscription.json").status) + } + + @Test + fun `the RFC 8414 document exists at the root only with the rules and names the app path either way`() { + assertEquals(404, corpus.named("plain/oauth-authorization-server-root.json").status) + val issuer = body("root/oauth-authorization-server-root.json").field("issuer")?.jsonPrimitive?.content + assertTrue(issuer.orEmpty().endsWith("/index.php/apps/social/"), issuer) + } +} diff --git a/core/testing/src/test/kotlin/social/aloha/core/testing/ServerFinderTest.kt b/core/testing/src/test/kotlin/social/aloha/core/testing/ServerFinderTest.kt new file mode 100644 index 00000000..9faf7a77 --- /dev/null +++ b/core/testing/src/test/kotlin/social/aloha/core/testing/ServerFinderTest.kt @@ -0,0 +1,44 @@ +// SPDX-FileCopyrightText: 2026 Aloha Social contributors +// SPDX-License-Identifier: MIT + +package social.aloha.core.testing + +import kotlinx.coroutines.runBlocking +import okhttp3.OkHttpClient +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import social.aloha.core.data.ServerLookup +import social.aloha.core.network.RateLimiter + +/** What a build that refuses `http://` answers before any request is made, and what a found server shows. */ +class ServerFinderTest { + private val finder = testServerFinder(OkHttpClient(), RateLimiter(nowMillis = { 0L }), cleartextAllowed = false) + + @Test + fun `an http address is refused as insecure, not as malformed`() = runBlocking { + assertEquals(ServerLookup.InsecureAddress, finder.find("http://cloud.example")) + assertEquals(ServerLookup.InsecureAddress, finder.find("HTTP://cloud.example/social")) + assertEquals(ServerLookup.InsecureAddress, finder.findAt("http://cloud.example/index.php/apps/social/")) + } + + @Test + fun `text that is no address stays invalid, with or without a scheme`() = runBlocking { + assertEquals(ServerLookup.InvalidAddress, finder.find("not a host")) + assertEquals(ServerLookup.InvalidAddress, finder.find("http://not a host")) + assertEquals(ServerLookup.InvalidAddress, finder.findAt("http://")) + } + + @Test + fun `a found server's texts are trimmed`() = runBlocking { + MockSocialServer(MockServerConfiguration.Mastodon).start().use { mock -> + val server = testServerFinder(OkHttpClient(), RateLimiter(nowMillis = { 0L })).found(mock.origin.toString()) + // mastodon.social sends its description with trailing blank lines + assertEquals( + "The original server of Mastodon, operated by Mastodon GmbH for the common good.", + server.description, + ) + assertTrue(server.rules.isNotEmpty() && server.rules.all { it == it.trim() }) + } + } +} diff --git a/core/testing/src/test/kotlin/social/aloha/core/testing/SignInAcceptanceTest.kt b/core/testing/src/test/kotlin/social/aloha/core/testing/SignInAcceptanceTest.kt new file mode 100644 index 00000000..353d2fb4 --- /dev/null +++ b/core/testing/src/test/kotlin/social/aloha/core/testing/SignInAcceptanceTest.kt @@ -0,0 +1,116 @@ +// SPDX-FileCopyrightText: 2026 Aloha Social contributors +// SPDX-License-Identifier: MIT + +package social.aloha.core.testing + +import androidx.datastore.preferences.core.emptyPreferences +import androidx.room.Room +import androidx.test.core.app.ApplicationProvider +import java.time.Clock +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.OkHttpClient +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.ParameterizedRobolectricTestRunner +import social.aloha.core.data.AccountRepository +import social.aloha.core.data.Authorization +import social.aloha.core.data.ClientFactory +import social.aloha.core.data.SignInCoordinator +import social.aloha.core.data.SignInResult +import social.aloha.core.database.AccountsDatabase +import social.aloha.core.datastore.AppPreferences +import social.aloha.core.datastore.TokenVault +import social.aloha.core.network.ApiResult +import social.aloha.core.network.RateLimiter +import social.aloha.core.network.capabilities.CapabilityDetector +import social.aloha.core.network.oauth.OAuthClient +import social.aloha.core.network.oauth.OAuthIdentity + +/** + * Sign-in from a typed address to a stored, active account, against every server shape: probe, + * registration, the authorisation URL, the callback the browser would deliver, the token exchange, + * the account description and capability detection. + */ +@RunWith(ParameterizedRobolectricTestRunner::class) +class SignInAcceptanceTest(private val configuration: MockServerConfiguration) { + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + private val mock = MockSocialServer(configuration).start() + private val database = Room.inMemoryDatabaseBuilder( + ApplicationProvider.getApplicationContext(), + AccountsDatabase::class.java, + ).build() + private val vault = TokenVault(InMemoryDataStore(ByteArray(0)), FakeSecretCipher(), Dispatchers.IO) + private val accounts = AccountRepository( + database.accountDao(), + vault, + AppPreferences(InMemoryDataStore(emptyPreferences())), + Clock.systemUTC(), + scope, + ) + private val http = OkHttpClient() + private val limiter = RateLimiter(nowMillis = Clock.systemUTC()::millis) + private val coordinator = SignInCoordinator( + accounts = accounts, + vault = vault, + oauth = OAuthClient(http, limiter, Dispatchers.IO), + detector = CapabilityDetector(http, limiter, Dispatchers.IO) { java.time.Instant.now() }, + redirects = { OAuthIdentity.SCHEME_REDIRECT }, + clients = ClientFactory(http, limiter, Dispatchers.IO, accounts), + ) + private val finder = testServerFinder(http, limiter) + + @After + fun close() { + scope.cancel() + database.close() + mock.close() + } + + @Test + fun `sign-in ends with a stored, active account and its token in the vault`() = runBlocking { + val server = finder.found(mock.origin.toString()) + val authorize = (coordinator.beginAuthorization(server) as Authorization.Started).url.toHttpUrl() + assertEquals("S256", authorize.queryParameter("code_challenge_method")) + assertTrue(coordinator.hasPendingAuthorization()) + + val state = authorize.queryParameter("state") ?: error("state") + val result = coordinator.complete("alohasocial://oauth-callback/?code=mock-code&state=$state") + + assertTrue("sign-in ended with $result", result is SignInResult.SignedIn) + val account = (result as SignInResult.SignedIn).account + assertFalse(account.needsReauth) + assertEquals(server.apiBase, account.apiBase) + assertEquals(MockCredentials.ACCESS_TOKEN, accounts.token(account.id)?.value) + assertEquals(account.id, accounts.activeAccount.filterNotNull().first().id) + assertFalse(coordinator.hasPendingAuthorization()) + } + + @Test + fun `a callback carrying another state is dropped without an exchange`() = runBlocking { + coordinator.beginAuthorization(finder.found(mock.origin.toString())) + val before = mock.requests.size + assertEquals( + SignInResult.NotForUs, + coordinator.complete("alohasocial://oauth-callback/?code=stolen&state=evil"), + ) + assertEquals(before, mock.requests.size) + assertTrue(coordinator.hasPendingAuthorization()) + } + + companion object { + @JvmStatic + @ParameterizedRobolectricTestRunner.Parameters(name = "{0}") + fun configurations(): List> = MockServerConfiguration.entries.map { arrayOf(it) } + } +} diff --git a/core/testing/src/test/kotlin/social/aloha/core/testing/TestServerFinder.kt b/core/testing/src/test/kotlin/social/aloha/core/testing/TestServerFinder.kt new file mode 100644 index 00000000..0ea7e6a9 --- /dev/null +++ b/core/testing/src/test/kotlin/social/aloha/core/testing/TestServerFinder.kt @@ -0,0 +1,34 @@ +// SPDX-FileCopyrightText: 2026 Aloha Social contributors +// SPDX-License-Identifier: MIT + +package social.aloha.core.testing + +import java.io.File +import kotlinx.coroutines.Dispatchers +import okhttp3.OkHttpClient +import social.aloha.core.data.DiscoveredServer +import social.aloha.core.data.ServerFinder +import social.aloha.core.data.ServerLookup +import social.aloha.core.network.RateLimiter +import social.aloha.core.network.probe.ServerProbe +import social.aloha.core.network.tls.ClientCertificateAliases +import social.aloha.core.network.tls.UserTrustStore + +/** + * A finder with throwaway TLS stores; it accepts `http://` unless told otherwise, as the mock server and + * the dev instance need. + */ +internal fun testServerFinder( + http: OkHttpClient, + limiter: RateLimiter, + cleartextAllowed: Boolean = true, +): ServerFinder = ServerFinder( + ServerProbe(http, limiter, Dispatchers.IO), + UserTrustStore(File.createTempFile("trust", ".p12").apply { delete() }), + ClientCertificateAliases(File.createTempFile("aliases", ".properties").apply { delete() }), + cleartextAllowed = cleartextAllowed, + ioDispatcher = Dispatchers.IO, +) + +internal suspend fun ServerFinder.found(typed: String): DiscoveredServer = + (find(typed) as? ServerLookup.Found)?.server ?: error("nothing found at $typed") diff --git a/docs/02-server-api.md b/docs/02-server-api.md index c5cf01a3..2e167283 100644 --- a/docs/02-server-api.md +++ b/docs/02-server-api.md @@ -14,6 +14,40 @@ This is the Apple app's specification, carried over as the product contract for | New accounts | `verify_credentials`, `accounts/lookup` and the first post answer 500 until the avatar cache job runs. | | Places and reactions | `place_*` is never stored; reactions come only from `/statuses/{id}/reactions`. | | Web-server rules | Show the server's own `contrib/webserver` files; the snippet described here is outdated. | +| Client | Plain OkHttp with kotlinx.serialization, one `ApiClient` per account base. Decoding is lenient (ids, URLs, booleans and dates in any of the shapes servers send) and lossy (a malformed row in a list is dropped and recorded, the rest of the page stays). | +| Errors | `ApiError` adds three cases to §6: `UntrustedCertificate` (the chain, for the person to decide), `ForeignCursor` (a `Link` cursor on another origin than the API base, never followed) and `UnsafePath` (a path segment from server data that would resolve as `.` or `..`, never sent). 404 and 410 are both `NotFound`. | +| Rate limiting | A read answered 429 with a `Retry-After` of five seconds or less (or none) is retried once, after the per-host limiter has waited it out; a write never is. | +| TLS | System CAs, plus certificates a person trusted for one host, kept in app-private storage and never system-wide. A client certificate is picked from the system KeyChain per host and presented to that host only. | + +### Finding the API base + +Mastodon apps build `https://host/api/v1/…`. Nextcloud Social serves its routes under the app path, and at the domain root only where the administrator installed the rewrite rules. A native client can target the app path directly, so the probe tries every shape at once: + +| Rank | Candidate | Accepted when | +|---|---|---| +| 0 | the `issuer` of `/.well-known/oauth-authorization-server` at the domain root | the issuer is on the host the person typed | +| 1 | the domain root | | +| 2 | `/index.php/apps/social/` | | +| 3 | `/apps/social/` (pretty URLs) | | +| 4, 5 | a path the person typed, and the app path beneath it | a path was typed | + +A candidate qualifies when `api/v2/instance`, or `api/v1/instance`, answers with a non-empty `domain`, so a Nextcloud login page answering 200 does not. The lowest rank that qualifies wins, however fast a worse one answered. Each request has five seconds and the whole probe ten. NodeInfo is read from the domain root's `/.well-known/nodeinfo` directory, with 2.1 then 2.0 as fallbacks, as the cross-check of the software. + +A hand-typed API address is probed on its own, keeping only scheme, host, port and path: credentials, a query or a fragment typed into it would otherwise be shown back and sent with every request. When no candidate gets any HTTP answer (the name does not resolve, the connection fails or times out), the server is reported unreachable, not as missing its API, so a typo never reads as a missing web-server rule. An untrusted certificate stops the probe with the chain, for the person to decide. A base that stopped answering is probed for anew at most once an hour per host. + +### Signing in + +- The app registers once per server with both redirect URIs, newline-separated, and the scopes `read write follow push`. A registration the token endpoint refuses with 401 is forgotten, so the next attempt registers again. +- The redirect is the verified App Link `https://aloha.social/oauth/callback`; the custom scheme `alohasocial://oauth-callback` is used only where link verification failed on the device. Nextcloud Social adds a `/` to the scheme redirect, so it arrives as `alohasocial://oauth-callback/?code=…`. +- PKCE is S256 only. Verified on 0.26.97: `code_challenge_method=plain` answers 400 `unsupported code_challenge_method`, a wrong verifier answers 401 `invalid code_verifier`, and a code is single-use. +- The authorisation, token and userinfo endpoints come from the server's metadata only when they are on the API base's origin, and are fixed when the attempt begins; the exchange never fetches metadata again. OAuth requests never follow a redirect, which would carry the code, the secret or the verifier to wherever it points. +- The attempt in flight (state, verifier, endpoints, NodeInfo) is kept in the encrypted vault, not in saved state, so it survives the process dying while the browser tab is open and never reaches a Bundle. +- A callback is matched by its `state` before anything else in it is read: one for another attempt, a denial included, is dropped, and the sign-in on screen keeps waiting. +- A new account that `verify_credentials` answers 500 for is created from `/oauth/userinfo` with its profile pending. + +### Capabilities on Android + +§4 applies with these differences. A route counts as present on 2xx, 401 or 403 and absent on 404. Pixelfed's `/api/v2/config` `features`, which Nextcloud Social serves publicly, is taken at its word for stories and collections. The Nextcloud theme comes from the public `theming` capability at the Nextcloud root, not the API base, so no app password is needed. On every launch each account's API base is asked for its instance: a base that stopped answering is probed for anew, and capabilities are detected again when the base moved or they are older than a day. --- diff --git a/feature/signin/build.gradle.kts b/feature/signin/build.gradle.kts index b7fe2f83..f075dd2d 100644 --- a/feature/signin/build.gradle.kts +++ b/feature/signin/build.gradle.kts @@ -3,4 +3,15 @@ plugins { alias(libs.plugins.aloha.android.feature) + alias(libs.plugins.aloha.screenshot) +} + +dependencies { + implementation(project(":core:data")) + implementation(project(":core:datastore")) + implementation(libs.androidx.browser) + testImplementation(project(":core:testing")) + testImplementation(project(":core:database")) + testImplementation(libs.kotlinx.coroutines.test) + testImplementation(libs.turbine) } diff --git a/feature/signin/src/main/kotlin/social/aloha/feature/signin/SignInActions.kt b/feature/signin/src/main/kotlin/social/aloha/feature/signin/SignInActions.kt new file mode 100644 index 00000000..cbdfc7fb --- /dev/null +++ b/feature/signin/src/main/kotlin/social/aloha/feature/signin/SignInActions.kt @@ -0,0 +1,37 @@ +// SPDX-FileCopyrightText: 2026 Aloha Social contributors +// SPDX-License-Identifier: MIT + +package social.aloha.feature.signin + +import androidx.compose.runtime.Immutable + +/** Everything the sign-in screen can ask for; the ViewModel implements it. */ +@Immutable +internal interface SignInActions { + fun onServerChange(text: String) + + fun onContinue() + + fun onSignIn() + + fun onOtherServer() + + fun onOpenBrowserAgain() + + fun onTryAgain() + + fun onCopyInstructions() + + fun onToggleManualApiAddress() + + fun onManualApiAddressChange(text: String) + + fun onUseManualApiAddress() + + fun onTrustCertificate() + + fun onRejectCertificate() + + /** Opens the system's certificate picker; the screen owns the activity it needs. */ + fun onChooseClientCertificate() +} diff --git a/feature/signin/src/main/kotlin/social/aloha/feature/signin/SignInEntry.kt b/feature/signin/src/main/kotlin/social/aloha/feature/signin/SignInEntry.kt new file mode 100644 index 00000000..2cf298fd --- /dev/null +++ b/feature/signin/src/main/kotlin/social/aloha/feature/signin/SignInEntry.kt @@ -0,0 +1,105 @@ +// SPDX-FileCopyrightText: 2026 Aloha Social contributors +// SPDX-License-Identifier: MIT + +package social.aloha.feature.signin + +import android.app.Activity +import android.content.ActivityNotFoundException +import android.content.ClipData +import android.content.Context +import android.content.ContextWrapper +import android.content.Intent +import android.security.KeyChain +import androidx.browser.customtabs.CustomTabsIntent +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.ClipEntry +import androidx.compose.ui.platform.LocalClipboard +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.core.net.toUri +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import kotlinx.coroutines.launch + +/** + * Where a person without an account starts: the terms, once per version, then sign-in. Signing in + * makes an account active, and the app leaves this screen by itself. + */ +@Composable +public fun SignInEntry(modifier: Modifier = Modifier) { + val terms: TermsViewModel = hiltViewModel() + val accepted by terms.accepted.collectAsStateWithLifecycle() + when (accepted) { + null -> Unit + false -> TermsScreen(onAccept = terms::accept, modifier = modifier) + true -> SignInRoute(modifier) + } +} + +@Composable +private fun SignInRoute(modifier: Modifier) { + val viewModel: SignInViewModel = hiltViewModel() + val state by viewModel.uiState.collectAsStateWithLifecycle() + val browserUrl by viewModel.pendingBrowserUrl.collectAsStateWithLifecycle() + val context = LocalContext.current + val clipboard = LocalClipboard.current + val scope = rememberCoroutineScope() + val instructions by rememberUpdatedState( + stringResource(R.string.signin_admin_instructions, state.server.trim(), WEB_SERVER_RULES), + ) + + LaunchedEffect(browserUrl) { + browserUrl?.let { + openInBrowser(context, it) + viewModel.onBrowserOpened() + } + } + val actions = remember(viewModel, context) { + object : SignInActions by viewModel { + override fun onCopyInstructions() { + scope.launch { + clipboard.setClipEntry(ClipEntry(ClipData.newPlainText("instructions", instructions))) + viewModel.onCopyInstructions() + } + } + + override fun onChooseClientCertificate() { + val host = viewModel.clientCertificateHost() ?: return + val activity = context.findActivity() ?: return + // the callback arrives on a binder thread; the ViewModel hands the write to I/O + KeyChain.choosePrivateKeyAlias(activity, { alias -> + viewModel.onClientCertificateChosen(host, alias) + }, null, null, host, -1, null) + } + } + } + SignInScreen(state, actions, modifier) +} + +private tailrec fun Context.findActivity(): Activity? = when (this) { + is Activity -> this + is ContextWrapper -> baseContext.findActivity() + else -> null +} + +/** + * A Custom Tab shares the browser's Nextcloud session, so single sign-on and two-factor prompts work + * as they do on the web; without a Custom Tabs browser the default browser opens the page. + */ +private fun openInBrowser(context: Context, url: String) { + val uri = url.toUri() + try { + CustomTabsIntent.Builder().setShowTitle(true).build().launchUrl(context, uri) + } catch (_: ActivityNotFoundException) { + context.startActivity(Intent(Intent.ACTION_VIEW, uri).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) + } +} + +/** The web-server rules that ship with Nextcloud Social, with notes on proxies and the Authorization header. */ +private const val WEB_SERVER_RULES = "https://github.com/nextcloud/social/tree/master/contrib/webserver" diff --git a/feature/signin/src/main/kotlin/social/aloha/feature/signin/SignInMapping.kt b/feature/signin/src/main/kotlin/social/aloha/feature/signin/SignInMapping.kt new file mode 100644 index 00000000..a9cc02ad --- /dev/null +++ b/feature/signin/src/main/kotlin/social/aloha/feature/signin/SignInMapping.kt @@ -0,0 +1,20 @@ +// SPDX-FileCopyrightText: 2026 Aloha Social contributors +// SPDX-License-Identifier: MIT + +package social.aloha.feature.signin + +import java.text.DateFormat +import java.util.Date +import social.aloha.core.data.DiscoveredServer +import social.aloha.core.data.ServerCertificate + +internal fun DiscoveredServer.toCard(): InstanceCard = + InstanceCard(title, domain, description, userCount, rules, isNextcloudSocial) + +internal fun ServerCertificate.toSummary(): CertificateSummary = CertificateSummary( + host = host, + subject = subject, + issuer = issuer, + validUntil = DateFormat.getDateInstance(DateFormat.LONG).format(Date.from(validUntil)), + sha256 = sha256, +) diff --git a/feature/signin/src/main/kotlin/social/aloha/feature/signin/SignInScreen.kt b/feature/signin/src/main/kotlin/social/aloha/feature/signin/SignInScreen.kt new file mode 100644 index 00000000..3b6fb7f5 --- /dev/null +++ b/feature/signin/src/main/kotlin/social/aloha/feature/signin/SignInScreen.kt @@ -0,0 +1,114 @@ +// SPDX-FileCopyrightText: 2026 Aloha Social contributors +// SPDX-License-Identifier: MIT + +package social.aloha.feature.signin + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +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.error +import androidx.compose.ui.semantics.liveRegion +import androidx.compose.ui.semantics.paneTitle +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import social.aloha.core.designsystem.AlohaSpacing + +private val ContentWidth = 560.dp + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun SignInScreen(state: SignInUiState, actions: SignInActions, modifier: Modifier = Modifier) { + val title = stringResource(R.string.signin_title) + Scaffold( + modifier = modifier.semantics { paneTitle = title }, + topBar = { TopAppBar(title = { Text(title) }) }, + ) { padding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .imePadding() + .verticalScroll(rememberScrollState()), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Column( + modifier = Modifier + .widthIn(max = ContentWidth) + .fillMaxWidth() + .padding(AlohaSpacing.l), + verticalArrangement = Arrangement.spacedBy(AlohaSpacing.l), + ) { + SignInStepContent(state, actions) + } + } + } +} + +@Composable +private fun SignInStepContent(state: SignInUiState, actions: SignInActions) { + when (val step = state.step) { + is SignInStep.EnterServerWith -> ServerEntry(state, step.problem, actions) + SignInStep.Probing -> StatusMessage(stringResource(R.string.signin_probing)) + is SignInStep.Instance -> InstanceCardContent(step.card, actions) + SignInStep.WaitingForBrowser -> WaitingForBrowser(actions) + SignInStep.Finishing -> StatusMessage(stringResource(R.string.signin_finishing)) + is SignInStep.Failed -> FailureContent(state, step.failure, actions) + is SignInStep.UntrustedCertificate -> CertificatePrompt(step.certificate, actions) + } +} + +@Composable +private fun ServerEntry(state: SignInUiState, problem: AddressProblem?, actions: SignInActions) { + val invalid = problem != null + val invalidText = when (problem) { + AddressProblem.NotSecure -> stringResource(R.string.signin_server_not_secure) + else -> stringResource(R.string.signin_server_invalid) + } + OutlinedTextField( + value = state.server, + onValueChange = actions::onServerChange, + modifier = Modifier + .fillMaxWidth() + .semantics { if (invalid) error(invalidText) }, + label = { Text(stringResource(R.string.signin_server_label)) }, + supportingText = { + Text( + if (invalid) invalidText else stringResource(R.string.signin_server_supporting), + modifier = Modifier.semantics { if (invalid) liveRegion = LiveRegionMode.Assertive }, + ) + }, + isError = invalid, + singleLine = true, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Uri, + imeAction = ImeAction.Go, + autoCorrectEnabled = false, + ), + keyboardActions = KeyboardActions(onGo = { actions.onContinue() }), + ) + Button(onClick = actions::onContinue, enabled = state.server.isNotBlank(), modifier = Modifier.fillMaxWidth()) { + Text(stringResource(R.string.signin_continue)) + } + ManualApiAddress(state, actions) +} diff --git a/feature/signin/src/main/kotlin/social/aloha/feature/signin/SignInSteps.kt b/feature/signin/src/main/kotlin/social/aloha/feature/signin/SignInSteps.kt new file mode 100644 index 00000000..5c88fb28 --- /dev/null +++ b/feature/signin/src/main/kotlin/social/aloha/feature/signin/SignInSteps.kt @@ -0,0 +1,279 @@ +// SPDX-FileCopyrightText: 2026 Aloha Social contributors +// SPDX-License-Identifier: MIT + +package social.aloha.feature.signin + +import androidx.compose.foundation.layout.Arrangement +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.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.LiveRegionMode +import androidx.compose.ui.semantics.collapse +import androidx.compose.ui.semantics.expand +import androidx.compose.ui.semantics.heading +import androidx.compose.ui.semantics.liveRegion +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.stateDescription +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import java.text.NumberFormat +import social.aloha.core.data.SignInProblem +import social.aloha.core.data.TriedBecause +import social.aloha.core.designsystem.AlohaSpacing + +/** A progress indicator with a status line that screen readers announce when it changes. */ +@Composable +internal fun StatusMessage(text: String, detail: String? = null) { + Row( + modifier = Modifier + .fillMaxWidth() + .semantics(mergeDescendants = true) { liveRegion = LiveRegionMode.Polite }, + horizontalArrangement = Arrangement.spacedBy(AlohaSpacing.m), + verticalAlignment = Alignment.CenterVertically, + ) { + CircularProgressIndicator() + Column { + Text(text, style = MaterialTheme.typography.titleMedium) + detail?.let { Text(it, style = MaterialTheme.typography.bodyMedium) } + } + } +} + +@Composable +internal fun InstanceCardContent(card: InstanceCard, actions: SignInActions) { + Card(modifier = Modifier.fillMaxWidth()) { + Column(Modifier.padding(AlohaSpacing.m), verticalArrangement = Arrangement.spacedBy(AlohaSpacing.xs)) { + Text( + card.title, + style = MaterialTheme.typography.headlineSmall, + modifier = Modifier.semantics { + heading() + }, + ) + Text( + card.domain, + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + val software = stringResource(R.string.signin_nextcloud_social) + // the software is named only where the server's own title does not already say it + if (card.isNextcloudSocial && !card.title.contains(software, ignoreCase = true)) { + Text(software, style = MaterialTheme.typography.labelMedium) + } + card.userCount?.let { + Text( + pluralStringResource(R.plurals.signin_user_count, it, NumberFormat.getIntegerInstance().format(it)), + ) + } + if (card.description.isNotBlank()) Text(card.description, style = MaterialTheme.typography.bodyMedium) + if (card.rules.isNotEmpty()) { + HorizontalDivider(Modifier.padding(vertical = AlohaSpacing.xs)) + Text( + stringResource(R.string.signin_rules), + style = MaterialTheme.typography.titleSmall, + modifier = Modifier.semantics { heading() }, + ) + card.rules.forEachIndexed { index, rule -> + Text("${index + 1}. $rule", style = MaterialTheme.typography.bodyMedium) + } + } + } + } + Button(onClick = actions::onSignIn, modifier = Modifier.fillMaxWidth()) { + Text(stringResource(R.string.signin_sign_in)) + } + TextButton(onClick = actions::onOtherServer, modifier = Modifier.fillMaxWidth()) { + Text(stringResource(R.string.signin_other_server)) + } +} + +@Composable +internal fun WaitingForBrowser(actions: SignInActions) { + StatusMessage(stringResource(R.string.signin_waiting), stringResource(R.string.signin_waiting_detail)) + OutlinedButton(onClick = actions::onOpenBrowserAgain, modifier = Modifier.fillMaxWidth()) { + Text(stringResource(R.string.signin_open_browser_again)) + } + TextButton(onClick = actions::onOtherServer, modifier = Modifier.fillMaxWidth()) { + Text(stringResource(R.string.signin_other_server)) + } +} + +@Composable +internal fun FailureContent(state: SignInUiState, failure: SignInFailure, actions: SignInActions) { + Text( + failureMessage(failure), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.error, + modifier = Modifier.semantics { liveRegion = LiveRegionMode.Polite }, + ) + if (failure is SignInFailure.NothingAnswered && failure.tried.isNotEmpty()) { + Text( + stringResource(R.string.signin_attempted), + style = MaterialTheme.typography.titleSmall, + modifier = Modifier.semantics { + heading() + }, + ) + failure.tried.forEach { attempt -> + Column(Modifier.semantics(mergeDescendants = true) {}) { + Text(attempt.url, style = MaterialTheme.typography.bodyMedium, fontFamily = FontFamily.Monospace) + Text(stringResource(attempt.reason.label()), style = MaterialTheme.typography.bodySmall) + } + } + } + Button(onClick = actions::onTryAgain, modifier = Modifier.fillMaxWidth()) { + Text(stringResource(R.string.signin_try_again)) + } + if (failure is SignInFailure.NothingAnswered) { + OutlinedButton(onClick = actions::onCopyInstructions, modifier = Modifier.fillMaxWidth()) { + Text(stringResource(R.string.signin_copy_instructions)) + } + if (state.copied) { + Text( + stringResource(R.string.signin_copied), + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.semantics { liveRegion = LiveRegionMode.Polite }, + ) + } + } + TextButton(onClick = actions::onOtherServer, modifier = Modifier.fillMaxWidth()) { + Text(stringResource(R.string.signin_other_server)) + } + ManualApiAddress(state, actions) +} + +@Composable +private fun failureMessage(failure: SignInFailure): String = when (failure) { + is SignInFailure.NothingAnswered -> stringResource(R.string.signin_failure_nothing_answered) + is SignInFailure.Problem -> problemMessage(failure.problem) +} + +@Composable +private fun problemMessage(problem: SignInProblem): String = when (problem) { + is SignInProblem.Refused -> problem.reason?.let { stringResource(R.string.signin_failure_refused, it) } + ?: stringResource(R.string.signin_failure_refused_no_reason) + + // an untrusted certificate has its own step; reaching here means it could not be described + SignInProblem.Offline, is SignInProblem.UntrustedCertificate -> stringResource(R.string.signin_failure_offline) + + is SignInProblem.ServerProblem -> problem.detail?.let { stringResource(R.string.signin_failure_server, it) } + ?: stringResource(R.string.signin_failure_server_no_detail) +} + +private fun TriedBecause.label(): Int = when (this) { + TriedBecause.Discovery -> R.string.signin_attempt_discovery + TriedBecause.DomainRoot -> R.string.signin_attempt_domain_root + TriedBecause.AppPath -> R.string.signin_attempt_app_path + TriedBecause.PrettyAppPath -> R.string.signin_attempt_pretty_app_path + TriedBecause.TypedPath -> R.string.signin_attempt_typed_path + TriedBecause.TypedAppPath -> R.string.signin_attempt_typed_app_path + TriedBecause.Manual -> R.string.signin_attempt_manual +} + +/** The disclosure where a person who knows their API address types it by hand. */ +@Composable +internal fun ManualApiAddress(state: SignInUiState, actions: SignInActions) { + val expanded = state.showManualApiAddress + val status = stringResource(if (expanded) R.string.signin_expanded else R.string.signin_collapsed) + TextButton( + onClick = actions::onToggleManualApiAddress, + modifier = Modifier + .fillMaxWidth() + .semantics { + stateDescription = status + val toggle = { + actions.onToggleManualApiAddress() + true + } + if (expanded) collapse(action = toggle) else expand(action = toggle) + }, + ) { + Text(stringResource(R.string.signin_advanced)) + } + if (!expanded) return + OutlinedTextField( + value = state.manualApiAddress, + onValueChange = actions::onManualApiAddressChange, + modifier = Modifier.fillMaxWidth(), + label = { Text(stringResource(R.string.signin_api_address_label)) }, + singleLine = true, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Uri, + imeAction = ImeAction.Done, + autoCorrectEnabled = false, + ), + keyboardActions = KeyboardActions(onDone = { + if (state.manualApiAddress.isNotBlank()) actions.onUseManualApiAddress() + }), + ) + OutlinedButton( + onClick = actions::onUseManualApiAddress, + enabled = state.manualApiAddress.isNotBlank(), + modifier = Modifier.fillMaxWidth(), + ) { Text(stringResource(R.string.signin_use_api_address)) } + OutlinedButton( + onClick = actions::onChooseClientCertificate, + enabled = state.server.isNotBlank(), + modifier = Modifier.fillMaxWidth(), + ) { Text(stringResource(R.string.signin_client_certificate)) } + state.clientCertificate?.let { + Text(stringResource(R.string.signin_client_certificate_chosen, it), style = MaterialTheme.typography.bodyMedium) + } +} + +@Composable +internal fun CertificatePrompt(certificate: CertificateSummary, actions: SignInActions) { + Text( + stringResource(R.string.signin_certificate_title), + style = MaterialTheme.typography.titleLarge, + modifier = Modifier.semantics { heading() }, + ) + Text( + stringResource(R.string.signin_certificate_detail, certificate.host), + style = MaterialTheme.typography.bodyMedium, + ) + CertificateField(R.string.signin_certificate_subject, certificate.subject) + CertificateField(R.string.signin_certificate_issuer, certificate.issuer) + CertificateField(R.string.signin_certificate_valid_until, certificate.validUntil) + CertificateField(R.string.signin_certificate_fingerprint, certificate.sha256, monospace = true) + Button(onClick = actions::onRejectCertificate, modifier = Modifier.fillMaxWidth()) { + Text(stringResource(R.string.signin_certificate_cancel)) + } + OutlinedButton(onClick = actions::onTrustCertificate, modifier = Modifier.fillMaxWidth()) { + Text(stringResource(R.string.signin_certificate_trust)) + } +} + +@Composable +private fun CertificateField(label: Int, value: String, monospace: Boolean = false) { + Column(Modifier.semantics(mergeDescendants = true) {}) { + Text( + stringResource(label), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + value, + style = MaterialTheme.typography.bodyMedium, + fontFamily = if (monospace) FontFamily.Monospace else null, + ) + } +} diff --git a/feature/signin/src/main/kotlin/social/aloha/feature/signin/SignInUiState.kt b/feature/signin/src/main/kotlin/social/aloha/feature/signin/SignInUiState.kt new file mode 100644 index 00000000..39732ac4 --- /dev/null +++ b/feature/signin/src/main/kotlin/social/aloha/feature/signin/SignInUiState.kt @@ -0,0 +1,82 @@ +// SPDX-FileCopyrightText: 2026 Aloha Social contributors +// SPDX-License-Identifier: MIT + +package social.aloha.feature.signin + +import androidx.compose.runtime.Immutable +import social.aloha.core.data.SignInProblem +import social.aloha.core.data.TriedAddress + +/** What the sign-in screen shows. Everything in it is already worded for display. */ +@Immutable +internal data class SignInUiState( + val server: String = "", + val manualApiAddress: String = "", + val showManualApiAddress: Boolean = false, + val step: SignInStep = SignInStep.EnterServer, + /** The administrator instructions were just copied; cleared by the next step. */ + val copied: Boolean = false, + /** The KeyChain alias chosen as the client certificate for this server, if any. */ + val clientCertificate: String? = null, +) + +@Immutable +internal sealed interface SignInStep { + /** Typing the server; [problem] once the text was refused. */ + data class EnterServerWith(val problem: AddressProblem?) : SignInStep + + data object Probing : SignInStep + + data class Instance(val card: InstanceCard) : SignInStep + + /** The browser tab is open; the app waits for the callback. */ + data object WaitingForBrowser : SignInStep + + /** The code came back and the account is being set up. */ + data object Finishing : SignInStep + + data class Failed(val failure: SignInFailure) : SignInStep + + data class UntrustedCertificate(val certificate: CertificateSummary) : SignInStep + + companion object { + val EnterServer: SignInStep = EnterServerWith(problem = null) + } +} + +/** Why typed text was refused before anything was sent. */ +internal enum class AddressProblem { + /** It cannot be a server address. */ + NotAnAddress, + + /** It asks for `http://`, which only debug builds accept. */ + NotSecure, +} + +@Immutable +internal data class InstanceCard( + val title: String, + val domain: String, + val description: String, + val userCount: Int?, + val rules: List, + val isNextcloudSocial: Boolean, +) + +@Immutable +internal sealed interface SignInFailure { + /** No candidate API base answered; [tried] is what was tried, in order. */ + data class NothingAnswered(val tried: List) : SignInFailure + + /** A step answered with a problem other than an untrusted certificate, which has its own step. */ + data class Problem(val problem: SignInProblem) : SignInFailure +} + +@Immutable +internal data class CertificateSummary( + val host: String, + val subject: String, + val issuer: String, + val validUntil: String, + val sha256: String, +) diff --git a/feature/signin/src/main/kotlin/social/aloha/feature/signin/SignInViewModel.kt b/feature/signin/src/main/kotlin/social/aloha/feature/signin/SignInViewModel.kt new file mode 100644 index 00000000..a2d1dadb --- /dev/null +++ b/feature/signin/src/main/kotlin/social/aloha/feature/signin/SignInViewModel.kt @@ -0,0 +1,201 @@ +// SPDX-FileCopyrightText: 2026 Aloha Social contributors +// SPDX-License-Identifier: MIT + +package social.aloha.feature.signin + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import social.aloha.core.data.Authorization +import social.aloha.core.data.DiscoveredServer +import social.aloha.core.data.OAuthCallbackInbox +import social.aloha.core.data.ServerCertificate +import social.aloha.core.data.ServerFinder +import social.aloha.core.data.ServerLookup +import social.aloha.core.data.SignInCoordinator +import social.aloha.core.data.SignInProblem +import social.aloha.core.data.SignInResult + +@HiltViewModel +internal class SignInViewModel @Inject constructor( + private val finder: ServerFinder, + private val coordinator: SignInCoordinator, + private val inbox: OAuthCallbackInbox, + private val savedState: SavedStateHandle, +) : ViewModel(), + SignInActions { + private val state = MutableStateFlow(SignInUiState(server = savedState.get(SERVER).orEmpty())) + val uiState: StateFlow = state.asStateFlow() + + /** The authorisation page to open, set until the screen has opened it. */ + private val browser = MutableStateFlow(null) + val pendingBrowserUrl: StateFlow = browser.asStateFlow() + + private var discovered: DiscoveredServer? = null + private var untrusted: ServerCertificate? = null + private var work: Job? = null + + init { + viewModelScope.launch { + // A sign-in whose browser tab outlived the process resumes where it waited. + if (coordinator.hasPendingAuthorization()) show(SignInStep.WaitingForBrowser) + } + viewModelScope.launch { inbox.callback.filterNotNull().collect(::finish) } + } + + fun onBrowserOpened() { + browser.value = null + } + + /** The host a client certificate would be chosen for; null until the server text is an address. */ + fun clientCertificateHost(): String? = finder.hostOf(state.value.server) + + fun onClientCertificateChosen(host: String, alias: String?) { + viewModelScope.launch { + finder.useClientCertificate(host, alias) + state.update { it.copy(clientCertificate = alias) } + } + } + + override fun onServerChange(text: String) { + savedState[SERVER] = text + state.update { it.copy(server = text, step = SignInStep.EnterServer, copied = false, clientCertificate = null) } + } + + override fun onContinue() = look { finder.find(state.value.server) } + + override fun onManualApiAddressChange(text: String) { + state.update { it.copy(manualApiAddress = text) } + } + + override fun onToggleManualApiAddress() { + state.update { it.copy(showManualApiAddress = !it.showManualApiAddress) } + } + + override fun onUseManualApiAddress() = look { finder.findAt(state.value.manualApiAddress) } + + override fun onSignIn() { + val server = discovered ?: return + start { + when (val begun = coordinator.beginAuthorization(server)) { + is Authorization.Started -> { + browser.value = begun.url + show(SignInStep.WaitingForBrowser) + } + + is Authorization.Failed -> fail(begun.problem) + } + } + } + + override fun onOpenBrowserAgain() { + viewModelScope.launch { browser.value = coordinator.pendingAuthorizationUrl() } + } + + override fun onOtherServer() { + work?.cancel() + discovered = null + viewModelScope.launch { coordinator.cancel() } + show(SignInStep.EnterServer) + } + + override fun onTryAgain() { + if (state.value.showManualApiAddress && state.value.manualApiAddress.isNotBlank()) { + onUseManualApiAddress() + } else { + onContinue() + } + } + + /** The screen copies to the clipboard, which it owns, and then says so here. */ + override fun onCopyInstructions() { + state.update { it.copy(copied = true) } + } + + override fun onTrustCertificate() { + val certificate = untrusted ?: return + untrusted = null + start { + finder.trust(certificate) + onTryAgain() + } + } + + override fun onRejectCertificate() { + untrusted = null + show(SignInStep.EnterServer) + } + + /** The screen opens the picker and reports back through [onClientCertificateChosen]. */ + override fun onChooseClientCertificate() = Unit + + private fun start(block: suspend () -> Unit) { + work?.cancel() + work = viewModelScope.launch { block() } + } + + private fun look(lookup: suspend () -> ServerLookup) { + show(SignInStep.Probing) + start { + when (val found = lookup()) { + is ServerLookup.Found -> { + discovered = found.server + show(SignInStep.Instance(found.server.toCard())) + } + + is ServerLookup.NothingAnswered -> show(SignInStep.Failed(SignInFailure.NothingAnswered(found.tried))) + + ServerLookup.Unreachable -> fail(SignInProblem.Offline) + + is ServerLookup.UntrustedCertificate -> untrustedCertificate(found.certificate) + + ServerLookup.InvalidAddress -> show(SignInStep.EnterServerWith(AddressProblem.NotAnAddress)) + + ServerLookup.InsecureAddress -> show(SignInStep.EnterServerWith(AddressProblem.NotSecure)) + } + } + } + + private suspend fun finish(callback: String) { + val before = state.value.step + show(SignInStep.Finishing) + val result = coordinator.complete(callback) + inbox.consume(callback) + when (result) { + // the app leaves this screen once the account is active + is SignInResult.SignedIn -> Unit + + // someone else's callback changes nothing about the sign-in on screen + SignInResult.NotForUs -> show(before) + + is SignInResult.Failed -> fail(result.problem) + } + } + + private fun fail(problem: SignInProblem) = when (problem) { + is SignInProblem.UntrustedCertificate -> untrustedCertificate(problem.certificate) + else -> show(SignInStep.Failed(SignInFailure.Problem(problem))) + } + + private fun untrustedCertificate(certificate: ServerCertificate) { + untrusted = certificate + show(SignInStep.UntrustedCertificate(certificate.toSummary())) + } + + private fun show(step: SignInStep) { + state.update { it.copy(step = step, copied = false) } + } + + private companion object { + const val SERVER = "server" + } +} diff --git a/feature/signin/src/main/kotlin/social/aloha/feature/signin/TermsScreen.kt b/feature/signin/src/main/kotlin/social/aloha/feature/signin/TermsScreen.kt new file mode 100644 index 00000000..b9c63728 --- /dev/null +++ b/feature/signin/src/main/kotlin/social/aloha/feature/signin/TermsScreen.kt @@ -0,0 +1,68 @@ +// SPDX-FileCopyrightText: 2026 Aloha Social contributors +// SPDX-License-Identifier: MIT + +package social.aloha.feature.signin + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.paneTitle +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import social.aloha.core.datastore.AppPreferences +import social.aloha.core.designsystem.AlohaSpacing + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun TermsScreen(onAccept: () -> Unit, modifier: Modifier = Modifier) { + val title = stringResource(R.string.terms_title) + Scaffold( + modifier = modifier.semantics { paneTitle = title }, + topBar = { TopAppBar(title = { Text(title) }) }, + ) { padding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .verticalScroll(rememberScrollState()), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Column( + modifier = Modifier + .widthIn(max = 560.dp) + .fillMaxWidth() + .padding(AlohaSpacing.l), + verticalArrangement = Arrangement.spacedBy(AlohaSpacing.l), + ) { + Text(stringResource(R.string.terms_body), style = MaterialTheme.typography.bodyLarge) + Button(onClick = onAccept, modifier = Modifier.fillMaxWidth()) { + Text(stringResource(R.string.terms_accept)) + } + } + } + } +} diff --git a/feature/signin/src/main/kotlin/social/aloha/feature/signin/TermsViewModel.kt b/feature/signin/src/main/kotlin/social/aloha/feature/signin/TermsViewModel.kt new file mode 100644 index 00000000..64e60935 --- /dev/null +++ b/feature/signin/src/main/kotlin/social/aloha/feature/signin/TermsViewModel.kt @@ -0,0 +1,36 @@ +// SPDX-FileCopyrightText: 2026 Aloha Social contributors +// SPDX-License-Identifier: MIT + +package social.aloha.feature.signin + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import social.aloha.core.datastore.AppPreferences + +/** + * The terms a person accepts before adding an account. Versioned: raising [CURRENT_TERMS_VERSION] + * shows them again to everyone who accepted an older version. + */ +@HiltViewModel +internal class TermsViewModel @Inject constructor(private val preferences: AppPreferences) : ViewModel() { + /** Null until read, so the screen never flashes the terms at someone who accepted them. */ + val accepted: StateFlow = preferences.acceptedTermsVersion + .map { it >= CURRENT_TERMS_VERSION } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(STOP_TIMEOUT_MILLIS), initialValue = null) + + fun accept() { + viewModelScope.launch { preferences.acceptTerms(CURRENT_TERMS_VERSION) } + } + + private companion object { + const val CURRENT_TERMS_VERSION = 1 + const val STOP_TIMEOUT_MILLIS = 5_000L + } +} diff --git a/feature/signin/src/main/res/values/strings.xml b/feature/signin/src/main/res/values/strings.xml new file mode 100644 index 00000000..37b66450 --- /dev/null +++ b/feature/signin/src/main/res/values/strings.xml @@ -0,0 +1,114 @@ + + + + + Add account + + Your server + + A Nextcloud with Social, or any Mastodon server + + That doesn\'t look like a server address. Try something like cloud.example.com. + + Aloha Social only connects over a secure connection (https://), and this address doesn\'t use one. + + Continue + + Looking for your server… + + Server rules + + + %s person + %s people + + + Nextcloud Social + + Sign in + + Use another server + + Waiting for your server… + + Finish in the browser and you\'ll come straight back here. + + Open the browser again + + Setting up your account… + + Couldn\'t find a Mastodon API there. If this is a Nextcloud, its administrator may still need to add the web-server rules. + + Signing in was refused: %s + + Signing in was refused. + + Couldn\'t reach your server. Check your connection and try again. + + Your server had a problem: %s + + Your server had a problem. Try again in a moment. + + What was tried + + named by the server + + the domain root + + Nextcloud Social without the web-server rules + + Nextcloud Social with pretty URLs + + the address you typed + + Nextcloud Social in that folder + + the API address you entered + + Try again + + Copy instructions for your administrator + + Instructions copied + + The Aloha Social app could not find the Mastodon client API of Nextcloud Social on %1$s. Mastodon apps expect it at the root of the domain (/api/v1/…), which on Nextcloud needs the web-server rules that come with Nextcloud Social. The rules for Apache and nginx, with notes on proxies and the Authorization header, are here: %2$s + + Advanced + + Expanded + + Collapsed + + Use a client certificate + + Client certificate: %s + + API address + + Use this address + + This server\'s certificate isn\'t trusted + + %s presented a certificate your phone doesn\'t trust. Only trust it if you know this server uses its own certificate, and the fingerprint below matches what its administrator gave you. + + Issued to + + Issued by + + Valid until + + SHA-256 fingerprint + + Trust for this server + + Don\'t trust + + Before you start + + Aloha Social connects you to a server someone else runs. That server\'s own rules apply to what you post there, and its administrator can see what you share with it. The app itself collects nothing about you. + + Agree and continue + diff --git a/feature/signin/src/test/kotlin/social/aloha/feature/signin/SignInScreenshotTest.kt b/feature/signin/src/test/kotlin/social/aloha/feature/signin/SignInScreenshotTest.kt new file mode 100644 index 00000000..866f7e23 --- /dev/null +++ b/feature/signin/src/test/kotlin/social/aloha/feature/signin/SignInScreenshotTest.kt @@ -0,0 +1,164 @@ +// SPDX-FileCopyrightText: 2026 Aloha Social contributors +// SPDX-License-Identifier: MIT + +package social.aloha.feature.signin + +import android.app.Application +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onRoot +import androidx.compose.ui.unit.LayoutDirection +import com.github.takahirom.roborazzi.RobolectricDeviceQualifiers +import com.github.takahirom.roborazzi.captureRoboImage +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode +import social.aloha.core.data.TriedAddress +import social.aloha.core.data.TriedBecause +import social.aloha.core.designsystem.AlohaTheme +import social.aloha.core.designsystem.ThemeMode +import social.aloha.core.designsystem.ThemeSettings + +/** Every sign-in step on a phone, the card also dark, on a tablet, at 200 % font and right to left. */ +@RunWith(RobolectricTestRunner::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +@Config(sdk = [36], application = Application::class, qualifiers = RobolectricDeviceQualifiers.Pixel7) +class SignInScreenshotTest { + @get:Rule + val compose = createComposeRule() + + private val card = InstanceCard( + title = "Nextcloud Social", + domain = "cloud.example", + description = "a safe home for your data", + userCount = 10_188, + rules = listOf("Be kind.", "No spam, no scams."), + isNextcloudSocial = true, + ) + + @Test + fun enterServer() = capture("signin-enter-server", SignInUiState(server = "cloud.example")) + + @Test + fun invalidServer() = capture( + "signin-invalid", + SignInUiState(server = "not a host", step = SignInStep.EnterServerWith(AddressProblem.NotAnAddress)), + ) + + @Test + fun insecureServer() = capture( + "signin-not-secure", + SignInUiState(server = "http://cloud.example", step = SignInStep.EnterServerWith(AddressProblem.NotSecure)), + ) + + @Test + fun probing() = capture("signin-probing", SignInUiState(server = "cloud.example", step = SignInStep.Probing)) + + @Test + fun instanceCard() = capture("signin-card", SignInUiState(step = SignInStep.Instance(card))) + + @Test + fun instanceCardDark() = + capture("signin-card-dark", SignInUiState(step = SignInStep.Instance(card)), ThemeMode.Dark) + + @Test + @Config(qualifiers = RobolectricDeviceQualifiers.MediumTablet) + fun instanceCardTablet() = capture("signin-card-tablet", SignInUiState(step = SignInStep.Instance(card))) + + @Test + @Config(fontScale = 2f) + fun instanceCardLargeFont() = capture("signin-card-font200", SignInUiState(step = SignInStep.Instance(card))) + + @Test + @Config(qualifiers = "+ar-rXB-ldrtl") + fun instanceCardRightToLeft() = + capture("signin-card-rtl", SignInUiState(step = SignInStep.Instance(card)), direction = LayoutDirection.Rtl) + + @Test + fun waitingForBrowser() = capture("signin-waiting", SignInUiState(step = SignInStep.WaitingForBrowser)) + + @Test + fun nothingAnswered() = capture( + "signin-failed", + SignInUiState( + server = "cloud.example", + step = SignInStep.Failed( + SignInFailure.NothingAnswered( + listOf( + TriedAddress("https://cloud.example/", TriedBecause.DomainRoot), + TriedAddress("https://cloud.example/index.php/apps/social/", TriedBecause.AppPath), + ), + ), + ), + ), + ) + + @Test + fun untrustedCertificate() = capture( + "signin-certificate", + SignInUiState( + step = SignInStep.UntrustedCertificate( + CertificateSummary( + host = "cloud.example", + subject = "CN=cloud.example", + issuer = "CN=cloud.example", + validUntil = "28 September 2027", + sha256 = List(32) { "AB" }.joinToString(":"), + ), + ), + ), + ) + + @Test + fun terms() { + compose.setContent { AlohaTheme { TermsScreen(onAccept = {}) } } + compose.onRoot().captureRoboImage("src/test/screenshots/signin-terms.png") + } + + private fun capture( + name: String, + state: SignInUiState, + mode: ThemeMode = ThemeMode.Light, + direction: LayoutDirection = LayoutDirection.Ltr, + ) { + compose.setContent { + // Robolectric does not apply the locale's direction to Compose by itself + CompositionLocalProvider(LocalLayoutDirection provides direction) { + AlohaTheme(ThemeSettings(mode = mode)) { SignInScreen(state, NoActions) } + } + } + compose.onRoot().captureRoboImage("src/test/screenshots/$name.png") + } + + private object NoActions : SignInActions { + override fun onServerChange(text: String) = Unit + + override fun onContinue() = Unit + + override fun onSignIn() = Unit + + override fun onOtherServer() = Unit + + override fun onOpenBrowserAgain() = Unit + + override fun onTryAgain() = Unit + + override fun onCopyInstructions() = Unit + + override fun onToggleManualApiAddress() = Unit + + override fun onManualApiAddressChange(text: String) = Unit + + override fun onUseManualApiAddress() = Unit + + override fun onTrustCertificate() = Unit + + override fun onRejectCertificate() = Unit + + override fun onChooseClientCertificate() = Unit + } +} diff --git a/feature/signin/src/test/kotlin/social/aloha/feature/signin/SignInViewModelTest.kt b/feature/signin/src/test/kotlin/social/aloha/feature/signin/SignInViewModelTest.kt new file mode 100644 index 00000000..5990aed1 --- /dev/null +++ b/feature/signin/src/test/kotlin/social/aloha/feature/signin/SignInViewModelTest.kt @@ -0,0 +1,156 @@ +// SPDX-FileCopyrightText: 2026 Aloha Social contributors +// SPDX-License-Identifier: MIT + +package social.aloha.feature.signin + +import androidx.datastore.preferences.core.emptyPreferences +import androidx.lifecycle.SavedStateHandle +import androidx.room.Room +import androidx.test.core.app.ApplicationProvider +import java.io.File +import java.time.Clock +import java.time.Instant +import kotlin.time.Duration.Companion.seconds +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import kotlinx.coroutines.withTimeout +import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.OkHttpClient +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import social.aloha.core.data.AccountRepository +import social.aloha.core.data.ClientFactory +import social.aloha.core.data.OAuthCallbackInbox +import social.aloha.core.data.ServerFinder +import social.aloha.core.data.SignInCoordinator +import social.aloha.core.data.SignInProblem +import social.aloha.core.database.AccountsDatabase +import social.aloha.core.datastore.AppPreferences +import social.aloha.core.datastore.TokenVault +import social.aloha.core.network.RateLimiter +import social.aloha.core.network.capabilities.CapabilityDetector +import social.aloha.core.network.oauth.OAuthClient +import social.aloha.core.network.oauth.OAuthIdentity +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.testing.FakeSecretCipher +import social.aloha.core.testing.InMemoryDataStore +import social.aloha.core.testing.MockServerConfiguration +import social.aloha.core.testing.MockSocialServer + +// the ViewModel's scope is the main dispatcher, which a JVM test replaces +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +class SignInViewModelTest { + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + private val mock = MockSocialServer(MockServerConfiguration.NextcloudWithoutRewrite).start() + private val database = Room.inMemoryDatabaseBuilder( + ApplicationProvider.getApplicationContext(), + AccountsDatabase::class.java, + ).build() + private val vault = TokenVault(InMemoryDataStore(ByteArray(0)), FakeSecretCipher(), Dispatchers.IO) + private val accounts = AccountRepository( + database.accountDao(), + vault, + AppPreferences(InMemoryDataStore(emptyPreferences())), + Clock.systemUTC(), + scope, + ) + private val inbox = OAuthCallbackInbox() + private lateinit var viewModel: SignInViewModel + + @Before + fun create() { + Dispatchers.setMain(Dispatchers.Unconfined) + val http = OkHttpClient() + val limiter = RateLimiter(nowMillis = Clock.systemUTC()::millis) + viewModel = SignInViewModel( + finder = ServerFinder( + ServerProbe(http, limiter, Dispatchers.IO), + UserTrustStore(File.createTempFile("trust", ".p12").apply { delete() }), + ClientCertificateAliases(File.createTempFile("aliases", ".properties").apply { delete() }), + cleartextAllowed = true, + ioDispatcher = Dispatchers.IO, + ), + coordinator = SignInCoordinator( + accounts, + vault, + OAuthClient(http, limiter, Dispatchers.IO), + CapabilityDetector(http, limiter, Dispatchers.IO) { Instant.now() }, + { OAuthIdentity.SCHEME_REDIRECT }, + ClientFactory(http, limiter, Dispatchers.IO, accounts), + ), + inbox = inbox, + savedState = SavedStateHandle(), + ) + } + + @After + fun close() { + Dispatchers.resetMain() + scope.cancel() + database.close() + mock.close() + } + + private suspend fun awaitStep(predicate: (SignInStep) -> Boolean): SignInStep = + withTimeout(15.seconds) { viewModel.uiState.first { predicate(it.step) }.step } + + @Test + fun `text that cannot be an address is marked invalid without a request`() { + viewModel.onServerChange("not a host") + viewModel.onContinue() + assertEquals(SignInStep.EnterServerWith(AddressProblem.NotAnAddress), viewModel.uiState.value.step) + assertEquals(0, mock.requests.size) + } + + @Test + fun `a found server shows its card, and the callback ends signed in`() = runBlocking { + viewModel.onServerChange(mock.origin.toString()) + viewModel.onContinue() + val card = awaitStep { it is SignInStep.Instance } as SignInStep.Instance + assertEquals("nextcloud.local", card.card.domain) + assertTrue(card.card.isNextcloudSocial) + + viewModel.onSignIn() + val url = withTimeout(15.seconds) { viewModel.pendingBrowserUrl.filterNotNull().first() }.toHttpUrl() + viewModel.onBrowserOpened() + assertEquals(SignInStep.WaitingForBrowser, awaitStep { it == SignInStep.WaitingForBrowser }) + + // a stray callback leaves the wait as it was, and the page can still be opened again + inbox.deliver("alohasocial://oauth-callback/?code=stolen&state=evil") + withTimeout(15.seconds) { inbox.callback.first { it == null } } + assertEquals(SignInStep.WaitingForBrowser, viewModel.uiState.value.step) + viewModel.onOpenBrowserAgain() + assertEquals(url.toString(), withTimeout(15.seconds) { viewModel.pendingBrowserUrl.filterNotNull().first() }) + viewModel.onBrowserOpened() + + inbox.deliver("alohasocial://oauth-callback/?code=c&state=${url.queryParameter("state")}") + val account = withTimeout(15.seconds) { accounts.activeAccount.filterNotNull().first() } + assertEquals(mock.origin.host, account.host) + withTimeout(15.seconds) { inbox.callback.first { it == null } } + } + + @Test + fun `a server nobody answers for says it could not be reached, without the administrator note`() = + runBlocking { + viewModel.onServerChange("http://127.0.0.1:1/") + viewModel.onContinue() + val failed = awaitStep { it is SignInStep.Failed } as SignInStep.Failed + assertEquals(SignInFailure.Problem(SignInProblem.Offline), failed.failure) + } +} diff --git a/feature/signin/src/test/screenshots/signin-card-dark.png b/feature/signin/src/test/screenshots/signin-card-dark.png new file mode 100644 index 00000000..9b652d24 Binary files /dev/null and b/feature/signin/src/test/screenshots/signin-card-dark.png differ diff --git a/feature/signin/src/test/screenshots/signin-card-font200.png b/feature/signin/src/test/screenshots/signin-card-font200.png new file mode 100644 index 00000000..52031064 Binary files /dev/null and b/feature/signin/src/test/screenshots/signin-card-font200.png differ diff --git a/feature/signin/src/test/screenshots/signin-card-rtl.png b/feature/signin/src/test/screenshots/signin-card-rtl.png new file mode 100644 index 00000000..8173d68c Binary files /dev/null and b/feature/signin/src/test/screenshots/signin-card-rtl.png differ diff --git a/feature/signin/src/test/screenshots/signin-card-tablet.png b/feature/signin/src/test/screenshots/signin-card-tablet.png new file mode 100644 index 00000000..bf80b4a9 Binary files /dev/null and b/feature/signin/src/test/screenshots/signin-card-tablet.png differ diff --git a/feature/signin/src/test/screenshots/signin-card.png b/feature/signin/src/test/screenshots/signin-card.png new file mode 100644 index 00000000..131b69fd Binary files /dev/null and b/feature/signin/src/test/screenshots/signin-card.png differ diff --git a/feature/signin/src/test/screenshots/signin-certificate.png b/feature/signin/src/test/screenshots/signin-certificate.png new file mode 100644 index 00000000..7c9ca039 Binary files /dev/null and b/feature/signin/src/test/screenshots/signin-certificate.png differ diff --git a/feature/signin/src/test/screenshots/signin-enter-server.png b/feature/signin/src/test/screenshots/signin-enter-server.png new file mode 100644 index 00000000..ac266d31 Binary files /dev/null and b/feature/signin/src/test/screenshots/signin-enter-server.png differ diff --git a/feature/signin/src/test/screenshots/signin-failed.png b/feature/signin/src/test/screenshots/signin-failed.png new file mode 100644 index 00000000..d9c28915 Binary files /dev/null and b/feature/signin/src/test/screenshots/signin-failed.png differ diff --git a/feature/signin/src/test/screenshots/signin-invalid.png b/feature/signin/src/test/screenshots/signin-invalid.png new file mode 100644 index 00000000..fbfc7181 Binary files /dev/null and b/feature/signin/src/test/screenshots/signin-invalid.png differ diff --git a/feature/signin/src/test/screenshots/signin-not-secure.png b/feature/signin/src/test/screenshots/signin-not-secure.png new file mode 100644 index 00000000..9a85c33c Binary files /dev/null and b/feature/signin/src/test/screenshots/signin-not-secure.png differ diff --git a/feature/signin/src/test/screenshots/signin-probing.png b/feature/signin/src/test/screenshots/signin-probing.png new file mode 100644 index 00000000..0c7fe5db Binary files /dev/null and b/feature/signin/src/test/screenshots/signin-probing.png differ diff --git a/feature/signin/src/test/screenshots/signin-terms.png b/feature/signin/src/test/screenshots/signin-terms.png new file mode 100644 index 00000000..2fea6d72 Binary files /dev/null and b/feature/signin/src/test/screenshots/signin-terms.png differ diff --git a/feature/signin/src/test/screenshots/signin-waiting.png b/feature/signin/src/test/screenshots/signin-waiting.png new file mode 100644 index 00000000..3a86e5ea Binary files /dev/null and b/feature/signin/src/test/screenshots/signin-waiting.png differ diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index d22f5865..97fbd3e0 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -17,6 +17,12 @@ navigation3 = "1.2.0" adaptive = "1.3.0" kotlinxSerialization = "1.11.0" coroutines = "1.11.0" +okhttp = "5.5.0" +room = "2.8.5" +datastore = "1.2.1" +browser = "1.10.0" +turbine = "1.2.1" +androidxTestCore = "1.7.0" detekt = "1.23.8" ktlintGradle = "14.2.0" ktlint = "1.8.0" @@ -61,9 +67,22 @@ hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hilt" hilt-compiler = { module = "com.google.dagger:hilt-compiler", version.ref = "hilt" } kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerialization" } kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" } +kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines" } +okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" } +okhttp-mockwebserver = { module = "com.squareup.okhttp3:mockwebserver3", version.ref = "okhttp" } +okhttp-tls = { module = "com.squareup.okhttp3:okhttp-tls", version.ref = "okhttp" } +room-runtime = { module = "androidx.room:room-runtime", version.ref = "room" } +room-compiler = { module = "androidx.room:room-compiler", version.ref = "room" } +room-testing = { module = "androidx.room:room-testing", version.ref = "room" } +androidx-datastore = { module = "androidx.datastore:datastore", version.ref = "datastore" } +androidx-datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastore" } +androidx-browser = { module = "androidx.browser:browser", version.ref = "browser" } +androidx-test-core = { module = "androidx.test:core", version.ref = "androidxTestCore" } +turbine = { module = "app.cash.turbine:turbine", version.ref = "turbine" } junit4 = { module = "junit:junit", version.ref = "junit4" } junit-bom = { module = "org.junit:junit-bom", version.ref = "junitBom" } junit-jupiter = { module = "org.junit.jupiter:junit-jupiter" } +junit-vintage-engine = { module = "org.junit.vintage:junit-vintage-engine" } junit-platform-launcher = { module = "org.junit.platform:junit-platform-launcher" } robolectric = { module = "org.robolectric:robolectric", version.ref = "robolectric" } roborazzi = { module = "io.github.takahirom.roborazzi:roborazzi", version.ref = "roborazzi" } @@ -77,6 +96,7 @@ android-gradle-plugin = { module = "com.android.tools.build:gradle", version.ref kotlin-gradle-plugin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" } compose-compiler-gradle-plugin = { module = "org.jetbrains.kotlin:compose-compiler-gradle-plugin", version.ref = "kotlin" } ksp-gradle-plugin = { module = "com.google.devtools.ksp:symbol-processing-gradle-plugin", version.ref = "ksp" } +room-gradle-plugin = { module = "androidx.room:room-gradle-plugin", version.ref = "room" } hilt-gradle-plugin = { module = "com.google.dagger:hilt-android-gradle-plugin", version.ref = "hilt" } detekt-gradle-plugin = { module = "io.gitlab.arturbosch.detekt:detekt-gradle-plugin", version.ref = "detekt" } ktlint-gradle-plugin = { module = "org.jlleitschuh.gradle:ktlint-gradle", version.ref = "ktlintGradle" } @@ -95,6 +115,7 @@ detekt = { id = "io.gitlab.arturbosch.detekt", version.ref = "detekt" } ktlint = { id = "org.jlleitschuh.gradle.ktlint", version.ref = "ktlintGradle" } roborazzi = { id = "io.github.takahirom.roborazzi", version.ref = "roborazzi" } baselineprofile = { id = "androidx.baselineprofile", version.ref = "benchmark" } +room = { id = "androidx.room", version.ref = "room" } # convention plugins (build-logic) aloha-android-application = { id = "aloha.android.application" } aloha-android-library = { id = "aloha.android.library" } @@ -102,5 +123,6 @@ aloha-android-compose = { id = "aloha.android.compose" } aloha-android-hilt = { id = "aloha.android.hilt" } aloha-android-feature = { id = "aloha.android.feature" } aloha-jvm-library = { id = "aloha.jvm.library" } +aloha-android-room = { id = "aloha.android.room" } aloha-screenshot = { id = "aloha.screenshot" } aloha-architecture = { id = "aloha.architecture" } diff --git a/gradle/verification-keyring.keys b/gradle/verification-keyring.keys index e1e42a20..47c6271f 100644 --- a/gradle/verification-keyring.keys +++ b/gradle/verification-keyring.keys @@ -3722,3 +3722,103 @@ byp2Ws27l1oNhMexztBivHz1OLxGJY8odrCtuxK3JMllDdln/HHdwrp6h7SDRdxR 6llX74zIdctZVsii8eJHvA== =LFgr -----END PGP PUBLIC KEY BLOCK----- + +pub 21939FF0CA2A6567 +uid Alex Herbert (CODE SIGNING KEY) + +sub A98BD25BE464EA45 +-----BEGIN PGP PUBLIC KEY BLOCK----- + +xsFNBFxmwqABEADNTTxqFiBcLLQwARbc0bmPUlxFl0A0Di9dTycUEjn0wTGS2xgF +dFxWomZd8R4b/lVb9jHf0r+AEul7U7sBoKinjwk0EuPDAZK5PEy3P8ILcAulwQqW +8lc+lnjGsmTG6GBecCQMEXeRPZv3DM4kUkljBFG7nDiFLNPfdSQqovZFTsQmmepA +EUu/t6y0GRrsbbTMipWJtVR+J4aGKX6kJlYgB2Nja1mbaTrI77KupK/VYzi6k6Kk +tzyxzqapJVDCLS4ypBH2JJLKSWWGghcgoVfeXtmB6iAki/nFNSRQODGru32lnLkU +0toprQkEh+TM8giT7Ph30VKlBqruNq43qxWZso0GYNrKxStvVB2+CA95oLAyROtG +6QrePLKkkgW1uQDN3e4iluPirLkd+QoZ4jJku44LyW/dJE63wGUKzlMIPZSb+joq +730rqovBSayI+snQjvJv2ImfO48yGsx0Gaojv+hKhgPTjKNzQo+QxqhWV3AWHjFn +j6vwSjDYkx45OSKEZSwfkr4AHHnvYMVb3sFuyM1a6/nQdhsGu5cc9mGvNKmXE71U +ArDBDq1w129pi3qttrCwxXdUdTE/PtnvQyaKlVX9lD5QLORD1Pis62p4t9CEr+x+ +BaZZdy7PeLAV8pobv7H7jpfhVWnb6SrLfhokA3Uy3gMyfcq9dmIs6iteKQARAQAB +tDVBbGV4IEhlcmJlcnQgKENPREUgU0lHTklORyBLRVkpIDxhaGVyYmVydEBhcGFj +aGUub3JnPs7BTQRcZsKgARAA26nkY8QpNQFu/NK31KQ7AkAzYQFBtnvHz2wKgxX0 +WtZ2zoDQaVBfXeoTvlKmMcSx/MULVFvcfzP7+4RHRINcwlDFFOr0iKSrRIOHLUhG +7/VZbDDN2agUOO0qTJplUj5bF4qfD6hAV+bIX7/K8QqaB7YB2K5D4RoSHRAKIOyc +HJc+Q4MAeXLdlWBCa2xx/3FiBdu0AF2gBaYc7KVdpEZYK6yAURC/j3rj0SVCSmDc +W07syOg2WckCRGfCWXJk6kRCnFRfeJJTKteUW3xUaYqHQ1yvd1GKduyzDlWKvb2D +sl5zyKQJortt/iXCGZUHv1DG9se81xViSTvvoKQfLG7sa4RgoZeotpBhlBOCWFO0 +XAwOmIGazxSSwLj/j+ecYVyOCZdDh3S5SUfcrYFofAeGeECtNyOag5tglQ1zli7W +9Grahi+M1qFJ4ZLHk8p0Teukb+gqMQEP6NZ+zeBrxv8ixjZHAgWTu0KQsX4ajk+/ +DqrRb2zl6DAA3f3ExYjSj9Ds2BIqsrLtOqw/cyQgEqKwBCz5lm3HHED9BchSooEc +PGMIx/jJalNI9hb7cP+aPgLMtk+f+Gh/DyfL8taZ5xUit6jxJQf1oKR445IW9IDC +hpcvHrLclcAAe7JxgsRe1+w3HSq6wd6XVmZMdFAlfuS04U1beXiHj4jFMED180yr +gwUAEQEAAcLBXwQYAQIACQUCXGbCoAIbDAAKCRAhk5/wyiplZ8j8EACytQj6GJMH +EYbBF+zvdmLMnnX35eXsI/pEFo83iI6yJMPrqFu9v0xMx0WYP95qSEhJYYrjed0K +GUO+/VZKI0fR9qtKC5+JdTN98vFTFuUtWK4x+1G8YlKZHjJETyWsjpScAt9C9HtL +aUk653Tu5qB788TCZDSp3uV824W8LgccQ5bTWMfslnAO9c8i4qx1e5ob8pdbWmTv +O8KFxZbPup45UXQ88XoyT0KvpVJSGzZ+0OFcRCk7XqQZGhdGhKpwGi/QQHikk5wn +w27W1L0SmdBAfafFj6KLVLDQO3DYu4OmPBUpsgW93PYfh4gaXYsPdNEPXHNDoXv7 +DJIJZWWEKasjoH8hUPLCHahB8I46nJeGL0Th9rCMa8wm4P/fWAaudy+u579nM4f6 +oz8lEs/IYCLzrnqQRqlca6JgUU6wpLr0d0diXIbIxYfh8uS6y+inl8uDd2nqmnwa +QHdHpSrPAWvUkuY2R7nd/iWDtt3i+dJgJVdpXaO9ItYYEl7jSn4RU6k/vHDQv1k9 +ABpc1kO4cUiT5IC3cj9ZFNgW8Rei23XRaFQ8eXcbaLhBwcXK8m00nNuZlMdGBdFu +PGiPNb66a9ruAOlc35TcCH52AWNFVPlPVio51JQ0V4kn16Tk6pIHQX9kgMBzZwkj +NblrOf6LII4Pqx0ip7s95q54JSpekQZ65g== +=idaz +-----END PGP PUBLIC KEY BLOCK----- + +pub 3FAAD2CD5ECBB314 +sub 3260CB2DEF74135B +-----BEGIN PGP PUBLIC KEY BLOCK----- + +xsFNBFhqdSMBEACmveOOsQrTky8b5M+Cq6lbhqRB4+INnfigxr7+EMpswo4AxYuA +Op/YG+G7NU5h6EK6Tj2dVfXga90GYFkehtFRZgOUJUGKPU/53upsbnsWS8qjJD8g +MvWpHbuhK6WsXGxjqWykAk8D2o2jfJEsUGeJhbG/12BoT87pjsUcZu7DkKilx6/L +WoM2/sirH2e4B1FLZvE7NCKpGttZv+vEI9oZmoKgm+ZHt4cSGOPrPtrAtf19irP1 +02/+kIPghmRd9ZwnK4xEazYe6mrY+8kQlrsSWFKTaWfvXQRJjyBJCuSwZCaWgMku +vP4P7SWTqGX471bdDhVbG8naGhil8aJjgZJlsOUZKYXUCMU6KVKf0f7qzDlJuIPx +4nrQ3lu2QvF9H9PCnj6pCx8tD+DJBq4nRi8kE2k3lAnpjZ5VpVuW+tSwsai50Son +ymZe5QZj9T5Nvy8tMkF4LwxA+2alWfvdHWRISuEO6jNwOuxHMtbprbD9KxY9Smd6 +YcRKKsLmKR8J6a5V7pELFTVGSLhSL2H+Z2j14fkswGE5vkxAQpGCfxQh7rbvrhw2 +lpx9OmvljnWFM7U26nfUG5tCp+ieE6pT76hcPZ5MPaqWl18Rk5dVJQhNZ3Gd52In +ai/y0v96pn8XZBRuNFULMb2PFG88hvU2M49Y8Rdi2VW/IfN3hIh2e4FT2wARAQAB +zsFNBFhqdSMBEACzwFoQH1MJLn3UYF+viqE8yw/CESTkU1aLoI5sXBSA4wIAGC5C +mI4kCvb/1xJEsIqtEJkNJSna3GgR8ov5NIJmx+MqqhemDKDNJS0IKvFkesNk/khd +t0zXF7wK9O6zY3XE6lh/usB8/34mHaR0WkU5Td4kCgEhFJQIeOfPKMaG83lrxiXe +ttRBIfmhldX+1LIRwoqYON+C0wqpfDtAeycYbOTCrjArUsYmiUkzhB23XdTive/+ +BUlvRL9ioHb+p5riHl7YfTl0vcqOKYdOfScb2d8lqgQZLtZoKzySdyIouWOriRQb +40I/UMjVuVtGyfuhWYkIH0rPwVwpABd5kGxkBkJlrSFGPx1/o2kOx24isexGM4WX +h56WB8K+KQMUtVEJHaSIU3fuwItcdIHoG1Xf6RXJHW9Wgw/MSZYJhDclVwfznHI2 +D5HFS+hRLKbAF1G1IVauXZBbXbOhcPyIAPwuTFdULhnPieu5ZGFetRfD9+t95rbu +pKMt54Lvx4cG8R27LvJL86X9KrhPm4WdsDL9lKs8riEUmTliZjmbTjZD9/trIcxP +QKHtfwtgoQnFm3aeMa7HO4lUo8KgEQiHqFbQQ4WaQruium13SlXTRgGGZuqdEtWE +MdTEIy+3c1STPR0CkoruBxlPCe/COf8XTn2h3EoyRWnNeNqudErVq34POwARAQAB +wsF2BBgBAgAJBQJYanUjAhsMACEJED+q0s1ey7MUFiEEtuc9hOpPzEcWYIclP6rS +zV7LsxQpKw//YzIs4eHJfxmxrPOBuST2N06dX1/gK93+5ArvxzfxHj+1+Ila0hsm +BFHm/Xxls7vjYAXBxjgfkL2/CZHwltTaWj5APz69lkWK7ZUuhGufKtMNrF9Gjv5S +wCtCXt09DDYRrOENqC7JsxVhjQmSsu7ULg6SYNhJ0Xe+MfXUAKdCnMaGn+TgX9n5 +yluljNDdcBNVixNyDAqTh05bodcxEcNkVlVV5K4A45fJe4rGBNxOD3adS2UBFp2g +qjGhoVLWv5NGL0dzFL/aAcQxRf+I9ejO0ZuHFxc+mvmnsV2SN43CtQfWQARQaGqa +nEsn8nrXlj6WPVqvm7ShnMxJx/86yaGi6Q+FqvT4ZsPmToWxlTUqHMiDDeozidOT +9FvGYBNWrcDkBleQeE5thHQmItJQf/Aa3PzpP9C7ImOj/FSpL3i1qdhaYOT9EZ3c +2qvRI7zpAC0p7LdK4WwqG7oHLUIRsqk2WDmQbEMVC/SrXN7fBTxplWqFX3Kf5oXz +d4IPWQlfyVWLoV/b1ktgKOekgqnWZKLThDga+7kDKib6XXK9Vi/pqiRgM4V7jj3N +/+5iTFL+qK9+oWj7ZDB2tWI82sNpJBeQ89PsREOGLD8qvn4EOx4ZZL91cn6N1K8V +bCSvsEa2cBXwSbD+0JRfuRvpa8CC4KDFkbU3Nb26dEvWPz+jpC3BnVI= +=xM5m +-----END PGP PUBLIC KEY BLOCK----- + +pub 59E05CE618187ED4 +uid Taro L. Saito (For GitHub Actions) + +sub 8857595B73BFD468 +-----BEGIN PGP PUBLIC KEY BLOCK----- + +xjMEYuRVGhYJKwYBBAHaRw8BAQdA2Dp4m1Yhtb1g94pQzzL24FuP6b9KXF8lP9Dh +hZnynhe0M1Rhcm8gTC4gU2FpdG8gKEZvciBHaXRIdWIgQWN0aW9ucykgPGxlb0B4 +ZXJpYWwub3JnPs44BGLkVRoSCisGAQQBl1UBBQEBB0Atu9kejBi+6wfOT0a9z/LY +EEdNXM/VX6xt1onKToPPdQMBCAfCeAQYFgoAIBYhBMHLp17JvQuvgGGTVFngXOYY +GH7UBQJi5FUaAhsMAAoJEFngXOYYGH7UlMABAKyRCazhVyUFg5FOpAnmckBY38Ca +MGPPLXVyY8Kr6dYFAP9wYLu7nsDZCOXkAgS+et4Pk1WZCggoYUkxsX1o0KZXBQ== +=UTdL +-----END PGP PUBLIC KEY BLOCK----- diff --git a/gradle/verification-metadata.xml b/gradle/verification-metadata.xml index 03d83327..e7d47e15 100644 --- a/gradle/verification-metadata.xml +++ b/gradle/verification-metadata.xml @@ -9,6 +9,11 @@ + + + + + @@ -23,11 +28,14 @@ + + + @@ -40,6 +48,7 @@ + @@ -50,7 +59,10 @@ - + + + + @@ -110,6 +122,7 @@ + @@ -118,22 +131,29 @@ + + + - + + + + + @@ -142,6 +162,7 @@ + @@ -167,6 +188,9 @@ + + + @@ -263,6 +287,9 @@ + + + @@ -341,6 +368,14 @@ + + + + + + + + @@ -428,6 +463,9 @@ + + + @@ -1009,6 +1047,9 @@ + + + @@ -1052,6 +1093,11 @@ + + + + + @@ -1080,6 +1126,9 @@ + + + @@ -1127,6 +1176,14 @@ + + + + + + + + @@ -1256,6 +1313,9 @@ + + + @@ -1340,6 +1400,14 @@ + + + + + + + + @@ -1360,6 +1428,9 @@ + + + @@ -1430,6 +1501,9 @@ + + + @@ -1646,6 +1720,9 @@ + + + @@ -1763,6 +1840,9 @@ + + + @@ -1920,6 +2000,11 @@ + + + + + @@ -1940,6 +2025,9 @@ + + + @@ -2834,6 +2922,11 @@ + + + + + @@ -3263,6 +3356,14 @@ + + + + + + + + @@ -3423,11 +3524,53 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -3436,6 +3579,14 @@ + + + + + + + + @@ -4010,6 +4161,11 @@ + + + + + diff --git a/scripts/capture-mastodon-fixtures.py b/scripts/capture-mastodon-fixtures.py new file mode 100644 index 00000000..6799939b --- /dev/null +++ b/scripts/capture-mastodon-fixtures.py @@ -0,0 +1,78 @@ +#!/usr/bin/env python3 +# SPDX-FileCopyrightText: 2026 Aloha Social contributors +# SPDX-License-Identifier: MIT +"""Capture mastodon.social's public discovery documents into the fixture corpus. + +Only documents without personal data are fetched. The instance's contact account is +replaced by a fictional one, so no real person's profile lands in the repository. +Run from the repository root: python3 scripts/capture-mastodon-fixtures.py +""" + +import json +import pathlib +import urllib.request + +HOST = "https://mastodon.social" +KEEP_HEADERS = {"content-type", "link", "cache-control", "etag"} +ROUTES = { + "instance-v1": "/api/v1/instance", + "instance-v2": "/api/v2/instance", + "nodeinfo-links": "/.well-known/nodeinfo", + "nodeinfo-2.0": "/nodeinfo/2.0", + "oauth-authorization-server": "/.well-known/oauth-authorization-server", +} +FICTIONAL_CONTACT = { + "id": "1", + "username": "admin", + "acct": "admin", + "display_name": "Instance admin", + "locked": False, + "bot": False, + "created_at": "2016-03-16T00:00:00.000Z", + "note": "", + "url": "https://mastodon.social/@admin", + "avatar": "", + "avatar_static": "", + "header": "", + "header_static": "", + "followers_count": 0, + "following_count": 0, + "statuses_count": 0, + "fields": [], + "emojis": [], +} + + +def fetch(path): + request = urllib.request.Request(HOST + path, headers={"Accept": "application/json"}) + with urllib.request.urlopen(request, timeout=20) as response: + headers = {k.lower(): v for k, v in response.headers.items() if k.lower() in KEEP_HEADERS} + return response.status, headers, json.loads(response.read().decode("utf-8")) + + +def scrub(body): + if isinstance(body, dict): + if "contact_account" in body: + body["contact_account"] = FICTIONAL_CONTACT + contact = body.get("contact") + if isinstance(contact, dict) and "account" in contact: + contact["account"] = FICTIONAL_CONTACT + contact["email"] = "admin@example.invalid" + if "email" in body: + body["email"] = "admin@example.invalid" + return body + + +def main(): + version = scrub(fetch("/api/v2/instance")[2])["version"].split()[0].split("+")[0] + target = pathlib.Path("core/testing/src/main/resources/fixtures") / f"mastodon-social-{version}" + target.mkdir(parents=True, exist_ok=True) + for name, path in ROUTES.items(): + status, headers, body = fetch(path) + fixture = {"request": {"method": "GET", "path": path}, "status": status, "headers": headers, "body": scrub(body)} + (target / f"{name}.json").write_text(json.dumps(fixture, indent=2, ensure_ascii=False) + "\n", encoding="utf-8") + print(f"{name}: {status}") + + +if __name__ == "__main__": + main()