> 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.12.2
```

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
OctopusSDK.isUserConnected
```

    

<!-- /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.12.2)

```dart
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;
    },
);
```

<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 -->

*Entitlements are not yet available on React Native.*

<!-- /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 -->

*Guest detection is not yet available on React Native.*

<!-- /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.
    case OctopusInvalidArguments<RefreshEntitlementsError>(:final errors):
        // Handled SDK failures, typed in `errors`:
        // RefreshEntitlementsNoClientTokenProviderError, RefreshEntitlementsUserNotConnectedError,
        // RefreshEntitlementsNoNetworkError, RefreshEntitlementsUserBannedError (errorMessage displayable),
        // RefreshEntitlementsServerError
    case OctopusConnectionFailure():
        // Transport/auth failure
}
```

<!-- /tab -->

<!-- tab: React Native -->

*Refreshing entitlements is not yet available on React Native.*

<!-- /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.

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 -->

Opening a specific screen is not yet available on React Native.

<!-- /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 -->

*Bridge Share image signing is not yet available on React Native.*

<!-- /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 host bottom-nav height plus the safe-area bottom inset so the floating "Write a post" button clears your app's own bottom chrome (e.g. a `BottomNavigationBar`).

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

The default is `0`, which lets each platform apply its own native default (Android falls back to the SDK's default content padding; iOS keeps a 10pt floor to clear the home indicator). Hosts with their own bottom chrome should pass an explicit positive value.

<!-- /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 -->

*Unified Profile is not yet available on Flutter.*

<!-- /tab -->

<!-- tab: React Native -->

*Unified Profile is not yet available on React Native.*

<!-- /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 -->

*Unified Profile is not yet available on Flutter.*

<!-- /tab -->

<!-- tab: React Native -->

*Unified Profile is not yet available on React Native.*

<!-- /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 -->

*Unified Profile is not yet available on Flutter.*

<!-- /tab -->

<!-- tab: React Native -->

*Unified Profile 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 -->

*Unified Profile is not yet available on Flutter.*

<!-- /tab -->

<!-- tab: React Native -->

*Unified Profile is not yet available on React Native.*

<!-- /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 -->

*Unified Profile is not yet available on Flutter.*

<!-- /tab -->

<!-- tab: React Native -->

*Unified Profile is not yet available on React Native.*

<!-- /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 -->

*Unified Profile is not yet available on Flutter.*

<!-- /tab -->

<!-- tab: React Native -->

*Unified Profile is not yet available on React Native.*

<!-- /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 and Android** since 1.13.0. Not yet available on Flutter, React Native, or 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.
:::

<!-- /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,
    },
});
```

<!-- /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
    - `otherUserProfile` — The profile screen of another Octopus user
        
        **Params:**
        
        - `profileId: String` — The id of the profile that is 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 -->

*Groups listing is not yet available on React Native.*

<!-- /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)

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 -->

*Group access gating is not yet available on React Native.*

<!-- /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 -->

*Group access gating is not yet available on React Native.*

<!-- /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 -->

Bridges are not yet available on React Native.

<!-- /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<ClientPostError>(:final errors):
        // Content/validation problems — typed ClientPostError(s) in `errors`
    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 -->

Bridges are not yet available on React Native.

<!-- /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 -->

Bridges are not yet available on React Native.

<!-- /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 -->

Bridges are not yet available on React Native.

<!-- /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 -->

Bridges are not yet available on React Native.

<!-- /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 -->

Bridges are not yet available on React Native.

<!-- /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 -->

Bridges are not yet available on React Native.

<!-- /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 -->

Bridges are not yet available on React Native.

<!-- /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
    case OctopusInvalidArguments<OverrideCommunityAccessError>(:final errors):
        // Handled SDK failures (not connected, banned, server error, …)
    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 -->

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

// Override the community access for the current user
await overrideCommunityAccess(canAccessCommunity);
```

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).

<!-- /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: English, French, German, Italian, Polish, Portuguese, Spanish, Swedish, Turkish. 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: Unity -->

This feature is not yet available on Unity.

<!-- /tab -->

---
## 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: 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 -->
