> Markdown version of https://doc.octopuscommunity.com/SDK/sso/ — Octopus Developer Guide.
> Platform-specific sections are delimited by `tab:` / `/tab` HTML comments; only apply the block matching your platform.

# SDK Setup Guide

---
## Install the SDK
    

<!-- tab: Android -->

Octopus is available on Maven Central.
Add the dependencies to your `build.gradle` file:

```kotlin
dependencies {
    // Core SDK functionalities
    implementation("com.octopuscommunity:octopus-sdk:x.x.x")
    // SDK UI Components (optional)
    implementation("com.octopuscommunity:octopus-sdk-ui:x.x.x")
}
```

See the [Octopus SDK GitHub Release section](https://github.com/Octopus-Community/octopus-sdk-android/releases) to get the latest published version.

<!-- /tab -->

<!-- tab: iOS -->

Octopus can be installed: 

<details>
<summary>In an Xcode project</summary>

Open your workspace (`.xcworkspace`) or your project (`.xcodeproj`), open the `File` menu and open `Add Package Dependencies`. Then, paste the url of the Octopus Community SDK:
`https://github.com/Octopus-Community/octopus-sdk-swift.git` 

On the next window, add both `Octopus` and `OctopusUI` to your target.
</details>
OR
<details>
<summary>In a Swift Package</summary>

Add the dependency to your package:
```swift
dependencies: [
    .package(url: "https://github.com/Octopus-Community/octopus-sdk-swift.git", from: "1.0.0"),
],
```

Add the SDK as a dependency to your target:
```swift 
.target(
    name: "YourTarget",
    dependencies: [
        .product(name: "Octopus", package: "octopus-sdk-swift"),
        .product(name: "OctopusUI", package: "octopus-sdk-swift"),
    ]
),
```
</details>

<details>
    <summary>Not recommended: Cocoapods</summary>

As CocoaPods is starting to be less and less used, some libraries are not available anymore. This is the case of SwiftGrpc, which the Octopus SDK's backend relies on. Hence, if you use the SDK using Cocoapods, you will be using an old version of SwiftGrpc.

If you **really** have to use the SDK with Cocoapods, here is how to do it:
- Copy the [content of our podfile example](https://github.com/Octopus-Community/octopus-sdk-swift/blob/main/CocoaPodsValidationApp/Podfile) in your podfile. Pay attention to the fact that the post_install script is setting iOS 14 as minimum requirement and setting `ENABLE_USER_SCRIPT_SANDBOXING` to `NO`.
- Then you can run `pod install` 
</details>

See the [Octopus SDK GitHub Release section](https://github.com/Octopus-Community/octopus-sdk-swift/releases) to get the latest published version.

<!-- /tab -->

<!-- tab: Flutter -->

The Octopus Flutter SDK is available on [pub.dev](https://pub.dev/packages/octopus_sdk_flutter).

Add the dependency to your `pubspec.yaml`:

```yaml
dependencies:
  octopus_sdk_flutter: ^1.13.1
```

Then run:
```bash
flutter pub get
```

<details>
<summary>Android setup</summary>

In your `android/app/build.gradle`, make sure you have:
```groovy
android {
    compileSdk 35
    defaultConfig {
        minSdk 24
    }
}
```

In your `AndroidManifest.xml`, add the `INTERNET` permission if not already present:
```xml
<uses-permission android:name="android.permission.INTERNET"/>
```

In your `MainActivity.kt`, make sure your activity extends `FlutterFragmentActivity`:
```kotlin
import io.flutter.embedding.android.FlutterFragmentActivity

class MainActivity : FlutterFragmentActivity()
```
</details>

<details>
<summary>iOS setup</summary>

In your `ios/Podfile`, set the minimum iOS version:
```ruby
platform :ios, '14.0'
```

The plugin ships for both Swift Package Manager and CocoaPods, so it integrates either way.

**Swift Package Manager (default)**

Since Flutter 3.44, Swift Package Manager is enabled by default and the plugin is resolved through SPM automatically. No extra step is required beyond the deployment target above — `flutter pub get` is enough.

If you had previously turned SPM off, re-enable it with:
```bash
flutter config --enable-swift-package-manager
```

**CocoaPods (fallback)**

If your app is not using Swift Package Manager, the plugin is resolved through CocoaPods instead. Run:
```bash
cd ios && pod install
```

:::warning
If you encounter a gRPC conflict, add the following to your `Podfile`:
```ruby
pod 'gRPC-Swift', :modular_headers => true
```
:::
</details>

See the [Octopus Flutter SDK GitHub repository](https://github.com/Octopus-Community/octopus-sdk-flutter) to get the latest published version.

<!-- /tab -->

<!-- tab: React Native -->

Install the package using npm or yarn:

```bash
npm install @octopus-community/react-native
```

<details>
<summary>iOS setup</summary>

Add `use_frameworks! :linkage => :static` to your `ios/Podfile`, then run:

```bash
cd ios && pod install
```

:::warning
Xcode 16+ is required.
:::
</details>

<details>
<summary>Android setup</summary>

In your `android/app/build.gradle`, make sure you have:

```groovy
android {
    compileSdk 35
    defaultConfig {
        minSdk 24
    }
}
```

Kotlin 2.x is required.
</details>

See the [Octopus React Native SDK repository](https://github.com/Octopus-Community/octopus-sdk-react-native) for the latest version and full setup instructions.

<!-- /tab -->

<!-- tab: Unity -->

Octopus SDK for Unity is available via **Unity Package Manager (UPM)** or as a legacy `.unitypackage` file.

<details>
<summary>Unity Package Manager (recommended)</summary>

Add the Octopus SDK and the External Dependency Manager to your `Packages/manifest.json`:
```json
{
  "dependencies": {
    "com.google.external-dependency-manager": "https://github.com/googlesamples/unity-jar-resolver.git?path=upm#v1.2.187",
    "com.octopuscommunity.octopus_sdk_for_unity": "https://github.com/Octopus-Community/octopus-sdk-unity.git?path=UnityPackage"
  }
}
```
</details>

<details>
<summary>Legacy .unitypackage</summary>

1. Download the [External Dependency Manager](https://github.com/googlesamples/unity-jar-resolver/blob/master/external-dependency-manager-latest.unitypackage).
2. Download [OctopusCommunitySDK.unitypackage](https://raw.githubusercontent.com/Octopus-Community/octopus-sdk-unity/refs/heads/main/OctopusCommunitySDK.unitypackage).
3. Import both files into your Unity project.
</details>

**Minimum Unity version:** 2019.4. **Build targets:** Android, iOS.

See the [Octopus SDK for Unity GitHub repository](https://github.com/Octopus-Community/octopus-sdk-unity) for the latest release.

<!-- /tab -->

---
## Use the SDK

As early as possible in your code, you should initialize the `OctopusSDK` object.

This object is expecting two things:
- the Octopus Community API key
- the connection mode

<!--
:::warning
*You are reading the SSO connection mode documentation, if your community is configured to use Octopus Authentication, please open the documentation for [this mode](/SDK/octopus-auth/android#use-the-sdk) (Android) or [this mode](/SDK/octopus-auth/ios#use-the-sdk) (iOS).*
:::
-->

You need to know the app managed fields (also called associated fields) that your community is configured for.

As a reminder, **every associated profile field** (nickname, picture and/or bio) of your users **will be used in the Octopus Community profile of this user**. 
The user will only be able to change it in your profile edition interface and the data will be **synced** to its community profile.<br/>
On the opposite, **every dissociated profile fields will only be used as prefilled values during Octopus profile creation**. 
After that, if a user changes its nickname in your app, it won't be reflected in Octopus Community, and the user will be able to change its community nickname in the community part.

<!-- tab: Android -->

<details>
<summary>At least one app managed field</summary>

If your community has at least one associated field, you will have to create the SSO connection mode with the list of the associated fields.

Call the OctopusSDK initialization function in your Application's `onCreate()` block:

```kotlin
class YourApplication : Application() {

    override fun onCreate() {
        super.onCreate()
            
        OctopusSDK.initialize(
            context = this, // Application Context
            apiKey = "YOUR_API_KEY",
            connectionMode = ConnectionMode.SSO(
                // The list of associated fields
                appManagedFields = setOf(ProfileField.NICKNAME, ProfileField.PICTURE)
            )
        )
    }
}
```

</details>

OR

<details>
<summary>No app managed fields</summary>

When there is no app managed fields (i.e. all fields are dissociated), the API is simpler since you only have to configure it in SSO connection mode.

Call the OctopusSDK initialization function in your Application's `onCreate()` block:

```kotlin
class YourApplication : Application() {

    override fun onCreate() {
        super.onCreate()
            
        OctopusSDK.initialize(
            context = this, // Application Context
            apiKey = "YOUR_API_KEY",
            connectionMode = ConnectionMode.SSO()
        )
    }
}
```

</details>

The `initialize` function of `OctopusSDK` also accepts an optional configuration parameter:
- `apiServer: ApiServer?` (≥ 1.12.0): Custom server endpoint the SDK targets. When `null` (default), the SDK uses the Octopus default endpoint. Use this to route SDK traffic through a corporate WAF, a reverse-proxy, or an alternate environment.

    `ApiServer(host, port)` is throwing — `host` is validated at construction time. Accepted host forms: DNS name (`"api.example.com"`), IPv4 literal (`"192.0.2.10"`), or IPv6 literal — bracketed (`"[::1]"`) or unbracketed (`"::1"`). The host must not contain a scheme (`https://`), an embedded port (`:443`), a path, or whitespace. The default `port` is `443`. On validation failure, the constructor throws `ApiServer.ValidationError` (a sealed `IllegalArgumentException` subclass with cases `EmptyHost`, `HostContainsScheme`, `HostContainsPortOrPath`, `HostContainsWhitespace`, `InvalidIPv6Bracketing` — read `message` to diagnose).

Here is how to use it during SDK initialization:
```kotlin
import com.octopuscommunity.sdk.ApiServer
import com.octopuscommunity.sdk.OctopusSDK

OctopusSDK.initialize(
    context = this,
    apiKey = "YOUR_API_KEY",
    connectionMode = ConnectionMode.SSO(),
    // Optional — omit to use the Octopus default endpoint.
    apiServer = ApiServer(host = "api.example.com")
)
```

:::info
Pass `apiServer` only when you need to point the SDK at a non-default environment — otherwise leave the default.
:::

<!-- /tab -->

<!-- tab: iOS -->

<details>
<summary>At least one app managed field</summary>

If your community has at least one associated field, you will have to create the SSO connection mode with:
- the list of the associated fields
- the block that will be called when OctopusSDK needs a logged in user. When this block is called, you should start to display your login process.
- the block that will be called when the user tries to modify some fields related to its profile. When this block is called, you should open the profile edition. This block has a ProfileField optional parameter. It indicates the field that the user tapped to edit if there is one.

```swift 
import Octopus

/* (...) */

let octopus = try OctopusSDK(
    apiKey: "YOUR_API_KEY", 
    connectionMode: .sso(
      .init(
        appManagedFields: [.nickname, .picture], // the list of associated fields
        loginRequired: { 
            // Put the code here to open your login flow
        },
        modifyUser: { fieldToEdit in
            // Put the code here to open your profile edition screen
            // `fieldToEdit` is the field that has been asked to be edited by the user. Nil if the user tapped on "Edit my profile".
        }
      )
    )
)
```

</details>

OR

<details>
<summary>No app managed fields</summary>

When there is no app managed fields (i.e. all fields are dissociated), the API is simpler since it only requires a callback to display the login flow. 
When this block is called, you should start to display your login process.

```swift 
import Octopus

/* (...) */

let octopus = try OctopusSDK(
    apiKey: "YOUR_API_KEY", 
    connectionMode: .sso(
      .init(
        loginRequired: { 
            // Put the code here to open your login flow
        }
      )
    )
)
```
</details>

The `init` function of the OctopusSDK also lets you provide a custom configuration (default config is used if you don't pass it). Here is what you can configure:
- `appManagedAudioSession: Bool`: If false, the SDK will set the AVAudioSession category to .playback or .ambient when a video is playing to ensure audio plays in silent mode. Default is false. You can set it to true if your app is already managing audio session, to avoid that Octopus changes your config.
- `apiServer: OctopusSDK.Configuration.ApiServer?` (≥ 1.12.0): Custom server endpoint the SDK targets. When `nil` (default), the SDK uses the Octopus default endpoint. Use this to route SDK traffic through a corporate WAF, a reverse-proxy, or an alternate environment.

    `ApiServer(host:port:)` is throwing — `host` is validated at construction time. Accepted host forms: DNS name (`"api.example.com"`), IPv4 literal (`"192.0.2.10"`), or IPv6 literal — bracketed (`"[::1]"`) or unbracketed (`"::1"`). The host must not contain a scheme (`https://`), an embedded port (`:443`), a path, or whitespace. The default `port` is `443`. On validation failure, the initializer throws an `OctopusSDK.Configuration.ApiServer.ValidationError` whose `debugDescription` describes the problem.

Here is how to use the config during SDK initialization:
```swift
import Octopus

let octopus = try OctopusSDK(
    apiKey: "YOUR_API_KEY",
    connectionMode: ..., // see above to chose the connection mode
    configuration: OctopusSDK.Configuration(
        // Optional — omit to use the Octopus default endpoint.
        apiServer: try .init(host: "api.example.com"),
        appManagedAudioSession: true
    )
)
```

:::info
Pass `apiServer` only when you need to point the SDK at a non-default environment — otherwise leave the default.
:::

<!-- /tab -->

<!-- tab: Flutter -->

<details>
<summary>At least one app managed field</summary>

If your community has at least one associated field, you will have to create the SSO connection mode with the list of the associated fields.

Initialize the SDK as early as possible in your app:

```dart
import 'package:octopus_sdk_flutter/octopus_sdk_flutter.dart';

final octopus = OctopusSDK();

await octopus.initialize(
    apiKey: 'YOUR_API_KEY',
    appManagedFields: [ProfileField.nickname, ProfileField.picture],
);
```

</details>

OR

<details>
<summary>No app managed fields</summary>

When there is no app managed fields (i.e. all fields are dissociated), the API is simpler since you only have to configure it in SSO connection mode.

Initialize the SDK as early as possible in your app:

```dart
import 'package:octopus_sdk_flutter/octopus_sdk_flutter.dart';

final octopus = OctopusSDK();

await octopus.initialize(
    apiKey: 'YOUR_API_KEY',
);
```

</details>

The `initialize` function also accepts an optional configuration parameter:
- `apiServer: ApiServer?` (≥ 1.12.0): Custom server endpoint the SDK targets. When `null` (default), the SDK uses the Octopus default endpoint over TLS. Use this to route SDK traffic through a corporate WAF, a reverse-proxy, or an alternate environment.

    `ApiServer(host:, port:)` is throwing — `host` is validated at construction time. Accepted host forms: DNS name (`"api.example.com"`), IPv4 literal (`"192.0.2.10"`), or IPv6 literal — bracketed (`"[::1]"`) or unbracketed (`"::1"`). The host must not contain a scheme (`https://`), an embedded port (`:443`), a path, or whitespace. The default `port` is `443`. On validation failure, the constructor throws an `ApiServerValidationError` (an `ArgumentError` subclass); inspect its `kind` to diagnose.

Here is how to use it during SDK initialization:
```dart
import 'package:octopus_sdk_flutter/octopus_sdk_flutter.dart';

final octopus = OctopusSDK();

await octopus.initialize(
    apiKey: 'YOUR_API_KEY',
    // Optional — omit to use the Octopus default endpoint.
    apiServer: ApiServer(host: 'api.example.com'),
);
```

:::info
Pass `apiServer` only when you need to point the SDK at a non-default environment — otherwise leave the default.
:::

:::warning
**Upgrading from < 1.11.0 with `ProfileField.picture`:** A bug in versions before 1.11.0 caused `ProfileField.picture` to be silently dropped when passed in `appManagedFields`, so users could still edit their profile picture inside the Octopus UI. This is fixed in 1.11.0 — the field is now correctly locked. If your app passes `ProfileField.picture` in `appManagedFields`, **re-test your profile flows after upgrading**: picture edits will now be blocked in the Octopus UI and routed through the `onModifyUser` callback instead.
:::

<!-- /tab -->

<!-- tab: React Native -->

<details>
<summary>At least one app managed field</summary>

If your community has at least one associated field, you will have to create the SSO connection mode with the list of the associated fields.

Initialize the SDK as early as possible in your app (e.g. in `App.tsx` before any navigation):

```typescript
import { initialize } from '@octopus-community/react-native';

await initialize({
    apiKey: 'YOUR_API_KEY',
    connectionMode: {
        type: 'sso',
        appManagedFields: ['username', 'profilePicture'],
    },
});
```

</details>

OR

<details>
<summary>No app managed fields</summary>

When there is no app managed fields (i.e. all fields are dissociated), the API is simpler since you only have to configure it in SSO connection mode.

Initialize the SDK as early as possible in your app:

```typescript
import { initialize } from '@octopus-community/react-native';

await initialize({
    apiKey: 'YOUR_API_KEY',
    connectionMode: {
        type: 'sso',
        appManagedFields: [],
    },
});
```

</details>

<!-- /tab -->

<!-- tab: Unity -->

Call `Initialize` as early as possible — for example in a `MonoBehaviour.Start()`:

```csharp
// With app-managed profile fields
OctopusSDK.Initialize("YOUR_API_KEY",
    ConnectionMode.SSO(
        ProfileField.NICKNAME
        // ,ProfileField.BIO
        // ,ProfileField.PICTURE
    )
);

// Without app-managed profile fields
OctopusSDK.Initialize("YOUR_API_KEY", ConnectionMode.SSO());
```

The `Initialize` function also accepts two optional parameters:
- `apiServerHost: string` (≥ 1.12.2) — Custom gRPC server host (host only, no scheme or port, e.g. `"api.your-environment.example"`). When `null` or empty (default), the SDK targets the default Octopus production endpoint. Only set this if Octopus has given you a dedicated environment host.
- `apiServerPort: int` (≥ 1.12.2) — Port for the custom host. Defaults to `443`. Ignored when no host is set. Traffic is always over TLS.

```csharp
OctopusSDK.Initialize(
    "YOUR_API_KEY",
    ConnectionMode.SSO(),
    // Optional — omit to use the Octopus default endpoint.
    apiServerHost: "api.your-environment.example",
    apiServerPort: 443
);
```

:::info
Pass `apiServerHost` only when you need to point the SDK at a non-default environment — otherwise leave the defaults.
:::

:::tip Develop without a device build
In the Unity Editor, the SDK runs against a built-in mock backend (≥ 1.12.1) so you can integrate and write EditMode tests without building to a device. See [Editor Mock Mode for Unity](/SDK/sso/unity-editor-mock-mode).
:::

<!-- /tab -->

---
## Link your user to the SDK

    Your application is managing the connection status of the user and inform the Octopus SDK when the user is connected/disconnected

    The `connectUser` API lets you pass the `userId`. This userId is the string that will identify your user for Octopus.
     It needs to be unique and always refer to the same user.

    You can also pass profile information (nickname, bio, picture). These fields will be used differently depending on
    whether the field is associated (i.e. in the app managed fields that you provided during SDK initialization) or not:

    - if the field is associated, each time the connectUser function will be called, Octopus will update the field in the
     user's community profile with the data you provided. Hence, the field in the community profile of the user will always be the same value as in its app profile

    - if the field is not associated, the data you provided will only be used to fill the community profile until the user
    edits its community profile. Once its done, their community profile remains separate from yours, meaning any updates
    made to your profile will not be reflected in theirs, and vice versa.

    This is why **you should call the `connectUser` function as soon as the user profile changes**.

    #### Client User Token

    For security reasons, to ensure that the connected user is legitimate, the API informing the SDK of a user’s connection
     includes a callback that provides a signed token for authentication. In other words, the SDK will request a token from
      you when needed to authenticate the user.
    Therefore, you must add a route like `/generateOctopusSsoToken` to your backend to generate this token.
     Follow the [Generate a signed JWT for SSO guide](/backend/sso) for more information

<!-- tab: Android -->

   
    
Inform the SDK that your user is connected:

(≥ 1.6.0)

```kotlin
OctopusSDK.connectUser(
    user = ClientUser(
        userId = yourUserId, // Unique identifier of your user
        profile = ClientUser.Profile(
            nickname = yourUserNickname, // nickname is String?
            bio = yourUserBio, // bio is String?
            picture = yourUserPicture // A Remote URL or a Local Uri
        )
    ),
    tokenProvider = {
        // Fetch asynchronously this user token (suspended callback)
        // by calling your /generateOctopusSsoToken route
    }
)
```
:::tip
Check the [Community ViewModel Sample](https://github.com/Octopus-Community/octopus-sdk-android/blob/main/samples/src/main/java/com/octopuscommunity/sample/MainViewModel.kt) for a complete use case.
:::

<details>
<summary>Deprecated (< 1.6.0)</summary>

```kotlin
OctopusSDK.connectUser(
    user = ClientUser(
        userId = yourUser.id,
        profile = ClientUser.Profile(
            nickname = yourUser.name,
            bio = yourUser.bio,
            picture = yourUser.picture,
            // Age Information:
            // - LegalAgeReached = Your user is more than 16 years old
            // - Underaged = Your user is less than 16 years old
            // - null = You don't know
            ageInformation = AgeInformation.LegalAgeReached
        )
    ),
    tokenProvider = {
        // Return asynchronously this user token (suspended callback)
        // by calling your /generateOctopusSsoToken route
    }
)
```
</details>

- Inform the SDK that your user is disconnected:
```kotlin
// suspend function — call from a coroutine
OctopusSDK.disconnectUser()
```

- Optional: Monitor whether the user is connected to the Octopus platform:
```kotlin
// Flow<Boolean>
OctopusSDK.isUserConnected.collect { isConnected ->
    // Update your host-side UI
}
```

:::info Collecting before `initialize()` (≥ 1.13.2)
Every observable `OctopusSDK` exposes — `isUserConnected`, `connectionState`, `profile`, `groups`, `events`, `notSeenNotificationsCount`, `hasAccessToCommunity` — can be collected before `OctopusSDK.initialize()` has run. The Flow stays silent until the SDK is initialized, then emits the real state, and re-binds itself across a `switchCommunity()`. No ordering guard is needed on your side, so a collector can safely live in a `ViewModel` or a repository created before your `Application.onCreate()` finishes.

On **earlier versions** a collector registered before `initialize()` bound to the SDK's preview fakes for its whole lifetime and never reflected the real user, so the collection had to be gated:

```kotlin
// Only needed below 1.13.2
OctopusSDK.isInitialisedFlow.filter { it }
    .flatMapLatest { OctopusSDK.isUserConnected }
    .collect { isConnected -> /* … */ }
```
:::

    

<!-- /tab -->

<!-- tab: iOS -->

    
Inform the SDK that your user is connected:

(≥ 1.11.0)

```swift
do {
    try await octopus.connectUser(
        ClientUser(
            userId: yourUser.id,
            profile: ClientUser.Profile(
                nickname: yourUser.name,   // nickname is String?
                bio: yourUser.bio,         // bio is String?
                picture: yourUser.picture  // picture is Data?, this Data will be transformed into an UIImage using `UIImage(data:)` so it must be compatible.
            )
        ),
        tokenProvider: {
            // Fetch asynchronously this user token
            // by calling your /generateOctopusSsoToken route
        }
    )
} catch {
    // handle the error
}
```

<details>
<summary>Deprecated (< 1.11.0)</summary>

```swift
octopus.connectUser(
    ClientUser(
        userId: yourUser.id,
        profile: ClientUser.Profile(
            nickname: yourUser.name,   // nickname is String?
            bio: yourUser.bio,         // bio is String?
            picture: yourUser.picture  // picture is Data?, this Data will be transformed into an UIImage using `UIImage(data:)` so it must be compatible.
        )
    ),
    tokenProvider: {
        // Fetch asynchronously this user token
        // by calling your /generateOctopusSsoToken route
    }
)
```
</details>

<details>
<summary>Deprecated (< 1.6.0)</summary>

```swift
octopus.connectUser(
    ClientUser(
        userId: yourUser.id,
        profile: ClientUser.Profile(
            nickname: yourUser.name,
            bio: yourUser.bio,
            picture: yourUser.picture,
            ageInformation: .legalAgeReached // if your user is more than 16 years old. Pass .underaged if they are less than 16. Pass nil if you don't know
        )
    ),
    tokenProvider: {
        // Fetch asynchronously this user token
        // by calling your /generateOctopusSsoToken route
    }
)
```
</details>

Inform the SDK that your user is disconnected:

(≥ 1.11.0)

```swift
do {
    try await octopus.disconnectUser()
} catch {
    // Disconnection failed (typically a network or server issue).
    // You can retry or ignore — the local session state is cleared regardless.
}
```

<details>
<summary>Deprecated (< 1.11.0)</summary>

```swift
octopus.disconnectUser()
```
</details>

<!-- /tab -->

<!-- tab: Flutter -->

Inform the SDK that your user is connected. Provide a `tokenProvider`
callback — the SDK calls it whenever it needs a signed token to
authenticate the user.

(≥ 1.13.0) `connectUser` and `connectUserWithTokenProvider` return `OctopusResult<void, ClientUserError>` and are annotated `@useResult`, so ignoring the result is an analyzer warning. Before 1.13.0 they returned `Future<void>` and reported success even when the connection had been refused. Your existing call sites keep compiling — `void` is a top type — so handle the result to stop a refused login from looking like a successful one. For a call whose outcome you really do not need, discard it explicitly with `final _ = await octopus.connectUser(…)` (a non-binding `_` needs Dart 3.7+; below that, `// ignore: unused_result` on the line above). What *does* break at compile time: a class that **overrides** `connectUser` / `connectUserWithTokenProvider` while still declaring `Future<void>` — a hand-written wrapper, or your own `OctopusSDKPlatform` fake — now fails with `invalid_override`. Widen the return type to `Future<OctopusResult<void, ClientUserError>>`. Mocks that never redeclare the method are unaffected.

(≥ 1.13.0) **On iOS the returned `Future` now waits for the attempt.** It used to resolve within milliseconds, before the native connect had done anything — which is why it could not report a refusal. Android already awaited, so the two agree now. Nothing to change in your code, but if you `await` this on a blocking path (a splash screen, a route guard, a `FutureBuilder` gating the first frame) that wait is now as long as the network takes, and up to **60 s** if your `tokenProvider` never answers. Show a progress state around the call, or don't await it and react when the result lands.

(≥ 1.13.0)

```dart
final result = await octopus.connectUser(
    userId: yourUserId, // Unique identifier of your user
    nickname: yourUserNickname, // String? - optional
    bio: yourUserBio, // String? - optional
    picture: yourUserPicture, // String? - A Remote URL
    tokenProvider: () async {
        // Fetch asynchronously this user token
        // by calling your /generateOctopusSsoToken route
        return token;
    },
);
switch (result) {
    case OctopusSuccess():
        // Connected. `OctopusSDK.connectionState` emits independently.
        break; // required: an empty case body falls into the next one
    case OctopusInvalidArguments<OctopusServerError>(:final errors):
        // The connection was refused. The explicit `<OctopusServerError>`
        // argument is what makes this switch exhaustive — the analyzer builds
        // the sealed space from each subtype's declared bound, so naming the
        // narrower leaf type here is NOT exhaustive. `errors` then binds as
        // `List<OctopusServerError>` — narrow it to the SSO leaves:
        for (final error in errors.whereType<ClientUserError>()) {
            // ClientUserBannedError (errorMessage is displayable), ClientUserProfileError,
            // ClientUserOtherError — reported by both platforms;
            // ClientUserMissingTokenError — Android only;
            // ClientUserInvalidTokenError, ClientUserCommunityAccessDeniedError — iOS only.
            // Switch on the leaves exhaustively, with NO `default`: the analyzer
            // reports an unreachable one as a warning, and `flutter analyze`
            // fails on warnings. A wire error the SDK does not recognise arrives
            // as ClientUserOtherError, so this set cannot grow under a running
            // host — a future SDK version adding a leaf gives you a compile
            // error at upgrade time, which is the signal you want.
            // Surface error.errorMessage through your app's own error UI.
        }
    case OctopusConnectionFailure():
        // Transport/auth failure
}
```

(1.12.2 – 1.12.3) the
same named arguments apply, but the call returns `Future<void>`: drop the
`final result =` and the `switch`.

:::warning
On iOS, a token refused on a **first** connect does not surface here: the native SDK falls back to a guest connection and returns normally, so `connectUser` reports success while the user browses anonymously. Confirm the outcome with `OctopusSDK.connectionState`, which emits `OctopusConnected(isGuest: true)` in that case, or with `OctopusSDK.isUserConnected`. Android reports every refusal.
:::

<details>
<summary>Deprecated (< 1.12.2)</summary>

```dart
await octopus.connectUserWithTokenProvider(
    userId: yourUserId, // Unique identifier of your user
    nickname: yourUserNickname, // String? - optional
    bio: yourUserBio, // String? - optional
    picture: yourUserPicture, // String? - A Remote URL
    tokenProvider: () async {
        // Fetch asynchronously this user token
        // by calling your /generateOctopusSsoToken route
        return token;
    },
);
```

Still works, but is deprecated — call `connectUser(tokenProvider: ...)` instead (identical behavior).
</details>

:::warning
Always use a `tokenProvider` rather than a pre-minted static token. SSO JWTs
expire; the SDK re-invokes the provider when it needs to re-authenticate the
user (for example when refreshing entitlements), so it must be able to return
a freshly signed token each time it is called.
:::

Inform the SDK that your user is disconnected:
```dart
await octopus.disconnectUser();
```

<!-- /tab -->

<!-- tab: React Native -->

First, set up a token provider. In React components, use the `useUserTokenProvider` hook:

```typescript
import { useUserTokenProvider, connectUser, disconnectUser } from '@octopus-community/react-native';

function App() {
    useUserTokenProvider(async () => {
        // Fetch the user token from your backend
        // by calling your /generateOctopusSsoToken route
        const response = await fetch('https://your-backend.com/generateOctopusSsoToken');
        const { token } = await response.json();
        return token;
    });

    // ...
}
```

Outside of React components, use `addUserTokenRequestListener` instead:

```typescript
import { addUserTokenRequestListener } from '@octopus-community/react-native';

const subscription = addUserTokenRequestListener(async () => {
    const response = await fetch('https://your-backend.com/generateOctopusSsoToken');
    const { token } = await response.json();
    return token;
});

// Later, to unsubscribe:
subscription.remove();
```

Inform the SDK that your user is connected:

```typescript
await connectUser({
    userId: yourUserId, // Required — unique identifier of your user
    profile: {
        username: yourUserNickname, // string | undefined
        biography: yourUserBio, // string | undefined
        profilePicture: yourUserPicture, // string | undefined — a remote URL or local file path
    },
});
```

:::warning
**Breaking change in v1.9:** The `legalAgeReached` field has been removed from the `profile` object. If your code was passing `legalAgeReached`, remove it when upgrading to v1.9.
:::

Inform the SDK that your user is disconnected:

```typescript
await disconnectUser();
```

<!-- /tab -->

<!-- tab: Unity -->

Inform the SDK when your user signs in:

```csharp
await OctopusSDK.ConnectUser(userId, nickname, bio, picture, GetToken);

async Task<string> GetToken()
{
    // Contact your backend to obtain a signed JWT
    return "signed_jwt_from_your_backend";
}
```

Inform the SDK when your user signs out:

```csharp
await OctopusSDK.DisconnectUser();
```

When using app-managed profile fields, handle edit requests from the community UI:

```csharp
OctopusSDK.OnModifyUser += (ProfileField? field) =>
{
    // Open your own profile editor
};
```

If your SSO setup uses forced login (contact us for this setting), handle the login-required event:

```csharp
OctopusSDK.OnLoginRequired += () =>
{
    // Trigger your app's sign-in flow, then call ConnectUser
};
```

:::warning Token provider threading (≥ 1.12.2)
The `tokenProvider` (`GetToken`) callback may be invoked on a **background thread** while the Octopus UI is open and the game loop is suspended — the SDK requests a fresh token mid-session when the JWT expires. The callback must complete without the Unity player loop: use loop-independent I/O (`System.Net.Http`), **not** `UnityWebRequest`, and do not await continuations that marshal back to the main thread. This applies on both iOS and Android since 1.12.2 (iOS game loop is now also suspended while the Octopus UI is open).
:::

<!-- /tab -->

### Read connected-user entitlements (≥ 1.12.0) {#read-connected-user-entitlements}

The SDK exposes the entitlements held by the connected user as an observable. Entitlement identifiers are opaque tokens defined by your backend and injected via the SSO JWT — the SDK does **not** intersect them against per-group requirements client-side. Group access decisions are pre-resolved server-side and surfaced via `OctopusGroup.canAccess` (see [Gate access to locked groups](#gate-access-to-locked-groups)). Use the entitlement set only for your own host-side UI (badges, feature gating, etc.).

The profile is `null` until a user is connected.

<!-- tab: Android -->

```kotlin
OctopusSDK.profile.collect { profile ->
    val entitlements: Set<String> = profile?.entitlements ?: emptySet()
    // Update your host-side UI based on the held entitlements
}
```

<!-- /tab -->

<!-- tab: iOS -->

```swift
octopus.$profile
    .sink { profile in
        let entitlements: Set<String> = profile?.entitlements ?? []
        // Update your host-side UI based on the held entitlements
    }
```

<!-- /tab -->

<!-- tab: Flutter -->

```dart
OctopusSDK.profile.listen((profile) {
    final Set<String> entitlements = profile?.entitlements ?? <String>{};
    // Update your host-side UI based on the held entitlements
});
```

<!-- /tab -->

<!-- tab: React Native -->

(≥ 1.13.0)

```typescript
import { addProfileListener } from '@octopus-community/react-native';

const subscription = addProfileListener((profile) => {
    const entitlements: string[] = profile?.entitlements ?? [];
    // Update your host-side UI based on the held entitlements
});

// Later, to unsubscribe:
subscription.remove();
```

The current profile is replayed immediately to a listener added after the SDK already
published one. A synchronous `getProfile()` is also available for a one-off read.

<!-- /tab -->

<!-- tab: Unity -->

*Entitlements are not yet available on Unity.*

<!-- /tab -->

### Detect a guest session (≥ 1.12.6) {#detect-a-guest-session}

Tell an anonymous **guest** apart from a real, authenticated user. In forced-login communities a guest session is re-established right after `disconnectUser()`, so the connected user becomes non-`null` again without the user having authenticated — check this flag to gate real-user features and avoid treating a guest as a signed-in user.

<!-- tab: Android -->

```kotlin
OctopusSDK.connectionState.collect { state ->
    val isGuest = (state as? ConnectionState.Connected)?.isGuest ?: false
    // Gate real-user features on !isGuest
}
```

<!-- /tab -->

<!-- tab: iOS -->

```swift
octopus.$profile
    .sink { profile in
        let isGuest = profile?.isGuest ?? true
        // Gate real-user features on !isGuest
    }
```

<!-- /tab -->

<!-- tab: Flutter -->

```dart
OctopusSDK.connectionState.listen((state) {
  final isGuest = state is OctopusConnected ? state.isGuest : false;
  // Gate real-user features on !isGuest
});
```

<!-- /tab -->

<!-- tab: React Native -->

(≥ 1.13.0)

```typescript
import { addConnectionStateListener } from '@octopus-community/react-native';

const subscription = addConnectionStateListener((state) => {
    const isGuest = state.connected ? state.isGuest : false;
    // Gate real-user features on !isGuest
});

// Later, to unsubscribe:
subscription.remove();
```

Or use `isUserConnected()` / `addIsUserConnectedListener()`, which already collapse to
the simpler "a real, non-guest user is connected" signal.

<!-- /tab -->

<!-- tab: Unity -->

*Guest detection is not yet available on Unity.*

<!-- /tab -->

### Refresh entitlements (≥ 1.12.0) {#refresh-entitlements}

When your user's entitlements change in your backend (for example, after a premium purchase), call `refreshEntitlements()` to fetch the fresh set. The SDK re-invokes the token provider registered at `connectUser` time to obtain a fresh signed JWT, exchanges it for a new Octopus JWT carrying the updated entitlements, updates the cached profile, and refetches the groups so observing screens recompose with the new access decisions.

:::info
Only supported in SSO connection mode with a connected (non-guest) user that was connected **with a token provider**. Magic-link / guest sessions, or a user connected with a static pre-minted token, cannot refresh entitlements — there is no client-signed JWT to re-mint.
:::

<!-- tab: Android -->

```kotlin
when (val result = OctopusSDK.refreshEntitlements()) {
    is OctopusResult.Success -> {
        // Entitlements refreshed. OctopusSDK.profile and OctopusSDK.groups re-emit.
    }
    is OctopusResult.Failure.InvalidArguments -> {
        when (val error = result.error) {
            is RefreshEntitlementsError.NoClientTokenProvider -> {} // not in SSO / no token provider
            is RefreshEntitlementsError.UserNotConnected      -> {} // no connected user
            is RefreshEntitlementsError.NoNetwork             -> {} // device offline
            is RefreshEntitlementsError.UserBanned            -> {
                // error.errorMessage is BE-provided and suitable for direct display
            }
            is RefreshEntitlementsError.ServerError           -> {} // backend error
        }
    }
    else -> {
        // Other transport-level failures (e.g. unauthenticated)
    }
}
```

<!-- /tab -->

<!-- tab: iOS -->

```swift
do {
    try await octopus.refreshEntitlements()
    // Entitlements refreshed. octopus.$profile and octopus.$groups re-emit.
} catch let error as OctopusRefreshEntitlementsError {
    switch error {
    case .noClientTokenProvider: break // not in SSO / no token provider
    case .userNotConnected:      break // no connected user
    case .noNetwork:             break // device offline
    case .userBanned(let message):
        break // `message` is BE-provided and suitable for direct display
    case .serverError(let underlying):
        break // backend error
    }
}
```

<!-- /tab -->

<!-- tab: Flutter -->

Requires a user connected with `connectUserWithTokenProvider` — a static pre-minted token cannot be re-minted.

```dart
final result = await OctopusSDK().refreshEntitlements();
switch (result) {
    case OctopusSuccess():
        // Entitlements refreshed. OctopusSDK.profile and OctopusSDK.groups re-emit.
        break; // required: an empty case body falls into the next one
    case OctopusInvalidArguments<OctopusServerError>(:final errors):
        // The explicit `<OctopusServerError>` argument is what makes this switch
        // exhaustive — the analyzer builds the sealed space from each subtype's
        // declared bound, so naming the narrower leaf type is NOT exhaustive.
        // `errors` then binds as `List<OctopusServerError>` — narrow it to this
        // call's leaves:
        // RefreshEntitlementsNoClientTokenProviderError, RefreshEntitlementsUserNotConnectedError,
        // RefreshEntitlementsNoNetworkError, RefreshEntitlementsUserBannedError (errorMessage displayable),
        // RefreshEntitlementsServerError
        for (final error in errors.whereType<RefreshEntitlementsError>()) {
            // Surface error.errorMessage through your app's own error UI.
        }
    case OctopusConnectionFailure():
        // Transport/auth failure
}
```

<!-- /tab -->

<!-- tab: React Native -->

(≥ 1.13.0)

Requires a user connected in SSO mode **with a token provider** — a static pre-minted token cannot be re-minted.

```typescript
import { refreshEntitlements, isRefreshEntitlementsError } from '@octopus-community/react-native';

try {
    await refreshEntitlements();
    // Entitlements refreshed. addProfileListener / addGroupsListener re-emit.
} catch (error) {
    if (isRefreshEntitlementsError(error)) {
        switch (error.code) {
            case 'NO_CLIENT_TOKEN_PROVIDER': break; // not in SSO / no token provider
            case 'USER_NOT_CONNECTED':       break; // no connected user
            case 'NO_NETWORK':                break; // device offline
            case 'USER_BANNED':
                // error.message is BE-provided and suitable for direct display
                break;
            case 'SERVER_ERROR':              break; // backend error
            default:                           break; // fallback / unclassified
        }
    }
}
```

<!-- /tab -->

<!-- tab: Unity -->

*Refreshing entitlements is not yet available on Unity.*

<!-- /tab -->

---
## Display the Octopus Community UI

    Now that you have the SDK properly configured, you can add a button in your app that opens the Octopus Community UI.

<!-- tab: Android -->

1. Add the `OctopusHomeContent` composable to your community screen.

```kotlin
OctopusTheme(...) { // Customize the Octopus Theme here
    OctopusHomeContent(
        modifier = Modifier.fillMaxSize(),
        navController = navController, // Your main NavHostController
        onNavigateToLogin = {
            // This block will be called when OctopusSDK needs a logged-in user.
            // You should launch your login process here.
            // Example: navController.navigate(LoginRoute)
            // Once the user is logged in, you need to call OctopusSDK.connectUser(...) to link
            // them with Octopus
        },
        // Optional: If your community has at least one associated field:
        onNavigateToProfileEdit = { profileField ->
            // This block will be called when the user tries to modify some fields related
            // to their profile.
            // When this block is called, you should open your profile edition screen.
            // Example: navController.navigate(ProfileScreen(focusNickname = fieldToEdit == ProfileField.NICKNAME))
            // Once the user has edited their profile, you should call OctopusSDK.connectUser(...) to update the Octopus profile.
        }
    )
}
```
:::tip
Check the [Community Screen Sample](https://github.com/Octopus-Community/octopus-sdk-android/blob/main/samples/src/main/java/com/octopuscommunity/sample/screens/community/CommunityScreen.kt) for basic usage.
:::

2. Declare other Octopus sub-screens in your main `NavHost`:

```kotlin {2-11}
NavHost(...) { // Your main NavHost
    octopusComposables(
        navController = navController, // Your main NavHostController
        onNavigateToLogin = { ... },
        onNavigateToProfileEdit = { ... }
    ) { backStackEntry, content ->
        // Customize the Octopus Theme possibly based on a specific Octopus Screen here
        OctopusTheme(...) {
            content()
        }
    }
}
```
This function registers multiple `composable()` destinations in your `NavGraphBuilder` for adding the Octopus SDK navigation flow to your app.

:::warning Hosting Octopus inside your own Scaffold? Consume the insets
When `OctopusHomeContent` — or the `NavHost` containing these `octopusComposables` sub-screens — lives **inside your own `Scaffold`** (for example under a bottom navigation bar), pass the Scaffold's `innerPadding` **and** consume it. Otherwise the system-bar and keyboard insets are counted twice:

- keyboard open → the comment composer floats above the keyboard by the height of your bottom bar
- keyboard closed → an extra gap shows below the composer

```kotlin {6-7}
Scaffold(bottomBar = { YourBottomNavigationBar() }) { innerPadding ->
    OctopusHomeContent(
        navController = navController,
        modifier = Modifier
            .fillMaxSize()
            .padding(innerPadding)
            .consumeWindowInsets(innerPadding), // required for nested Scaffolds
    )
}
```

The same two modifiers go on the `NavHost` hosting the `octopusComposables` sub-screens whenever it is nested under your `Scaffold` too — this is the [standard Compose guidance for nested `Scaffold`s](https://developer.android.com/develop/ui/compose/layouts/insets).
:::

:::tip
Depending on your use case integration mode check the various samples:
- [Full Screen Sample](https://github.com/Octopus-Community/octopus-sdk-android/blob/main/samples/src/fullscreen/java/com/octopuscommunity/sample/screens/MainScreen.kt)
- [Bottom Navigation Bar Sample](https://github.com/Octopus-Community/octopus-sdk-android/blob/main/samples/src/bottomnavigationbar/java/com/octopuscommunity/sample/screens/MainScreen.kt)
- [Floating Bottom Navigation Bar Sample](https://github.com/Octopus-Community/octopus-sdk-android/blob/main/samples/src/contentpadding/java/com/octopuscommunity/sample/screens/MainScreen.kt)
- [Single Activity / Intent Sample](https://github.com/Octopus-Community/octopus-sdk-android/blob/main/samples/src/singleactivity/java/com/octopuscommunity/sample/CommunityActivity.kt)
:::

<!-- /tab -->

<!-- tab: iOS -->

First, import Octopus UI:

```swift
import OctopusUI
```

**Octopus handles its own navigation, so you must not embed it in a navigation stack.**

```swift
@State private var openOctopus = false

var body: some View {
    Button("Open Octopus Community") {
        openOctopus = true
    }.fullScreenCover(isPresented: $openOctopus) {
        OctopusHomeScreen(octopus: octopus, navigationMode: .navigationStack)
    }
}
```

:::note Navigation mode for modal hosting
When `OctopusHomeScreen` is presented inside a modal (`.sheet`, `.fullScreenCover`, or a Flutter / React Native modal route), pass **`navigationMode: .navigationStack`** so that in-app navigation (e.g. tapping a post to open its detail) works correctly. Without it, the default internal navigation container can silently drop pushes in modal contexts.

When displaying `OctopusHomeScreen` in a non-modal context (e.g. as a tab or pushed onto an existing stack), the default **`.automatic`** mode is correct — you can omit the parameter entirely:

```swift
OctopusHomeScreen(octopus: octopus)
```
:::

:::note
If you're using a `UITabBarController` to display the Octopus UI inside a UIHostingController, you might encounter a safe area bug. This is a known bug not related to the Octopus SDK UI. To fix it, you might want to add the UITabBarController children in the code instead of in the Storyboard. Otherwise, you can add a negative padding equal to `tabBarController.tabBar.frame.size.height` to the OctopusUI.
:::

<!-- /tab -->

<!-- tab: Flutter -->

You can display the Octopus Community UI as an embedded widget.

**Embedded widget:**

```dart
OctopusHomeScreen(
    onNavigateToLogin: () {
        // This callback will be called when OctopusSDK needs a logged-in user.
        // You should launch your login process here.
    },
    onModifyUser: (String? field) {
        // This callback will be called when the user tries to modify some fields
        // related to their profile.
        // `field` indicates the field that the user tapped to edit, if any.
    },
)
```

<!-- /tab -->

<!-- tab: React Native -->

**Fullscreen mode:**

Call `openUI()` to display the Octopus Community in fullscreen. Use `closeUI()` to dismiss it programmatically:

```typescript
import { openUI, closeUI } from '@octopus-community/react-native';

// Open the community UI
await openUI();

// Optionally, close it programmatically
await closeUI();
```

**Embedded mode:**

Use the `OctopusUIView` component to embed the community UI inline within a screen:

```tsx
import { OctopusUIView } from '@octopus-community/react-native';
import { View, StyleSheet } from 'react-native';

function CommunityScreen() {
    return (
        <View style={{ flex: 1 }}>
            <OctopusUIView style={StyleSheet.absoluteFill} />
        </View>
    );
}
```

:::info
Both modes require `initialize()` to be called first.
:::

<!-- /tab -->

<!-- tab: Unity -->

Open the Octopus Community UI from any button or event:

```csharp
OctopusSDK.Open();
```

<!-- /tab -->

### Open a specific screen (≥ 1.11.0) {#open-a-specific-screen}

By default, the Octopus UI opens on the main feed. You can also open it directly on a specific screen, such as a post or a group.

<!-- tab: Android -->

Android does not use a single `initialScreen` parameter — navigation is driven by the standard `NavHostController`. Two patterns are available depending on the integration mode:

#### Open in the community context (with back navigation to the main feed)

If your app integrates the Octopus navigation graph via `octopusComposables(navController)`, use the dedicated `navigateToOctopusPost` / `navigateToOctopusGroup` / `navigateToOctopusCreatePost` helpers. The user can then navigate back to the main feed naturally.

**Open a post:**

```kotlin
navController.navigateToOctopusPost(postId = "your-post-id")
```

**Open a group:** (≥ 1.11.0)

```kotlin
navController.navigateToOctopusGroup(groupId = "your-group-id")
```

See the [List available groups](#list-available-groups-and-read-their-state) section to retrieve the list of available group ids.

A `navigateToOctopusHome()` helper is also available to return to the main feed from anywhere.

**Open a member's activity (posts) screen:** (≥ 1.13.0)

`navigateToOctopusActivity(userId)` / `navigateToOctopusActivityByClientUserId(clientUserId)` open a member's posts-only activity screen. Part of [Unified Profile](#unified-profile) — see [Open a member's posts screen directly](#unified-profile-open-activity).

**Open the post creation (Bridge Share):** (≥ 1.12.0)

Opens the post editor for a **Bridge Share** — optionally prefilled with content supplied by your app (text, image, group, optional CTA). The user can freely edit text, image, and group in the editor; the CTA travels invisibly through and is attached to the published post.

Parameters of `OctopusPrefilledPost`:
- `text: String?` — Optional initial text shown in the editor.
- `image: Uri?` — Optional local content URI of an initial image. The SDK does not fetch remote URLs — the host materializes the image first. If the URI is granted via `ActivityResultContracts.PickVisualMedia` (or a similar contract), call `contentResolver.takePersistableUriPermission(...)` before navigating so the URI survives configuration changes and process death.
- `topicId: String?` — Optional id of the group the post should land in. If `null` or inaccessible, the editor forces the user to pick a group before publishing.
- `cta: OctopusPostCTA?` — Optional call-to-action attached to the published post. Not displayed in the editor.

At least one of `text` / `image` must be non-null. Empty strings for `text` and blank values for `image` / `topicId` are normalised to `null` before validation. The constructor throws `OctopusPrefilledPost.ValidationError` (a sealed `IllegalArgumentException` subclass) on invalid input. `OctopusPostCTA(url, label)` is a plain data class — its fields are validated inside `OctopusPrefilledPost` when a CTA is attached.

```kotlin
import androidx.core.net.toUri
import com.octopuscommunity.sdk.domain.model.CreatePostScreenInfo
import com.octopuscommunity.sdk.domain.model.OctopusPostCTA
import com.octopuscommunity.sdk.domain.model.OctopusPrefilledPost
import com.octopuscommunity.sdk.ui.navigateToOctopusCreatePost

try {
    val prefill = OctopusPrefilledPost(
        text = "The perfect Canelés",
        image = canelesImageUri,        // android.net.Uri or null
        topicId = foodRecipeGroupId,    // null = let the user pick a group
        cta = OctopusPostCTA(
            url = "https://example.com/recipes/caneles".toUri(),
            label = "Read the recipe",
        ),
    )
    navController.navigateToOctopusCreatePost(
        info = CreatePostScreenInfo(prefilledPost = prefill),
    )
} catch (e: OctopusPrefilledPost.ValidationError) {
    // Display the host-side error UX; no community navigation happened.
}
```

Passing `CreatePostScreenInfo()` (or omitting `info`) opens the editor empty — equivalent to today's in-SDK new-post flow.

:::info
Image-dimension validation (decode, ratio, min side) runs inside the editor pipeline when the screen opens — same shape as a user-picked image. The CTA is invisible in the editor — the user can edit text / image / group, but cannot view, edit, or remove the CTA before publishing.
:::

#### Open a single post or group in isolation (bridge mode)

When you do not want to integrate the full Octopus navigation graph, use the dedicated public composables in standalone mode. The screen displays a back arrow in the top app bar; tapping it (or using the system back gesture) returns to your app.

**Open a post:**

```kotlin
OctopusPostDetailsScreen(
    navController = navController,
    postId = "your-post-id"
)
```

**Open a group:** (≥ 1.11.0)

```kotlin
OctopusGroupDetailsScreen(
    navController = navController,
    groupId = "your-group-id"
)
```

**Open the post creation (Bridge Share):** (≥ 1.12.0)

```kotlin
OctopusCreatePostScreen(
    navController = navController,
    info = CreatePostScreenInfo(prefilledPost = prefill),
)
```

Both `OctopusPostDetailsScreen` and `OctopusGroupDetailsScreen` also have a `Content` variant (`OctopusPostDetailsContent`, `OctopusGroupDetailsContent`) that exposes navigation callbacks for SSO integration (login, profile edit, URL handling).

<!-- /tab -->

<!-- tab: iOS -->

The `initialScreen` parameter accepts the following values:

- **`.mainFeed`** (default) — Opens the main feed with the feed selector.
- **`.post(.init(postId: "your-post-id"))`** — Opens a specific post in **bridge mode**: a focused single-post screen, separate from the main-feed navigation.
- **`.group(.init(groupId: "your-group-id"))`** — Opens a specific group's feed in **bridge mode** (same as `.post`). See the [List available groups](#list-available-groups-and-read-their-state) section to retrieve the list of available group ids.
- **`.createPost(.init(prefilledPost: ...))`** (≥ 1.12.0) — Opens the post editor, optionally prefilled with content supplied by your app (text, image, group, optional CTA). The user can freely edit text, image, and group in the editor; the CTA travels invisibly through and is attached to the published post.
- **`.activity(.init(clientUserId: "..."))` / `.activity(.init(profileId: "..."))`** (≥ 1.13.0) — Opens a member's posts-only activity screen, identified by your app's own id for them or by their Octopus profile id. Part of [Unified Profile](#unified-profile) — see [Open a member's posts screen directly](#unified-profile-open-activity).

**Open a post:**

```swift
OctopusHomeScreen(
    octopus: octopus,
    initialScreen: .post(.init(postId: postId))
)
```

**Open a group:**

```swift
OctopusHomeScreen(
    octopus: octopus,
    initialScreen: .group(.init(groupId: groupId))
)
```

**Open the post creation (Bridge Share):** (≥ 1.12.0)

Parameters of `OctopusPrefilledPost`:
- `text: String?` — Optional initial text shown in the editor.
- `image: Data?` — Optional local image bytes (e.g. `UIImage(...).jpegData(...)`). The SDK does not fetch remote URLs — the host materializes the image first.
- `topicId: String?` — Optional id of the group the post should land in. If `nil` or inaccessible, the editor forces the user to pick a group before publishing.
- `cta: OctopusPrefilledPost.CTA?` — Optional call-to-action attached to the published post. Not displayed in the editor.

At least one of `text` / `image` must be non-nil. The initializer is throwing — it reuses the same validation rules the editor enforces at publish time (text length, image decode / size / ratio), so integration bugs surface during client-app QA instead of after the editor opens. Failures throw `OctopusPrefilledPost.ValidationError`; read `debugDescription` to diagnose.

```swift
import Octopus
import OctopusUI

// Build the payload — handle validation errors before any navigation:
let prefill: OctopusPrefilledPost
do {
    prefill = try OctopusPrefilledPost(
        text: "The perfect Canelés",
        image: image.jpegData(compressionQuality: 1),
        topicId: foodRecipeGroupId,   // nil = let the user pick a group
        cta: try .init(
            url: URL(string: "https://example.com/recipes/caneles")!,
            label: "Read the recipe"
        )
    )
} catch let error as OctopusPrefilledPost.ValidationError {
    // Display the host-side error UX; the editor was never opened.
    return
}

// Then display OctopusHomeScreen as usual (e.g. inside a .fullScreenCover):
OctopusHomeScreen(
    octopus: octopus,
    initialScreen: .createPost(.init(prefilledPost: prefill))
)
```

Passing `.createPost(.init())` (with `prefilledPost: nil`) opens the editor empty — equivalent to a regular in-SDK new-post flow.

:::info
On publish or cancel, the SDK auto-dismisses `OctopusHomeScreen` back to your host. The CTA is invisible in the editor — the user can edit text / image / group, but cannot view, edit, or remove the CTA before publishing.
:::

<!-- /tab -->

<!-- tab: Flutter -->

(≥ 1.12.0)

Pass an `OctopusInitialScreen` to the `initialScreen` parameter of the `OctopusHomeScreen` widget. It accepts the following values:

- **`OctopusInitialScreen.mainFeed()`** (default) — Opens the main feed with the feed selector.
- **`OctopusInitialScreen.post(PostScreenInfo(postId: "your-post-id"))`** — Opens a specific post in **bridge mode** (the user cannot navigate back to the main feed; they dismiss the screen to return to your app).
- **`OctopusInitialScreen.group(GroupScreenInfo(groupId: "your-group-id"))`** — Opens a specific group's feed in bridge mode. See the [List available groups](#list-available-groups-and-read-their-state) section to retrieve the list of available group ids.
- **`OctopusInitialScreen.createPost(CreatePostScreenInfo(...))`** — Opens the post editor, optionally prefilled with content supplied by your app.
- **`OctopusInitialScreen.activity(ActivityScreenInfo.clientUserId("..."))` / `OctopusInitialScreen.activity(ActivityScreenInfo.profileId("..."))`** (≥ 1.13.1) — Opens a member's posts-only activity screen, identified by your app's own id for them or by their Octopus profile id. The two named constructors are mutually exclusive — there is no constructor taking both. Part of [Unified Profile](#unified-profile) — see [Open a member's posts screen directly](#unified-profile-open-activity).
- **`OctopusInitialScreen.profile(clientUserId: "...")`** (≥ 1.13.1) — Opens a member's read-only profile screen, or the connected user's own editable profile when `clientUserId` is omitted. Part of [Unified Profile](#unified-profile) — see [Open a member's profile screen directly](#unified-profile-open-profile).

When omitted (`null`), the view opens on the main feed. If a `notification` carrying a non-empty deep link is also supplied at the same mount, the deep link wins and `initialScreen` is ignored.

**Open a post:**

```dart
OctopusHomeScreen(
    initialScreen: OctopusInitialScreen.post(
        PostScreenInfo(postId: "your-post-id"),
    ),
)
```

**Open a group:**

```dart
OctopusHomeScreen(
    initialScreen: OctopusInitialScreen.group(
        GroupScreenInfo(groupId: "your-group-id"),
    ),
)
```

As a convenience, the dedicated `OctopusPostDetailsScreen` / `OctopusGroupDetailsScreen` widgets open a single post or group in bridge mode without building an `OctopusInitialScreen` yourself. They expose the same callbacks as `OctopusHomeScreen` (`onNavigateToLogin`, `onModifyUser`, `onNavigateToUrl`):

```dart
OctopusPostDetailsScreen(postId: "your-post-id")

OctopusGroupDetailsScreen(groupId: "your-group-id")
```

**Open the post creation (Bridge Share):**

Prefill the editor with an `OctopusPrefilledPost` (text, target group, optional invisible CTA). The user can freely edit the text and group; the CTA travels invisibly through and is attached to the published post.

Parameters of `OctopusPrefilledPost`:
- `text: String?` — Optional initial text. When present, its length must be between 10 and 5000 characters.
- `image: Uint8List?` — Optional local image bytes. **Dropped when opened through `OctopusHomeScreen` (the embedded route) — use `OctopusSDK().showOctopusCreatePostScreen(...)` for the image-share flow.** The SDK never fetches remote URLs — materialize the bytes first.
- `topicId: String?` — Optional id of the group the post should land in. If `null` or inaccessible, the editor forces the user to pick a group before publishing.
- `cta: OctopusPostCTA?` — Optional call-to-action (`OctopusPostCTA(url:, label:)`) attached to the published post. Not displayed in the editor.

At least one of `text` / `image` must be non-null. Empty `text`, empty `image`, and blank `topicId` are normalised to `null` before validation. The constructor throws an `OctopusPrefilledPostValidationError` (a sealed `ArgumentError` subclass) on invalid input.

```dart
try {
    final prefill = OctopusPrefilledPost(
        text: "The perfect Canelés",
        topicId: foodRecipeGroupId, // null = let the user pick a group
        cta: OctopusPostCTA(
            url: Uri.parse("https://example.com/recipes/caneles"),
            label: "Read the recipe",
        ),
    );
    // Opens prefilled with text / group / CTA. Image bytes are not carried on this route.
    return OctopusHomeScreen(
        initialScreen: OctopusInitialScreen.createPost(
            CreatePostScreenInfo(prefilledPost: prefill),
        ),
    );
} on OctopusPrefilledPostValidationError catch (_) {
    // Display the host-side error UX; no community navigation happened.
}
```

Passing `OctopusInitialScreen.createPost(CreatePostScreenInfo())` (with no `prefilledPost`) opens the editor empty — equivalent to the regular in-SDK new-post flow.

:::info
To prefill the editor **with an image**, open the platform-owned editor with `OctopusSDK().showOctopusCreatePostScreen(info: CreatePostScreenInfo(prefilledPost: prefill))` — a dedicated Activity on Android, a full-screen modal on iOS — which carries the image bytes. The `OctopusInitialScreen.createPost` embedded route drops image bytes on both platforms.
:::

<!-- /tab -->

<!-- tab: React Native -->

(≥ 1.13.0)

Pass `initialScreen` to `openUI()` or as a prop on `<OctopusUIView>` — same union, same normalization, same notification-wins precedence on both entry points. `<OctopusUIView>` reads the prop on mount only: remount with a new `key` to target a different screen.

- **`{ type: 'mainFeed' }`** (default) — the main feed with the feed selector.
- **`{ type: 'post', postId }`** — a specific post in bridge mode.
- **`{ type: 'group', groupId }`** — a specific group's feed in bridge mode. See [List available groups](#list-available-groups-and-read-their-state).
- **`{ type: 'createPost', prefilledPost? }`** — the post editor, optionally prefilled.
- **`{ type: 'activity', member }`** — a member's posts-only screen. See [Open a member's posts screen directly](#unified-profile-open-activity).
- **`{ type: 'profile', clientUserId? }`** — a member's profile, or the connected user's own editable profile when `clientUserId` is omitted. See [Open a member's profile screen directly](#unified-profile-open-profile).

**Open a post:**

```typescript
import { openUI } from '@octopus-community/react-native';

await openUI({ initialScreen: { type: 'post', postId: 'your-post-id' } });
```

**Open a group:**

```typescript
await openUI({ initialScreen: { type: 'group', groupId: 'your-group-id' } });
```

**Open the post creation (Bridge Share):**

```typescript
await openUI({
    initialScreen: {
        type: 'createPost',
        prefilledPost: {
            text: 'The perfect Canelés',
            imageUri: canelesImageUri, // a file:// URI, or a bundled asset name
            topicId: foodRecipeGroupId, // omit to let the user pick a group
            cta: { url: 'https://example.com/recipes/caneles', label: 'Read the recipe' },
        },
    },
});
```

`navigateToOctopusCreatePost(prefilledPost)` remains the ergonomic shorthand for this same case, and rejects with the same `NavigateToOctopusCreatePostError` codes when the prefill fails native validation. `<OctopusUIView>` cannot reject a promise, so there a failed prefill opens a blank editor with a native log instead of throwing.

A tapped push-notification deep link passed as `notification` always wins over `initialScreen` when both are provided — the initial screen is dropped with a warning.

<!-- /tab -->

<!-- tab: Unity -->

**Open a group:** (≥ 1.12.0)

```csharp
OctopusSDK.OpenGroup(groupId);
```

See the [List available groups](#list-available-groups-and-read-their-state) section to retrieve available group ids.

**Open a post:** (≥ 1.12.0)

```csharp
OctopusSDK.OpenPost("your-post-id");
```

An empty or null `postId` falls back to the main feed.

**Open the post creation (Bridge Share):** (≥ 1.12.0)

Use `OpenCreatePost` with an optional `OctopusPrefilledPost` to open the editor prefilled with content your app supplies. Pass `null` or omit the argument for a blank editor.

Parameters of `OctopusPrefilledPost`:
- `Text: string` — Optional initial body text shown in the editor.
- `TopicId: string` — Optional id of the group the post should land in. If `null` or empty, the editor forces the user to pick a group before publishing.
- `ImagePath: string` — Optional absolute local file path to an initial image (e.g. `Application.persistentDataPath + "/shot.png"`). The SDK does not fetch remote URLs — materialize the image to a local file first.
- `CtaLabel: string` (≥ 1.12.1) — Optional text label for a call-to-action button attached to the published post. Must be set together with `CtaUrl`; if only one of the two is provided, the CTA is silently dropped.
- `CtaUrl: string` (≥ 1.12.1) — Optional URL opened when the user taps the CTA button on the published post. Must be set together with `CtaLabel`.

All fields are optional — leave any field `null` or empty to omit it. The CTA is invisible in the editor — the user can edit text, image, and group, but cannot view, edit, or remove the CTA before publishing.

:::note
Unlike iOS and Android, Unity does not throw a validation error at call time — invalid or empty fields are silently normalised by the bridge before forwarding to the native SDK.
:::

```csharp
// Blank editor
OctopusSDK.OpenCreatePost();

// Prefilled editor
OctopusSDK.OpenCreatePost(new OctopusPrefilledPost
{
    Text      = "Check this out!",
    TopicId   = groupId,
    ImagePath = Application.persistentDataPath + "/shot.png"
});

// Prefilled editor with a CTA button
OctopusSDK.OpenCreatePost(new OctopusPrefilledPost
{
    Text      = "Check this out!",
    TopicId   = groupId,
    ImagePath = Application.persistentDataPath + "/shot.png",
    CtaLabel  = "Visit our shop",
    CtaUrl    = "https://example.com/shop"
});
```

<!-- /tab -->

### Sign a Bridge Share image for a picture-restricted community (≥ 1.12.3) {#bridge-share-image-signing}

When you open the Bridge Share editor (above) prefilled **with an image** and the target community forbids member pictures, the SDK needs a host-signed token authorising that image. Provide a signing callback: the SDK computes a content fingerprint (SHA-256 of the post's text, CTA and image), calls your callback with it, and your **backend** returns a compact JWT signed HS256 with your shared secret, carrying the `bridge_fingerprint` claim equal to the value passed in. Sign on your backend — never ship the secret in the app. When you omit it, an image in such a community is rejected; text-only posts and communities that allow pictures don't need it.

<!-- tab: Android -->

Set `bridgeShareTokenProvider` on the `CreatePostScreenInfo` — it is honored by **both** entry points (`navigateToOctopusCreatePost` for the community context and `OctopusCreatePostScreen` for bridge mode):

```kotlin
val info = CreatePostScreenInfo(
    prefilledPost = prefill,
    bridgeShareTokenProvider = { bridgeFingerprint ->
        // Ask YOUR backend to sign the fingerprint and return the JWT (or null to send unsigned)
        myBackend.signBridgeShare(bridgeFingerprint)
    },
)

// In the community context:
navController.navigateToOctopusCreatePost(info = info)

// Or in bridge mode (standalone composable):
OctopusCreatePostScreen(navController = navController, info = info)
```

<!-- /tab -->

<!-- tab: iOS -->

Pass the `sign` closure when building the `OctopusPrefilledPost`:

```swift
let prefill = try OctopusPrefilledPost(
    text: "The perfect Canelés",
    image: image.jpegData(compressionQuality: 1),
    topicId: foodRecipeGroupId,
    sign: { bridgeFingerprint in
        // Ask YOUR backend to sign the fingerprint and return the JWT
        try await myBackend.signBridgeShare(bridgeFingerprint)
    }
)
```

<!-- /tab -->

<!-- tab: Flutter -->

(≥ 1.12.2)

Set `bridgeShareTokenProvider` on the `CreatePostScreenInfo` passed to `showOctopusCreatePostScreen`:

```dart
await octopus.showOctopusCreatePostScreen(
  info: CreatePostScreenInfo(
    prefilledPost: prefilledPost,
    bridgeShareTokenProvider: (fingerprint) async {
      // Ask YOUR backend to sign the fingerprint and return the JWT (or null to send unsigned)
      return myBackend.signBridgeShare(fingerprint);
    },
  ),
);
```

<!-- /tab -->

<!-- tab: React Native -->

(≥ 1.13.0)

Unlike the other platforms, the signer is not a per-call parameter — register it once, globally, with `useBridgeShareTokenProvider` (a hook) or `addBridgeShareTokenRequestListener` (a plain function, outside a component):

```typescript
import { useBridgeShareTokenProvider } from '@octopus-community/react-native';

useBridgeShareTokenProvider(async (bridgeFingerprint) => {
    // Ask YOUR backend to sign the fingerprint and return the JWT (or null to send unsigned)
    return myBackend.signBridgeShare(bridgeFingerprint);
});
```

The native side only asks for a signature while a provider is registered — with none registered, prefilled shares are published unsigned exactly as before. The same registration also serves `fetchOrCreateClientObjectRelatedPost` (see [Bridges](#bridges--150)): register it once and it covers both. A request left unanswered for 60s is treated as declined.

:::info
Declining a signature (returning `null`, or throwing) is handled differently per platform: Android publishes the post unsigned and lets the server refuse it, while iOS has no unsigned channel on its native signing closure and fails the publish client-side. This divergence lives in the native SDKs, not in the React Native bridge — the same asymmetry exists on Flutter.
:::

<!-- /tab -->

<!-- tab: Unity -->

(≥ 1.12.2)

Set the `SignBridgeShare` field on the `OctopusPrefilledPost` you pass to `OpenCreatePost`. It is a `Func<string, Task<string>>` — receives the SDK-computed content fingerprint, must return a compact HS256 JWT your backend mints with the `bridge_fingerprint` claim set to that value.

- **When to set it:** only when the prefilled post carries an image AND the target community restricts member pictures. Leave `null` otherwise.
- **Error handling:** if your callback throws or returns `null`/empty, the post is aborted with a clean error — it does not hang the editor.
- **Security:** never ship the signing secret in the app — sign on your backend.

:::warning Threading
`SignBridgeShare` runs on a **background thread** while the game loop is suspended. It must complete without the Unity player loop: use loop-independent I/O (`System.Net.Http`), **not** `UnityWebRequest`, and do not await continuations that marshal back to the main thread.
:::

```csharp
using System.Net.Http;
using System.Threading.Tasks;
using UnityEngine;

public class BridgeShareExample : MonoBehaviour
{
    static readonly HttpClient Http = new HttpClient();

    public void ShareWithSignedImage(string groupId, string imagePath)
    {
        OctopusSDK.OpenCreatePost(new OctopusPrefilledPost
        {
            Text      = "Check this out!",
            TopicId   = groupId,
            ImagePath = imagePath,
            SignBridgeShare = async fingerprint =>
            {
                // Call YOUR backend — never sign client-side.
                var response = await Http.GetStringAsync(
                    $"https://your-backend.com/sign-bridge-share?fingerprint={fingerprint}");
                return response; // the compact JWT
            }
        });
    }
}
```

<!-- /tab -->

See [Generate a JWT for a bridge "Share" with an image](/backend/jwt/generate_jwt#generate-jwt-for-a-bridge-share-with-an-image) for the JWT signing contract your backend implements.

### Modify bottom safe area

According to your app, you might want to add a bottom padding to the Octopus UI content.

<!-- tab: Android -->

This can be done by using the `contentPadding` parameter of the `OctopusHomeContent` composable.

```kotlin {3}
OctopusHomeContent(
    modifier = Modifier.fillMaxSize(),
    contentPadding = PaddingValues(bottom = 10.dp) // This will add a 10dp padding at the bottom of the Octopus UI
    // ...
)
```

To see a full example of how you can achieve that, you can check the [Floating Bottom Navigation Bar Sample](https://github.com/Octopus-Community/octopus-sdk-android/blob/main/samples/src/contentpadding/java/com/octopuscommunity/sample/screens/MainScreen.kt).

<!-- /tab -->

<!-- tab: iOS -->

This can be done by using the `bottomSafeAreaInset` parameter of the `OctopusHomeScreen`.

```swift
OctopusHomeScreen(
    octopus: octopus,
    bottomSafeAreaInset: 10 // this will add a 10px safe area at the bottom of the Octopus UI
)
```

:::warning When (and when not) to set a value
`bottomSafeAreaInset` adds space **on top of** the safe area the system already reserves — it does not replace it.

- If you embed `OctopusHomeScreen` in a **standard `TabView` / `UITabBarController` tab**, the system **already** keeps the content above the tab bar and the home indicator. **Keep the default (`0`).** Passing your tab bar's height here **double-counts** it and leaves a large empty band between the Octopus content (the floating "Write a post" button, the comment input) and your tab bar.
- Only pass a positive value when `OctopusHomeScreen` is drawn **underneath a custom / floating bottom bar** that the system does *not* reserve space for. In that case pass **only the height of your bar that actually overlaps the content** — do **not** add the system safe-area inset again (it is already applied).
:::

To see a full example of how you can achieve that, you can follow how it is done in the Samples, in the [Embedded Tab](https://github.com/Octopus-Community/octopus-sdk-swift/blob/main/Sample/OctopusSample/UI/Scenarios/EmbeddedOctopusAuth/EmbeddedOctopusAuthView.swift).

<!-- /tab -->

<!-- tab: Flutter -->

This can be done by using the `bottomSafeAreaInset` parameter of the `OctopusHomeScreen` widget. (≥ 1.12.0)

Pass the **total** bottom padding you want — typically the height of your own bottom chrome (e.g. a `BottomNavigationBar`) — so the floating "Write a post" button clears it. The system safe-area inset is already taken care of, no need to add it yourself.

```dart
OctopusHomeScreen(
    bottomSafeAreaInset: 10, // logical pixels reserved at the bottom of the Octopus UI
    // ...
)
```

Left at its `0` default, the SDK reserves whatever bottom padding your layout still has left, which is enough to clear the Android navigation bar or the iOS home indicator. (≥ 1.13.0) To reserve nothing at all, consume the padding yourself:

```dart
MediaQuery.removePadding(
    context: context,
    removeBottom: true,
    child: OctopusHomeScreen(/* ... */),
)
```

<!-- /tab -->

<!-- tab: React Native -->

Pass the `ui` option inside the `initialize()` call:

```typescript
import { initialize } from '@octopus-community/react-native';

await initialize({
    apiKey: 'YOUR_API_KEY',
    connectionMode: { type: 'sso', appManagedFields: [] },
    ui: {
        bottomSafeAreaInset: 10, // In points (iOS) or dp (Android)
    },
});
```

<!-- /tab -->

<!-- tab: Unity -->

This feature is not yet available on Unity.

<!-- /tab -->

---
## Unified Profile (≥ 1.13.0) {#unified-profile}

By default, tapping a member's profile inside the community opens the SDK's **native profile screens**. With **Unified Profile**, your app takes over: every profile tap is handed back to you with **your app's own id** for the tapped member (their **client user id** — the same id you pass to `connectUser`), so you can open your own profile screen. The SDK never hands you an Octopus id.

Enabling Unified Profile also:

- replaces the connected user's own profile access point (the avatar on the main feed's floating button) with an **Activity screen** — **Notifications** and **Posts** tabs, plus an overflow menu that can deep-link back into your app's profile screens;
- unlocks a read-only **community data** API so your own profile screens can display a member's community stats (message count, gamification level);
- unlocks a **posts-only activity screen** you can open for any member from your own UI.

### Activation

Unified Profile is activated by an **AND gate** — both conditions must hold:

1. **Your community exposes client user ids** — a backend flag enabled per community by the Octopus team. Contact your Octopus representative to turn it on.
2. **Your app wires the profile navigation callback** (see below).

If either one is missing, the SDK keeps its native profile screens everywhere — so you can safely ship the callback wiring before the flag is enabled for your community (or vice versa).

When Unified Profile is active, profile taps route as follows:

- Member **with** a client user id → your callback is invoked with their client user id: open your own profile screen.
- Member **without** a client user id (a guest, or a profile created from the back office) → the SDK opens its posts-only activity screen for that member. Your callback is never invoked with a null id.
- The connected user's own profile access point (the floating button on the main feed, whose avatar becomes the Activity icon) → the SDK's [Activity screen](#unified-profile-activity-screen). A self-tap on their own name or avatar inside a post or comment follows the first rule above.

### Route profile taps to your own profile screen

<!-- tab: Android -->

Parameters (on `octopusComposables` — the same parameter also exists on `OctopusHomeScreen`, `OctopusPostDetailsScreen`, and `OctopusGroupDetailsScreen`):

- `onNavigateToProfile: ((clientUserId: String) -> Unit)?` — Called when a user taps any profile inside the community (another member's or the connected user's own). Leave it `null` (default) to keep the SDK's native profile screens.

```kotlin
NavHost(...) { // Your main NavHost
    octopusComposables(
        navController = navController,
        onNavigateToLogin = { ... },
        onNavigateToProfileEdit = { fieldToEdit -> ... },
        // Unified Profile: wire onNavigateToProfile to open YOUR profile
        // screen for the tapped member.
        onNavigateToProfile = { clientUserId ->
            // `clientUserId` is your app's own id for the tapped member.
            // Example: navController.navigate(YourProfileScreen(userId = clientUserId))
        }
    ) { backStackEntry, content ->
        OctopusTheme {
            content()
        }
    }
}
```

:::info
Wiring the callback is only half of the activation AND gate — profile taps keep opening the SDK's native profile screens until client user id exposure is also enabled for your community.
:::

<!-- /tab -->

<!-- tab: iOS -->

Parameters:

- `onNavigateToProfileCallback: ((_ clientUserId: String) -> Void)?` — Called when a user taps any profile inside the community (another member's or the connected user's own). Pass `nil` to unwire it and keep the SDK's native profile screens.

```swift
octopus.set(onNavigateToProfileCallback: { clientUserId in
    // `clientUserId` is your app's own id for the tapped member.
    // Open your own profile screen here.
})
```

:::info
Wiring the callback is only half of the activation AND gate — profile taps keep opening the SDK's native profile screens until client user id exposure is also enabled for your community.
:::

<!-- /tab -->

<!-- tab: Flutter -->

Parameters (on `OctopusHomeScreen` — the same parameter also exists on `OctopusHomeContent`, `OctopusPostDetailsScreen` and `OctopusGroupDetailsScreen`, and on the `OctopusSDK().showOctopusHomeScreen` and `OctopusSDK().openNotification` helpers, which are instance methods):

- `onNavigateToProfile: void Function(String clientUserId)?` — Called when a user taps any profile inside the community (another member's or the connected user's own). Leave it `null` (default) to keep the SDK's native profile screens.

```dart
OctopusHomeScreen(
    onNavigateToLogin: () { ... },
    onModifyUser: (fieldToEdit) { ... },
    // Unified Profile: wire onNavigateToProfile to open YOUR profile
    // screen for the tapped member.
    onNavigateToProfile: (clientUserId) {
        // `clientUserId` is your app's own id for the tapped member.
        // Example: Navigator.of(context).push(yourProfileRoute(clientUserId));
    },
)
```

:::info
Wiring the callback is only half of the activation AND gate — profile taps keep opening the SDK's native profile screens until client user id exposure is also enabled for your community.
:::

:::note
Whether profile taps are intercepted is decided when the view is created. Swapping one callback for another applies immediately, but switching between `null` and non-`null` only takes effect on the next mount — change the widget's `key` if you need to toggle it at runtime.

On iOS, mounting a second Octopus view also applies its own choice to the first one. Keep a single view alive, or wire them all the same way.
:::

<!-- /tab -->

<!-- tab: React Native -->

(≥ 1.13.0)

Opt in with `interceptProfileTaps` on either entry point, then subscribe with `addNavigateToProfileListener`:

```typescript
import { openUI, addNavigateToProfileListener } from '@octopus-community/react-native';

const subscription = addNavigateToProfileListener(({ clientUserId }) => {
    // `clientUserId` is your app's own id for the tapped member.
    navigation.navigate('Profile', { userId: clientUserId });
});

await openUI({ interceptProfileTaps: true });
// Or, on <OctopusUIView interceptProfileTaps />
```

:::info
Opting in is only half of the activation AND gate — profile taps keep opening the SDK's native profile screens until client user id exposure is also enabled for your community.
:::

<!-- /tab -->

<!-- tab: Unity -->

*Unified Profile is not yet available on Unity.*

<!-- /tab -->

### The connected user's Activity screen {#unified-profile-activity-screen}

When Unified Profile is active, the connected user's avatar on the main feed's floating button is replaced by a customizable **Activity icon**. Tapping it opens the **Activity screen** — **Notifications** and **Posts** tabs (no profile header) — with an overflow menu containing:

- **View my profile** — deep-links back into your app through the profile navigation callback, with the connected user's own client user id. Shown only for a non-guest member that has a client user id.
- **Edit my profile** — deep-links into your app's profile editor through the profile-edit callback (see below). Shown only when that callback is wired **and** the same condition as **View my profile** holds (a non-guest member with a client user id), so the item never dead-ends.
- The community's legal links (community guidelines, privacy policy, terms of use).
- **Report inappropriate content.**

#### Open your profile editor from the Activity screen

<!-- tab: Android -->

Parameters:

- `onNavigateToProfileEdit: ((fieldToEdit: ProfileField?) -> Unit)?` — The **Edit my profile** menu item reuses this existing parameter of `octopusComposables` / `OctopusHomeScreen` — the same one described in [Display the Octopus Community UI](#display-the-octopus-community-ui). When opened from the Activity menu, the callback receives a `null` field (open your full profile editor).

```kotlin
octopusComposables(
    navController = navController,
    onNavigateToProfileEdit = { fieldToEdit ->
        // `fieldToEdit` is null when opened from the Activity screen's menu:
        // open your full profile edition screen.
    },
    onNavigateToProfile = { clientUserId -> ... }
) { ... }
```

<!-- /tab -->

<!-- tab: iOS -->

The **Edit my profile** menu item uses a dedicated callback, independent of SSO's `modifyUser` (which keeps driving the SDK's native profile screens).

Parameters:

- `onNavigateToProfileEditCallback: ((_ fieldToEdit: ConnectionMode.SSOConfiguration.ProfileField?) -> Void)?` — Called when the connected user asks to edit their profile from the Activity screen. It receives the field the user wants to edit, or `nil` to open your full profile editor. It works in both `.octopus` and `.sso` connection modes. Pass `nil` to unwire it, which also hides the **Edit my profile** menu item.

```swift
octopus.set(onNavigateToProfileEditCallback: { fieldToEdit in
    // `fieldToEdit` is nil when opened from the Activity screen's menu:
    // open your full profile edition screen.
})
```

<!-- /tab -->

<!-- tab: Flutter -->

There is no dedicated profile-edit callback on Flutter: the **Edit my profile** menu item reuses the existing `onModifyUser` parameter of `OctopusHomeScreen` / `OctopusHomeContent` — the same one described in [Display the Octopus Community UI](#display-the-octopus-community-ui).

Parameters:

- `onModifyUser: Function(String?)?` — Called with the field the user asked to edit, or `null` when opened from the Activity screen's menu (open your full profile editor).

```dart
OctopusHomeScreen(
    onModifyUser: (fieldToEdit) {
        // `fieldToEdit` is null when opened from the Activity screen's menu:
        // open your full profile edition screen.
    },
    onNavigateToProfile: (clientUserId) { ... },
)
```

Wire `onModifyUser` whenever you wire `onNavigateToProfile`: on Android the **Edit my profile** item is shown either way, so leaving it out gives the user an item that does nothing.

<!-- /tab -->

<!-- tab: React Native -->

(≥ 1.13.0)

There is no dedicated profile-edit callback on React Native: the **Edit my profile** menu item reuses the existing `editUser` event, the same one described in [Link your user to the SDK](#link-your-user-to-the-sdk).

```typescript
import { addEditUserListener } from '@octopus-community/react-native';

const subscription = addEditUserListener(({ fieldToEdit }) => {
    // `fieldToEdit` is null when opened from the Activity screen's menu:
    // open your full profile edition screen.
});
```

<!-- /tab -->

<!-- tab: Unity -->

*Unified Profile is not yet available on Unity.*

<!-- /tab -->

#### Customize the Activity icon

The Activity icon follows the same rules as the other [customizable icons](#customize-icons) (tinted by the SDK, ideally 24×24). If you do not override it, the SDK's default bell glyph is used.

<!-- tab: Android -->

```kotlin
import com.octopuscommunity.sdk.ui.OctopusIconsDefaults
import com.octopuscommunity.sdk.ui.OctopusImagesDefaults
import com.octopuscommunity.sdk.ui.OctopusTheme

OctopusTheme(
    images = OctopusImagesDefaults.images(
        icons = OctopusIconsDefaults.icons(
            activityButton = { painterResource(R.drawable.your_activity_icon) }
        )
    )
) {
    OctopusHomeContent(...)
}
```

<!-- /tab -->

<!-- tab: iOS -->

```swift
import OctopusUI

let theme = OctopusTheme(
    assets: .init(
        icons: .init(
            common: .init(
                activityButton: UIImage(named: "myActivityIcon")
            )
        )
    )
)
```

<!-- /tab -->

<!-- tab: Flutter -->

*Customizing the Activity icon is not yet available on Flutter.*

<!-- /tab -->

<!-- tab: React Native -->

*Customizing the Activity icon is not yet available on React Native.*

<!-- /tab -->

<!-- tab: Unity -->

*Unified Profile is not yet available on Unity.*

<!-- /tab -->

### Open a member's posts screen directly {#unified-profile-open-activity}

From your own profile screen, you can surface a member's community posts by opening the SDK's posts-only activity screen. Identify the member either by **your app's own id** for them (client user id — requires the community to expose client user ids) or by their **Octopus profile id** (e.g. one returned by the community data API, always available).

If the id cannot be resolved (unknown or stale mapping, or the community does not expose client user ids), the screen shows its empty state. If the id resolves to the connected user, their two-tab [Activity screen](#unified-profile-activity-screen) opens instead.

<!-- tab: Android -->

Requires `octopusComposables()` to be registered in your `NavHost` (see [Display the Octopus Community UI](#display-the-octopus-community-ui)).

```kotlin
// By your app's own id for the member:
navController.navigateToOctopusActivityByClientUserId(clientUserId = "your-user-id")

// By Octopus profile id:
navController.navigateToOctopusActivity(userId = octopusUserId)
```

<!-- /tab -->

<!-- tab: iOS -->

Pass the `activity` initial screen to `OctopusHomeScreen` (see [Open a specific screen](#open-a-specific-screen)):

```swift
// By your app's own id for the member:
OctopusHomeScreen(
    octopus: octopus,
    initialScreen: .activity(.init(clientUserId: "your-user-id"))
)

// By Octopus profile id:
OctopusHomeScreen(
    octopus: octopus,
    initialScreen: .activity(.init(profileId: octopusProfileId))
)
```

<!-- /tab -->

<!-- tab: Flutter -->

Pass the `activity` initial screen to `OctopusHomeScreen` (see [Open a specific screen](#open-a-specific-screen)):

```dart
// By your app's own id for the member:
OctopusHomeScreen(
    initialScreen: OctopusInitialScreen.activity(
        ActivityScreenInfo.clientUserId("your-user-id"),
    ),
)

// By Octopus profile id:
OctopusHomeScreen(
    initialScreen: OctopusInitialScreen.activity(
        ActivityScreenInfo.profileId(octopusProfileId),
    ),
)
```

<!-- /tab -->

<!-- tab: React Native -->

```typescript
import { openUI } from '@octopus-community/react-native';

// By your app's own id for the member:
await openUI({
    initialScreen: { type: 'activity', member: { clientUserId: 'your-user-id' } },
});

// By Octopus profile id:
await openUI({
    initialScreen: { type: 'activity', member: { profileId: octopusProfileId } },
});
```

`member` takes exactly one of `clientUserId` / `profileId` — passing both or neither throws a plain `Error` synchronously, before the native call.

<!-- /tab -->

<!-- tab: Unity -->

*Unified Profile is not yet available on Unity.*

<!-- /tab -->

### Open a member's profile screen directly {#unified-profile-open-profile}

From your own UI — for example, a member list your app owns — you can open a member's Octopus profile directly: a public, read-only view of their profile information and posts (no edit affordances). It always opens the read-only view, even when the id happens to be the connected user's own — to open the connected user's **editable** profile, use the existing self-profile entry point instead (see [Open a specific screen](#open-a-specific-screen)).

Identify the member either by their **Octopus profile id** or by **your app's own id** for them (client user id — requires the community to expose client user ids). A client user id that does not resolve (unknown/deleted mapping, network failure, or the community not exposing client ids) shows the generic error state — it never falls back to the connected user's own profile.

<!-- tab: Android -->

Requires `octopusComposables()` to be registered in your `NavHost` (see [Display the Octopus Community UI](#display-the-octopus-community-ui)).

```kotlin
// By Octopus profile id:
navController.navigateToOctopusProfile(userId = octopusUserId)

// By your app's own id for the member:
navController.navigateToOctopusProfileByClientUserId(clientUserId = "your-user-id")
```

<!-- /tab -->

<!-- tab: iOS -->

Use `OctopusProfileScreen`, presented natively (it contains its own navigation container — do **not** embed it in another one, same rule as `OctopusHomeScreen`):

```swift
// By your app's own id for the member:
OctopusProfileScreen(octopus: octopus, clientUserId: "your-user-id")
```

Passing `clientUserId: nil` (the default) shows the connected user's own profile. On iOS a member is addressed by **client user id** (requires the community to expose client user ids); the by-Octopus-profile-id variant is Android-only for now.

<!-- /tab -->

<!-- tab: Flutter -->

Use `OctopusProfileScreen`, the same convenience-widget pattern as `OctopusPostDetailsScreen` / `OctopusGroupDetailsScreen` (see [Open a specific screen](#open-a-specific-screen)):

```dart
// By your app's own id for the member:
OctopusProfileScreen(clientUserId: "your-user-id")
```

Omitting `clientUserId` (the default) shows the connected user's own, editable profile. Equivalently, you can pass the `profile` initial screen to `OctopusHomeScreen` directly:

```dart
OctopusHomeScreen(
    initialScreen: OctopusInitialScreen.profile(
        clientUserId: "your-user-id",
    ),
)
```

As on iOS, a member is addressed by **client user id** only (requires the community to expose client user ids) — there is no by-Octopus-profile-id variant; holding only a profile id, open the member's posts screen instead (see above).

<!-- /tab -->

<!-- tab: React Native -->

```typescript
import { openUI } from '@octopus-community/react-native';

// By your app's own id for the member:
await openUI({
    initialScreen: { type: 'profile', clientUserId: 'your-user-id' },
});
```

Omitting `clientUserId` (or passing a blank string) opens the connected user's own, editable profile instead — there is no by-Octopus-profile-id variant here; holding only a profile id, open the member's posts screen instead (see above).

<!-- /tab -->

<!-- tab: Unity -->

*Unified Profile is not yet available on Unity.*

<!-- /tab -->

### Read a member's community data {#unified-profile-community-data}

To display a member's community stats inside your own profile screen, the SDK exposes a read-only snapshot per member, fetchable on demand or observable reactively. The snapshot carries:

- the member's **Octopus profile id**;
- their **message count** (posts + comments + replies), `null` when the community does not surface it;
- their **gamification** standing, `null` when the community has gamification disabled: a `level` (0-based index) and a `score` (currently always `null` for other members — kept for forward compatibility).

Like the navigation entry points, the by-client-user-id variants require the community to expose client user ids; the by-Octopus-id variants are always available. The observable variants emit `null` while the member is unknown, and update on every refresh (e.g. after a fetch).

<!-- tab: Android -->

```kotlin
// One-off refresh (suspend). Returns null when the member is unknown
// or the network lookup fails (unexpected errors still propagate).
val data: OctopusCommunityData? =
    OctopusSDK.fetchCommunityDataByClientUserId(clientUserId = "your-user-id")

// Observe reactively (emits null while the member is unknown or the lookup fails):
OctopusSDK.communityDataFlowByClientUserId(clientUserId = "your-user-id")
    .collect { data ->
        val messageCount: Int? = data?.messageCount
        val level: Int? = data?.gamification?.level
    }
```

The by-Octopus-id counterparts are `fetchCommunityData(userId)` and `communityDataFlow(userId)` (distinct names instead of overloads — the JVM signatures would be identical).

<!-- /tab -->

<!-- tab: iOS -->

```swift
// One-off refresh (async). Returns nil when the member is unknown;
// throws on lookup / network / server errors.
let data: OctopusCommunityData? =
    try await octopus.fetchCommunityData(clientUserId: "your-user-id")

// Observe reactively (the publisher never fails; a failed resolution emits nil):
octopus.communityDataPublisher(clientUserId: "your-user-id")
    .sink { data in
        let messageCount: Int? = data?.messageCount
        let level: Int? = data?.gamification?.level
    }
```

The by-Octopus-id counterparts are the `fetchCommunityData(profileId:)` and `communityDataPublisher(profileId:)` overloads.

<!-- /tab -->

<!-- tab: Flutter -->

```dart
// One-off refresh. Returns null when the member is unknown — but it refreshes
// from the server, so it can also *fail*: the future completes with an error on
// a network/server failure, or when the community does not expose client user
// ids and you passed `clientUserId`. Unknown member and failed lookup are two
// different outcomes here.
try {
    final OctopusCommunityData? data = await OctopusSDK().fetchCommunityData(
        clientUserId: 'your-user-id',
    );
    final int? messageCount = data?.messageCount;
    final int? level = data?.gamification?.level;
    // Render them on your own profile screen.
} on ArgumentError {
    // You passed both ids, or neither. A programming error — let it through
    // in debug rather than folding it into the "refresh failed" branch below.
    rethrow;
} catch (_) {
    // The refresh failed — keep showing whatever you already had.
}

// Observe reactively. Here both outcomes collapse to null: a resolution failure
// surfaces as null rather than as a stream error. Three things it does not
// absorb: the stream is single-subscription (a second `listen` throws), it does
// forward an error if the native observation cannot start at all — listening
// before `initialize()`, for instance — and it stops emitting after
// `switchCommunity`, so re-subscribe there.
OctopusSDK.communityDataFlow(clientUserId: 'your-user-id').listen((data) {
    final int? messageCount = data?.messageCount;
    final int? level = data?.gamification?.level;
});
```

Both take the member id as a **named parameter** rather than splitting into two method names: pass either `clientUserId:` or `profileId:`. Mind the asymmetry between them — `fetchCommunityData` is an **instance** method (`OctopusSDK()`), while `communityDataFlow` is **static** (`OctopusSDK.`).

:::warning
Exactly one of `profileId` / `clientUserId` must be passed. Passing both, or neither, raises an `ArgumentError` in every build — a programming error, not a runtime failure to handle.

The two APIs raise it differently, which matters for where you catch it. `communityDataFlow` throws **synchronously**, as the stream is built, so it never reaches a listener. `fetchCommunityData` is `async`, so its `ArgumentError` completes the returned future with an error like any other failure — a blanket `catch (_)` around the call swallows the programming error as if it were a transient refresh failure. Let it through, as the snippet above does.
:::

<!-- /tab -->

<!-- tab: React Native -->

```typescript
import {
    fetchCommunityData,
    startObservingCommunityData,
    stopObservingCommunityData,
    addCommunityDataListener,
} from '@octopus-community/react-native';

// One-off refresh. Resolves to null when the member is unknown; rejects on a
// network/server failure.
const data = await fetchCommunityData({ clientUserId: 'your-user-id' });
const messageCount: number | null = data?.messageCount ?? null;
const level: number | null = data?.gamification?.level ?? null;

// Observe reactively instead. Only one member can be observed at a time —
// starting a new observation replaces the previous one.
const subscription = addCommunityDataListener((data) => {
    const messageCount = data?.messageCount ?? null;
    const level = data?.gamification?.level ?? null;
});
await startObservingCommunityData({ profileId: octopusProfileId });
// ...later
await stopObservingCommunityData();
subscription.remove();
```

Both `fetchCommunityData` and `startObservingCommunityData` take a single `{ profileId }` or `{ clientUserId }` object rather than splitting into two function names. Exactly one of the two must be set — passing both or neither throws a plain `Error` **synchronously**, before any native call, on either function.

<!-- /tab -->

<!-- tab: Unity -->

*Unified Profile is not yet available on Unity.*

<!-- /tab -->

:::tip
The connected user's own client user id is also exposed on the profile object (`clientUserId`), alongside the fields described in [Read connected-user entitlements](#read-connected-user-entitlements). It is `null` for guests and for users authenticated in Octopus (magic-link) mode.
:::

---
## Explicit terms acceptance (≥ 1.13.0) {#terms-acceptance}

By default, a legal disclaimer is shown at the bottom of the post / comment / reply editor and acceptance is **implicit** when the user publishes. A community can instead require **explicit** acceptance of its legal documents (Terms of Use, Privacy Policy, Community Guidelines) via a consent sheet shown at the user's **first contribution**.

The mode is set **per community in the Octopus back office** — there is **nothing to integrate in your app**; the SDK renders the right experience automatically from the community configuration. Three modes:

- **Implicit** (default, unchanged) — the inline legal footer at the bottom of the editor; acceptance is implicit on publish.
- **Explicit — one checkbox per document** — a bottom sheet at the first contribution with one required checkbox per legal document.
- **Explicit — single combined checkbox** — the same sheet with a single combined checkbox plus a passive privacy-policy acknowledgement.

In an explicit mode the action button stays disabled until the required box(es) are ticked; on confirmation the content publishes and consent is recorded once per user, so the sheet is never shown again. Dismissing it keeps the editor untouched and publishes nothing. Existing communities are unaffected — they stay on the implicit default.

Available on **iOS, Android, Flutter, and React Native** since 1.13.0. Supported on Flutter and React Native: this behaviour is driven entirely by the community configuration and rendered by the embedded native UI — no additional wrapper API is required. Not yet available on Unity.

---
## Modify the theme

The Octopus SDK lets you modify its theme so its UI looks more like yours.

    You can modify:
    - the colors:

      **Please pass colors without any transparency.**
      - the primary colors (a main color, a low contrast and a high contrast variations of the main color). **If you do not pass a custom value for it, black/white default values will be used.**
      - the color of the elements (mostly texts) displayed over the primary color. **If you do not pass a custom value for it, white/black default value will be used.**
      - the color of links (URLs displayed in posts and comments) — the color scheme's `link` color (≥ 1.13.0). **If you do not pass a custom value for it, the SDK's default link color is used.**
    - the fonts. You can customize the styles (title1, body2, caption1...) used in the sdk. **If you do not pass a custom value for it, default value will be used.**
    - the logo. This is an image displayed on the Octopus home page and profile creation view. **If you do not pass a custom image for it, Octopus logo value will be used.**
    - the icons. You can override any icon (or group of icons) used across all SDK screens to match your app's design language. **If you do not pass a custom value for it, the SDK's built-in icons will be used.**
    - the TopAppBar. You can customize the title, alignment, and nav-bar background color. Each platform configures this differently — see the Customize the TopAppBar subsection below.

<!-- tab: Android -->

By default, Octopus will rely on your application's `MaterialTheme.colorScheme`, but you can customize the UI more precisely by surrounding composables with the `OctopusTheme`:

To do that, you can override the theme by passing it as an environment object:

```kotlin
octopusComposables(
    navController = navController
) { backStackEntry, content ->
    OctopusTheme(
        colorScheme = if (isSystemInDarkTheme()) {
            octopusDarkColorScheme(
                primary = yourDarkPrimaryColor, // Default: MaterialTheme.colorScheme.primary
                primaryLow = lowContrastVersionOfYourPrimaryColor, // Default: MaterialTheme.colorScheme.primaryContainer
                primaryHigh = highContrastVersionOfYourPrimaryColor, // Default: MaterialTheme.colorScheme.inversePrimary
                onPrimary = yourDarkOnPrimaryColor, // Default: MaterialTheme.colorScheme.onPrimary
                background = yourDarkBackgroundColor // Default: MaterialTheme.colorScheme.background
                // See the complete list in the sources documentation
            )
        } else {
            octopusLightColorScheme(
                primary = yourLightPrimaryColor, // Default: MaterialTheme.colorScheme.primary
                primaryLow = lowContrastVersionOfYourPrimaryColor, // Default: MaterialTheme.colorScheme.primaryContainer
                primaryHigh = highContrastVersionOfYourPrimaryColor, // Default: MaterialTheme.colorScheme.inversePrimary
                onPrimary = yourLightOnPrimaryColor, // Default: MaterialTheme.colorScheme.onPrimary
                background = yourLightBackgroundColor // Default: MaterialTheme.colorScheme.background
                // See the complete list in the sources documentation
            )
        },
        typography = OctopusTypographyDefaults.typography(
            title1 = yourCustomTitle1Style, // Default: TextStyle(fontSize = 26.sp)
            title2 = yourCustomTitle2Style  // Default: TextStyle(fontSize = 22.sp)
            // See the complete list in the sources documentation
        ),
        images = OctopusImagesDefaults.images(
            logo = painterResource(R.drawable.your_custom_logo), // Default: null
            icons = OctopusIconsDefaults.icons(
                // Override any icon or icon group here
            )
        )
        // ... Check the complete parameters list in the sources documentation
    ) {
        content()
    }
}
```
:::tip
Use the [OctopusThemeGenerator](https://github.com/Octopus-Community/octopus-sdk-android/blob/main/tools/src/main/java/com/octopuscommunity/tools/OctopusThemeConfigurator.kt) to configure your Community theme with a live preview of the various Octopus screens.
:::

All parameters of the theme have default values. Only override the ones that you want to customize. In the following example, you are creating an OctopusTheme with **default colors**, **default fonts except for the title1**, and a **custom logo**:

```kotlin {2-7}
OctopusTheme(
    typography = OctopusTypographyDefaults.typography(
        title1 = TextStyle(fontFamily = FontFamily.SansSerif, fontSize = 24.sp)
    ),
    images = OctopusImagesDefaults.images(
        logo = painterResource(R.drawable.your_custom_logo)
    )
) {
    OctopusHomeContent(...)
}
```

<!-- /tab -->

<!-- tab: iOS -->

To do that, you can override the theme by passing it as an environment object:

```swift
OctopusHomeScreen(octopus: octopus)
    .environment(
        \.octopusTheme,
        OctopusTheme(
            colors: .init(
                primarySet: OctopusTheme.Colors.ColorSet(
                    main: yourPrimaryColor,
                    lowContrast: lowContrastVersionOfYourPrimaryColor,
                    highContrast: highContrastVersionOfYourPrimaryColor
                ),
                onPrimary: contentOverPrimaryColor
            ),
            fonts: .init(
                title1: Font.custom("Courier New", size: 26),
                title2: Font.custom("Courier New", size: 20),
                body1: Font.custom("Courier New", size: 17),
                body2: Font.custom("Courier New", size: 14),
                caption1: Font.custom("Courier New", size: 12),
                caption2: Font.custom("Courier New", size: 10),
                navBarItem: Font.custom("Courier New", size: 17)
            ),
            assets: .init(
                logo: yourLogoAsUIImage,
                icons: .init(
                    common: .init(close: UIImage(named: "close"))
                )
            )
        )
    )
```

All parameters of the theme have default values. Only override the ones that you want to customize. In the following example, you are creating an OctopusTheme with **default colors**, **default fonts except for the title1**, **default icons** and a **custom logo**:

```swift
let octopusTheme = OctopusTheme(
    fonts: .init(
        title1: Font.custom("Courier New", size: 26)
    ),
    assets: .init(logo: yourLogoAsUIImage)
)
```

:::tip
If your app supports only light or dark mode, add `UIUserInterfaceStyle` to your Info.plist with the value `Light` or `Dark` to force the SDK to use the mode you specified.
:::

<!-- /tab -->

<!-- tab: Flutter -->

You can customize the Octopus theme by passing an `OctopusTheme` to the `OctopusHomeScreen` widget or `showOctopusHomeScreen()`:

```dart
OctopusHomeScreen(
    theme: OctopusTheme(
        primaryMain: Color(0xFF6200EE),
        primaryLowContrast: Color(0xFFBB86FC),
        primaryHighContrast: Color(0xFF3700B3),
        onPrimary: Colors.white,
        fontSizeTitle1: 26,
        fontSizeTitle2: 22,
        fontSizeBody1: 17,
        fontSizeBody2: 14,
        fontSizeCaption1: 12,
        fontSizeCaption2: 10,
        logoBase64: yourBase64EncodedLogo, // String? - base64 encoded image
        themeMode: OctopusThemeMode.dark, // OctopusThemeMode.light or .dark; omit to follow the system
    ),
)
```

All parameters of the theme have default values. Only override the ones that you want to customize. In the following example, you are creating an OctopusTheme with **default colors**, **default font sizes except for the title1**, and a **custom logo**:

```dart
OctopusTheme(
    fontSizeTitle1: 24,
    logoBase64: yourBase64EncodedLogo,
)
```

:::tip
Use the `themeMode` parameter to control light/dark mode: set it to `OctopusThemeMode.light` or `OctopusThemeMode.dark` to force a specific mode. Leave it unset (the default) to follow the system setting.
:::

#### Background and link colors (≥ 1.13.1)

Two more optional colors, wired to both native color schemes: `background` is the
community screens' background, `link` the color of clickable links in posts and
comments. Both default to `null`, which keeps the native SDK's own default.

```dart
OctopusTheme(
    background: Color(0xFFF7F7FB),
    link: Color(0xFF0B6BCB),
)
```

#### Custom font family and weight (≥ 1.13.1)

`fontFamily` and `fontWeight` apply to every SDK text style — titles, body,
captions — and to the top app bar / navigation bar title. `fontWeight` is a plain
100–900 `int` and needs no native setup; a value outside that range is rejected by
the `OctopusTheme` constructor.

```dart
OctopusTheme(
    fontFamily: 'Inter', // must ALSO be registered natively, under this exact name
    fontWeight: 600,
)
```

:::warning `fontFamily` is not read from the Flutter asset bundle
Declaring the font in `pubspec.yaml` is not enough. It must additionally be
registered natively, under the **exact same name**:

- **Android** — as a font resource under `android/app/src/main/res/font/`.
- **iOS** — as a PostScript name declared under `UIAppFonts` in
  `ios/Runner/Info.plist`.

An unregistered name is logged as a warning on both platforms and falls back to the
SDK's default font, rather than being silently ignored.
:::

Two platform notes on the navigation bar. On Android, the font override restyles
only the top app bar's *title* text — back and close are icon-only, with no font to
override — while on iOS it also affects the navigation bar's icon buttons. And
setting `fontFamily` or `fontWeight` currently also drops the Android navigation-bar
title to the size of `fontSizeBody1`, which is smaller than the title size applied
with no font override; `fontSizeBody1` controls the resulting size.

#### Navigation-bar item size — iOS only (≥ 1.13.1)

`fontSizeNavBarItem` sets the font size of navigation-bar items. The native iOS
theme has a dedicated slot for them; the native Android typography has no
counterpart, so **the Android bridge ignores this key** rather than approximating it
by resizing another slot. Left unset, nav-bar items follow `fontSizeBody1`. It
reaches both theme consumption paths, so it applies to
`showOctopusCreatePostScreen` too, not only to the embedded community view.

:::info iOS theming changed in 1.13.1
Before 1.13.1, the iOS bridge substituted a value for every theme slot the host left
unset, so a partial `OctopusTheme` silently redefined the rest of the theme: colors
became a fixed blue instead of the SDK's adaptive palette, and the six font sizes
became `26 / 20 / 17 / 14 / 12 / 10` — never the SDK's own defaults
(`26 / 22 / 18 / 16 / 14 / 12`), and fixed where the native defaults scale with the
reader's text-size setting. An unset slot now keeps the SDK's own default, and an
explicit size is a *base* size on both platforms rather than a frozen one.

This also affects a theme carrying nothing but `themeMode`, which used to yield that
blue primary and those invented type sizes. If your iOS build was tuned against the
old substituted values, this is the release where that compensation becomes visible.
:::

<!-- /tab -->

<!-- tab: React Native -->

Pass a `theme` object inside the `initialize()` call. The theme supports colors, fonts, and a logo.

You can pass a single color set (applied to both light and dark modes) or separate sets for each mode:

```typescript
import { initialize } from '@octopus-community/react-native';
import { Image } from 'react-native';

await initialize({
    apiKey: 'YOUR_API_KEY',
    connectionMode: { type: 'sso', appManagedFields: [] },
    theme: {
        colors: {
            light: {
                primary: '#FF6B35',
                primaryLowContrast: '#FFB899',
                primaryHighContrast: '#CC4400',
                onPrimary: '#FFFFFF',
            },
            dark: {
                primary: '#FF8F5E',
                primaryLowContrast: '#CC7244',
                primaryHighContrast: '#FFB899',
                onPrimary: '#1A1A1A',
            },
        },
        fonts: {
            textStyles: {
                title1: { fontType: 'serif', fontSize: { size: 26 } },
                title2: { fontType: 'default', fontSize: { size: 22 } },
                body1: { fontSize: { size: 17 } },
                body2: { fontSize: { size: 14 } },
                caption1: { fontSize: { size: 12 } },
                caption2: { fontSize: { size: 10 } },
            },
        },
        logo: {
            image: Image.resolveAssetSource(require('./assets/logo.png')),
        },
    },
});
```

:::info Dark / Light mode
The React Native SDK automatically follows the device's system appearance (light or dark mode). There is no SDK-level `colorScheme` override — to control appearance, provide separate `light` and `dark` color sets as shown above, and the SDK will select the appropriate set based on the system setting.
:::

All parameters of the theme have default values. Only override the ones that you want to customize. In the following example, you are creating an OctopusTheme with **default colors**, **default fonts except for the title1**, and a **custom logo**:

```typescript
await initialize({
    apiKey: 'YOUR_API_KEY',
    connectionMode: { type: 'sso', appManagedFields: [] },
    theme: {
        fonts: {
            textStyles: {
                title1: { fontType: 'serif', fontSize: { size: 24 } },
            },
        },
        logo: {
            image: Image.resolveAssetSource(require('./assets/logo.png')),
        },
    },
});
```

<!-- /tab -->

<!-- tab: Unity -->

Unity supports two ways to theme the SDK.

<details>
<summary>Option 1 — Unity Editor (no code)</summary>

Open **Octopus SDK > Theme Configuration** from the Unity menu bar. Colors, logos, and fonts selected in this window are automatically imported as native Android/iOS resources at build time.

The window has four tabs: **Top Bar**, **Fonts**, **Colors**, and **Behavior**. The **Behavior** tab holds non-visual settings — today **Forced Orientation** (see [Force the community orientation](#force-the-community-orientation) below).
</details>

<details>
<summary>Option 2 — Runtime API (OctopusSDK.SetTheme)</summary>

```csharp
OctopusSDK.SetTheme(
    colorScheme: new OctopusColorScheme(
        primary:     new Color32(255, 0, 0, 255),
        primaryLow:  new Color32(255, 179, 179, 255),
        primaryHigh: new Color32(204, 0, 0, 255),
        onPrimary:   new Color32(255, 255, 255, 255)
    ),
    logo: new OctopusLogo(
        androidDrawableName: "my_logo",
        iOSResourceName: "Data/Raw/my_logo.png"
    ),
    fonts: new OctopusFonts(
        title1: new OctopusFont("onest_extralight", "Onest-ExtraLight", 18),
        title2: new OctopusFont("onest_extralight", "Onest-ExtraLight", 12)
    )
);
```

:::warning
Logos and fonts are **not** standard Unity assets. If you use the runtime API you must add the underlying native resources (Android drawables, iOS bundle resources) yourself. The Editor-based option does this automatically.
:::

Orientation is **not** a parameter of `SetTheme(...)` — it has its own setter, `OctopusSDK.SetForcedOrientation(int)`, described in [Force the community orientation](#force-the-community-orientation) below.
</details>

#### Force the community orientation (≥ 1.12.5) \{#force-the-community-orientation}

If your game runs in a fixed orientation but you want the Octopus community UI shown in a different one — for example a portrait community inside a landscape game — you can lock the community to `Portrait` or `Landscape`. The default, `None`, lets the community follow the game/device.

The forced orientation applies **only** to the Octopus community UI. Your game keeps its own orientation, and it is restored automatically when the community is closed.

<details>
<summary>Option 1 — Unity Editor (Behavior tab)</summary>

Open **Octopus SDK > Theme Configuration > Behavior** and set **Forced Orientation** to `None`, `Portrait`, or `Landscape`. The value is stored on the `OctopusThemeSettings` asset and applied automatically at initialization.
</details>

<details>
<summary>Option 2 — Runtime API (OctopusSDK.SetForcedOrientation)</summary>

`OctopusSDK.SetForcedOrientation(int forcedOrientation)` takes the same encoding as the Editor setting: `0` = None (follow the game/device), `1` = Portrait, `2` = Landscape. Use the `OctopusThemeSettings.ForcedOrientationType` enum rather than a raw literal:

```csharp
using UnityEngine;

public class CommunityLauncher : MonoBehaviour
{
    void Start()
    {
        OctopusSDK.Initialize("YOUR_API_KEY", ConnectionMode.SSO());

        // The game runs in landscape, but we want the community in portrait.
        OctopusSDK.SetForcedOrientation((int)OctopusThemeSettings.ForcedOrientationType.Portrait);
    }
}
```

Call it any time after `Initialize`; the value is read when the community is opened, so a change applies to the next opening.
</details>

:::info iOS 16+ required
On iOS this feature relies on the public `requestGeometryUpdate` API, available from **iOS 16**. On earlier versions the setting is a no-op and the community follows the game/device, exactly as before.
:::

:::info Android 8.0 (API 26)
Android 8.0 cannot lock the orientation of a translucent activity — a platform limitation, relaxed in API 27. The community opens **unlocked** on that OS version only; API 27+ behaves as expected.
:::

:::warning iOS builds using only the runtime API
On iOS the app's `Info.plist` must allow the forced orientation. The Editor path widens `UISupportedInterfaceOrientations` automatically at build time, based on the **Behavior** tab value. If you set the orientation **only** through `SetForcedOrientation` at runtime, set the matching orientation in your Unity Player Settings (or the generated `Info.plist`) yourself — otherwise the community cannot rotate to it. Adding the orientation does not rotate your game: Unity's own view controller keeps reporting its Player Settings orientation.
:::

<!-- /tab -->

Here is a summary of the impacts of the theme you choose:
![Light Mode](/img/Lightmode.png)
![Dark Mode](/img/Darkmode.png)

Here is a summary of the text styles used in the main screens of the SDK:
![Text Styles](/img/text_styles.png)

### Customize Icons (≥ 1.10.0) {#customize-icons}

All icons used inside the Octopus Community UI can be replaced with your own assets. Icons  will be **automatically tinted** by the SDK — the colors of your assets will be ignored. Icons should ideally be **24×24 pixels** with drawn content of **14.5×14.5** (i.e. 4.75 px transparent border on each side) and **square**. If your icon is not square, it will be displayed in fit mode and may appear smaller than expected.

If you do not override an icon, the SDK default will be used.

Icons are organized into groups matching different areas of the UI: **groups**, **content** (posts, comments, replies, video, polls), **profile**, **gamification**, **settings**, and **common** (radio buttons, checkboxes, toggles, close, more actions).

:::warning
As the icons are linked to the UI and the UI can change quite often, this API might change often, some icons won't be used anymore and will be quickly deprecated.
:::

<!-- tab: Android -->

Icons are organized in a hierarchy under `OctopusIcons`. The structure mirrors the UI areas: **groups**, **content** (posts, comments, replies, video, polls), **profile**, **notifications**, **gamification**, and **settings**. Cross-cutting icons (`radio`, `checkbox`, `toggle`, `close`, `moreActions`, `report`, `cellNavIndicator`) live at the top level of `OctopusIcons` directly — Android does not group them under a `common` namespace.

All icon parameters are optional — only specify the ones you want to change. Pass the custom theme to the SDK by wrapping the Octopus composable in an `OctopusTheme`:

```kotlin
import com.octopuscommunity.sdk.ui.OctopusIconsDefaults
import com.octopuscommunity.sdk.ui.OctopusImagesDefaults
import com.octopuscommunity.sdk.ui.OctopusTheme

OctopusTheme(
    images = OctopusImagesDefaults.images(
        icons = OctopusIconsDefaults.icons(/* … */)
    )
) {
    OctopusHomeContent(/* … */)
}
```

#### Override icons within a specific group

You can customize only the icons you need. Unspecified parameters keep SDK defaults. For example, to customize some post creation icons:

```kotlin
import com.octopuscommunity.sdk.ui.OctopusIconsDefaults
import com.octopuscommunity.sdk.ui.OctopusImagesDefaults
import com.octopuscommunity.sdk.ui.OctopusTheme

OctopusTheme(
    images = OctopusImagesDefaults.images(
        icons = OctopusIconsDefaults.icons(
            content = OctopusIconsDefaults.content(
                post = OctopusIconsDefaults.post(
                    creation = OctopusIconsDefaults.postCreation(
                        create = painterResource(R.drawable.your_create_post_icon),
                        addPicture = painterResource(R.drawable.your_add_picture_icon),
                        addPoll = painterResource(R.drawable.your_add_poll_icon)
                    )
                )
            )
        )
    )
) {
    OctopusHomeContent(...)
}
```

#### Convenience parameters (shared icons)

Some icons share the same meaning across different parts of the UI (for example, the "report" icon appears in both content and profile contexts). To make it easy to replace all related icons at once, the `OctopusIconsDefaults.icons()` and `OctopusIconsDefaults.content()` helpers provide **top-level parameters** that propagate to nested groups:

- `OctopusIconsDefaults.icons()` provides:
  - **`report`** → applies to `content.report` and `profile.report`
  - **`close`** → applies to `content.deletePicture` (which itself propagates to `post.creation.deletePicture`, `comment.creation.deletePicture` and `reply.creation.deletePicture`)
- `OctopusIconsDefaults.content()` provides:
  - **`notAvailable`** → applies to `post.notAvailable` and `comment.notAvailable`
  - **`likeNotSelected`** → applies to `post.likeNotSelected`, `comment.likeNotSelected` and `reply.likeNotSelected`
  - **`addPicture`** → applies to `post.creation.addPicture`, `comment.creation.addPicture` and `reply.creation.addPicture`
  - **`deletePicture`** → applies to `post.creation.deletePicture`, `comment.creation.deletePicture` and `reply.creation.deletePicture`
  - **`send`** → applies to `comment.creation.create` and `reply.creation.create`
  - **`openResponseCreation`** → applies to `post.openCommentCreation` and `comment.openReplyCreation`

Individual icon overrides always take priority over convenience parameters.

**Example** — replace the report icon everywhere with a single line:

```kotlin
import com.octopuscommunity.sdk.ui.OctopusIconsDefaults
import com.octopuscommunity.sdk.ui.OctopusImagesDefaults
import com.octopuscommunity.sdk.ui.OctopusTheme

OctopusTheme(
    images = OctopusImagesDefaults.images(
        icons = OctopusIconsDefaults.icons(
            report = painterResource(R.drawable.your_custom_report_icon)
        )
    )
) {
    OctopusHomeContent(...)
}
```

**Example** — set a default "add picture" icon for all creation forms, but override it specifically for post creation:

```kotlin
import com.octopuscommunity.sdk.ui.OctopusIconsDefaults
import com.octopuscommunity.sdk.ui.OctopusImagesDefaults
import com.octopuscommunity.sdk.ui.OctopusTheme

OctopusTheme(
    images = OctopusImagesDefaults.images(
        icons = OctopusIconsDefaults.icons(
            content = OctopusIconsDefaults.content(
                post = OctopusIconsDefaults.post(
                    creation = OctopusIconsDefaults.postCreation(
                        addPicture = painterResource(R.drawable.my_post_add_picture)
                    )
                ),
                // Applies to comment and reply creation (but not post, since it's overridden above)
                addPicture = painterResource(R.drawable.my_generic_add_picture)
            )
        )
    )
) {
    OctopusHomeContent(...)
}
```

#### Customize radio, checkbox, and toggle icons

By default, the SDK uses native Material3 `RadioButton`, `Checkbox`, and `Switch` widgets. You can replace them with custom icon pairs by providing an `OctopusIcons.OnOff` instance:

```kotlin
import com.octopuscommunity.sdk.ui.OctopusIcons
import com.octopuscommunity.sdk.ui.OctopusIconsDefaults
import com.octopuscommunity.sdk.ui.OctopusImagesDefaults
import com.octopuscommunity.sdk.ui.OctopusTheme

OctopusTheme(
    images = OctopusImagesDefaults.images(
        icons = OctopusIconsDefaults.icons(
            radio = OctopusIcons.OnOff(
                on = painterResource(R.drawable.your_radio_selected),
                off = painterResource(R.drawable.your_radio_unselected)
            ),
            checkbox = OctopusIcons.OnOff(
                on = painterResource(R.drawable.your_checkbox_checked),
                off = painterResource(R.drawable.your_checkbox_unchecked)
            ),
            toggle = OctopusIcons.OnOff(
                on = painterResource(R.drawable.your_toggle_on),
                off = painterResource(R.drawable.your_toggle_off)
            )
        )
    )
) {
    OctopusHomeContent(...)
}
```

:::info
When `radio`, `checkbox`, or `toggle` is `null` (the default), the native Material3 component is used. Pass an `OctopusIcons.OnOff` instance to replace it with your custom painters.
:::

#### Customize reaction images (≥ 1.11.0)

The reaction images displayed on posts, comments, and replies (`heart`, `joy`, `mouthOpen`, `clap`, `cry`, `rage`) can be replaced individually. Like the other icons, all parameters are optional — only specify the ones you want to change, and unspecified ones keep the SDK defaults.

:::info
Unlike other icons, **reaction images are rendered with their original colors** — they are not tinted by the SDK. Provide assets that already match your visual identity (color, shading, etc.).
:::

:::warning
Each replacement image must visually match the meaning of the reaction it represents. The reaction's display name (`heart` → **"Like"**, `joy` → **"Haha"**, `mouthOpen` → **"Wow"**, `clap` → **"Celebrate"**, `cry` → **"Sad"**, `rage` → **"Angry"**) appears as text alongside the image in some surfaces, and is used as the **content description** read by TalkBack. For example, replacing `cry` with a party 🎉 image would show a party icon labelled "Sad" — confusing on screen and misleading for accessibility users.
:::

```kotlin
import com.octopuscommunity.sdk.ui.OctopusIconsDefaults
import com.octopuscommunity.sdk.ui.OctopusImagesDefaults
import com.octopuscommunity.sdk.ui.OctopusTheme

OctopusTheme(
    images = OctopusImagesDefaults.images(
        icons = OctopusIconsDefaults.icons(
            content = OctopusIconsDefaults.content(
                reaction = OctopusIconsDefaults.reaction(
                    heart = { painterResource(R.drawable.my_heart_reaction) },
                    joy = { painterResource(R.drawable.my_joy_reaction) },
                    mouthOpen = { painterResource(R.drawable.my_mouth_open_reaction) },
                    clap = { painterResource(R.drawable.my_clap_reaction) },
                    cry = { painterResource(R.drawable.my_cry_reaction) },
                    rage = { painterResource(R.drawable.my_rage_reaction) }
                )
            )
        )
    )
) {
    OctopusHomeContent(...)
}
```

#### Full icon customization example

You can combine all the above into a single `OctopusTheme` setup:

```kotlin
import com.octopuscommunity.sdk.ui.OctopusIcons
import com.octopuscommunity.sdk.ui.OctopusIconsDefaults
import com.octopuscommunity.sdk.ui.OctopusImagesDefaults
import com.octopuscommunity.sdk.ui.OctopusTheme

OctopusTheme(
    images = OctopusImagesDefaults.images(
        logo = painterResource(R.drawable.your_custom_logo),
        icons = OctopusIconsDefaults.icons(
            close = painterResource(R.drawable.your_close_icon),
            moreActions = painterResource(R.drawable.your_more_actions_icon),
            report = painterResource(R.drawable.your_report_icon),
            content = OctopusIconsDefaults.content(
                addPicture = painterResource(R.drawable.your_add_picture_icon)
            ),
            profile = OctopusIconsDefaults.profile(
                defaultAvatar = painterResource(R.drawable.your_default_avatar)
            ),
            radio = OctopusIcons.OnOff(
                on = painterResource(R.drawable.your_radio_on),
                off = painterResource(R.drawable.your_radio_off)
            ),
            checkbox = OctopusIcons.OnOff(
                on = painterResource(R.drawable.your_checkbox_on),
                off = painterResource(R.drawable.your_checkbox_off)
            ),
            toggle = OctopusIcons.OnOff(
                on = painterResource(R.drawable.your_toggle_on),
                off = painterResource(R.drawable.your_toggle_off)
            )
        )
    )
) {
    OctopusHomeContent(...)
}
```

<!-- /tab -->

<!-- tab: iOS -->

    
Icons are organized in a hierarchy under `OctopusTheme.Assets.Icons`. The structure mirrors the UI areas: **groups**, **content** (posts, comments, replies, video, polls), **profile**, **gamification**, **settings**, and **common** (radio buttons, checkboxes, toggles, close, more actions).

All icon parameters are optional `UIImage?` — only specify the ones you want to change. Pass the custom theme to the SDK using the `.environment` modifier:

```swift
OctopusUIView(octopus: octopus)
    .environment(\.octopusTheme, myCustomTheme)
```

#### Override icons within a specific group

You can customize only the icons you need. Unspecified parameters keep SDK defaults. For example, to customize some post creation icons:

```swift
import OctopusUI

let theme = OctopusTheme(
    assets: .init(
        icons: .init(
            content: .init(
                post: .init(
                    creation: .init(
                        open: UIImage(named: "myCreatePostIcon"),
                        addPoll: UIImage(named: "myAddPollIcon")
                    ),
                    commentCount: UIImage(named: "myCommentCountIcon")
                ),
                delete: UIImage(named: "myDeleteIcon")
            )
        )
    )
)
```

#### Convenience parameters (shared icons)

Some icons share the same meaning across different parts of the UI (for example, the "report" icon appears in both content and profile contexts). To make it easy to replace all related icons at once, the `Icons` and `Content` initializers provide **convenience parameters**:

- `Icons.init` provides **`defaultReport`**: when set, it applies to both `content.report` and `profile.report` — unless you override those individually.
- `Content.init` provides:
  - **`defaultNotAvailable`** → applies to `post.notAvailable` and `comment.notAvailable`
  - **`defaultLikeNotSelected`** → applies to `post.likeNotSelected`, `comment.likeNotSelected` and `reply.likeNotSelected`
  - **`defaultAddPicture`** → applies to `post.creation.addPicture`, `comment.creation.addPicture`, and `reply.creation.addPicture`
  - **`defaultDeletePicture`** → applies to `post.creation.deletePicture`, `comment.creation.deletePicture`, and `reply.creation.deletePicture`
  - **`defaultCreateResponse`** → applies to `comment.creation.create` and `reply.creation.create`
  - **`defaultOpenResponseCreation`** → applies to `comment.creation.open` and `reply.creation.open`

Individual icon overrides always take priority over convenience parameters.

**Example** — replace the report icon everywhere with a single line:

```swift
import OctopusUI

let theme = OctopusTheme(
    assets: .init(
        icons: .init(
            defaultReport: UIImage(named: "myReportIcon")
        )
    )
)
```

**Example** — set a default "add picture" icon for all creation forms, but override it specifically for post creation:

```swift
import OctopusUI

let theme = OctopusTheme(
    assets: .init(
        icons: .init(
            content: .init(
                post: .init(
                    creation: .init(
                        addPicture: UIImage(named: "myPostAddPictureIcon")
                    )
                ),
                // Applies to comment and reply creation (but not post, since it's overridden above)
                defaultAddPicture: UIImage(named: "myGenericAddPictureIcon")
            )
        )
    )
)
```

#### Customize radio, checkbox, and toggle icons

The `common` group contains `OnOff` components for radio buttons, checkboxes, and toggles. Unlike other icons, `OnOff` requires **two non-optional `UIImage` parameters** (`on` and `off`):

```swift
import OctopusUI

let theme = OctopusTheme(
    assets: .init(
        icons: .init(
            common: .init(
                radio: .init(
                    on: UIImage(named: "myRadioOnIcon")!,
                    off: UIImage(named: "myRadioOffIcon")!
                ),
                checkbox: .init(
                    on: UIImage(named: "myCheckboxOnIcon")!,
                    off: UIImage(named: "myCheckboxOffIcon")!
                ),
                toggle: .init(
                    on: UIImage(named: "myToggleOnIcon")!,
                    off: UIImage(named: "myToggleOffIcon")!
                )
            )
        )
    )
)
```

#### Customize reaction images (≥ 1.11.0)

The reaction images displayed on posts, comments, and replies (`heart`, `joy`, `mouthOpen`, `clap`, `cry`, `rage`) can be replaced individually. Like the other icons, all parameters are optional — only specify the ones you want to change, and unspecified ones keep the SDK defaults.

:::info
Unlike other icons, **reaction images are rendered with their original colors** — they are not tinted by the SDK. Provide assets that already match your visual identity (color, shading, etc.).
:::

:::warning
Each replacement image must visually match the meaning of the reaction it represents. The reaction's display name (`heart` → **"Like"**, `joy` → **"Haha"**, `mouthOpen` → **"Wow"**, `clap` → **"Celebrate"**, `cry` → **"Sad"**, `rage` → **"Angry"**) appears as text alongside the image in some surfaces, and is used as the **accessibility label** read by VoiceOver. For example, replacing `cry` with a party 🎉 image would show a party icon labelled "Sad" — confusing on screen and misleading for VoiceOver users.
:::

```swift
import OctopusUI

let theme = OctopusTheme(
    assets: .init(
        icons: .init(
            content: .init(
                reaction: .init(
                    heart: UIImage(named: "myHeartReaction"),
                    joy: UIImage(named: "myJoyReaction"),
                    mouthOpen: UIImage(named: "myMouthOpenReaction"),
                    clap: UIImage(named: "myClapReaction"),
                    cry: UIImage(named: "myCryReaction"),
                    rage: UIImage(named: "myRageReaction")
                )
            )
        )
    )
)
```

#### Full icon customization example

You can customize icons across all groups in a single `OctopusTheme`:

```swift
import OctopusUI

let icons = OctopusTheme.Assets.Icons(
    groups: .init(
        openList: UIImage(named: "myGroupListIcon"),
        selected: UIImage(named: "myGroupSelectedIcon")
    ),
    content: .init(
        post: .init(
            creation: .init(
                open: UIImage(named: "myCreatePostIcon"),
                topicSelection: UIImage(named: "myTopicArrowIcon"),
                addPicture: UIImage(named: "myPostAddPictureIcon"),
                deletePicture: UIImage(named: "myPostDeletePictureIcon"),
                addPoll: UIImage(named: "myAddPollIcon"),
                addPollOption: UIImage(named: "myAddPollOptionIcon"),
                deletePoll: UIImage(named: "myDeletePollIcon"),
                deletePollOption: UIImage(named: "myDeletePollOptionIcon")
            ),
            emptyFeedInGroups: UIImage(named: "myEmptyGroupFeedIcon"),
            emptyFeedInCurrentUserProfile: UIImage(named: "myEmptyCurrentUserFeedIcon"),
            emptyFeedInOtherUserProfile: UIImage(named: "myEmptyOtherUserFeedIcon"),
            notAvailable: UIImage(named: "myPostNotAvailableIcon"),
            commentCount: UIImage(named: "myCommentCountIcon"),
            viewCount: UIImage(named: "myViewCountIcon"),
            moreReactions: UIImage(named: "myMoreReactionsIcon"),
            likeNotSelected: UIImage(named: "myPostLikeIcon"),
            moderated: UIImage(named: "myPostModerated")

        ),
        comment: .init(
            creation: .init(
                open: UIImage(named: "myOpenCommentCreationIcon"),
                create: UIImage(named: "mySendCommentIcon"),
                addPicture: UIImage(named: "myCommentAddPictureIcon"),
                deletePicture: UIImage(named: "myCommentDeletePictureIcon")
            ),
            emptyFeed: UIImage(named: "myNoCommentsIcon"),
            notAvailable: UIImage(named: "myCommentNotAvailableIcon"),
            seeReply: UIImage(named: "mySeeReplyIcon"),
            likeNotSelected: UIImage(named: "myCommentLikeIcon")
        ),
        reply: .init(
            creation: .init(
                open: UIImage(named: "myOpenReplyCreationIcon"),
                create: UIImage(named: "mySendReplyIcon"),
                addPicture: UIImage(named: "myReplyAddPictureIcon"),
                deletePicture: UIImage(named: "myReplyDeletePictureIcon")
            ),
            likeNotSelected: UIImage(named: "myReplyLikeIcon")
        ),
        video: .init(
            muted: UIImage(named: "myMutedIcon"),
            notMuted: UIImage(named: "myNotMutedIcon"),
            pause: UIImage(named: "myPauseIcon"),
            play: UIImage(named: "myPlayIcon"),
            replay: UIImage(named: "myReplayIcon")
        ),
        poll: .init(
            selectedOption: UIImage(named: "myCheckIcon")
        ),
        reaction: .init(
            heart: UIImage(named: "myHeartReaction"),
            joy: UIImage(named: "myJoyReaction"),
            mouthOpen: UIImage(named: "myMouthOpenReaction"),
            clap: UIImage(named: "myClapReaction"),
            cry: UIImage(named: "myCryReaction"),
            rage: UIImage(named: "myRageReaction")
        ),
        delete: UIImage(named: "myDeleteIcon"),
        report: UIImage(named: "myContentReportIcon")
    ),
    profile: .init(
        addPicture: UIImage(named: "myAddAvatarIcon"),
        editPicture: UIImage(named: "myEditAvatarIcon"),
        addBio: UIImage(named: "myAddBioIcon"),
        emptyNotifications: UIImage(named: "myNoNotificationsIcon"),
        report: UIImage(named: "myProfileReportIcon"),
        notConnected: UIImage(named: "myNotConnectedIcon"),
        blockUser: UIImage(named: "myBlockUserIcon")
    ),
    gamification: .init(
        badge: UIImage(named: "myBadgeIcon"),
        info: UIImage(named: "myGamificationInfoIcon"),
        rulesHeader: UIImage(named: "myRulesHeaderIcon")
    ),
    settings: .init(
        account: UIImage(named: "myAccountIcon"),
        help: UIImage(named: "myHelpIcon"),
        info: UIImage(named: "myInfoIcon"),
        logout: UIImage(named: "myLogoutIcon"),
        deleteAccountWarning: UIImage(named: "myDeleteAccountIcon")
    ),
    common: .init(
        radio: .init(
            on: UIImage(named: "myRadioOnIcon")!,
            off: UIImage(named: "myRadioOffIcon")!
        ),
        checkbox: .init(
            on: UIImage(named: "myCheckboxOnIcon")!,
            off: UIImage(named: "myCheckboxOffIcon")!
        ),
        toggle: .init(
            on: UIImage(named: "myToggleOnIcon")!,
            off: UIImage(named: "myToggleOffIcon")!
        ),
        close: UIImage(named: "myCloseIcon"),
        moreActions: UIImage(named: "myMoreActionsIcon"),
        listCellNavIndicator: UIImage(named: "myListCellNavIndicatorIcon")
    )
)

let theme = OctopusTheme(
    assets: .init(icons: icons)
)
```

:::note
In this full example, `report` is set individually on `content` and `profile`. You could instead use `defaultReport` at the `Icons` level to apply the same report icon to both (see [Convenience parameters](#convenience-parameters-shared-icons) above).
:::

:::note
The `account`, `logout`, and `deleteAccountWarning` icons inside `settings` are only displayed when using Octopus authentication mode. They are not shown in SSO mode.
:::

<!-- /tab -->

<!-- tab: Flutter -->

Icon customization is not yet available on Flutter.

#### Customize reaction images

*Per-reaction image overrides are not yet available on Flutter.*

<!-- /tab -->

<!-- tab: Unity -->

Icon customization is not yet available on Unity.

<!-- /tab -->

### Customize the TopAppBar

<!-- tab: Android -->

You can customize the title displayed in the navigation bar on the main feed screen directly from the `OctopusHomeScreen` composable. It accepts three parameters for this:
- `titleText`: an optional `String` that overrides the title text shown in the nav bar. When `null` (default), falls back to the SDK default title (or nothing if a logo is displayed). **We highly recommend keeping the text under 18 characters** for best results.
- `titleCentered`: if `true`, the title is centered in the nav bar. If `false` (default), the title is left-aligned (leading).
- `logo`: an optional `@Composable () -> Painter` that provides a custom logo to display in the nav bar. When `null` (default), the logo from your `OctopusTheme` is used.

Here is how to display a leading text title:

```kotlin
OctopusHomeScreen(
    navController = navController,
    titleText = "App Name",
    titleCentered = false
)
```

And here is how to display a centered logo:

```kotlin
OctopusHomeScreen(
    navController = navController,
    titleCentered = true,
    logo = { painterResource(R.drawable.your_logo) }
)
```

To show a host-driven leading navigation icon on the home root — for example a **Close** icon when you present `OctopusHomeScreen` inside a modal / bottom sheet and need a dismiss affordance — pass `leadingNavigationIcon` (≥ 1.12.3) (`NavigationIconType.Close` or `NavigationIconType.Back`). It fires `onBack` on tap and overrides `backIcon` when set. This is the Android counterpart to the iOS `navBarLeadingAction`.

```kotlin
OctopusHomeScreen(
    navController = navController,
    leadingNavigationIcon = NavigationIconType.Close, // or NavigationIconType.Back
    onBack = { /* dismiss your modal / pop your route */ },
)
```

:::info
For more advanced customization of the `TopAppBar` (colors, navigation icons, text styles), you can provide your own `OctopusTopAppBar` configuration at the theme level. See the [Advanced Screen-Based Theming](#advanced-screen-based-theming-android-only) section below.
:::

<!-- /tab -->

<!-- tab: iOS -->

You can customize the title displayed in the navigation bar on the main feed screen using `OctopusMainFeedTitle`. This struct lets you control both the **content** and the **placement** of the title.

The `OctopusHomeScreen` constructor accepts two parameters for this:
- `mainFeedNavBarTitle`: an optional `OctopusMainFeedTitle` that defines what is shown in the nav bar. When `nil` (default), nothing is displayed. It has two properties:
    - `content` — either `.logo` to display the logo from your `OctopusTheme` (see [Modify the theme](#modify-the-theme)), or `.text(TextTitle)` to display a text. **We highly recommend keeping the text under 18 characters** for best results. If set to `.logo` and no custom logo is set in your `OctopusTheme`, the title slot is empty regardless of placement.
    - `placement` — either `.leading` (left-aligned) or `.center` (centered).
- `mainFeedColoredNavBar`: if `true`, the navigation bar background uses the primary theme color (see [Modify the theme](#modify-the-theme)). Requires iOS 16+; on earlier versions this parameter is ignored. Default is `false`.

Here is how to display a leading text title with a colored nav bar:

```swift
OctopusHomeScreen(
    octopus: octopus,
    mainFeedNavBarTitle: .init(
        content: .text(.init(text: "App Name")),
        placement: .leading
    ),
    mainFeedColoredNavBar: true
)
```

And here is how to display a centered logo:

```swift
OctopusHomeScreen(
    octopus: octopus,
    mainFeedNavBarTitle: .init(
        content: .logo,
        placement: .center
    )
)
```

:::caution
The `navBarLeadingItem` and `navBarPrimaryColor` parameters are deprecated. Use `mainFeedNavBarTitle` and `mainFeedColoredNavBar` instead. Existing code using the old parameters will continue to compile but will produce a deprecation warning.
:::

To see a full example of how you can achieve that, you can follow how it is done in the Samples, in the [Scenario "Custom theme"](https://github.com/Octopus-Community/octopus-sdk-swift/tree/main/Sample/OctopusSample/UI/Scenarios/CustomTheme).

#### Leading navigation button

`OctopusHomeScreen`'s built-in close button is only shown when the view is presented natively via `.sheet` or `.fullScreenCover`. When you push `OctopusHomeScreen` onto your own `UINavigationController` stack, or mount it inside a Flutter / React Native plugin via `UIHostingController`, no dismiss affordance is provided. In those cases, pass `navBarLeadingAction` to show a host-driven button in the leading nav-bar slot of the root screen.

Two cases are available:
- `.close(onTap:)` — renders the SDK's close icon (e.g. for a modally-presented host container that is not a native SwiftUI sheet).
- `.back(onTap:)` — renders a back chevron (e.g. for a host navigation route where the SDK is pushed).

The closure fires instead of dismissing a SwiftUI presentation — it is the host's responsibility to pop its route or dismiss its container.

**Close button** for a UIKit/hybrid modal host:

```swift
OctopusHomeScreen(
    octopus: octopus,
    navBarLeadingAction: .close(onTap: {
        // Dismiss your UIViewController or hybrid modal here
    })
)
```

**Back button** when pushed on a UINavigationController:

```swift
OctopusHomeScreen(
    octopus: octopus,
    navBarLeadingAction: .back(onTap: {
        // Pop your UINavigationController or hybrid route here
    })
)
```

:::info
When `navBarLeadingAction` is `nil` (the default), behavior is unchanged — the SDK renders its own close button when `presentationMode.isPresented` is true (i.e. inside a `.sheet` or `.fullScreenCover`). Pass `navBarLeadingAction` only when the SDK is hosted outside a native SwiftUI presentation context.
:::

:::info
When `navBarLeadingAction` is set alongside `mainFeedNavBarTitle`, the leading slot is occupied by the action button and the feed title is automatically repositioned to the centered slot — it is not lost.
:::

<!-- /tab -->

<!-- tab: Flutter -->

You can customize the navigation bar of the Octopus UI by passing parameters to the `OctopusHomeScreen` widget:

```dart
OctopusHomeScreen(
    navBarTitle: "My Community", // Short text title (less than 18 characters)
    navBarPrimaryColor: true, // Use primary color as background
    showBackButton: true, // Show the back button
    onBack: () { ... },
    titleCentered: true, // ≥ 1.12.0 — center the title
    navBarLeadingAction: OctopusNavBarLeadingAction.close, // ≥ 1.12.0 (iOS), ≥ 1.12.2 (Android)
    navigationMode: OctopusNavigationMode.navigationStack, // ≥ 1.12.0 — iOS-only
)
```

`titleCentered` is honored on both Android and iOS.

`navBarLeadingAction` is now honored on **both platforms**: wired through the iOS bridge since 1.12.0 (wrapped iOS SDK 1.12.2+), and through the Android bridge since 1.12.2 (wrapped native Android SDK 1.12.1+, mapping to `OctopusHomeScreen(leadingNavigationIcon:)`). When set, it overrides the root leading icon regardless of `showBackButton` and fires the existing `onBack` callback — the same contract on both platforms. When left `null` (default), Android keeps its previous behavior: a back arrow gated by `showBackButton`.

`navigationMode` remains **iOS-only** — it selects which native navigation container the iOS SDK uses internally and has no Android equivalent, so it is a no-op there.

<!-- /tab -->

<!-- tab: React Native -->

Unlike other platforms where the TopAppBar is configured at the composable/widget level, on React Native the TopAppBar is configured once at initialization time by passing a `topAppBar` object to `initialize()`.

Three optional fields are available:
- **`title`**: what to show as the nav-bar title.
  - `{ type: 'logo' }` — displays `theme.logo` (default when omitted).
  - `{ type: 'text', text: string }` — custom text. **Keep under ~18 characters** for best results.
- **`alignment`**: `'leading'` (default, left-aligned) or `'center'` (centered title).
- **`coloredBackground`**: `boolean` (default `false`) — when `true`, the navigation bar background uses the theme's primary color. On iOS this requires iOS 16+ and is silently ignored on earlier versions.

Here is how to display a leading text title:

```typescript
import { initialize } from '@octopus-community/react-native';

await initialize({
    apiKey: 'YOUR_API_KEY',
    connectionMode: { type: 'sso', appManagedFields: [] },
    topAppBar: {
        title: { type: 'text', text: 'My Community' },
        alignment: 'leading',
    },
});
```

And here is how to display a centered logo with a colored background:

```typescript
import { initialize } from '@octopus-community/react-native';

await initialize({
    apiKey: 'YOUR_API_KEY',
    connectionMode: { type: 'sso', appManagedFields: [] },
    topAppBar: {
        title: { type: 'logo' },
        alignment: 'center',
        coloredBackground: true,
    },
});
```

#### Leading navigation button and navigation container (≥ 1.13.0)

Unlike `topAppBar` above, `navBarLeadingAction` and `navigationMode` are **per-call** parameters: pass them to `openUI()` or as props on `<OctopusUIView>`, not to `initialize()`.

- **`navBarLeadingAction`**: `'close'` or `'back'` — overrides the leading (top-left) icon on the top app bar with a close (X) icon or a back arrow. Supported on both platforms (native Android SDK 1.12.1+, native iOS SDK 1.12.2+). When omitted, the native default back arrow applies.
- **`navigationMode`**: `'automatic'` or `'navigationStack'` — **iOS only** (no-op on Android, which always drives the SDK through its own Compose `NavHost`). Defaults to `'navigationStack'`, not the native SDK's own `'automatic'` default, because both RN entry points host the SDK in a shape (`openUI()`'s modally-presented full-screen controller, `<OctopusUIView>`'s embedding inside the host's own tree) that a legacy-container bug affects. Pass `'automatic'` explicitly to opt back into the native default.

```typescript
import { openUI } from '@octopus-community/react-native';

await openUI({
    navBarLeadingAction: 'close',
    navigationMode: 'navigationStack', // the default — shown here for clarity
});
```

:::caution
Tapping the leading icon does not yet fire a back/close event back to JS on either entry point: on `openUI()` the tap dismisses the fullscreen UI directly, native-side, without emitting anything to JS; on `<OctopusUIView>` the tap is inert (no per-instance channel back to JS yet). There is no callback to react to the tap from your own code today — this is a known gap on the React Native SDK, not the intended design.
:::

<!-- /tab -->

<!-- tab: Unity -->

TopAppBar customization is not available on Unity.

<!-- /tab -->

### Advanced Screen-Based Theming (Android only)

<!-- tab: Android -->

For more complex theming scenarios, you can customize any aspect of the Octopus theme (colors, typography, images, TopAppBar) based on the current screen:

```kotlin {6-11,15-22}
octopusComposables(
    navController = navController
) { backStackEntry, content ->
    OctopusTheme(
        colorScheme = when {
            // Post Details with branded theme
            backStackEntry.destination.hasRoute<OctopusDestination.PostDetails>() -> {
                octopusColorScheme().copy(
                    background = if (isSystemInDarkTheme()) Color.Black else Color.White
                )
            }
            else -> octopusColorScheme()
        },
        topAppBar = when {
            // Home screen with custom TopAppBar theme
            backStackEntry.destination.hasRoute<OctopusDestination.Home>() -> {
                OctopusTopAppBarDefaults.topAppBar(
                    title = { text ->
                        OctopusTopAppBarTitle(text = "My Community")
                    }
                )
            }
            else -> OctopusTopAppBarDefaults.topAppBar()
        }
    ) {
        content()
    }
}
```

<!-- /tab -->

<!-- tab: iOS -->

Screen-based theming is not available on iOS.

<!-- /tab -->

<!-- tab: Flutter -->

Screen-based theming is not available on Flutter.

<!-- /tab -->

<!-- tab: React Native -->

Screen-based theming is not available on React Native.

<!-- /tab -->

<!-- tab: Unity -->

Screen-based theming is not available on Unity.

<!-- /tab -->

---
## Push Notifications (≥ 1.4.0)
    
    To increase user engagement, you can provide information to the Octopus SDK so your users can receive push notifications when other users interact with them inside the community.

    :::note
    Octopus SDK is not asking for push notification permissions, we let you handle that part where it makes more sense in your app.
    :::

    :::note
    The container the notification's destination UI appears in differs per platform, and none of them impose a modal bottom sheet: **Android** deep-links into your own navigation graph (you control the container); **iOS** gives you an `isAnOctopusNotification` gate plus a `notificationUserInfo` binding, and you present `OctopusHomeScreen` however you like (a `.sheet`, a `.fullScreenCover`, or pushed on your own navigation stack); **Flutter** pushes a full-screen route via `openNotification`.
    :::

<!-- tab: Android -->

If your app does not support Push Notifications yet, you can follow the official [Firebase documentation](https://firebase.google.com/docs/cloud-messaging/android/client).

Our servers need your service account's private key file to be authorized to send notifications to your app on your behalf.

<details>
<summary>If you don't have this JSON file yet, follow this tutorial</summary>
- In the Firebase console, open **Settings >** [Service Accounts](https://console.firebase.google.com/project/_/settings/serviceaccounts/adminsdk)
- Click **Generate New Private Key**, then confirm by clicking **Generate Key**.
- Securely store the JSON file containing the key.

More information can be found in the official [Firebase documentation](https://firebase.google.com/docs/cloud-messaging/auth-server#provide-credentials-manually)
</details>

Please send the `json` file using the online form sent by the Octopus team.

:::warning
This JSON file is different from the one you are using to configure Firebase in your app (`google-services.json`)
:::

Once your project is correctly set up for push notifications, you should forward the Firebase Cloud Messaging Token (FCM Token) to the Octopus SDK:
```kotlin {5-6}
class MessagingService : FirebaseMessagingService() {
    override fun onNewToken(token: String) {
        super.onNewToken(token)

        // Register the new token with Octopus
        OctopusSDK.registerNotificationsToken(token)
    }
}
```

:::note
<details>
<summary>You are in charge of requesting the Notification permission and referencing your messaging service:</summary>
```xml
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
```
```kotlin
if (checkSelfPermission(POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
    // Request launcher for notification permission
    registerForActivityResult(
        contract = ActivityResultContracts.RequestPermission(),
        callback = {
            FirebaseMessaging.getInstance().token.addOnCompleteListener { task ->
                if (task.isSuccessful) {
                    OctopusSDK.registerNotificationsToken(task.result)
                }
            }
        }
    ).launch(POST_NOTIFICATIONS)
}
```
```xml
<service
    android:name=".notifications.MessagingService"
    android:exported="false">
    <intent-filter>
        <action android:name="com.google.firebase.MESSAGING_EVENT" />
    </intent-filter>
</service>
```
</details>
:::

When you receive a notification message, first check if it is an Octopus notification by calling:
```kotlin {4-7}
override fun onMessageReceived(remoteMessage: RemoteMessage) {
    super.onMessageReceived(remoteMessage)

    val isOctopusNotification = remoteMessage.data.isOctopusNotification
    if (isOctopusNotification) {
        // ... Handle the Octopus notification here (see below)
    }
}
```

If it's the case, display the Octopus notification using the Notification Manager:
```kotlin
val octopusNotification = OctopusSDK.getOctopusNotification(data = remoteMessage.data)
if (octopusNotification != null) {
    notificationManager.notify(
        octopusNotification.id,
        Notification.Builder(this, CHANNEL_ID)
            .setSmallIcon(R.drawable.ic_stat_notification)
            .setColor(getColor(R.color.accent))
            .setAutoCancel(true)
            // Extension function to extract the title, text and deep link from the OctopusNotification
            .setOctopusContent(
                context = this,
                activityClass = MainActivity::class,
                octopusNotification = octopusNotification
            ).build()
    )
}
```
Then you can display the notification's targeted Octopus UI from the `octopusNotification.deepLink`:
```kotlin
intent.data?.let { uri -> navController.navigate(deepLink = uri)}
```
:::note
- The `activityClass` must be the one containing the compose content with the `octopusNavigation()` subgraph.
- The `setOctopusContent` function is based on a deep link mechanism that will automatically launch the activity and navigate to the corresponding screen within the Octopus navigation graph.
- You can also handle the content manually by using the `OctopusNotification`'s `title`, `body`, and `linkPath` fields and configuring your own `PendingIntent`.
:::

<!-- /tab -->

<!-- tab: iOS -->

If your app does not support Push Notifications yet, you can follow the official [Apple documentation](https://developer.apple.com/documentation/usernotifications/registering-your-app-with-apns).

Our servers need to have a key in the `.p8` format in order to send push notifications to your app on your behalf.

<details>
<summary>If you don't have this key yet, follow this tutorial</summary>

- Go to the [Apple Dev Website](https://developer.apple.com/account/resources/authkeys/list) and create a new key.
- Name it and check `Apple Push Notifications service (APNs)`.
- Click on configure for this line and select `Sandbox & Production` in the `Environment` menu if you intend to have the same key for sandbox and production builds.
- Download the key and be sure to store it in a secure and retrievable place.
</details>

Once you have that file, you should send it using the secure form provided by the Octopus Team, along with:
- the key ID: can be found in the list of keys on the [Apple Dev Website](https://developer.apple.com/account/resources/authkeys/list)
- your Bundle ID: can be found in your Xcode project
- your Team ID: can be found [here](https://developer.apple.com/account#MembershipDetailsCard) in the `Membership details` card

Once your project is correctly set up for push notifications, you should forward the notification device token to the Octopus SDK:
```swift
func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
    let token = deviceToken.map { String(format: "%02.2hhx", $0) }.joined()
    octopus.set(notificationDeviceToken: token)
}
```

:::note
<details>
<summary>You are in charge of requesting the Notification permission and registering for remote notifications:</summary>
```swift
// Request the user's authorization
let center = UNUserNotificationCenter.current()
try await center.requestAuthorization(options: [.alert, .badge, .sound])
```
```swift
// Register for remote notifications
UIApplication.shared.registerForRemoteNotifications()
```
</details>
:::

When you receive a notification response (i.e., when the user tapped a notification), extract the `userInfo` dictionary and check if it is an Octopus notification:

(≥ 1.11.0)

```swift
let userInfo = notificationResponse.notification.request.content.userInfo
if OctopusSDK.isAnOctopusNotification(userInfo: userInfo) {
    // Store the userInfo and display the Octopus UI (see below)
}
```

If it is one, display the Octopus UI and pass the `userInfo` as a Binding:
```swift
OctopusHomeScreen(octopus: octopus, notificationUserInfo: $octopusNotificationUserInfo)
```

:::note
The `notificationUserInfo` binding accepts a `[AnyHashable: Any]?`. The SDK sets it back to `nil` after navigating to the notification's target screen, signaling that the notification has been consumed.
:::

<details>
<summary>Deprecated (< 1.11.0)</summary>

```swift
OctopusSDK.isAnOctopusNotification(notification: notificationResponse.notification)
```
```swift
OctopusHomeScreen(octopus: octopus, notificationResponse: $octopusNotification)
```
</details>

<!-- /tab -->

<!-- tab: Flutter -->

(≥ 1.11.0)

**Step 1 — Register the device token**

Call `registerPushNotificationToken` on startup and on every token refresh. On iOS, pass the APNs device token (`getAPNSToken()`); on Android, pass the FCM registration token (`getToken()`).

```dart
import 'dart:io' show Platform;
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:octopus_sdk_flutter/octopus_sdk_flutter.dart';

final messaging = FirebaseMessaging.instance;

await messaging.requestPermission();
await _registerToken(messaging);

// The token rotates from time to time — re-register on every refresh.
// Discard the callback token — re-fetch to get the platform-correct token
// (APNs device token on iOS, FCM registration token on Android).
messaging.onTokenRefresh.listen((_) => _registerToken(messaging));

Future<void> _registerToken(FirebaseMessaging messaging) async {
  final token = Platform.isIOS
      ? await messaging.getAPNSToken()
      : await messaging.getToken();
  if (token == null || token.isEmpty) return;
  await OctopusSDK().registerPushNotificationToken(token);
}
```

:::note
The Octopus SDK does not request push notification permissions — your app is responsible for calling `requestPermission()` before registering the token.
:::

:::warning
When using `firebase_messaging` on iOS, always pass `getAPNSToken()` — not `getToken()` — to `registerPushNotificationToken`. The FCM token is different from the APNs device token; registering the FCM token will produce a successful API call but no notification delivery.
:::

**Step 2 — Detect and parse incoming notifications**

When a notification arrives, check whether it comes from Octopus with `OctopusSDK.isOctopusNotification(payload)`. The payload is a `Map` — on Android it is `RemoteMessage.data`; on iOS raw APNs it is the `userInfo` dict (with a nested `data` envelope). Both shapes are handled transparently.

If the check passes, call `OctopusSDK.getOctopusNotification(payload)` to get a typed `OctopusNotification` (fields: `title`, `body`, `linkPath`, optional `postId` / `commentId` / `replyId`). Returns `null` if `link_path` is missing.

**Step 3 — Open the Octopus UI**

Call `openNotification` to display the Octopus UI pre-navigated to the content referenced by the notification. It pushes a full-screen route (a `MaterialPageRoute`) hosting the community UI — not a modal bottom sheet. To present it differently (e.g. a `fullscreenDialog` modal), copy the inline pattern from the sample's scenarios instead of the helper:

```dart
await OctopusSDK().openNotification(
  context,
  notification,
  onNavigateToLogin: () {
    // Navigate to your app's login screen
  },
);
```

The `onNavigateToLogin` callback is required. `navBarTitle`, `navBarPrimaryColor`, `theme`, `onModifyUser`, and `onNavigateToUrl` are optional.

**Complete example — firebase_messaging integration**

```dart
import 'dart:io' show Platform;
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter/material.dart';
import 'package:octopus_sdk_flutter/octopus_sdk_flutter.dart';

final navigatorKey = GlobalKey<NavigatorState>();

class MyApp extends StatefulWidget {
  const MyApp({super.key});
  @override
  State<MyApp> createState() => _MyAppState();
}

class _MyAppState extends State<MyApp> {
  final _messaging = FirebaseMessaging.instance;

  @override
  void initState() {
    super.initState();
    _setupPushNotifications();
  }

  Future<void> _setupPushNotifications() async {
    await _messaging.requestPermission();
    await _registerToken();

    // Discard the callback token — re-fetch to get the platform-correct token
    // (APNs device token on iOS, FCM registration token on Android).
    _messaging.onTokenRefresh.listen((_) => _registerToken());

    // Foreground — app is open when the notification arrives
    FirebaseMessaging.onMessage.listen(_handleNotificationTap);

    // Cold start — app was terminated when the user tapped the notification
    final initial = await _messaging.getInitialMessage();
    if (initial != null) _handleNotificationTap(initial);

    // Background tap — app was in background
    FirebaseMessaging.onMessageOpenedApp.listen(_handleNotificationTap);
  }

  Future<void> _registerToken() async {
    final token = Platform.isIOS
        ? await _messaging.getAPNSToken()
        : await _messaging.getToken();
    if (token == null || token.isEmpty) return;
    await OctopusSDK().registerPushNotificationToken(token);
  }

  void _handleNotificationTap(RemoteMessage message) {
    // Merge notification title/body into the data map so the parser
    // carries user-facing copy in the typed OctopusNotification.
    final payload = <String, Object?>{...message.data};
    final notif = message.notification;
    if (notif?.title != null) payload.putIfAbsent('title', () => notif!.title!);
    if (notif?.body != null) payload.putIfAbsent('body', () => notif!.body!);

    if (!OctopusSDK.isOctopusNotification(payload)) return;
    final notification = OctopusSDK.getOctopusNotification(payload);
    if (notification == null) return;

    final ctx = navigatorKey.currentContext;
    if (ctx == null) return;
    OctopusSDK().openNotification(
      ctx,
      notification,
      onNavigateToLogin: () {
        Navigator.of(ctx).pushNamed('/login');
      },
    );
  }

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      navigatorKey: navigatorKey,
      // ...
    );
  }
}
```

<!-- /tab -->

<!-- tab: React Native -->

**Step 1 — Register the device push token**

Call `registerPushNotificationToken` at startup and on every token refresh. On iOS, pass the **APNs device token** (hex string); on Android, pass the **FCM registration token**.

- **Android**: use `@react-native-firebase/messaging` — `messaging().getToken()`.
- **iOS**: use `messaging().getAPNSToken()` from `@react-native-firebase/messaging`, or obtain the token from a native module bridging `UNUserNotificationCenterDelegate`.

:::note
For iOS apps that do not use Firebase, handling notification taps (cold-start and background) requires a dedicated native module. The SDK repository includes reference native code in `ios/OctopusReactNativeSdkExample/OctopusPushModule.swift` and `AppDelegate.swift` that you can use as a starting point.
:::

No prior `connectUser()` is required — the token is associated with the user when `connectUser` is later called and re-associated on user change.

:::warning
On iOS, always pass the **APNs device token** (`getAPNSToken()`), not the FCM token (`getToken()`). Octopus delivers iOS pushes directly via APNs with its own key — Firebase is not required on iOS. Registering the FCM token will produce a successful API call but **no notification delivery**.
:::

:::info
The Octopus SDK does not request push notification permissions — your app is responsible for calling the platform permission API (e.g. `messaging().requestPermission()`) before registering the token.
:::

**Step 2 — Detect Octopus notifications**

Use `isOctopusNotification(payload)` on the raw push payload. The function handles both the flat Android FCM shape (`RemoteMessage.data`) and the iOS APNs data-envelope shape transparently — pass the raw payload straight in. Pure JS, safe to call before `initialize()`.

**Step 3 — Parse and navigate**

Use `getOctopusNotification(payload)` to get a typed `OctopusNotification` (fields: `title`, `body`, `linkPath`, `rawPayload`, optional `postId` / `commentId` / `replyId`). Returns `null` if `link_path` is missing. Then call `openNotification(notification)` to open the Octopus UI pre-navigated to the notification's target (post, comment, etc.).

**Complete example — @react-native-firebase/messaging integration**

```typescript
import { useEffect } from 'react';
import { Platform } from 'react-native';
import messaging from '@react-native-firebase/messaging';
import {
    registerPushNotificationToken,
    isOctopusNotification,
    getOctopusNotification,
    openNotification,
} from '@octopus-community/react-native';

async function registerToken() {
    const token = Platform.OS === 'ios'
        ? await messaging().getAPNSToken()
        : await messaging().getToken();
    if (token) {
        await registerPushNotificationToken(token);
    }
}

function handleNotification(data: Record<string, any>) {
    if (!isOctopusNotification(data)) return;
    const notification = getOctopusNotification(data);
    if (notification) {
        openNotification(notification);
    }
}

function useOctopusPushNotifications() {
    useEffect(() => {
        // Register the device token at startup
        registerToken();

        // Re-register on token refresh
        const unsubscribeTokenRefresh = messaging().onTokenRefresh(() => {
            registerToken();
        });

        // Background tap — app was in background when the user tapped
        const unsubscribeOpenedApp = messaging().onNotificationOpenedApp(
            (remoteMessage) => {
                handleNotification(remoteMessage.data ?? {});
            },
        );

        // Cold start — app was terminated when the user tapped
        messaging()
            .getInitialNotification()
            .then((remoteMessage) => {
                if (remoteMessage) {
                    handleNotification(remoteMessage.data ?? {});
                }
            });

        // Foreground — app is open when the notification arrives
        const unsubscribeMessage = messaging().onMessage(
            (remoteMessage) => {
                handleNotification(remoteMessage.data ?? {});
            },
        );

        return () => {
            unsubscribeTokenRefresh();
            unsubscribeOpenedApp();
            unsubscribeMessage();
        };
    }, []);
}
```

:::tip Testing
iOS Simulator never issues a real APNs token — `registerPushNotificationToken` is a no-op there. Use a physical device for token registration. You can simulate the tap path on the simulator with `xcrun simctl push booted <bundleId> /tmp/payload.apns`.
:::

<!-- /tab -->

<!-- tab: Unity -->

Once your project is set up for push notifications, register the device token with the SDK. The native SDK expects a raw token — APNs on iOS, FCM on Android:

```csharp
OctopusSDK.RegisterNotificationsToken(deviceToken);
```

:::note
The Octopus SDK does not request notification permissions — your app is responsible for that.
:::

When a notification is tapped, check whether it comes from Octopus with `IsOctopusNotification`, extract the typed notification with `GetOctopusNotification`, and pass it to `Open` to navigate to the relevant content:

```csharp
void HandleTappedPayload(IDictionary<string, string> payload)
{
    if (!OctopusSDK.IsOctopusNotification(payload)) return;
    var notification = OctopusSDK.GetOctopusNotification(payload);
    OctopusSDK.Open(notification);
}
```

The `payload` is an `IDictionary<string, string>` — the FCM data map on Android, or the notification's `UserInfo` on iOS. Both shapes are handled transparently.

#### iOS — handle notification taps

iOS uses the [Unity Mobile Notifications](https://docs.unity3d.com/Packages/com.unity.mobile.notifications@latest) package (`com.unity.mobile.notifications`). No Firebase dependency and no native Objective-C file are required on iOS.

**Request authorization and register the APNs token:**

```csharp
using Unity.Notifications.iOS;

IEnumerator RequestIOSAuthorization()
{
    using (var req = new AuthorizationRequest(
        AuthorizationOption.Alert | AuthorizationOption.Sound | AuthorizationOption.Badge,
        registerForRemoteNotifications: true))
    {
        while (!req.IsFinished) yield return null;
        if (req.Granted && !string.IsNullOrEmpty(req.DeviceToken))
            OctopusSDK.RegisterNotificationsToken(req.DeviceToken);
    }
}
```

**Handle taps — cold start and background resume:**

A tap is exposed via `iOSNotificationCenter.GetLastRespondedNotification()`. Check it in `Start()` (cold start) and in `OnApplicationFocus(true)` (resume after a backgrounded tap). `OnRemoteNotificationReceived` fires only when a notification *arrives* in the foreground — not on a tap.

```csharp
using Unity.Notifications.iOS;
using System.Collections.Generic;
using UnityEngine;

public class PushHandler : MonoBehaviour
{
    string _lastHandledDeepLink;   // null by default — intentional

    void Start()
    {
        StartCoroutine(RequestIOSAuthorization());
        iOSNotificationCenter.OnRemoteNotificationReceived += OnRemoteNotification;
        HandleRespondedNotification();
    }

    void OnDestroy()
    {
        iOSNotificationCenter.OnRemoteNotificationReceived -= OnRemoteNotification;
    }

    void OnApplicationFocus(bool hasFocus)
    {
        if (hasFocus) HandleRespondedNotification();
    }

    void HandleRespondedNotification()
    {
        var responded = iOSNotificationCenter.GetLastRespondedNotification();
        if (responded != null) HandleTappedPayload(responded.UserInfo);
    }

    void OnRemoteNotification(iOSNotification notification)
    {
        HandleTappedPayload(notification.UserInfo);
    }

    void HandleTappedPayload(IDictionary<string, string> payload)
    {
        if (!OctopusSDK.IsOctopusNotification(payload)) return;
        var notification = OctopusSDK.GetOctopusNotification(payload);
        if (notification.DeepLink == _lastHandledDeepLink) return;
        _lastHandledDeepLink = notification.DeepLink;
        OctopusSDK.Open(notification);
    }
}
```

:::info
`OnRemoteNotificationReceived` fires when an Octopus notification *arrives* while the app is in the foreground. The example above auto-navigates the user into the Octopus community UI on arrival. This is intentional — remove or replace the `OnRemoteNotification` handler if you prefer to show a local notification banner instead and only navigate on user tap.
:::

:::warning
The responded notification must be read from whichever scene loads first. If your app launches into a menu scene, handle it there (or in a `DontDestroyOnLoad` persistent object) — not only inside the community screen.
:::

#### Android — handle notification taps

Add a `google-services.json` for your Firebase project to `Assets/` in your Unity project. Create the file from the [Firebase Console](https://console.firebase.google.com) by adding an **Android** app with your package name (e.g. `com.octopuscommunity.example`).

Use Firebase Messaging to detect notification taps and pass the data to the SDK:

```csharp
if (OctopusSDK.IsOctopusNotification(e.Message.Data) && e.Message.NotificationOpened)
{
    var notification = OctopusSDK.GetOctopusNotification(e.Message.Data);
    OctopusSDK.Open(notification);
}
```

No additional native file is needed on Android.

:::warning
Attach the `MessageReceived` listener from whichever scene loads first so a tap that launches a closed app is handled immediately.
:::

<!-- /tab -->

---
## Not seen notifications (≥ 1.3.0)

    To increase user engagement, let your users know that they have new notifications from the Octopus notification center. Displaying a badge with the number of new notifications in your app can be a great way to suggest your users to look at what's new in the community again.

<!-- tab: Android -->

To do that, the Octopus SDK exposes a `val notSeenNotificationsCount: Flow<Int>`
that you can collect:
```kotlin
OctopusSDK.notSeenNotificationsCount.collect {}
```

If you want to update this value with the latest count, simply call:
```kotlin
OctopusSDK.updateNotSeenNotificationsCount()
```

To see a full example of how you can achieve that, you can follow how it is done in the Samples, in the [Octopus Sample app](https://github.com/Octopus-Community/octopus-sdk-android/tree/main/samples/src/main/java/com/octopuscommunity/sample).

<!-- /tab -->

<!-- tab: iOS -->

To do that, the Octopus SDK exposes a `@Published var notSeenNotificationsCount: Int`.

If you want to update this value with the latest count, simply call :
```
try await octopus.updateNotSeenNotificationsCount()
```

To see a full example of how you can achieve that, you can follow how it is done in the Samples, in the [Scenario "Notification Badge"](https://github.com/Octopus-Community/octopus-sdk-swift/tree/main/Sample/OctopusSample/UI/Scenarios/NotSeenNotifications).

<!-- /tab -->

<!-- tab: Flutter -->

To do that, the Octopus SDK exposes a `Stream<int>` that emits the unseen notifications count whenever it changes:
```dart
OctopusSDK.notSeenNotificationsCount.listen((count) {
    // Update your badge with the new count
});
```

If you want to update this value with the latest count, simply call:
```dart
await octopus.updateNotSeenNotificationsCount();
```

<!-- /tab -->

<!-- tab: React Native -->

Subscribe to unseen notification count changes using `addNotSeenNotificationsCountListener`. The SDK emits the current count automatically after initialization and whenever the count changes:

```typescript
import {
    addNotSeenNotificationsCountListener,
    updateNotSeenNotificationsCount,
} from '@octopus-community/react-native';

// Subscribe to count changes
const subscription = addNotSeenNotificationsCountListener((count) => {
    // Update your badge with the new count
    console.log('Unseen notifications:', count);
});

// Manually trigger a server refresh of the count
await updateNotSeenNotificationsCount();

// Later, to unsubscribe:
subscription.remove();
```

<!-- /tab -->

<!-- tab: Unity -->

Subscribe to the not-seen count event:

```csharp
OctopusSDK.OnNotSeenNotificationsCount += (int count) =>
{
    // Update your badge UI with 'count'
};
```

Request the latest count at any time:

```csharp
OctopusSDK.UpdateNotSeenNotificationsCount();
```

<!-- /tab -->

---
## Analytics

    Octopus Community provides analytics to help you better understand your users' behavior within the community. To improve the quality of these analytics, we offer features that allow you to provide additional information about your users.
    
    ### Custom events (≥ 1.4.0)
    You define your own custom event **types** — there's no fixed catalog. Each event has a free-form `name` and any `properties` you want to attach (property values are strings). Send as many different types as your app needs — for example `Purchase`, `SignUp`, `TutorialCompleted`, or `AddToCart`.

:::info Adding a new event type
To have an event type included in your reports, contact us first so we can set it up.
:::

<details>
<summary>Privacy notice</summary>

You're responsible for what you send through this API.

**Don't transmit:**
- Sensitive data — health, religion, political opinions, sexual orientation, biometric or genetic data, criminal records
- Payment details, government IDs, or login credentials
- The content of private messages

Where privacy laws apply to your End Users (GDPR in the EEA/UK, CCPA in California, LGPD in Brazil, etc.), make sure you've obtained the necessary consent for analytics.

</details>

<!-- tab: Android -->

```kotlin
OctopusSDK.track(
    event = TrackerEvent.Custom(
        name = "Purchase",
        properties = mapOf(
            "price" to TrackerEvent.Custom.Property(
                value = String.format(Locale.US, "%.2f", 1.99)
            ),
            "currency" to TrackerEvent.Custom.Property(value = "EUR"),
            "product_id" to TrackerEvent.Custom.Property(value = "product1")
        )
    )
)

// A different event type — send as many as you need
OctopusSDK.track(
    event = TrackerEvent.Custom(
        name = "TutorialCompleted",
        properties = mapOf(
            "level" to TrackerEvent.Custom.Property(value = "3")
        )
    )
)
```

<!-- /tab -->

<!-- tab: iOS -->

```swift 
try await viewModel.octopus?.track(customEvent: CustomEvent(
    name: "Purchase",
    properties: [
        "price": .init(value: "\(String(format: "%.2f", 1.99))"),
        "currency": .init(value: "EUR"),
        "product_id": .init(value: "product1"),
    ]))

// A different event type — send as many as you need
try await viewModel.octopus?.track(customEvent: CustomEvent(
    name: "TutorialCompleted",
    properties: [
        "level": .init(value: "3"),
    ]))
```

To see a full example of how you can achieve that, you can follow how it is done in the Samples, in the [Scenario "Custom events"](https://github.com/Octopus-Community/octopus-sdk-swift/tree/main/Sample/OctopusSample/UI/Scenarios/CustomEvents).

<!-- /tab -->

<!-- tab: Flutter -->

```dart
await octopus.trackCustomEvent('Purchase', {
    'price': '1.99',
    'currency': 'EUR',
    'product_id': 'product1',
});

// A different event type — send as many as you need
await octopus.trackCustomEvent('TutorialCompleted', {
    'level': '3',
});
```

<!-- /tab -->

<!-- tab: React Native -->

```typescript
import { trackCustomEvent } from '@octopus-community/react-native';

await trackCustomEvent('Purchase', {
    price: '1.99',
    currency: 'EUR',
    product_id: 'product1',
});

// A different event type — send as many as you need
await trackCustomEvent('TutorialCompleted', {
    level: '3',
});
```

:::info
All property values must be strings. Convert numbers and booleans to strings before calling `trackCustomEvent`.
:::

<!-- /tab -->

<!-- tab: Unity -->

Track a custom event:

```csharp
OctopusSDK.Track("Purchase", new Dictionary<string, string>
{
    { "price", "1.99" },
    { "currency", "EUR" },
    { "product_id", "product1" }
});

// A different event type — send as many as you need
OctopusSDK.Track("TutorialCompleted", new Dictionary<string, string>
{
    { "level", "3" }
});
```

<!-- /tab -->

    ### Community Visibility (≥ 1.3.0)

    If you enable access to the community for only a subset of your users and want the analytics we provide to take this into account, you can inform the SDK accordingly.

    This information is reset at each SDK launch so be sure to call the function everytime and as soon as possible after SDK init.

    :::warning
    This section is only useful when the host app (i.e. your app) manages its own A/B testing logic.
    Call this if your app is running its own A/B test and you want Octopus to log whether a given user is in the "community-enabled" or "control" group. This is useful for reporting and engagement analytics, but does not change the SDK’s runtime behavior.
    :::

<!-- tab: Android -->

(≥ 1.6.0)

```kotlin
OctopusSDK.trackAccessToCommunity(hasAccess = canAccessCommunity)
```

<details>
<summary>Deprecated (< 1.6.0)</summary>

```kotlin
OctopusSDK.setHasAccessToCommunity(canAccessCommunity)
```
</details>

<!-- /tab -->

<!-- tab: iOS -->

(≥ 1.6.0)

```swift
octopus.track(hasAccessToCommunity: canAccessCommunity)
```

<details>
<summary>Deprecated (< 1.6.0)</summary>

```swift
octopus.set(hasAccessToCommunity: canAccessCommunity)
```
</details>

To see a full example of how you can achieve that, you can follow how it is done in the Samples, in the [Scenario "Track A/B Tests"](https://github.com/Octopus-Community/octopus-sdk-swift/tree/main/Sample/OctopusSample/UI/Scenarios/TrackABTests).

<!-- /tab -->

<!-- tab: Flutter -->

```dart
await octopus.trackCommunityAccess(canAccessCommunity);
```

<!-- /tab -->

<!-- tab: React Native -->

```typescript
import { trackCommunityAccess } from '@octopus-community/react-native';

await trackCommunityAccess(canAccessCommunity);
```

<!-- /tab -->

<!-- tab: Unity -->

If only a subset of your users can access the community, inform the SDK so analytics reflect this accurately. Call this after every initialization — the value is reset at each SDK launch.

```csharp
bool canAccess = true;
OctopusSDK.TrackAccessToCommunity(canAccess);
```

<!-- /tab -->

### Octopus Events (≥ 1.9.0)

As what the user does inside the Octopus UI is pretty opaque for you, we provide a way to register to be informed about events done by the user occuring inside Octopus. 

If you have your own tracking service, you can use this publisher to listen to events that occured inside the Octopus UI part and feed your tracking service with them.

<details>
<summary>Privacy notice</summary>

Octopus emits these events locally inside your app — we don't send them anywhere.

If you forward them to your own analytics tools (Google Analytics, Firebase, Mixpanel, Amplitude, Contentsquare, etc.), you're responsible for that forwarding.

Where privacy laws apply to your End Users (GDPR in the EEA/UK, CCPA in California, LGPD in Brazil, etc.), make sure you've obtained the necessary consent for analytics.

</details>

<details>
    <summary>Here is the list of all events and their params that are emitted from the Octopus SDK</summary>

<h2>Content</h2>
#### `postCreated`
Sent when a post has been created by the current user.

**Params:**

- `postId: String` — The id of the post.
- `content: PostContent` — Content of the post. `PostContent` is a set of `text`, `image` and `poll` indicating the content of the post.
- `groupId: String` (≥ 1.11.0) — The id of the group to which the post has been linked. (Was `topicId` before 1.11.0)
- `textLength: Int` — The length of the text of this post.

---
#### `commentCreated`
Sent when a **comment** has been created by the current user.

**Params:**

- `commentId: String` — The id of the comment.
- `postId: String` — The id of the post in which the comment has been posted.
- `textLength: Int` — The length of the text of this comment.

---
#### `replyCreated`
Sent when a **reply** has been created by the current user.

**Params:**

- `replyId: String` — The id of the reply.
- `commentId: String` — The id of the comment in which this reply has been posted.
- `textLength: Int` — The length of the text of this reply.

---
#### `contentDeleted`

Sent when a post, a comment or a reply has been deleted by the current user.

**Params:**

- `contentId: String` — The id of the content that has been deleted.
- `kind: ContentKind` — The kind of content. Can be `post`, `comment` or `reply`.

---
#### `reactionModified`

Sent when a reaction is modified (added, deleted or changed) on a content by the current user.

**Params:**

- `previousReaction: ReactionKind?` — The previous reaction. Can be null.
- `newReaction: ReactionKind?` — The new reaction. If null, it means that the reaction has been deleted. `ReactionKind` is either `heart`, `joy`, `mouthOpen`, `clap`, `cry` or `rage`.
- `contentId: String` — The id of the content.
- `contentKind: ContentKind` — The kind of content. Can be `post`, `comment` or `reply`.

---
#### `pollVoted`

Sent when the current user votes for a poll.

**Params:**

- `contentId: String` — The id of the content (i.e. the post).
- `optionId: String` — The id of the option voted by the user.

---
#### `contentReported`

Sent when a content has been reported by the current user.

**Params:**

- `contentId: String` — The id of the content.
- `reasons: [ReportReason]` — The reasons of reporting this content.

---
<h2>Gamification</h2>

#### `gamificationPointsGained`

Sent when gamification points are gained. Please note that only the points triggered by an in-app action are reported live.

**Params:**

- `pointsGained: Int` — The points that have been added.
- `action: GamificationPointsGainedAction` — The action that led to gaining points.

---
#### `gamificationPointsRemoved`

Sent when gamification points are removed. Please note that only the points triggered by an in-app action are reported live. For example, if a post of this user gets moderated, you won't receive the information about points removed.

**Params:**

- `pointsRemoved: Int` — The points that have been removed.
- `action: GamificationPointsRemovedAction` — The action that led to losing points.

---
<h2>Groups</h2>

#### `groupFollowingChanged`

Sent when the current user follows or unfollows a group.

**Params:**

- `groupId: String` — The id of the group.
- `followed: Bool` — `true` if the user followed the group, `false` if they unfollowed it.

---
<h2>Navigation & UI</h2>

#### `screenDisplayed`

Sent when the user navigates to a given screen.

**Params:**

- `screen: Screen` — The screen that has been displayed.

    `Screen` can be:
    - `mainFeed` (≥ 1.10.0) — The main feed (i.e. list of posts selected for the user)

        **Params:**

        - `feedId: String` — The id of the feed that is displayed.

    - `groups` (≥ 1.10.0) — The groups list screen.
    - `groupDetail` (≥ 1.10.0) — The group detail screen with the posts of the group

        **Params:**

        - `groupId: String` — The id of the group that is displayed.
        - `source` (≥ 1.11.0)  — The source from which this screen was opened. Can be `bridge` (opened from the client app via bridge mode) or `community` (opened from within the SDK community).

    - `postDetail` — The post detail screen with the list of comments
        
        **Params:**
        
        - `postId: String` — The id of the post that is displayed.
    - `commentDetail` — The comment detail screen with the list of replies
        
        **Params:**
        
        - `commentId: String` — The id of the comment that is displayed.
    - `createPost` — The create post screen
    - `profile` — The user profile screen
    - `activity` (≥ 1.13.0) — The connected user's own activity screen. Emitted instead of `profile` when the community runs in Unified Profile mode. **Android only** — the iOS SDK has no equivalent screen event, so do not key cross-platform logic on it.
    - `otherUserProfile` — The profile screen of another Octopus user
        
        **Params:**
        
        - `profileId: String` — The id of the profile that is displayed.
    - `otherUserPosts` (≥ 1.13.0) — The activity screen of another Octopus user (their list of posts), as opposed to `otherUserProfile` (their profile summary). Emitted on both platforms, but **only in Unified Profile mode**: outside it, a tap on a member's avatar or name opens the Octopus profile and emits `otherUserProfile` instead. In Unified Profile mode it is emitted when the tapped member has no client user id (a member you cannot route to your own profile screen), and on Android also when your app opens that screen itself through the SDK's activity navigation API. Do not key a member-activity funnel on it for a community that is not in Unified Profile mode — it never fires there.
        
        **Params:**
        
        - `profileId: String` — The id of the profile whose posts are displayed.
    - `editProfile` — The edit profile screen
    - `reportContent` — The report content screen
    - `reportProfile` — The report profile screen
    - `validateNickname` — The validate nickname screen (displayed after a user with a non-modified nickname has created a post)
    - `settingsList` — The settings screen
    - `settingsAccount` — The account settings screen. Only visible if the SDK is configured in Octopus authentication (not SSO)
    - _`settingsAbout`_ — The about settings screen ⚠️ Removed since 1.13.0 — the standalone "About the community" screen was retired, its legal links (Community Guidelines, Privacy Policy, Terms of Use) already being duplicated in the Activity and current-user Profile overflow menus.
    - `reportExplanation` — The report explanation screen
    - `deleteAccount` — The delete account screen. Only visible if the SDK is configured in Octopus authentication (not SSO)
    - _`postsFeed`_ — The posts feed (i.e. list of posts) ⚠️ Deprecated since 1.10.0 — split into `mainFeed` and `groupDetail`.
        
        **Params:**
        
        - `feedId: String` — The id of the feed that is displayed.
        - `relatedTopicId: String?` — The id of the group related to this feed. Null if the feed is not representing a group or is multi-group (for example, the feed "For You").

---
#### `notificationClicked`

Sent when the user clicks on an internal notification (from the Octopus Notification Center).

**Params:**

- `notificationId: String` — The id of the notification.
- `contentId: String?` — The target content id. Can be null if the notification does not target a content.

---
#### `postClicked`

Sent when the user clicks on a post.

**Params:**

- `postId: String` — The id of the post.
- `source: PostClickedSource` — The source of the click. Can be `feed` (if the post was displayed in the posts feed) or `profile` (if the post was displayed in a user profile posts list).

---
#### `translationButtonClicked`

Sent when the user clicks on a translation button.

**Params:**

- `contentId: String` — The id of the content.
- `viewTranslated: Bool` — Whether the user wants to display the translated or the original content.
- `contentKind: ContentKind` — The kind of content. Can be `post`, `comment` or `reply`.

---
#### `commentButtonClicked`

Sent when the user clicks the comment button of a post.

**Params:**

- `postId: String` — The id of the post.

---
#### `replyButtonClicked`

Sent when the user clicks the reply button of a comment.

**Params:**

- `commentId: String` — The id of the comment.

---
#### `seeRepliesButtonClicked`

Sent when the user clicks on the replies button of a comment.

**Params:**

- `commentId: String` — The id of the comment.

---
<h2>Profile</h2>

#### `profileModified`

Sent when the profile is modified by the user.

**Params:**

- `nickname: ProfileFieldUpdate<NicknameUpdateContext>` — The nickname update information. Either unchanged or changed.
- `bio: ProfileFieldUpdate<BioUpdateContext>` — The bio update information. Either unchanged or changed.
    
    **BioUpdateContext params:**
    
    - `bioLength: Int` — The length of the bio.
- `picture: ProfileFieldUpdate<PictureUpdateContext>` — The picture update information. Either unchanged or changed.
    
    **PictureUpdateContext params:**
    
    - `hasPicture: Int` — Whether the user has added a picture or deleted the existing one.

---
<h2>Session</h2>

#### `sessionStarted`

Sent when an Octopus UI session is started.

**Params:**

- `sessionId: String` — The id of the session.

---
#### `sessionStopped`

Sent when an Octopus UI session is stopped (call either when the Octopus UI is closed or when the app is put in background).

**Params:**

- `sessionId: String` — The id of the session.

---
<h2>Types</h2>

#### `ContentKind`

Can be `post`, `comment` or `reply`.

---
#### `ReactionKind`

Can be `heart`, `joy`, `mouthOpen`, `clap`, `cry` or `rage`.

---
#### `PostContent`

A set of `text`, `image` and `poll` indicating the content of the post.
    
</details>

Here is how to listen to these events:

<!-- tab: Android -->

```kotlin
// Listen to Octopus Events and convert into events for your analytics tool,
// for example here with Firebase Analytics
OctopusSDK.events.collect { event ->
    when(event) {
        is PostCreated -> {
            FirebaseAnalytics.getInstance().logEvent(
                name = "post_created",
                params = Bundle().apply {
                    putString("group", event.groupId) // event.topicId before 1.11.0
                    putInt("text_length", event.textLength)
                    putBoolean("has_poll", event.content.contains(POLL)
                    putBoolean("has_image", event.content.contains(IMAGE)
                }
            )
        }
        else -> { //... }
    }
}
```

<!-- /tab -->

<!-- tab: iOS -->

```swift
// Listen to Octopus Events and convert into events for your analytics tool,
// for example here with Firebase Analytics
octopus.eventPublisher.sink { event in
    switch event {
    case let .postCreated(context):
        Analytics.logEvent("post_created", parameters: [
            "group": context.groupId, // context.topicId before 1.11.0
            "text_length": context.textLength,
            "has_poll": context.content.contains(.poll),
            "has_image": context.content.contains(.image)
        ])
    case ...
    }
}
```

To see a full example of how you can achieve that, you can follow how it is done in the Samples, in the [Scenario "Events"](https://github.com/Octopus-Community/octopus-sdk-swift/tree/main/Sample/OctopusSample/UI/Scenarios/Events).

<!-- /tab -->

<!-- tab: Flutter -->

```dart
// Listen to Octopus Events and convert into events for your analytics tool
OctopusSDK.events.listen((event) {
    switch (event) {
        case PostCreatedEvent():
            analytics.logEvent(
                name: 'post_created',
                parameters: {
                    'topic': event.topicId,
                    'text_length': event.textLength,
                },
            );
            
        // ... handle other events
        default:
            break;
    }
});
```

<!-- /tab -->

<!-- tab: React Native -->

:::info Platform-specific field names
In React Native, the gamification event fields are named `points` (not `pointsGained` / `pointsRemoved` as on other platforms). The `content` field on `postCreated` is a `PostContentType[]` array of string values (`'text'`, `'image'`, `'poll'`), so use `.includes()` to check for content types.
:::

```typescript
import { addSDKEventListener } from '@octopus-community/react-native';
import type { SDKEvent } from '@octopus-community/react-native';

// Listen to Octopus Events and convert into events for your analytics tool
const subscription = addSDKEventListener((event: SDKEvent) => {
    switch (event.type) {
        case 'postCreated':
            analytics.logEvent('post_created', {
                topic: event.topicId,
                text_length: event.textLength,
                has_poll: event.content.includes('poll'),
                has_image: event.content.includes('image'),
            });
            break;
        case 'gamificationPointsGained':
            analytics.logEvent('points_gained', {
                points: event.points, // 'points' in React Native (not 'pointsGained')
                action: event.action,
            });
            break;
        case 'gamificationPointsRemoved':
            analytics.logEvent('points_removed', {
                points: event.points, // 'points' in React Native (not 'pointsRemoved')
                action: event.action,
            });
            break;
        // ... handle other events — use a default case for forward compatibility
        default:
            break;
    }
});

// Later, to unsubscribe:
subscription.remove();
```

<!-- /tab -->

<!-- tab: Unity -->

(≥ 1.12.2)

Subscribe to `OctopusSDK.OnOctopusEvent` — a static C# event (`Action<OctopusEvent>`) raised every time the user does something inside the Octopus UI. Covers post/comment/reply creation, content deletion, reactions, polls, reports, group follows, gamification points, screen navigation, button clicks, profile modifications, and session start/stop.

:::warning Threading
Handlers fire on a dedicated **background thread** on both iOS and Android — they must be thread-safe and must **not** call Unity APIs directly. Use `OctopusMainThread.Post(action)` to marshal work onto the Unity main thread.

On both iOS and Android, the player loop is paused while the Octopus UI is open, so actions posted via `OctopusMainThread.Post` run when the user returns to the game. Do data and backend work directly in the event handler; post only the Unity-side UI refresh.
:::

```csharp
using UnityEngine;
using UnityEngine.UI;

public class EventTracker : MonoBehaviour
{
    [SerializeField] Text statusText;

    void Start()
    {
        OctopusSDK.OnOctopusEvent += OnOctopusEvent;
    }

    void OnDestroy()
    {
        OctopusSDK.OnOctopusEvent -= OnOctopusEvent;
    }

    void OnOctopusEvent(OctopusEvent e)
    {
        // Runs on a background thread — do backend/analytics work here.
        switch (e.Kind)
        {
            case OctopusEventKind.PostCreated:
                var post = (PostCreatedEvent)e;
                Debug.Log($"Post created in group {post.GroupId}, length {post.TextLength}");
                break;
            case OctopusEventKind.GamificationPointsGained:
                var pts = (GamificationPointsGainedEvent)e;
                Debug.Log($"+{pts.Points} points for {pts.Action}");
                break;
        }

        // Marshal Unity UI updates to the main thread.
        OctopusMainThread.Post(() =>
        {
            statusText.text = $"Last event: {e.Kind}";
        });
    }
}
```

:::note Android-only fields
Some event fields are only populated on Android and will be `null` or default on iOS:
- `ProfileReportedEvent.Reasons` — report reason strings (raw platform tokens)
- `ContentDeletedEvent.ParentId` — id of the parent content
- `ContentReportedEvent.ContentKind` — the kind of reported content
:::

<!-- /tab -->

---
## Groups

Groups are content categories users follow. The SDK exposes the list of groups available in the community, each group's follow state for the current user, and a programmatic API to set that follow state from the client.

### List available groups and read their state

The SDK publishes the list of groups as observable state and exposes a method to force a refresh from the server. Each group carries its identifier, display name, and the connected user's current follow state — including whether the user is allowed to change that state.

<!-- tab: Android -->

```kotlin
// Get cached groups
OctopusSDK.groups.collect { groups ->
    // groups: List<OctopusGroup>
    // each has: id, name, isFollowed, canChangeFollowStatus, canAccess, canCreateChildren
}

// Fetch the latest groups from the server
OctopusSDK.fetchGroups()
```

`canAccess` is `false` for a visible-but-locked group (typically premium/gated) and `canCreateChildren` is `false` when the user may not post in the group. Route taps on a locked group through your access-denied callback rather than opening it directly — see [Gate access to locked groups](#gate-access-to-locked-groups).

<details>
<summary>Deprecated (&lt; 1.11.0)</summary>

```kotlin
OctopusSDK.topics.collect { topics ->
    // List of available topics
}

OctopusSDK.fetchTopics()
```
</details>

<!-- /tab -->

<!-- tab: iOS -->

```swift
// Get cached groups
octopus.$groups
    .sink { groups in
        // groups: [OctopusGroup]
        // each has: id, name, isFollowed, canChangeFollowStatus, canAccess, canCreateChildren
    }

// Fetch the latest groups from the server
try await octopus.fetchGroups()
```

`canAccess` is `false` for a visible-but-locked group (typically premium/gated) and `canCreateChildren` is `false` when the user may not post in the group. Route taps on a locked group through your access-denied callback rather than opening it directly — see [Gate access to locked groups](#gate-access-to-locked-groups).

<details>
<summary>Deprecated (&lt; 1.11.0)</summary>

```swift
octopus.$topics
    .sink { topics in
        // List of available topics
    }

octopus.fetchTopics()
```
</details>

<!-- /tab -->

<!-- tab: Flutter -->

(≥ 1.12.0)

```dart
// Listen to the cached groups (re-emits on any change: follow/unfollow, admin updates)
OctopusSDK.groups.listen((groups) {
    // groups: List<OctopusGroup>
    // each has: id, name, isFollowed, canChangeFollowStatus, canAccess, canCreateChildren
});

// Fetch the latest groups from the server
final result = await octopus.fetchGroups();
result.onSuccess((groups) {
    // groups: List<OctopusGroup>
});
```

`canAccess` is `false` for a visible-but-locked group (typically premium/gated) and `canCreateChildren` is `false` when the user may not post in the group. Route taps on a locked group through your access-denied callback rather than opening it directly — see [Gate access to locked groups](#gate-access-to-locked-groups).

<!-- /tab -->

<!-- tab: React Native -->

(≥ 1.13.0)

Observe the group list reactively with `addGroupsListener` (replays the current list immediately, then on every change), or fetch it on demand with `fetchGroups()`:

```typescript
import { addGroupsListener, fetchGroups } from '@octopus-community/react-native';

// Reactive: fires with the current list, then on every change
const subscription = addGroupsListener((groups) => {
    // groups: OctopusGroup[]
});

// One-off refresh from the server
const groups = await fetchGroups();
```

`canAccess` is `false` for a visible-but-locked group (typically premium/gated) and `canCreateChildren` is `false` when the user may not post in the group. Route taps on a locked group through your access-denied callback rather than opening it directly — see [Gate access to locked groups](#gate-access-to-locked-groups).

Follow or unfollow a group with `followGroup(groupId)` / `unfollowGroup(groupId)`:

```typescript
import { followGroup, unfollowGroup, isGroupFollowUnfollowError } from '@octopus-community/react-native';

try {
    await followGroup(groupId);
} catch (error) {
    if (isGroupFollowUnfollowError(error)) {
        console.warn(`followGroup failed — ${error.code}: ${error.message}`);
    }
}
```

:::info
iOS has no native per-group follow/unfollow method — the bridge implements both calls by delegating to the batch [`syncFollowGroups`](#sync-follow-state-in-batch) API with a single action, matching the approach already used by the Flutter SDK's own iOS plugin. Unfollowing the user's last followed group is refused on Android with `LAST_FOLLOWED_GROUP`; iOS has no equivalent guard and succeeds silently in that case.
:::

<!-- /tab -->

<!-- tab: Unity -->

```csharp
// Fetch the latest groups from the server
OctopusSDK.FetchGroups(
    onCompleted: groups =>
    {
        foreach (var g in groups)
        {
            // g.Id — stable identifier for OpenGroup / SyncFollowGroups
            // g.Name — display name
            // g.IsFollowed — whether the connected user follows this group
            // g.CanChangeFollowStatus — whether the user can follow/unfollow
        }
    },
    onError: msg => Debug.LogError(msg));

// Subscribe to live group-state changes
OctopusSDK.OnGroupsChanged += groups =>
{
    // Fired after FetchGroups completes and after SyncFollowGroups
};
```

The `onError` callback is optional.

<!-- /tab -->

### Sync follow state in batch (≥ 1.11.0) {#sync-follow-state-in-batch}

Client apps can push a batch of follow/unfollow actions for the current user, with a per-action timestamp. The backend reconciles each action against the **last recorded action on that group** — whether it came from a previous sync or from a manual action the user took in the app. The action with the most recent `actionDate` wins; if `actionDate` is older than (or equal to) what is already stored, the action is silently skipped. `actionDate` is a precedence timestamp, not a scheduler — every action is applied immediately, never deferred. Essential (i.e. force-followed) groups always stay followed — client attempts to unfollow them are rejected.

**Parameters for each action:**
- `groupId: String` — the id of the group to follow or unfollow.
- `followed: Bool` — `true` to follow, `false` to unfollow.
- `actionDate: Date` — the date at which the client observed the action. The backend compares this to the last recorded action for the `(user, group)` pair.

<!-- tab: Android -->

```kotlin
val actions = listOf(
    SyncFollowGroupAction(groupId = "g1", followed = true,  actionDate = Date()),
    SyncFollowGroupAction(groupId = "g2", followed = false, actionDate = Date()),
)

when (val result = OctopusSDK.syncFollowGroups(actions = actions)) {
    is OctopusResult.Success -> {
        for (r in result.data) {
            // r.groupId — the group this result refers to
            when (r.status) {
                is SyncFollowGroupStatus.Applied            -> {} // action was applied
                is SyncFollowGroupStatus.Skipped            -> {} // user acted more recently — client action ignored
                is SyncFollowGroupStatus.AlreadyFollowed    -> {} // user already follows — no change
                is SyncFollowGroupStatus.AlreadyUnfollowed  -> {} // user already does not follow — no change
                is SyncFollowGroupStatus.GroupNotFound      -> {} // no group with that id
                is SyncFollowGroupStatus.NotFollowable      -> {} // group is admin-restricted
                is SyncFollowGroupStatus.NotUnfollowable    -> {} // essential (i.e. force-followed) group
                is SyncFollowGroupStatus.UnknownError       -> {} // unclassified server error for this action
            }
        }
    }
    is OctopusResult.Failure -> {
        // Handle the transport-level failure (NoNetwork, UserNotAuthenticated, StatusError, …)
    }
}
```

<!-- /tab -->

<!-- tab: iOS -->

```swift
let actions: [OctopusSyncFollowGroup.Action] = [
    .init(groupId: "g1", followed: true,  actionDate: .now),
    .init(groupId: "g2", followed: false, actionDate: .now),
]

do {
    let results = try await octopus.syncFollowGroups(actions: actions)
    for r in results {
        // r.groupId — the group this result refers to
        switch r.status {
        case .applied:            break // action was applied
        case .skipped:            break // user acted more recently — client action ignored
        case .alreadyFollowed:    break // user already follows — no change
        case .alreadyUnfollowed:  break // user already does not follow — no change
        case .groupNotFound:      break // no group with that id
        case .notFollowable:      break // group is admin-restricted
        case .notUnfollowable:    break // essential (i.e. force-followed) group
        case .unknownError:       break // unclassified server error for this action
        }
    }
} catch {
    // OctopusSyncFollowGroup.Error:
    //   .notConnected   — SDK has no user context yet (rare; guests are auto-provisioned)
    //   .noNetwork      — device is offline
    //   .server(_)      — server error (carries the underlying error)
    //   .other(_)       — catch-all
    print(error.debugDescription)
}
```

<!-- /tab -->

<!-- tab: Flutter -->

```dart
import 'package:flutter/services.dart';
import 'package:octopus_sdk_flutter/octopus_sdk_flutter.dart';

final actions = [
  SyncFollowGroupAction(groupId: 'g1', followed: true,  actionDate: DateTime.now()),
  SyncFollowGroupAction(groupId: 'g2', followed: false, actionDate: DateTime.now()),
];

try {
  final results = await OctopusSDK().syncFollowGroups(actions);
  for (final r in results) {
    // r.groupId — the group this result refers to
    switch (r.status) {
      case SyncFollowGroupStatus.applied:           break; // action was applied
      case SyncFollowGroupStatus.skipped:           break; // user acted more recently — client action ignored
      case SyncFollowGroupStatus.alreadyFollowed:   break; // user already follows — no change
      case SyncFollowGroupStatus.alreadyUnfollowed: break; // user already does not follow — no change
      case SyncFollowGroupStatus.groupNotFound:     break; // no group with that id
      case SyncFollowGroupStatus.notFollowable:     break; // group is admin-restricted
      case SyncFollowGroupStatus.notUnfollowable:   break; // essential (i.e. force-followed) group
      case SyncFollowGroupStatus.unknownError:      break; // unclassified server error for this action
    }
  }
} on PlatformException catch (e) {
  // e.code: 'not_connected', 'no_network', 'server', 'other'
}
```

<!-- /tab -->

<!-- tab: React Native -->

```typescript
import {
    syncFollowGroups,
    SyncFollowGroupStatus,
} from '@octopus-community/react-native';
import type {
    SyncFollowGroupAction,
    SyncFollowGroupResult,
} from '@octopus-community/react-native';

const actions: SyncFollowGroupAction[] = [
    { groupId: 'g1', followed: true,  actionDate: new Date() },
    { groupId: 'g2', followed: false, actionDate: new Date() },
];

try {
    const results: SyncFollowGroupResult[] = await syncFollowGroups(actions);
    for (const r of results) {
        // r.groupId — the group this result refers to
        switch (r.status) {
            case SyncFollowGroupStatus.Applied:           break; // action was applied
            case SyncFollowGroupStatus.Skipped:           break; // user acted more recently — client action ignored
            case SyncFollowGroupStatus.AlreadyFollowed:   break; // user already follows — no change
            case SyncFollowGroupStatus.AlreadyUnfollowed: break; // user already does not follow — no change
            case SyncFollowGroupStatus.GroupNotFound:      break; // no group with that id
            case SyncFollowGroupStatus.NotFollowable:     break; // group is admin-restricted
            case SyncFollowGroupStatus.NotUnfollowable:   break; // essential (i.e. force-followed) group
            case SyncFollowGroupStatus.UnknownError:      break; // unclassified server error for this action
        }
    }
} catch (error: any) {
    // Native error codes: 'not_connected', 'no_network', 'server', 'other'
    console.error(error.code, error.message);
}
```

Results are matched to inputs by `groupId` — order is not guaranteed. An empty `actions` array resolves immediately to `[]` without a bridge call. Requires a connected user.

<!-- /tab -->

<!-- tab: Unity -->

Pass a list of `OctopusSyncFollowGroupAction` — one per group — with a `GroupId`, a `Followed` flag, and an `ActionDate` timestamp. The SDK calls `onCompleted` with a list of `OctopusSyncFollowGroupResult` (one per action). If the actions list is empty, `onCompleted` is called immediately with an empty list (no network call).

```csharp
using System;
using System.Collections.Generic;

var actions = new List<OctopusSyncFollowGroupAction>
{
    new OctopusSyncFollowGroupAction { GroupId = "g1", Followed = true,  ActionDate = DateTime.UtcNow },
    new OctopusSyncFollowGroupAction { GroupId = "g2", Followed = false, ActionDate = DateTime.UtcNow },
};

OctopusSDK.SyncFollowGroups(
    actions,
    onCompleted: results =>
    {
        foreach (var r in results)
        {
            // r.GroupId — which group this result is for
            switch (r.Status)
            {
                case OctopusSyncFollowGroupStatus.Applied:           break; // action applied
                case OctopusSyncFollowGroupStatus.Skipped:           break; // user acted more recently — ignored
                case OctopusSyncFollowGroupStatus.AlreadyFollowed:   break; // no change
                case OctopusSyncFollowGroupStatus.AlreadyUnfollowed: break; // no change
                case OctopusSyncFollowGroupStatus.GroupNotFound:     break; // unknown group id
                case OctopusSyncFollowGroupStatus.NotFollowable:     break; // admin-restricted group
                case OctopusSyncFollowGroupStatus.NotUnfollowable:   break; // essential group
                case OctopusSyncFollowGroupStatus.UnknownError:      break; // unclassified server error
            }
        }
    },
    onError: msg => Debug.LogError(msg));
```

The `onError` callback is optional — it fires on transport-level failures.

<!-- /tab -->

#### How `actionDate` resolves conflicts

Starting from a group `g1` with no recorded state, the action with the most recent `actionDate` always wins — regardless of the order in which the backend receives the actions:

| # | Source           | Action   | `actionDate`  | Result  | Follow state after    |
|---|------------------|----------|---------------|---------|-----------------------|
| 1 | client sync      | follow   | Jan 10        | Applied | followed (Jan 10)     |
| 2 | user, in the app | unfollow | Jan 12 (now)  | Applied | not followed (Jan 12) |
| 3 | client sync      | follow   | Jan 11        | Skipped | not followed (Jan 12) |

A manual action the user takes in the app carries the current time as its `actionDate`, so it naturally wins over older client syncs. Action #3 is skipped because its `actionDate` (Jan 11) is older than the last recorded one (Jan 12) — **only `actionDate` matters, not the order in which the backend receives the actions.**

#### `actionDate` is not a scheduler

A future `actionDate` is applied **immediately**, and it becomes the baseline that later actions must beat — it does not defer the action to that date:

| # | Source           | Action   | `actionDate`    | Result  | Effect                                                  |
|---|------------------|----------|-----------------|---------|---------------------------------------------------------|
| 1 | client sync      | follow   | Mar 31 (future) | Applied | user follows `g2` **now**; baseline set to Mar 31       |
| 2 | user, in the app | unfollow | Jan 12 (now)    | Skipped | Jan 12 ≤ Mar 31 → the user cannot unfollow until Mar 31 |

:::warning
Do not use a future `actionDate` to "schedule" a change. There is no deferral: the action takes effect immediately and then locks the group against any earlier-dated action — including the user's own — until that date is reached. To apply a change at a later time, keep the timing in your app and send the action with `actionDate` set to the current time when it should take effect.
:::

:::tip
Typical triggers for this API are lifecycle events in your app: app launch, onboarding completion, a premium unlock, or a preference change that should pre-shape the user's community experience.
:::

### Gate access to locked groups (≥ 1.12.0) {#gate-access-to-locked-groups}

Groups can be configured server-side to require an entitlement before they can be opened (typically a premium / gated group). Such "locked" groups still appear in the listing and the community UI, but tapping them does not open the group detail — the SDK invokes a host-app callback instead, so your app can present an upsell screen, a paywall, or any other gating UI. Group access decisions are pre-resolved by the backend; the SDK never enforces them client-side and never navigates on the user's behalf.

The same callback fires whenever the user attempts to interact with a locked group:
- tapping the row in the group list,
- tapping the follow button on a locked row,
- selecting a locked group in the create-post group picker,
- interacting with the group detail screen after access was revoked mid-session (e.g. an entitlement expired while the user was browsing).

**Read `canAccess` on a group:**

<!-- tab: Android -->

```kotlin
OctopusSDK.groups.collect { groups ->
    groups.forEach { group ->
        if (group.canAccess) {
            // Render normally
        } else {
            // Render as locked (e.g. show a lock icon). Taps route through the callback set below.
        }
    }
}
```

<!-- /tab -->

<!-- tab: iOS -->

```swift
octopus.$groups
    .sink { groups in
        for group in groups {
            if group.canAccess {
                // Render normally
            } else {
                // Render as locked (e.g. show a lock icon). Taps route through the callback set below.
            }
        }
    }
```

<!-- /tab -->

<!-- tab: Flutter -->

```dart
OctopusSDK.groups.listen((groups) {
    for (final group in groups) {
        if (group.canAccess) {
            // Render normally
        } else {
            // Render as locked (e.g. show a lock icon). Taps route through the callback set below.
        }
    }
});
```

<!-- /tab -->

<!-- tab: React Native -->

(≥ 1.13.0)

```typescript
import { addGroupsListener } from '@octopus-community/react-native';

const subscription = addGroupsListener((groups) => {
    for (const group of groups) {
        if (group.canAccess) {
            // Render normally
        } else {
            // Render as locked (e.g. show a lock icon). Taps route through the callback set below.
        }
    }
});
```

<!-- /tab -->

<!-- tab: Unity -->

*Group access gating is not yet available on Unity.*

<!-- /tab -->

**Wire the locked-group callback:**

<!-- tab: Android -->

Two equivalent ways to register the callback — use whichever matches your integration.

**Option 1 — per-screen on `octopusComposables(...)`:** scoped to the navigation graph you register.

```kotlin
octopusComposables(
    navController = navController,
    onGroupAccessDenied = { groupId ->
        // Open your upsell flow, paywall, etc.
    },
    // … other callbacks
)
```

**Option 2 — SDK-level callback:** a single registration that applies wherever no per-screen callback is set.

```kotlin
OctopusSDK.setGroupAccessDeniedCallback { groupId ->
    // Open your upsell flow, paywall, etc.
}
```

The per-screen `onGroupAccessDenied` takes precedence when both are set.

<!-- /tab -->

<!-- tab: iOS -->

```swift
octopus.set(groupAccessDeniedCallback: { groupId in
    // Open your upsell flow, paywall, etc.
})
```

<!-- /tab -->

<!-- tab: Flutter -->

`setGroupAccessDeniedCallback` returns a handle you call to unregister (e.g. in `dispose`). Registering again replaces the previous callback.

```dart
final unregister = OctopusSDK.setGroupAccessDeniedCallback((groupId) {
    // Open your upsell flow, paywall, etc.
});

// Later, to stop receiving callbacks:
unregister();
```

<!-- /tab -->

<!-- tab: React Native -->

(≥ 1.13.0)

`setGroupAccessDeniedCallback` is a last-write-wins setter, not a subscription: calling it again replaces the previously set callback. It returns an unregister handle.

```typescript
import { setGroupAccessDeniedCallback } from '@octopus-community/react-native';

useEffect(() => {
    return setGroupAccessDeniedCallback((groupId) => {
        // Open your upsell flow, paywall, etc.
    });
}, []);
```

<!-- /tab -->

<!-- tab: Unity -->

*Group access gating is not yet available on Unity.*

<!-- /tab -->

:::info
The SDK does not navigate on the user's behalf. If you do not register a callback, taps on locked groups have no effect inside the community UI — register one so users get a clear next step.
:::

---
## Bridges (≥ 1.5.0)

Octopus provides a way of linking a specific object in your app (e.g. a page, a product, any kind of content) to a dynamic post in the community, created automatically. We call this a bridge. 
To create a bridge, your content (an article, a product, etc.) must have a unique identifier. This ID will be used to link your content to the post. It will also allow users in the Octopus Community to directly view your content.

The bridge process involves four main steps:

**Step 1: Prepare the post data**
Use the SDK to get (or create) the ID of the post related to your content.
First get from the SDK the id of the post to display. The SDK will create the post if it is not already created. To do that, call the dedicated API and pass the required data:
   - **Content id** (called clientObjectId) that the post will be about
   - **Text of the post**. **It must be between 10 and 5000 (3000 before the 1.8.0) characters.**
   - **Catchphrase** (optional). It will be displayed below the title, in bold. You can use something like *"What do you think about this?"*. **It must be less than 84 characters** and we recommend between 6 and 38 characters.
   - **Image for the post** (optional). You can either pass a remote image using an URL or a local image.
   The image must respect these requirements:
        - File size must be **less than 50Mb**
        - File format must be **jpg or png**
        - Image sides must be **between 50px and 4000px**, with a **max ratio of 32:9** (bigger side / smaller side)
        - If you pass a remote image, the resource should be **public**
   - **Text of the button** that will invite your users to display your content (optional). You can use something like *"Buy it"* if your content is a purchasable product, or *"Read the article"* if it is a press article. When the button containing this text will be tapped, the SDK will ask you to display your content. If no text is provided, the button won't be displayed. **It must be less than 28 characters**, we recommend between 4 and 28 characters.
   - **Group id** (optional) (≥ 1.11.0 (was "Topic id")). This is the id of the group that you want the post to be labelled with. If not provided, the group will be automatically set according to your community settings. See the [Groups](#list-available-groups-and-read-their-state) section to retrieve the list of available groups.
   - **Signature** (Deprecated (< 1.10.0)): replaced by enhanced security in the `fetchOrCreateClientObjectRelatedPost` function using a callback and a unique token per post.

<!-- tab: Android -->

```kotlin
val clientPost = ClientPost(
    objectId = "recipe-129302938", // A unique identifier for your content
    text = "The perfect Canelés", // Between 10 and 5000 chars
    attachment = Image.Remote(url = imageUrl), // You can also pass Image.Local(uri)
    groupId = foodRecipeGroupId, // The id of the Octopus group. Null if default.
    catchPhrase = "Tried the canelés? Tell us how good they were!", // Less than 84 characters
    viewObjectButtonText = "Read the recipe" // Less than 28 characters
)
```

<details>
<summary>Deprecated (&lt; 1.11.0)</summary>

```kotlin
val clientPost = ClientPost(
    objectId = "recipe-129302938",
    text = "The perfect Canelés",
    attachment = Image.Remote(url = imageUrl),
    topicId = foodRecipeId, // renamed to `groupId` in 1.11.0
    catchPhrase = "Tried the canelés? Tell us how good they were!",
    viewObjectButtonText = "Read the recipe"
)
```
</details>

<!-- /tab -->

<!-- tab: iOS -->

```swift
let postContent = ClientPost(
    clientObjectId: "recipe-129302938", // a unique identifier for your content
    groupId: foodRecipeGroupId, // the id of the Octopus group. Nil if default.
    text: "The perfect Canelés", // between 10 and 5000 chars
    catchPhrase: "Tried the canelés? Tell us how good they were!", // Less than 84 characters
    attachment: .localImage(image.jpegData(compressionQuality: 1)!), // you can also pass .distantImage(url)
    viewClientObjectButtonText: "Read the recipe" // Less than 28 characters, not displayed if you did not set any `displayClientObjectCallback`
)
```

<details>
<summary>Deprecated (< 1.11.0)</summary>
```swift
let postContent = ClientPost(
    clientObjectId: "recipe-129302938", // a unique identifier for your content
    topicId: foodRecipeId, // the id of the Octopus topic. Nil if default.
    text: "The perfect Canelés", // between 10 and 5000 chars
    catchPhrase: "Tried the canelés? Tell us how good they were!", // Less than 84 characters
    attachment: .localImage(image.jpegData(compressionQuality: 1)!), // you can also pass .distantImage(url)
    viewClientObjectButtonText: "Read the recipe" // Less than 28 characters, not displayed if you did not set any `displayClientObjectCallback`
)
```
</details>

<!-- /tab -->

<!-- tab: Flutter -->

(≥ 1.12.0)

```dart
final clientPost = ClientPost(
    objectId: "recipe-129302938", // A unique identifier for your content
    text: "The perfect Canelés", // Between 10 and 5000 chars
    attachment: OctopusRemoteImageAttachment(Uri.parse(imageUrl)), // or OctopusLocalImageAttachment(bytes)
    groupId: foodRecipeGroupId, // The id of the Octopus group. Null if default.
    catchPhrase: "Tried the canelés? Tell us how good they were!", // Less than 84 characters
    viewObjectButtonText: "Read the recipe", // Less than 28 characters
);
```

The image `attachment` is a sealed `OctopusClientPostAttachment`: `OctopusLocalImageAttachment(bytes)` for bytes you already hold, or `OctopusRemoteImageAttachment(url)` for an image the backend fetches from a public URL.

<!-- /tab -->

<!-- tab: React Native -->

(≥ 1.13.0)

```typescript
import type { ClientPost } from '@octopus-community/react-native';

const clientPost: ClientPost = {
    objectId: 'recipe-129302938', // A unique identifier for your content
    text: 'The perfect Canelés', // Between 10 and 5000 chars
    attachment: { type: 'remoteImage', url: imageUrl }, // or { type: 'localImage', uri }
    groupId: foodRecipeGroupId, // The id of the Octopus group. Omit if default.
    catchPhrase: 'Tried the canelés? Tell us how good they were!', // Less than 84 characters
    viewObjectButtonText: 'Read the recipe', // Less than 28 characters
};
```

The image `attachment` is a discriminated union: `{ type: 'localImage', uri }` for a `file://` URI or a bundled asset name (a `Uint8List` of raw bytes, as on Flutter, cannot cross the React Native bridge cheaply), or `{ type: 'remoteImage', url }` for an image the backend fetches from a public URL. This content is only used the **first** time the post is created for a given `objectId` — see the next step.

<!-- /tab -->

<!-- tab: Unity -->

This feature is not yet available on Unity.

<!-- /tab -->

**Step 2: Get the post ID**

Calling the API with the previously created post data will return a post ID. With this ID, you can display the post using the Octopus UI. If the post is not created, this API will create it, otherwise it won't change its content.

To ensure security, you must provide a callback called `tokenProvider`. The SDK calls this callback only when a new post needs to be created, to get the post signature from you. For maximum security (recommended), you should compute the bridge fingerprint yourself on the backend. You can find how to do it in the [Generate fingerprint for bridge](/backend/jwt/generate_jwt#generate-fingerprint) documentation. Alternatively, with reduced security, you can use the bridge fingerprint provided by the callback. 
In both cases, with the fingerprint, you should ask your server to sign it (see [Generate token for bridge](/backend/jwt/generate_jwt#generate-token-for-bridge) documentation for details on how to do this).

:::warning
Avoid repeatedly deleting and recreating a bridge post for the same content id. Recreating a post starts a brand-new bridge: its comments, reactions and engagement counters all reset to zero. On top of that, each content id only supports a limited number of delete-and-recreate cycles; past that limit, creating a new post for that content will fail. Treat deletion as occasional, not as part of a regular publishing loop.
:::

<!-- tab: Android -->

```kotlin
val result = OctopusSDK.fetchOrCreateClientObjectRelatedPost(
    clientPost = clientPost,
    tokenProvider = { bridgeFingerprint ->
        // For more security, ignore the bridgeFingerprint and get the jwt directly from a fingerprint
        // computed on your backend.
        // Otherwise, call your backend /generateBridgeSignature route with the bridgeFingerprint.
        // `server` represents your own backend client — replace with your actual implementation.
        // Return null if your community does not require a signature.
        server.getBridgeSignature(bridgeFingerprint)
    }
)
when (result) {
    is OctopusResult.Success -> {
        val post = result.data
        val postId = post.id
        // Navigate to the post or handle success
    }
    is OctopusResult.Error -> {
        // Handle error
    }
}
```

<details>
<summary>Deprecated (< 1.10.0)</summary>
```kotlin
// fetchOrCreateClientObjectRelatedPost without tokenProvider
val result = OctopusSDK.fetchOrCreateClientObjectRelatedPost(clientPost)
```
</details>

<!-- /tab -->

<!-- tab: iOS -->

```swift
let post = try await octopus.fetchOrCreateClientObjectRelatedPost(
    content: postContent,
    tokenProvider: { bridgeFingerprint in
        // For more security, ignore the bridgeFingerprint and get the jwt directly from a fingerprint 
        // computed on your backend.
        // Otherwise, call your backend /generateBridgeSignature route with the bridgeFingerprint.
        // `server` represents your own backend client — replace with your actual implementation.
        // Return nil if your community does not require a signature.
        return try await server.getBridgeSignature(bridgeFingerprint: bridgeFingerprint)
    }
)
let postId = post.id
```

<details>
<summary>Deprecated (< 1.10.0)</summary>
```swift
// fetchOrCreateClientObjectRelatedPost without tokenProvider
let post = try await octopus.fetchOrCreateClientObjectRelatedPost(content: postContent)
let postId = post.id
```
</details>

<details>
<summary>Deprecated (< 1.7.0)</summary>
```swift
let postId = try await octopus.getOrCreateClientObjectRelatedPostId(content: postContent)
```
</details>

<!-- /tab -->

<!-- tab: Flutter -->

(≥ 1.12.0)

```dart
final result = await octopus.fetchOrCreateClientObjectRelatedPost(
    clientPost,
    tokenProvider: (bridgeFingerprint) async {
        // For more security, ignore the bridgeFingerprint and get the JWT directly from a
        // fingerprint computed on your backend. Otherwise, call your backend
        // /generateBridgeSignature route with the bridgeFingerprint.
        // `server` represents your own backend client — replace with your actual implementation.
        // Return null if your community does not require a signature.
        return server.getBridgeSignature(bridgeFingerprint);
    },
);

switch (result) {
    case OctopusSuccess(:final data):
        final postId = data.id;
        // Navigate to the post or handle success
    case OctopusInvalidArguments<OctopusServerError>(:final errors):
        // Content/validation problems. The explicit `<OctopusServerError>`
        // argument is what makes this switch exhaustive — the analyzer builds
        // the sealed space from each subtype's declared bound, so naming the
        // narrower leaf type is NOT exhaustive. `errors` then binds as
        // `List<OctopusServerError>` — narrow it to this call's leaves:
        for (final error in errors.whereType<ClientPostError>()) {
            // Surface error.errorMessage through your app's own error UI.
        }
    case OctopusConnectionFailure():
        // Transport/auth failure (no network, not authenticated, …)
}
```

The `tokenProvider` callback is optional and invoked **only** when a new post must be created and your community requires a bridge signature; omit it if no signature is required. You can also use the result helpers — e.g. `result.getOrNull()?.id` or `result.onSuccess((post) { … })`.

<!-- /tab -->

<!-- tab: React Native -->

(≥ 1.13.0)

```typescript
import {
    fetchOrCreateClientObjectRelatedPost,
    isClientPostError,
    useBridgeShareTokenProvider,
} from '@octopus-community/react-native';

// Register once, at app start — not as a per-call argument (see note below).
useBridgeShareTokenProvider(async (bridgeFingerprint) => {
    // For more security, ignore `bridgeFingerprint` and get the JWT directly from a
    // fingerprint computed on your backend. Otherwise, call your backend
    // /generateBridgeSignature route with `bridgeFingerprint`.
    // Return null if your community does not require a signature.
    return server.getBridgeSignature(bridgeFingerprint);
});

try {
    const post = await fetchOrCreateClientObjectRelatedPost(clientPost);
    const postId = post.id;
    // Navigate to the post or handle success
} catch (error) {
    if (isClientPostError(error)) {
        // error.code — see the ClientPostErrorCode reference for which codes each
        // platform actually emits.
    }
}
```

Unlike Android/iOS/Flutter, the token provider is **not** a per-call parameter here — a `suspend`/`async` callback cannot cross the React Native bridge. It reuses the same global provider registered through `useBridgeShareTokenProvider` (or `addBridgeShareTokenRequestListener`) for the create-post editor's prefilled shares — register it once, and it serves both.

<!-- /tab -->

<!-- tab: Unity -->

This feature is not yet available on Unity.

<!-- /tab -->

**Step 3: Handle user interaction**

Optionally, you can register a callback to be notified when a user wants to view your content from a bridge post. The SDK will provide the clientObjectId so you can open the appropriate content.

<!-- tab: Android -->

```kotlin
// In your NavHost setup
octopusComposables(
    // ...
    onNavigateToClientObject = { objectId ->
        // Display the content that has the given objectId
    }
)
```
:::info
Note that if you don't set `onNavigateToClientObject`, the button containing the `viewObjectButtonText` you passed in the `ClientPost` won't be displayed.
:::

<!-- /tab -->

<!-- tab: iOS -->

```swift
octopus.set(displayClientObjectCallback: { objectId in
    // display the content that has the given objectId
})
```
:::info
Note that if you don't set the `displayClientObjectCallback`, the button containing the `viewClientObjectButtonText` you passed in the ClientPost won't be displayed.
:::

<!-- /tab -->

<!-- tab: Flutter -->

(≥ 1.12.0)

```dart
// Register early (e.g. in initState). The returned handle unregisters the callback —
// call it from dispose. Registering again replaces the previous callback (last-write-wins).
final cancel = OctopusSDK.setNavigateToClientObjectCallback((objectId) {
    // Display the content that has the given objectId
});

// later, on dispose:
cancel();
```
:::info
Note that if you don't set this callback, the button containing the `viewObjectButtonText` you passed in the `ClientPost` won't be displayed.
:::

<!-- /tab -->

<!-- tab: React Native -->

(≥ 1.13.0)

```typescript
import { setNavigateToClientObjectCallback } from '@octopus-community/react-native';

// Register once, at app start — see the note below on why timing matters here.
useEffect(() => {
    return setNavigateToClientObjectCallback((objectId) => {
        // Display the content that has the given objectId
    });
}, []);
```

Returns an unregister function — identity-guarded, so a stale `useEffect` cleanup cannot wipe a newer registration. **Register before opening the Octopus UI**: on Android the button's visibility is read when the UI is composed, so a late registration leaves it hidden until the UI is reopened; on iOS the button stays visible after unregistering (its native setter has no "unset"), and taps silently become no-ops. Registering once at app start, and never clearing it while the UI is on screen, is correct on both platforms.

:::info
If you don't register this callback, the button containing `viewObjectButtonText` won't be displayed (Android) or won't respond to taps (iOS).
:::

<!-- /tab -->

<!-- tab: Unity -->

Available on Unity since `1.12.1`. Subscribe to the static `OctopusSDK.OnNavigateToClientObject` event to be notified when a user taps the "view object" button on a bridge post. The `string` argument is the `clientObjectId` linked to the post, so you can open the matching content.

```csharp
using UnityEngine;

public class ClientObjectHandler : MonoBehaviour
{
    void Start()
    {
        OctopusSDK.OnNavigateToClientObject += OnNavigateToClientObject;
    }

    void OnDestroy()
    {
        OctopusSDK.OnNavigateToClientObject -= OnNavigateToClientObject;
    }

    void OnNavigateToClientObject(string clientObjectId)
    {
        // Open the content that has the given clientObjectId
    }
}
```

:::note
Unity can receive bridge navigation callbacks, but creating bridge posts from the SDK (`fetchOrCreateClientObjectRelatedPost`) is not yet available on Unity — bridge posts must be created from another platform or your backend.
:::

<!-- /tab -->

**Step 4: Display the post**

Using the post id, you can display it directly after the user taps a button on your object page. See [Open a specific screen](#open-a-specific-screen) for the per-platform integration details.

<!-- tab: Android -->

```kotlin
OctopusPostDetailsContent(
    modifier = Modifier.fillMaxSize(),
    postId = postId,
    // ...
)
```

<!-- /tab -->

<!-- tab: iOS -->

```swift
OctopusHomeScreen(octopus: octopus, initialScreen: .post(.init(postId: postId)))
```

<details>
<summary>Deprecated (< 1.11.0)</summary>
```swift
OctopusHomeScreen(octopus: octopus, postId: postId)
```
</details>

<!-- /tab -->

<!-- tab: Flutter -->

(≥ 1.12.0)

```dart
OctopusHomeScreen(
    initialScreen: OctopusInitialScreen.post(PostScreenInfo(postId: postId)),
)
```

Or use the dedicated bridge-mode widget `OctopusPostDetailsScreen(postId: postId)`. See [Open a specific screen](#open-a-specific-screen) for the full integration details.

<!-- /tab -->

<!-- tab: React Native -->

(≥ 1.13.0)

```typescript
import { openUI } from '@octopus-community/react-native';

await openUI({ initialScreen: { type: 'post', postId } });
```

See [Open a specific screen](#open-a-specific-screen) for the full integration details, including `<OctopusUIView>`.

<!-- /tab -->

**Optional: accessing live data about the post** (> 1.7.0)

Additionaly to the post id, we also provide more information about the post. You can access and display the reaction count that all Octopus users did on the post, the number of comments and the number of views.

You can retrieve an object that references the post and that will be updated with the current value and as soon as you call the `fetchOrCreateClientObjectRelatedPost` function.

<!-- tab: Android -->

```kotlin
OctopusSDK.getClientObjectRelatedPostFlow(clientObjectId = "recipe-129302938")
    .filterNotNull()
    .collect { post ->
        val reactions = post.reactions // Reactions is List<OctopusReactionCount> and OctopusReactionCount is a data class with a reaction and a count
        val commentCount = post.commentCount
        val viewCount = post.viewCount
    }
```

<!-- /tab -->

<!-- tab: iOS -->

```swift
octopus.getClientObjectRelatedPostPublisher(clientObjectId: "recipe-129302938")
    .sink { post in
        let reactions = post.reactions // reactions is [OctopusReactionCount] and OctopusReactionCount is a struct with a reaction and a count
        let commentCount = post.commentCount
        let viewCount = post.viewCount
    }
```

<!-- /tab -->

<!-- tab: Flutter -->

(≥ 1.12.0)

```dart
OctopusSDK.getClientObjectRelatedPostFlow("recipe-129302938")
    .where((post) => post != null)
    .listen((post) {
        final reactions = post!.reactions; // List<OctopusReactionCount> (each has reactionKind + count)
        final commentCount = post.commentCount;
        final viewCount = post.viewCount;
    });
```

Each subscription drives its own native observation, so observing the same `clientObjectId` from two places is safe; cancel the subscription to stop observing.

Render these counts in the same compact `K` / `M` / `B` style as the embedded feed with the top-level `formatOctopusCompactCount(count, {locale})` helper — e.g. `formatOctopusCompactCount(post.viewCount)`.

<!-- /tab -->

<!-- tab: React Native -->

(≥ 1.13.0)

```typescript
import { addClientObjectRelatedPostListener, formatOctopusCompactCount } from '@octopus-community/react-native';

useEffect(() => {
    const subscription = addClientObjectRelatedPostListener('recipe-129302938', (post) => {
        if (!post) return; // no post created yet for this object
        const reactions = post.reactions; // OctopusReactionCount[] — each has reactionKind + count
        const commentCount = formatOctopusCompactCount(post.commentCount);
        const viewCount = formatOctopusCompactCount(post.viewCount);
    });
    return () => subscription.remove();
}, []);
```

Each call opens its **own** native observation — several places can watch the same, or different, objects independently — and `remove()` tears down only its own. `formatOctopusCompactCount(count, { locale })` is a pure helper (no native call): pass the locale you handed to `overrideDefaultLocale` so the rendered counts match the embedded feed.

<!-- /tab -->

<!-- tab: Unity -->

This feature is not yet available on Unity.

<!-- /tab -->

**Optional: reading the current user's reaction** (≥ 1.9.1)

You can read the current user's reaction on a bridge post from the post object. This is useful if you want to display the user's reaction in your own UI outside of Octopus.

<!-- tab: Android -->

```kotlin
OctopusSDK.getClientObjectRelatedPostFlow(clientObjectId = "recipe-129302938")
    .filterNotNull()
    .collect { post ->
        val userReaction = post.userReactionKind // OctopusReactionKind? — null if the user has not reacted
    }
```

<!-- /tab -->

<!-- tab: iOS -->

```swift
octopus.getClientObjectRelatedPostPublisher(clientObjectId: "recipe-129302938")
    .sink { post in
        let userReaction = post.userReaction // OctopusReactionKind? — nil if the user has not reacted
    }
```

<!-- /tab -->

<!-- tab: Flutter -->

(≥ 1.12.0)

```dart
OctopusSDK.getClientObjectRelatedPostFlow("recipe-129302938")
    .where((post) => post != null)
    .listen((post) {
        final userReaction = post!.userReactionKind; // OctopusReactionKind? — null if the user has not reacted
    });
```

<!-- /tab -->

<!-- tab: React Native -->

The connected user's reaction is a field on the same post object returned by `fetchOrCreateClientObjectRelatedPost` / delivered by `addClientObjectRelatedPostListener` — no separate call needed:

```typescript
const userReaction = post.userReactionKind; // string | null — null if the user has not reacted
```

<!-- /tab -->

<!-- tab: Unity -->

This feature is not yet available on Unity.

<!-- /tab -->

**Optional: setting a reaction on a bridge post** (≥ 1.9.1)

If you display bridge post data in your own UI, you can let users react to the post without entering the Octopus Community screen. Pass a reaction kind to set a reaction, or pass `null`/`nil` to remove it.

Available reaction kinds: `heart` ❤️, `joy` 😂, `mouthOpen` 😮, `clap` 👏, `cry` 😢, `rage` 😡.

<!-- tab: Android -->

`setReaction(reaction, postId)` works on any post (bridge or community). (≥ 1.12.0)

```kotlin
// Set a reaction
OctopusSDK.setReaction(
    reaction = OctopusReactionKind.Heart,
    postId = "recipe-129302938"
)

// Remove the reaction
OctopusSDK.setReaction(reaction = null, postId = "recipe-129302938")
```

<details>
<summary>Deprecated (< 1.12.0)</summary>

```kotlin
// Set a reaction
OctopusSDK.setReaction(
    reaction = OctopusReactionKind.Heart,
    clientObjectRelatedPostId = "recipe-129302938"
)

// Remove the reaction
OctopusSDK.setReaction(reaction = null, clientObjectRelatedPostId = "recipe-129302938")
```
</details>

<!-- /tab -->

<!-- tab: iOS -->

`set(reaction:postId:)` works on any post (bridge or community). (≥ 1.12.0)

```swift
// Set a reaction
try await octopus.set(reaction: .heart, postId: "recipe-129302938")

// Remove the reaction
try await octopus.set(reaction: nil, postId: "recipe-129302938")
```

<details>
<summary>Deprecated (< 1.12.0)</summary>

```swift
// Set a reaction
try await octopus.set(reaction: .heart, clientObjectRelatedPostId: "recipe-129302938")

// Remove the reaction
try await octopus.set(reaction: nil, clientObjectRelatedPostId: "recipe-129302938")
```
</details>

<!-- /tab -->

<!-- tab: Flutter -->

`setReaction(reaction, postId)` works on any post (bridge or community). (≥ 1.12.0)

```dart
// Set a reaction
await octopus.setReaction(OctopusReactionKind.heart, "recipe-129302938");

// Remove the reaction
await octopus.setReaction(null, "recipe-129302938");
```

Returns an `OctopusResult<void, SetReactionError>` — inspect it for typed failures (e.g. `SetReactionPostNotFoundError`) or pass `null` to clear the current reaction.

<!-- /tab -->

<!-- tab: React Native -->

`setReaction(postId, reaction)` works on any post (bridge or community). Note the argument order: **`postId` first**, unlike Android/iOS/Flutter's `(reaction, postId)`.

```typescript
import { setReaction, isSetReactionError } from '@octopus-community/react-native';

try {
    // Set a reaction
    await setReaction('recipe-129302938', 'heart');

    // Remove the reaction
    await setReaction('recipe-129302938', null);
} catch (error) {
    if (isSetReactionError(error)) {
        // error.code — e.g. a post-not-found failure
    }
}
```

<!-- /tab -->

<!-- tab: Unity -->

This feature is not yet available on Unity.

<!-- /tab -->

**To see a full example of how you can achieve that, you can follow how it is done in the Samples:**

<!-- tab: Android -->

in the [Octopus Sample app](https://github.com/Octopus-Community/octopus-sdk-android/tree/main/samples/src/main/java/com/octopuscommunity/sample).
The `MainViewModel` is in charge of preparing the client post and getting the post.

<!-- /tab -->

<!-- tab: iOS -->

in the [Scenario "Bridge to Client Object"](https://github.com/Octopus-Community/octopus-sdk-swift/tree/main/Sample/OctopusSample/UI/Scenarios/BridgeToClientObject).
 The `RecipeViewModel` is in charge of preparing the client post and getting the post id and the `BridgeToClientObjectViewModel`
 is in charge of setting the callback.

<!-- /tab -->

<!-- tab: Flutter -->

in the [Flutter example app](https://github.com/Octopus-Community/octopus-sdk-flutter/tree/main/example).

<!-- /tab -->

<!-- tab: React Native -->

The [React Native example app](https://github.com/Octopus-Community/octopus-sdk-react-native/tree/main/example) does not include a Bridge scenario yet — follow the snippets above, or the equivalent Flutter/iOS/Android sample scenarios for the end-to-end flow.

<!-- /tab -->

<!-- tab: Unity -->

This feature is not yet available on Unity.

<!-- /tab -->

---
## Octopus A/B Testing (≥ 1.6.0)

The **A/B test feature** lets you measure the impact of the community on your app usage by splitting your audience into two cohorts:
- **Test group**: a portion of users (e.g., 30%) with full access to the community.
- **Control group**: the remaining users (e.g., 70%) without access. For them, when they tap the community button, Octopus displays a screen: “The community is not available yet for you, please come back later.”
In Octopus Analytics, you’ll then see clear comparisons between these two cohorts in terms of app session volume and retention.

Even if this A/B Test is totally internal, we let you know whether the user can access the community or not (i.e., in which group the user is).

We also let you override the group assigned to the connected user. This can be useful during your tests to check what will see the users of each group or even to provide or disable access to some given users.

Use this method when you need to guarantee that the user’s community access is enforced by Octopus, regardless of internal A/B testing rules.

**Here is how to override the user community access:**

<!-- tab: Android -->

```kotlin
OctopusSDK.overrideCommunityAccess(hasAccess = canAccessCommunity)
```

Here is how to know whether the current user has access to the community. It is a published value that is updated
as soon as the user group changes. (≥ 1.6.1)

```kotlin
OctopusSDK.hasAccessToCommunity.collect { hasAccessToCommunity ->
    // Use the hasAccessToCommunity new value
}
```

To see a full example of how you can achieve that, you can follow how it is done in the Samples,
in the [Octopus Sample app](https://github.com/Octopus-Community/octopus-sdk-android/tree/main/samples/src/main/java/com/octopuscommunity/sample).

<!-- /tab -->

<!-- tab: iOS -->

```swift
octopus.overrideCommunityAccess(canAccessCommunity)
```

Here is how to know whether the current user has access to the community. It is a published value that is updated
 as soon as the user group changes. (≥ 1.6.1)

```swift
// one shot value
let hasAccess = octopus.hasAccessToCommunity
// published value
octopus.$hasAccessToCommunity.sink { hasAccessToCommunity in
	// use the hasAccessToCommunity new value
}
```

To see a full example of how you can achieve that, you can follow how it is done in the Samples,
 in the [Scenario "Force Octopus A/B Tests Cohort"](https://github.com/Octopus-Community/octopus-sdk-swift/tree/main/Sample/OctopusSample/UI/Scenarios/ForceOctopusABTests).

<!-- /tab -->

<!-- tab: Flutter -->

`overrideCommunityAccess` returns a typed `OctopusResult<void, OverrideCommunityAccessError>` (≥ 1.12.0). Handle the result instead of letting it slip:

```dart
final result = await octopus.overrideCommunityAccess(canAccessCommunity);
switch (result) {
    case OctopusSuccess():
        // Override applied — `hasAccessToCommunity` will emit shortly
        break; // required: an empty case body falls into the next one
    case OctopusInvalidArguments<OctopusServerError>(:final errors):
        // The explicit `<OctopusServerError>` argument is what makes this
        // switch exhaustive — the analyzer builds the sealed space from each
        // subtype's declared bound, so naming the narrower leaf type is NOT
        // exhaustive. `errors` then binds as `List<OctopusServerError>`.
        // Narrow it to this call's leaves (not connected, banned, server
        // error, …):
        for (final error in errors.whereType<OverrideCommunityAccessError>()) {
            // Surface error.errorMessage through your app's own error UI.
        }
    case OctopusConnectionFailure():
        // Transport/auth failure
}
```

Fire-and-forget callers can still ignore the result — `await octopus.overrideCommunityAccess(canAccessCommunity);` compiles — but you lose visibility into handled failures.

Here is how to know whether the current user has access to the community. It is a reactive stream that is updated
as soon as the user group changes:

```dart
OctopusSDK.hasAccessToCommunity.listen((hasAccessToCommunity) {
    // Use the hasAccessToCommunity new value
});
```

<!-- /tab -->

<!-- tab: React Native -->

`overrideCommunityAccess` rejects with a typed `OverrideCommunityAccessError` (≥ 1.13.0). Handle it instead of letting it slip:

```typescript
import {
    overrideCommunityAccess,
    addHasAccessToCommunityListener,
    isOverrideCommunityAccessError,
} from '@octopus-community/react-native';

// Override the community access for the current user
try {
    await overrideCommunityAccess(canAccessCommunity);
    // Override applied — addHasAccessToCommunityListener will emit shortly
} catch (error) {
    if (isOverrideCommunityAccessError(error)) {
        console.warn(`overrideCommunityAccess failed — ${error.code}: ${error.message}`);
    }
}
```

Here is how to know whether the current user has access to the community. The listener is called whenever the access state changes:

```typescript
const subscription = addHasAccessToCommunityListener((hasAccess) => {
    // Update your UI based on the new access value
});

// Later, to unsubscribe:
subscription.remove();
```

<!-- /tab -->

<!-- tab: Unity -->

(≥ 1.12.6)

Override the community access for the current user. This takes full precedence over both the internal A/B test configuration and the analytics-only `TrackAccessToCommunity` signal. The optional `onCompleted` and `onError` callbacks fire on the Unity main thread.

```csharp
bool canAccessCommunity = true;
OctopusSDK.OverrideCommunityAccess(
    canAccessCommunity,
    onCompleted: () => Debug.Log("Community access override applied"),
    onError: error => Debug.LogError($"Failed to override community access: {error}"));
```

Here is how to know whether the current user has access to the community. Read the cached value for a one-shot check, or subscribe to `OnHasAccessToCommunityChanged` to react as soon as the user group changes:

```csharp
// One-shot read of the cached value
bool hasAccess = OctopusSDK.HasAccessToCommunity;

// React to changes (e.g. toggle a community entry-point button)
OctopusSDK.OnHasAccessToCommunityChanged += hasAccessToCommunity =>
{
    // Use the hasAccessToCommunity new value
};
```

<!-- /tab -->

---
## Intercept URL openings (≥ 1.9.0)

Users can open links from the SDK. These URLs can either be opened from a link tapped on a post/comment/reply content, or from a Post with CTA button tap. These links will be opened by default in the web browser.
Octopus SDK lets you the ability to catch these URL opening and decide what to do with the URL. Either using it yourself to do whatever you want or let Octopus handle them.
You could, for example, catch any URL opening your website and instead opening the correct page in your own app.

<!-- tab: Android -->

```kotlin
// Set the callback that will be called when a user tries to open a link inside the community.
// This link can come from a Post/Comment/Reply or when tapping on a Post with CTA button.
octopusComposables(
    // ...
    onNavigateToUrl = { url ->
        val uri = url.toUri()
        if(uri.host == "www.yourdomain.com" && uri.path == "/contact") {
            // Open the contact page inside the app
            ...

            // Link has been handled by app, let the Octopus SDK know that it should do nothing more
            return HandledByApp
        }

        // Let the SDK handle the other links by returning `HandledByOctopus`
        return HandledByOctopus
    }
)
```

To see a full example of how you can achieve that, you can follow how it is done in the Samples,
 in the [UrlHandler](https://github.com/Octopus-Community/octopus-sdk-android/tree/main/samples/src/main/java/com/octopuscommunity/sample/utils/UrlHandler.kt).

:::info How the SDK opens a link it handles (≥ 1.13.2)
Returning `HandledByOctopus` opens the link in a Chrome Custom Tab painted from the same
palette as the SDK screens — including the palette you install yourself through the theming
container of `octopusComposables`.

The tab's light/dark scheme is pinned when it opens, taken from that palette instead of from
the device setting. One consequence is worth knowing about: **an already-open tab no longer
re-themes itself when the user flips the system dark-mode setting mid-session.** Tabs opened
after the flip are painted from the palette your theme resolves to at that point.

Color slots you leave transparent, or never wire, are not forwarded to Chrome — it falls back
to its own default for them. Custom Tabs take raw ARGB and do not composite alpha the way
Compose does, so a non-opaque color would otherwise render as a see-through toolbar.
:::

<!-- /tab -->

<!-- tab: iOS -->

```swift
// Set the callback that will be called when a user tries to open a link inside the community.
// This link can come from a Post/Comment/Reply or when tapping on a Post with CTA button.
octopus.set(onNavigateToURLCallback: { url in
    if url.host == "www.yourdomain.com" && url.path == "/contact" {
        // open the contact page inside the app
        ...
            
        // link has been handled by app, let the Octopus SDK know that it should do nothing more
        return .handledByApp
    }

    // Let the SDK handle the other links by returning `handledByOctopus`
    return .handledByOctopus
}
```

To see a full example of how you can achieve that, you can follow how it is done in the Samples,
 in the [URLManager](https://github.com/Octopus-Community/octopus-sdk-swift/tree/main/Sample/OctopusSample/Model/URLManager.swift).

<!-- /tab -->

<!-- tab: Flutter -->

```dart
OctopusHomeScreen(
    onNavigateToUrl: (url) {
        final uri = Uri.parse(url);
        if (uri.host == "www.yourdomain.com" && uri.path == "/contact") {
            // Open the contact page inside the app
            // ...

            // Link has been handled by app, let the Octopus SDK know that it should do nothing more
            return UrlOpeningStrategy.handledByApp;
        }

        // Let the SDK handle the other links
        return UrlOpeningStrategy.handledByOctopus;
    },
    // ...
)
```

<!-- /tab -->

<!-- tab: React Native -->

Enable URL interception by passing `interceptUrls: true` when opening the UI:

```typescript
import {
    openUI,
    addNavigateToUrlListener,
    UrlOpeningStrategy,
} from '@octopus-community/react-native';

// Subscribe to URL navigation events
const subscription = addNavigateToUrlListener(async (url) => {
    const parsed = new URL(url);
    if (parsed.hostname === 'www.yourdomain.com' && parsed.pathname === '/contact') {
        // Open the contact page inside the app
        // ...

        // Link has been handled by app
        return UrlOpeningStrategy.handledByApp;
    }

    // Let the SDK handle the other links
    return UrlOpeningStrategy.handledByOctopus;
});

// Open the UI with URL interception enabled
await openUI({ interceptUrls: true });

// Later, to unsubscribe:
subscription.remove();
```

You can also enable URL interception on the embedded `OctopusUIView` component. Make sure to set up the `addNavigateToUrlListener` before the view mounts, and call `subscription.remove()` when the component unmounts:

```tsx
<OctopusUIView interceptUrls={true} style={StyleSheet.absoluteFill} />
```

<!-- /tab -->

<!-- tab: Unity -->

Available on Unity since `1.12.1`. Assign `OctopusSDK.NavigateToUrlHandler` to intercept every URL tapped inside the Octopus UI (links in posts/comments/replies and CTA-button taps). It is a `Func<string, UrlOpeningStrategy>` property — a single slot, not a multicast event. When no handler is set (default), Octopus opens every tapped URL in its own in-app browser. When a handler is set, return:

- `UrlOpeningStrategy.HandledByApp` — your app handled the URL; Octopus does nothing. Only this value brings the player to the foreground.
- `UrlOpeningStrategy.HandledByOctopus` — let Octopus open the URL in the device's system browser while keeping the Octopus community screen open.

:::warning Threading (≥ 1.12.2)
On **both iOS and Android**, the handler is resolved **synchronously on a background thread** the moment a URL is tapped — it no longer runs on the Unity main thread, and it runs even while the Octopus UI is open and the player loop is paused (over a loop-independent native channel). Keep the handler to a fast, thread-safe routing decision: do not call Unity APIs or do heavy computation inside it. Do any Unity-side work afterwards, once your app regains focus (i.e. after returning `HandledByApp`).
:::

```csharp
using UnityEngine;

public class UrlInterceptor : MonoBehaviour
{
    void Start()
    {
        OctopusSDK.NavigateToUrlHandler = OnNavigateToUrl;
    }

    void OnDestroy()
    {
        // NavigateToUrlHandler is a single shared slot, so only clear it if it's still ours.
        if (OctopusSDK.NavigateToUrlHandler == OnNavigateToUrl)
            OctopusSDK.NavigateToUrlHandler = null;
    }

    // On both iOS and Android (≥ 1.12.2) this runs synchronously on a background thread,
    // off the Unity player loop — keep it fast and thread-safe; no Unity API calls here.
    UrlOpeningStrategy OnNavigateToUrl(string url)
    {
        if (!string.IsNullOrEmpty(url) && url.StartsWith("mygame://"))
        {
            // Open the matching page inside the app
            return UrlOpeningStrategy.HandledByApp;
        }

        // Let the SDK handle the other links
        return UrlOpeningStrategy.HandledByOctopus;
    }
}
```

:::tip
`NavigateToUrlHandler` is a single slot — if multiple components need to intercept URLs, forward from one central handler rather than setting it from several places.
:::

<!-- /tab -->

---
## Override the language (≥ 1.9.0)

On both iOS and Android, the system lets the user choose a language for their device and also lets them customize this language per app. Some apps do not use this standard way of handling the language.
If you have a custom setting inside your app that does not set the system app language, you can call a function of Octopus in order to customize the language used (so Octopus does not use the system language but yours instead).

That being said, we recommend using the default system way of handling the locale, so system alerts and system screens (like the picture selection screen) are displayed in the desired language.

OctopusSDK supports these languages: Arabic, Chinese (Simplified), Chinese (Traditional), Danish, Dutch, English, Finnish, French, German, Hindi, Indonesian, Italian, Japanese, Malay, Norwegian (Bokmål), Polish, Portuguese, Romanian, Russian, Spanish, Swedish, Thai, Turkish, Vietnamese. Passing another locale will fallback to the default language (english).

:::warning
No check can be done on the Locale you pass, so ensure it is a valid Locale. If the locale is not valid, it will fallback on the default language (english).
:::

<!-- tab: Android -->

```kotlin
// Override the default (i.e. system or app based) locale.
// You can pass a Locale for a language only, or build one with a region.
// The language must be a two-letter ISO 639-1 code; the region (optional) a two-letter ISO 3166-1 code.

// only a language
OctopusSDK.overrideDefaultLocale(Locale.FRENCH)

// a language and a region (will use Portuguese inside Octopus because Brazilian Portuguese is not supported)
OctopusSDK.overrideDefaultLocale(Locale("pt", "BR"))
```

<!-- /tab -->

<!-- tab: iOS -->

```swift
// Override the default (i.e. system or app based) locale
// The locale should respect the BCP-47 standard: it can have a language and an optional region.
// The language must be two letters [ISO 639-1 code](https://en.wikipedia.org/wiki/List_of_ISO_639_language_codes) and the region should be two letters [ISO 3166-1](https://en.wikipedia.org/wiki/ISO_3166-1_alpha-2#Officially_assigned_code_elements).
// If the region is provided, it should be separated from the country by a `-`.

// only a language
octopus.overrideDefaultLocale(with: Locale(identifier: "fr"))

// a language and a region (will use Portuguese inside Octopus because Brasilian Portuguese is not supported)
octopus.overrideDefaultLocale(with: Locale(identifier: "pt-BR"))
```

To see a full example of how you can achieve that, you can follow how it is done in the Samples,
 in the [Scenario "Override the language of the SDK"](https://github.com/Octopus-Community/octopus-sdk-swift/tree/main/Sample/OctopusSample/UI/Scenarios/Language).

<!-- /tab -->

<!-- tab: Flutter -->

```dart
// Override the default (i.e. system or app based) locale
await octopus.overrideDefaultLocale(const Locale('fr'));

// You can also specify a country code
await octopus.overrideDefaultLocale(const Locale('en', 'US'));

// Reset to system default
await octopus.overrideDefaultLocale(null);
```

<!-- /tab -->

<!-- tab: React Native -->

```typescript
import { overrideDefaultLocale } from '@octopus-community/react-native';

// Override the default (i.e. system or app based) locale
await overrideDefaultLocale({ languageCode: 'fr' });

// You can also specify a country code
await overrideDefaultLocale({ languageCode: 'en', countryCode: 'US' });

// Reset to system default
await overrideDefaultLocale(null);
```

<!-- /tab -->

<!-- tab: Unity -->

Override the SDK's display language:

```csharp
OctopusSDK.OverrideDefaultLocale("fr");
```

<!-- /tab -->

---
## Multi-Community Support

If your app manages several communities with different API keys, use `switchCommunity()` to cleanly transition between them at runtime.

The `switchCommunity` method switches the active community to the one identified by a new API key. It performs a full internal cleanup — flushing pending analytics events, logging out the current user, clearing cached user data and files, and resetting all internal databases — before reinitializing the SDK internals with the new API key. The SDK object reference is preserved; only its internal state is replaced.

:::warning
- During the call, you must not call any other Octopus function until `switchCommunity` finishes.
- After the call finishes, if you are in SSO mode and have a logged-in user, you must call `connectUser` again to connect the user to the new community.
- After the call finishes, you must reconstruct any displayed `OctopusHomeScreen`.
:::

<!-- tab: Android -->

**Parameters:**
- `context` (`Context`) — **Required.** The application context.
- `apiKey` (`String`) — **Required.** The API key that identifies the new community.
- `connectionMode` (`ConnectionMode`) — Optional. The connection mode for the new community. Default: `ConnectionMode.SSO()`.

```kotlin
// Switch to a new community
OctopusSDK.switchCommunity(
    context = applicationContext,
    apiKey = "NEW_COMMUNITY_API_KEY",
    connectionMode = ConnectionMode.SSO()
)

// After switchCommunity returns, reconnect the user (SSO mode)
OctopusSDK.connectUser(
    ClientUser(
        userId = yourUser.id,
        profile = ClientUser.Profile(
            nickname = yourUser.name,
            bio = yourUser.bio,
            picture = yourUser.picture
        )
    )
) {
    // Fetch asynchronously this user token
    // by calling your /generateOctopusSsoToken route
    getTokenFromServer()
}
```

:::tip
`switchCommunity()` is safe to call even when the SDK is not initialized — it will simply initialize it.
:::

<!-- /tab -->

<!-- tab: iOS -->

**Parameters:**
- `apiKey: String` — **Required.** The API key that identifies the new community.
- `connectionMode: ConnectionMode` — Optional. The connection mode for the new community. Default: `.octopus(deepLink: nil)`.
- `configuration: Configuration` — Optional. The SDK configuration. Default: `.init()`.

```swift
// Switch to a new community
do {
  try await octopus.switchCommunity(
      apiKey: "NEW_COMMUNITY_API_KEY",
      connectionMode: .sso(
          .init(
              appManagedFields: [.nickname, .picture],
              loginRequired: {
                  // Put the code here to open your login flow
              },
              modifyUser: { fieldToEdit in
                  // Put the code here to open your profile edition screen
              }
          )
      )
  )

  // After switchCommunity returns, reconnect the user (SSO mode)
  try await octopus.connectUser(
      ClientUser(
          userId: yourUser.id,
          profile: ClientUser.Profile(
              nickname: yourUser.name,
              bio: yourUser.bio,
              picture: yourUser.picture
          )
      ),
      tokenProvider: {
          // Fetch asynchronously this user token
          // by calling your /generateOctopusSsoToken route
      }
  )

  // Force-reconstruct any displayed OctopusHomeScreen
  // For example, update an @State id to trigger a view re-init:
  // octopusHomeScreenId = UUID()
} catch {
  // Handle error
}
```

:::tip
Check the [Scenario "Switch Community"](https://github.com/Octopus-Community/octopus-sdk-swift/tree/main/Sample/OctopusSample/UI/Scenarios/SwitchCommunity) for a complete use case.
:::

<!-- /tab -->

<!-- tab: Flutter -->

(≥ 1.12.0)

**Parameters:**
- `apiKey` (`String`) — **Required.** The API key that identifies the new community.
- `appManagedFields` (`List<ProfileField>?`) — Optional. Profile fields managed by your app. See [Use the SDK](#use-the-sdk).
- `apiServer` (`ApiServer?`) — Optional. Custom server endpoint for the new community. When `null` (default), the Octopus default endpoint is used.

```dart
// Switch to a new community
await octopus.switchCommunity(
    apiKey: 'NEW_COMMUNITY_API_KEY',
    appManagedFields: [ProfileField.nickname, ProfileField.picture],
);

// After switchCommunity returns, reconnect the user (SSO mode)
await octopus.connectUserWithTokenProvider(
    userId: yourUserId,
    nickname: yourUserNickname,
    tokenProvider: () async {
        // Fetch asynchronously this user token
        // by calling your /generateOctopusSsoToken route
        return token;
    },
);
```

In Octopus Auth (magic-link) mode, use `switchCommunityOctopusAuth(apiKey:, deepLink:, apiServer:)` instead.

:::tip
Give any displayed `OctopusHomeScreen` a `key: ValueKey(apiKey)` so Flutter rebuilds the native view for the new community after the switch.
:::

<!-- /tab -->

<!-- tab: React Native -->

(≥ 1.13.0)

`switchCommunity` unifies what other platforms sometimes split into two functions: it reuses the same `connectionMode` discriminated union as `initialize()` (`sso` or `octopus`), for either connection mode.

**Parameters** (`SwitchCommunityParams`):
- `apiKey` (`string`) — **Required.** The API key that identifies the new community.
- `connectionMode` — **Required.** `{ type: 'sso', appManagedFields: [...] }` or `{ type: 'octopus', deepLink?: string }` — the same shape as `initialize()`.
- `apiServer` — Optional. Custom server endpoint for the new community. Omit it to use the Octopus default endpoint.

```typescript
import { switchCommunity, connectUser } from '@octopus-community/react-native';

// Switch to a new community
await switchCommunity({
    apiKey: 'NEW_COMMUNITY_API_KEY',
    connectionMode: { type: 'sso', appManagedFields: ['nickname', 'picture'] },
});

// After switchCommunity resolves, reconnect the user (SSO mode)
await connectUser(yourUser, {
    tokenProvider: async () => {
        // Fetch asynchronously this user token
        // by calling your /generateOctopusSsoToken route
        return token;
    },
});
```

:::tip
`switchCommunity()` is safe to call even when the SDK is not initialized — it initializes it directly on the new community. It is implemented as `reset()` + re-`initialize()` internally.
:::

:::info
You must remount `<OctopusUIView>` after switching — change the `key` prop you pass to it (e.g. to the new community's API key) so React re-creates the native view for the new community instead of reusing the one bound to the previous one.
:::

<!-- /tab -->

<!-- tab: Unity -->

This feature is not yet available on Unity.

<!-- /tab -->

### Manually stopping or resetting the SDK (React Native only) (≥ 1.13.0)

React Native additionally exposes the two lower-level lifecycle primitives `switchCommunity()` is built on, plus a synchronous initialization check — useful for a full app reset, or to gate a splash screen without awaiting a promise.

```typescript
import { reset, stop, isInitialised } from '@octopus-community/react-native';

// Disconnect the user and clear all locally cached data. The SDK stays initialised.
await reset();

// Fully stop the SDK and release every native resource it holds. Must call
// initialize() (or switchCommunity()) again before using the SDK.
await stop();

// Synchronous, client-side: mirrors the last initialize / switchCommunity / stop
// call this JS instance made. Does not query native.
if (!isInitialised()) {
    await initialize({ apiKey: 'YOUR_API_KEY', connectionMode: { type: 'octopus' } });
}
```

:::info
**iOS has no native `reset` or `stop` primitive.** Both are best-effort approximations there: `reset()` only disconnects the user (it does not clear iOS's local Core Data persistence, unlike Android's native `OctopusSDK.reset()`), and `stop()` disconnects the user then releases the bridge's own SDK instance. `reset()` never changes `isInitialised()` on either platform — the SDK stays initialised after a reset.
:::

Subscribe to `addIsInitialisedListener` to react to initialization changes instead of polling `isInitialised()`:

```typescript
import { addIsInitialisedListener } from '@octopus-community/react-native';

const subscription = addIsInitialisedListener((ready) => {
    setSdkReady(ready);
});

// Later, to unsubscribe:
subscription.remove();
```

---
## Follow the Samples

    Want to see code examples on how to use the SDK, no worries, we have that for you!

<!-- tab: Android -->

1. First, clone the OctopusSDK project

    [Android SDK](https://github.com/Octopus-Community/octopus-sdk-android)

2. Add those lines to the root project `local.properties` file:
    ```properties
    OCTOPUS_API_KEY=YOUR_API_KEY
    OCTOPUS_SSO_CLIENT_USER_TOKEN_SECRET=YOUR_USER_TOKEN_SECRET
    ```
    Replace `YOUR_API_KEY` with your own API key and `YOUR_USER_TOKEN_SECRET` with your own token secret.

3. According to your desired UI integration mode choose the corresponding sample:
    - [FullScreen Sample](https://github.com/Octopus-Community/octopus-sdk-android/tree/main/samples/src/fullscreen/java/com/octopuscommunity/sample/screens/MainScreen.kt)
    - [Bottom Navigation Tabs Sample](https://github.com/Octopus-Community/octopus-sdk-android/tree/main/samples/src/bottomnavigationbar/java/com/octopuscommunity/sample/screens/MainScreen.kt)
    - [Floating Bottom Navigation Sample](https://github.com/Octopus-Community/octopus-sdk-android/tree/main/samples/src/contentpadding/java/com/octopuscommunity/sample/screens/MainScreen.kt)

<!-- /tab -->

<!-- tab: iOS -->

   

1. First, clone or download the code (SDK+Sample are placed on the same git repository):

    [iOS SDK](https://github.com/Octopus-Community/octopus-sdk-swift)

2. Rename the `secrets.placeholder.xcconfig` as `secrets.xcconfig`
    
3. According to your connection mode, this matches how your app should integrate the SDK:
    - If you are in **SSO without any app managed fields**:
        * In `secrets.xcconfig`, replace `OCTOPUS_SSO_NO_MANAGED_FIELDS_API_KEY` with your own API key
        * Open the third tab `More`, then tap on the SSO Connection cell and then the No App Managed Fields.
    - If you are in **SSO with all app managed fields**:
        * In `secrets.xcconfig`, replace `OCTOPUS_SSO_ALL_MANAGED_FIELDS_API_KEY` with your own API key
        * Open the third tab `More`, then tap on the SSO Connection cell and then the With all App Managed Fields.
    - If you are in **SSO with some app managed fields**:
        * In `secrets.xcconfig`, replace `OCTOPUS_SSO_SOME_MANAGED_FIELDS_API_KEY` with your own API key
        * In `SSOWithSomeAppManagedFieldsViewModel`, set the fields that are associated in `appManagedFields`.
        * Open the third tab `More`, then tap on the SSO Connection cell and then the Some App Managed Fields.

<!-- /tab -->

<!-- tab: Flutter -->

1. First, clone the Flutter SDK example project:

    [Flutter SDK](https://github.com/Octopus-Community/octopus-sdk-flutter)

2. Navigate to the example directory:
    ```bash
    cd example
    ```

3. Install dependencies:
    ```bash
    flutter pub get
    ```

4. Replace `YOUR_API_KEY` in the example code with your own API key.

5. Run the example:
    ```bash
    flutter run
    ```

<!-- /tab -->

<!-- tab: React Native -->

1. First, clone the React Native SDK example project:

    [React Native SDK](https://github.com/Octopus-Community/octopus-sdk-react-native)

2. Navigate to the example directory and install dependencies:
    ```bash
    cd example
    yarn install
    ```

3. Copy the environment template and fill in your API key:
    ```bash
    cp .env.dist .env
    ```
    Set `OCTOPUS_COMMUNITY_API_KEY` (and, to try SSO, `OCTOPUS_SSO_USER_ID` / `OCTOPUS_SSO_USER_TOKEN`).

4. Start Metro, then build and run:
    ```bash
    yarn start
    # in another terminal:
    yarn android   # or: yarn ios
    ```

The example app's **Scenarios** tab demonstrates most of the reactive and analytics APIs; see its own README for the full tab-by-tab breakdown.

<!-- /tab -->

<!-- tab: Unity -->

A sample project is bundled with the Unity package. Import it from **Unity Package Manager > Octopus SDK for Unity > Samples**. You can also browse it on [GitHub](https://github.com/Octopus-Community/octopus-sdk-unity/tree/main/UnityExample). You will need an API key to run the sample.

<!-- /tab -->
