React Native SDK

@seenn/react-native — TypeScript-first SDK with React hooks, iOS Live Activity and Android Ongoing Notification support

npm version GitHub stars MIT License

#Installation

npm install @seenn/react-native
# or
yarn add @seenn/react-native

#iOS Setup (Live Activity)

Live Activity requires native code setup due to iOS module isolation. Follow these steps:

Step 1: Install and link the native module:

cd ios && pod install

Step 2: Add NSSupportsLiveActivities to your Info.plist:

<key>NSSupportsLiveActivities</key>
<true/>

Step 3: Copy bridge files from the SDK templates to your Xcode project:

  • node_modules/@seenn/react-native/templates/SeennLiveActivityBridge/SeennLiveActivityBridgeImpl.swift
  • node_modules/@seenn/react-native/templates/SeennWidgetExtension/SeennJobAttributes.swift

Step 4: Register the bridge in your AppDelegate.swift:

import SeennReactNative

@main
class AppDelegate: UIResponder, UIApplicationDelegate {
  func application(_ application: UIApplication,
    didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
  ) -> Bool {
    // Register Live Activity bridge
    if #available(iOS 16.2, *) {
      SeennLiveActivityRegistry.shared.register(SeennLiveActivityBridgeImpl.shared)
    }
    return true
  }
}

Step 5: Create a Widget Extension for the Lock Screen UI. Copy the template files from node_modules/@seenn/react-native/templates/SeennWidgetExtension/.

Why the bridge pattern? iOS requires the ActivityAttributes type to be defined in your app, not in a library. The SDK provides the bridge protocol, you provide the implementation. This ensures type identity between your app and Widget Extension.

#Initialization

Seenn.init(config)

Initialize the SDK once at app startup (inside your root App component or provider).

Parameters
apiKey string Your public API key (pk_*)required
userId string Unique identifier for the current userrequired
config SeennConfig? Optional configuration options
import { Seenn } from '@seenn/react-native';
import { useEffect } from 'react';

export default function App() {
  useEffect(() => {
    // Initialize once on mount
    Seenn.init({
      apiKey: 'pk_live_xxx',  // Your public API key
      userId: 'user_123',     // Your user ID
    });

    return () => {
      // Cleanup on unmount
      Seenn.dispose();
    };
  }, []);

  return <AppContent />;
}

#Configuration Options

Seenn.init({
  apiKey: 'pk_live_xxx',
  userId: 'user_123',
  config: {
    apiUrl: 'https://api.seenn.io',
    pollInterval: 5000,  // 5 seconds
    debug: __DEV__,  // Enable debug logs in development
  },
});

#Real-time Updates

The SDK uses polling to receive job updates from your backend:

Aspect Details
How it works SDK fetches job state via HTTP at intervals (default 5s)
Backend requirement GET /v1/jobs/:id endpoint only
Latency Poll interval (configurable, default 5s)
Best for All backends: Firebase, Supabase, custom, Seenn Cloud

#Polling Configuration

Configure polling interval and subscribe to job updates:

import { Seenn } from '@seenn/react-native';

// Initialize with polling mode
Seenn.init({
  apiKey: 'your-api-key',
  userId: 'user_123',
  config: {
    baseUrl: 'https://api.yourcompany.com',
    basePath: '/v1',
    mode: 'polling',           // ← Explicit polling mode
    pollInterval: 5000,       // 5 seconds (default)
  },
});

// Subscribe to job updates (starts polling)
seenn.subscribeJobForPolling('job_123');

// Or subscribe to multiple jobs
seenn.subscribeJobsForPolling(['job_456', 'job_789']);

// Unsubscribe when done
seenn.unsubscribeJobFromPolling('job_123');
Backend Requirements: Only GET /v1/jobs/:id endpoint needed. See API Reference.
Polling + Live Activity are independent: Polling updates the in-app UI via HTTP. Live Activity updates the Lock Screen via APNs push. They work on separate channels and can be used together or independently.

#useSeennJob()

useSeennJob(jobId) → { job, isLoading, error }

React hook to subscribe to real-time updates for a specific job. Automatically manages subscription lifecycle.

import { useSeennJob } from '@seenn/react-native';
import { View, Text, ActivityIndicator } from 'react-native';

function JobProgress({ jobId }: { jobId: string }) {
  const { job, isLoading, error } = useSeennJob(jobId);

  if (isLoading) {
    return <ActivityIndicator />;
  }

  if (error) {
    return <Text>Error: {error.message}</Text>;
  }

  return (
    <View>
      <Text>{job.title}</Text>
      <Text>Progress: {job.progress}%</Text>
      {job.message && <Text>{job.message}</Text>}

      {job.stage && (
        <Text>
          Stage: {job.stage.label} ({job.stage.index}/{job.stage.total})
        </Text>
      )}

      {job.eta && (
        <Text>ETA: {job.eta}s</Text>
      )}

      {job.status === 'completed' && (
        <Button title="Download" onPress={() => openUrl(job.resultUrl)} />
      )}
    </View>
  );
}

#Return Values

Property Type Description
job SeennJob | null Current job state (null while loading)
isLoading boolean True while fetching initial job data
error Error | null Error if job fetch/subscription fails

#useSeennJobs()

useSeennJobs(options?) → { jobs, isLoading, error, refetch }

React hook to list all jobs for the current user with optional filtering.

import { useSeennJobs } from '@seenn/react-native';
import { FlatList, Text } from 'react-native';

function JobsList() {
  const { jobs, isLoading, refetch } = useSeennJobs({
    status: ['running', 'queued'],
    limit: 20,
  });

  if (isLoading) {
    return <ActivityIndicator />;
  }

  return (
    <FlatList
      data={jobs}
      keyExtractor={(item) => item.jobId}
      onRefresh={refetch}
      refreshing={isLoading}
      renderItem={({ item }) => (
        <View>
          <Text>{item.title}</Text>
          <Text>{item.progress}% - {item.status}</Text>
        </View>
      )}
    />
  );
}

#Options

Property Type Description
status JobStatus[]? Filter by status (e.g., ['running', 'queued'])
limit number? Max number of jobs to return (default: 50)

#Reliability Methods

The SDK provides methods for manual connection recovery and state management (v0.2.5+).

#reconnect()

Force reconnect. Useful for recovering from connection issues or when the app returns to foreground.

import { useEffect } from 'react';
import { AppState } from 'react-native';

// Reconnect when app comes to foreground
useEffect(() => {
  const subscription = AppState.addEventListener('change', (state) => {
    if (state === 'active') {
      seenn.reconnect();
    }
  });
  return () => subscription.remove();
}, []);

#updateJob()

Manually update job state. Useful for optimistic updates or when you receive job data from other sources.

// Manually update job state from external source
const fetchJobManually = async (jobId: string) => {
  const response = await fetch(`https://api.seenn.io/v1/jobs/${jobId}`, {
    headers: { 'Authorization': `Bearer ${publicKey}` }
  });
  const job = await response.json();
  seenn.updateJob(job);
};

// Connection state based polling
const connectionState = useSeennConnectionState(seenn);

useEffect(() => {
  if (connectionState === 'disconnected') {
    const interval = setInterval(() => {
      fetchJobManually(activeJobId);
    }, 30000); // Poll every 30s
    return () => clearInterval(interval);
  }
}, [connectionState]);

#Job Events

The SDK handles these job events automatically:

Event Description
job.sync State reconciliation - sent for all active jobs on connect/reconnect
job.started Job has started processing
job.progress Job progress updated
job.completed Job completed successfully
job.failed Job failed with error
job.cancelled Job was cancelled
connection.idle Server closing due to 5 minutes of inactivity (auto-reconnect handles this)
Auto-sync: When connecting or reconnecting, the server sends job.sync events for all active jobs. This ensures the client has accurate state even after network interruptions.

#Live Activity (iOS)

The SDK provides native iOS Live Activity support (v0.2.0+), showing job progress on Dynamic Island and Lock Screen. Supports up to 5 concurrent Live Activities per app.

#Setup

1. Add Widget Extension to your Xcode project:

  1. Open your iOS project in Xcode
  2. File → New → Target → Widget Extension
  3. Name it SeennWidgetExtension
  4. Copy files from node_modules/@seenn/react-native/templates/SeennWidgetExtension/

2. Add NSSupportsLiveActivities to Info.plist:

<key>NSSupportsLiveActivities</key>
<true/>

3. Run pod install:

cd ios && pod install

#useLiveActivity() Hook (Recommended)

Auto-sync job state with Live Activity — the easiest way to integrate:

import { useSeennJob, useLiveActivity } from '@seenn/react-native';

function JobScreen({ jobId }: { jobId: string }) {
  const job = useSeennJob(seenn, jobId);

  // Auto-sync job state with Live Activity
  const { isActive, isSupported } = useLiveActivity(job, {
    autoStart: true,   // Start when job begins running
    autoEnd: true,     // End when job completes/fails
    dismissAfter: 300, // Keep on screen 5 min after completion
  });

  return (
    <View>
      <Text>{job?.title}</Text>
      <Text>Progress: {job?.progress}%</Text>
      {isSupported && <Text>Live Activity: {isActive ? 'On' : 'Off'}</Text>}
    </View>
  );
}

#Manual Control API

For full control over Live Activity lifecycle:

import { LiveActivity } from '@seenn/react-native';

// Check support
const supported = await LiveActivity.isSupported();

// Start activity
const result = await LiveActivity.start({
  jobId: 'job_123',
  title: 'Generating video...',
  jobType: 'video-generation',
  initialProgress: 0,
});

// Update progress
await LiveActivity.update({
  jobId: 'job_123',
  progress: 50,
  status: 'running',
  message: 'Encoding frames...',
  stageName: 'Encoding',
  stageIndex: 2,
  stageTotal: 3,
});

// End activity
await LiveActivity.end({
  jobId: 'job_123',
  finalStatus: 'completed',
  message: 'Video ready!',
  resultUrl: 'https://example.com/video.mp4',
  dismissAfter: 300,
});

// Get active activities
const activeIds = await LiveActivity.getActiveIds();
// ['job_123', 'job_456']

// Cancel all
await LiveActivity.cancelAll();

#Multi-Job Support

iOS allows up to 5 concurrent Live Activities per app:

// Start multiple activities
await LiveActivity.start({ jobId: 'job_1', title: 'Video 1', ... });
await LiveActivity.start({ jobId: 'job_2', title: 'Video 2', ... });
await LiveActivity.start({ jobId: 'job_3', title: 'Image Pack', ... });

// Each updates independently
await LiveActivity.update({ jobId: 'job_1', progress: 50, ... });
await LiveActivity.update({ jobId: 'job_2', progress: 75, ... });

// Check which are active
const activeIds = await LiveActivity.getActiveIds();
console.log(activeIds); // ['job_1', 'job_2', 'job_3']

#Push Token for Background Updates

import { LiveActivity } from '@seenn/react-native';

// Listen for push tokens
const unsubscribe = LiveActivity.onPushToken((event) => {
  console.log(`Token for ${event.jobId}: ${event.token}`);
  // Send to your backend for APNs push updates
  sendTokenToBackend(event.jobId, event.token);
});

// Later: unsubscribe()

Auto-sync with Polling

When using useLiveActivity() hook with autoStart: true, Live Activity updates are automatically synced via polling. You don't need to manually call update() — the SDK handles it for you!

#CTA Button (v0.6.0+)

Add a tappable button to your Live Activity when a job completes or fails. The button opens a deep link in your app:

import { useLiveActivity } from '@seenn/react-native';

const { isActive } = useLiveActivity(job, {
  autoStart: true,
  autoEnd: true,

  // CTA button on completion
  completionCTA: {
    text: 'See Your Photos ✨',
    deepLink: (job) => `myapp://jobs/${job.jobId}/results`,
  },

  // Or different buttons for completed vs failed
  ctaButtons: {
    completed: {
      text: 'View Results',
      deepLink: (job) => `myapp://results/${job.jobId}`,
    },
    failed: {
      text: 'Try Again',
      deepLink: (job) => `myapp://retry/${job.jobId}`,
    },
  },
});

Or use the manual API with custom styling:

await LiveActivity.end({
  jobId: 'job_123',
  finalStatus: 'completed',
  message: 'Your Photos are Ready!',
  ctaButton: {
    text: 'See Your Photos ✨',
    deepLink: 'myapp://jobs/job_123/results',
    style: 'primary', // 'primary' | 'secondary' | 'outline'
    backgroundColor: '#FFFFFF',
    textColor: '#000000',
    cornerRadius: 20,
  },
});

#Widget Setup CLI (v0.6.0+)

Use the @seenn/setup-widget CLI to create a customizable Widget Extension:

# Install the CLI
npm install @seenn/setup-widget --save-dev

# Initialize widget configuration
npx @seenn/setup-widget init

# After editing seenn.config.js, regenerate Swift files
npx @seenn/setup-widget update

This creates a seenn.config.js file for theme customization:

// seenn.config.js
module.exports = {
  theme: {
    preset: 'gradient-sunset', // or 'default', 'gradient-ocean', etc.
    colors: {
      progressBar: '#FFFFFF',
    },
    layout: {
      showIcon: true,
      showEta: true,
      showStage: true,
    },
    cta: {
      style: { backgroundColor: '#FFFFFF', textColor: '#000000' },
    },
  },
  jobTypes: {
    'video-generation': { icon: 'video.fill', color: '#9B59B6' },
    'image-generation': { icon: 'photo.fill', color: '#3498DB' },
  },
};

Available theme presets:

Preset Description
defaultDark background with blue progress bar
gradient-sunsetOrange to yellow gradient
gradient-oceanPurple to blue gradient
gradient-purplePurple to pink gradient
minimal-darkBlack background, white text
minimal-lightWhite background, black text

#Push Enhancements (Media & Communication)

iOS supports two types of enhanced push notifications with images:

TypeImage PositioniOS VersionUse Case
Media AttachmentRIGHT side (thumbnail)iOS 10+Product images, album art, photos
Communication NotificationLEFT side (avatar)iOS 15+Messages, DMs (iMessage/WhatsApp style)
Media Attachment (thumbnail RIGHT):
┌────────────────────────────────────┬────┐
│ Remix Complete!                   │ 🖼️ │  ← imageUrl
│ Summer Vibes is ready to play    │    │
└────────────────────────────────────┴────┘

Communication Notification (avatar LEFT):
┌────────────────────────────────────────┐
│ 👤 DJ Mix                              │  ← senderAvatar
│    Check out this remix I made!        │
└────────────────────────────────────────┘

#Setup

Both features require a Notification Service Extension. The @seenn/setup-widget CLI creates everything automatically:

# Creates Widget Extension + Notification Service Extension
npx @seenn/setup-widget init

# Skip if you only need Live Activities
npx @seenn/setup-widget init --skip-rich-push

After running the CLI, complete these steps in Xcode:

  1. Open your .xcworkspace in Xcode
  2. File → New → Target → Notification Service Extension
  3. Name: NotificationServiceExtension
  4. Run npx @seenn/setup-widget link to configure
  5. Link Intents framework: Select NotificationServiceExtension target → Build Phases → Link Binary With Libraries → + → Intents.framework
SDK handles the rest: The generated NotificationService.swift automatically routes notifications — media attachments and communication notifications work out of the box.

#Media Attachment (Thumbnail on RIGHT)

Display an image thumbnail on the right side of the notification. Works on iOS 10+. Can be added to any push type (alert, silent, or communication).

// Backend: Alert notification with media attachment
await job.complete({
  push: {
    alert: {
      title: 'Remix Complete!',
      body: 'Summer Vibes is ready to play',
    },
    // Media attachment - thumbnail on RIGHT side
    media: {
      imageUrl: 'https://example.com/album-art.jpg',
    },
  },
});

// Also works with silent/passive push!
await job.setProgress(50, {
  push: {
    silent: true,
    media: { imageUrl: 'https://example.com/preview.jpg' },
  },
});

#Communication Notification (Avatar on LEFT)

Display a sender avatar on the left side, like iMessage or WhatsApp. Requires iOS 15+ and uses INSendMessageIntent under the hood.

// Backend: Send communication notification
await job.complete({
  push: {
    // Communication notification - avatar on LEFT side
    communication: {
      senderName: 'DJ Mix',
      senderAvatar: 'https://example.com/avatar.jpg',
      conversationId: 'conv-123',  // Optional: for grouping
    },
    // Optional: break through Focus mode
    timeSensitive: true,
  },
});

Extra Setup for Communication Notifications

Communication notifications require one additional step — add this to your main app's Info.plist:

<!-- Add to ios/YourApp/Info.plist -->
<key>NSUserActivityTypes</key>
<array>
    <string>INSendMessageIntent</string>
</array>
iOS 15+ only: On older iOS versions, communication notifications gracefully fall back to standard notifications.

#Combined: Avatar + Thumbnail

You can use both communication and media together — avatar on LEFT, thumbnail on RIGHT:

// Backend: Communication + Media (avatar LEFT, thumbnail RIGHT)
await job.complete({
  push: {
    communication: {
      senderName: 'Sarah',
      senderAvatar: 'https://example.com/sarah.jpg',
    },
    media: {
      imageUrl: 'https://example.com/shared-photo.jpg',
    },
  },
});
Combined: Avatar LEFT + Thumbnail RIGHT
┌────────────────────────────────────┬────┐
│ 👤 Sarah                          │ 🖼️ │
│    Check out this photo!          │    │
└────────────────────────────────────┴────┘
  ↑ senderAvatar                      ↑ imageUrl

#API Reference

FieldTypeDescription
push.media (Media Attachment)
imageUrlstringURL of image to show as thumbnail on RIGHT
push.communication (Communication Notification)
senderNamestringSender name displayed as title
senderAvatarstring?URL of avatar image to show on LEFT
conversationIdstring?Identifier for grouping messages
push (Top-level options)
timeSensitiveboolean?Break through Focus mode (iOS 15+)

#Supported Image Formats

FormatExtensionNotes
JPEG.jpg, .jpegBest for photos
PNG.pngSupports transparency
GIF.gifStatic only (no animation)
WebP.webpiOS 14+
mutable-content required: The SDK automatically sets mutable-content: 1 in the APNs payload. Without it, iOS won't invoke the Notification Service Extension.

#Provisional Push (iOS 12+)

Request push notification permission without showing a prompt. Users see "quiet" notifications in Notification Center only — no sounds or banners.

When to use: Provisional push is ideal for non-critical updates like job progress. Higher opt-in rates because users aren't interrupted with a permission prompt.

#Usage

import { LiveActivity } from '@seenn/react-native';

// Check current authorization status
const status = await LiveActivity.getPushAuthorizationStatus();
console.log(status.status);       // 'provisional', 'authorized', etc.
console.log(status.isProvisional); // true if quiet notifications

// Request provisional push — no prompt shown!
const granted = await LiveActivity.requestProvisionalPushAuthorization();
if (granted) {
  console.log('Provisional push enabled');
}

// Later: upgrade to full push (shows prompt)
if (status.canRequestFullAuthorization) {
  const upgraded = await LiveActivity.upgradeToStandardPush();
  if (upgraded) {
    console.log('Full push access granted');
  }
}

#Authorization Status Values

StatusDescription
notDeterminedPermission never requested
deniedUser denied permission
authorizedFull push access (alerts, sounds, badges)
provisionalQuiet notifications only (Notification Center)
ephemeralApp Clips only (iOS 14+)
Note: Live Activity doesn't require push permission — it uses a separate system. Provisional push is for standard notifications, which can complement Live Activity for users who prefer traditional push.

#Device Push Token (v0.9.0+)

When you request provisional or standard push authorization, the SDK automatically registers for remote notifications and provides the device push token:

import { LiveActivity } from '@seenn/react-native';

// Listen for both Live Activity and device push tokens
const unsubscribe = LiveActivity.onPushToken((event) => {
  if (event.type === 'liveActivity') {
    // Live Activity token - for updating a specific Live Activity via APNs
    console.log(`Live Activity token for ${event.jobId}: ${event.token}`);
    myBackend.registerLiveActivityToken(event.jobId, event.token);
  } else if (event.type === 'device') {
    // Device token - for regular push notifications
    console.log(`Device push token: ${event.token}`);
    myBackend.registerDevicePushToken(event.token);
  }
});

// Request provisional push - token will be emitted via onPushToken
await LiveActivity.requestProvisionalPushAuthorization();

// Don't forget to unsubscribe when done
unsubscribe();
Token TypeDescriptionjobId
liveActivityAPNs token for updating a specific Live ActivityPresent
deviceAPNs token for regular push notificationsAbsent
OneSignal/Firebase Compatibility: The SDK uses method swizzling to capture device tokens without interfering with other push SDKs. Both Seenn and your other push provider will receive the token.

#Expo Live Activity

For Expo projects, Live Activity works via expo-live-activity (Software Mansion).

Expo Go Not Supported: Live Activity requires native code. Use Expo Dev Client or EAS Build.

#Installation (Expo)

# If you already have @seenn/react-native installed:
npx expo install expo-live-activity

# Fresh installation (both packages):
npx expo install expo-live-activity @seenn/react-native

Add to app.json:

{
  "expo": {
    "plugins": ["expo-live-activity"]
  }
}

Then prebuild:

npx expo prebuild --clean

#useExpoLiveActivity() Hook

import { useSeennJob, useExpoLiveActivity } from '@seenn/react-native';

function JobScreen({ jobId }: { jobId: string }) {
  const job = useSeennJob(seenn, jobId);

  // Auto-sync with Expo Live Activity
  const { isActive, isSupported } = useExpoLiveActivity(job, {
    autoStart: true,
    autoEnd: true,
    colors: {
      backgroundColor: '#1c1c1e',
      progressTint: '#3b82f6',
    },
    deepLinkUrl: 'myapp://jobs/job_123',
  });

  return (
    <View>
      <Text>{job?.title}</Text>
      <Text>Progress: {job?.progress}%</Text>
    </View>
  );
}

#Manual Control (Expo)

import { ExpoLiveActivity } from '@seenn/react-native';

// Check if available
const isAvailable = ExpoLiveActivity.isAvailable();

// Start activity
const activityId = await ExpoLiveActivity.start(job, {
  backgroundColor: '#1c1c1e',
  progressViewTint: '#3b82f6',
});

// Update activity
await ExpoLiveActivity.update(activityId, job);

// Stop activity
await ExpoLiveActivity.stop(activityId, job);

#Expo vs Native Module

Feature Native Module Expo (expo-live-activity)
Setup Manual Xcode npx expo prebuild
Custom UI Full SwiftUI Config-based
Expo Go No No
Dev Client No Yes
Multi-job Yes (5 max) Yes (5 max)
Expo Limitations: The expo-live-activity package has significant limitations compared to the Native Module approach:
  • CTA Button — Not supported (API doesn't expose it)
  • Gradient backgrounds — Only solid colors via config
  • Custom SwiftUI layout — Config-based UI only
  • ⚠️ Deep link — Works on activity tap, not via CTA button

Recommendation: If you need CTA buttons (tappable button on completion) or advanced customization, use the Native Module approach with @seenn/setup-widget CLI.

#Ongoing Notification (Android)

The SDK provides native Android Ongoing Notification support (v0.3.0+), showing job progress as a persistent notification in the notification drawer. This is the Android equivalent of iOS Live Activities.

#Setup

No additional setup required! The SDK automatically:

  • Creates a notification channel for job progress
  • Requests notification permission on Android 13+
  • Shows ongoing (non-dismissable) notifications during progress

#useJobNotification() Hook (Cross-Platform)

Works on both iOS and Android — the recommended way to integrate:

import { useSeennJob, useJobNotification } from '@seenn/react-native';

function JobScreen({ jobId }: { jobId: string }) {
  const job = useSeennJob(seenn, jobId);

  // Auto-sync job state with notification (iOS + Android)
  const { isActive, isSupported, platform } = useJobNotification(job);

  return (
    <View>
      <Text>{job?.title}</Text>
      <Text>Progress: {job?.progress}%</Text>
      {isSupported && <Text>Notification: {isActive ? 'Active' : 'Off'} ({platform})</Text>}
    </View>
  );
}

#Manual Control API (Android)

For Android-specific control:

import { OngoingNotification } from '@seenn/react-native';

// Check support (Android 8.0+)
const supported = await OngoingNotification.isSupported();

// Check permission (Android 13+)
const hasPermission = await OngoingNotification.hasPermission();

// Start notification
const result = await OngoingNotification.start({
  jobId: 'job_123',
  title: 'Generating video...',
  initialProgress: 0,
});

// Update progress
await OngoingNotification.update({
  jobId: 'job_123',
  progress: 50,
  status: 'running',
  message: 'Encoding frames...',
  stageName: 'Encoding',
  stageIndex: 2,
  stageTotal: 3,
});

// End notification
await OngoingNotification.end({
  jobId: 'job_123',
  finalStatus: 'completed',
  message: 'Video ready!',
  dismissAfter: 5000, // 5 seconds (milliseconds)
});

// Cancel all notifications
await OngoingNotification.cancelAll();

#Unified JobNotification API

Single API that works on both platforms:

import { JobNotification } from '@seenn/react-native';

// Works on iOS (Live Activity) and Android (Ongoing Notification)
await JobNotification.start({ jobId, title, initialProgress: 0 });
await JobNotification.update({ jobId, progress: 50, status: 'running' });
await JobNotification.end({ jobId, finalStatus: 'completed' });

// Check current platform
console.log(JobNotification.platform); // 'ios' | 'android' | 'other'

#Platform Comparison

Feature iOS (Live Activity) Android (Ongoing Notification)
Min Version iOS 16.2+ Android 8.0+ (API 26)
Location Dynamic Island + Lock Screen Notification Drawer
Concurrent Jobs 5 max No practical limit
Dismissable No (during progress) No (ongoing flag)
Permission Settings toggle POST_NOTIFICATIONS (13+)
Push Updates APNs token Not needed (polling)

#Parent-Child Jobs

Track batch processing jobs with automatic progress aggregation. Perfect for AI image packs, multi-stage pipelines, and bulk operations.

#Basic Batch UI

function BatchJobProgress({ jobId }: { jobId: string }) {
  const { job } = useSeennJob(jobId);

  return (
    <View>
      <Text style={styles.title}>{job.title}</Text>

      {/* Progress bar */}
      <View style={styles.progressBar}>
        <View style={[styles.progressFill, { width: `${job.progress}%` }]} />
      </View>

      {/* Batch counter: "3/5 images completed" */}
      {job.childrenTotal && (
        <Text style={styles.counter}>
          {job.childrenCompleted}/{job.childrenTotal} images completed
        </Text>
      )}

      {/* Individual child status */}
      {job.children?.map((child) => (
        <View key={child.childId} style={styles.childRow}>
          <StatusIcon status={child.status} />
          <Text>{child.title}</Text>
          {child.status === 'running' && <Text>{child.progress}%</Text>}
        </View>
      ))}
    </View>
  );
}

#Live Activity for Batch Jobs (iOS)

Show batch progress in Dynamic Island and Lock Screen:

Dynamic Island
S
Christmas Pack
3/5 completed
60%
Lock Screen
S
Christmas Pack
Generating images...
✓ Snowflake ✓ Tree ✓ Santa 3/5
import { Seenn, useSeennJob } from '@seenn/react-native';

function BatchWithLiveActivity({ parentJobId }: { parentJobId: string }) {
  const { job } = useSeennJob(parentJobId);

  // Start Live Activity when job begins
  useEffect(() => {
    const startActivity = async () => {
      if (job?.status === 'running') {
        await Seenn.liveActivity.start({
          jobId: parentJobId,
          title: job.title,
          // Pass batch info for custom UI
          metadata: {
            childrenTotal: job.childrenTotal,
            childrenCompleted: job.childrenCompleted,
          },
        });
      }
    };
    startActivity();
  }, [job?.status]);

  // Live Activity auto-updates via polling!
  // When job.childrenCompleted changes, widget refreshes

  return (
    <View>
      <Text>{job.childrenCompleted}/{job.childrenTotal} completed</Text>
    </View>
  );
}
Auto-sync: When using useSeennJob(), the Live Activity UI automatically updates as children complete. No manual update() calls needed!

#Parent-Child Hooks

Specialized hooks for parent-child job tracking with built-in helpers.

#useParentJob()

useParentJob(seenn: Seenn, parentJobId: string): ParentJobResult

Subscribe to a parent job with additional batch helpers.

import { useParentJob } from '@seenn/react-native';

function BatchProgress({ seenn, batchJobId }) {
  const {
    job,              // The parent job
    isParent,         // true if this is a parent job
    childStats,       // { total, completed, failed, running, pending }
    progress,         // 0-100
    allChildrenDone,  // true when all children finished
    allChildrenSuccess, // true when all completed successfully
    hasFailedChildren, // true if any child failed
  } = useParentJob(seenn, batchJobId);

  if (!job) return <Text>Loading...</Text>;

  return (
    <View>
      <Text>{job.title}</Text>
      <ProgressBar progress={progress} />

      {childStats && (
        <Text>{childStats.completed}/{childStats.total} completed</Text>
      )}

      {hasFailedChildren && (
        <Text style={{ color: 'red' }}>{childStats.failed} failed</Text>
      )}

      {allChildrenSuccess && <Text>All done!</Text>}
    </View>
  );
}

#useChildJob()

useChildJob(seenn: Seenn, childJobId: string): ChildJobResult

Subscribe to a child job with parent context.

import { useChildJob } from '@seenn/react-native';

function ChildJobCard({ seenn, childJobId }) {
  const {
    job,         // The child job
    isChild,     // true if this is a child job
    parentJobId, // Parent job ID
    childIndex,  // 0-based index within parent
    isComplete,  // true if completed
    isFailed,    // true if failed
  } = useChildJob(seenn, childJobId);

  if (!job) return null;

  return (
    <View style={styles.card}>
      <Text>#{childIndex + 1}: {job.title}</Text>
      <ProgressBar progress={job.progress} />
      {isComplete && <Icon name="check" color="green" />}
      {isFailed && <Icon name="x" color="red" />}
    </View>
  );
}

#useJobsByIds()

useJobsByIds(seenn: Seenn, jobIds: string[]): SeennJob[]

Subscribe to multiple specific jobs by ID. Useful for displaying all children of a parent.

import { useJobsByIds } from '@seenn/react-native';

function ChildrenList({ seenn, childJobIds }) {
  // Subscribe to all children by their IDs
  const childJobs = useJobsByIds(seenn, childJobIds);

  return (
    <View>
      {childJobs.map((job) => (
        <View key={job.jobId} style={styles.row}>
          <StatusIcon status={job.status} />
          <Text>{job.title}</Text>
          <Text>{job.progress}%</Text>
        </View>
      ))}
    </View>
  );
}

#TypeScript Types

All types are fully typed with TypeScript:

interface SeennJob {
  jobId: string;
  userId: string;
  status: JobStatus;  // 'pending' | 'queued' | 'running' | 'completed' | 'failed'
  title: string;
  progress: number;  // 0-100
  message?: string;
  stage?: StageInfo;
  queue?: QueueInfo;
  eta?: number;  // Seconds to completion
  resultUrl?: string;
  errorMessage?: string;
  createdAt: string;
  updatedAt: string;

  // Parent-child fields
  parentJobId?: string;
  childProgressMode?: 'average' | 'weighted' | 'sequential';
  children?: ChildJob[];
  childrenCompleted?: number;
  childrenTotal?: number;
}

interface StageInfo {
  id: string;
  label: string;
  index: number;
  total: number;
}

interface QueueInfo {
  position: number;
  total: number;
  estimatedWaitSeconds?: number;
}

#ETA Countdown

The SDK provides smart ETA (Estimated Time of Arrival) support. The backend calculates ETA based on historical job data, and the SDK provides hooks for smooth countdown display.

#useEtaCountdown() Hook

useEtaCountdown(job) → EtaCountdownResult

React hook for smooth ETA countdown. Updates every second for smooth UI, syncs with server ETA on each progress update.

Return Values
remaining number | null Remaining time in milliseconds (null if no ETA)
formatted string | null Human-readable time (e.g., "2m 30s")
isPastDue boolean True if job is taking longer than estimated
confidence number | null ETA confidence score (0.0 - 1.0)
basedOn number | null Number of historical jobs used to calculate ETA
import { useSeennJob, useEtaCountdown } from '@seenn/react-native';

function JobProgress({ seenn, jobId }) {
  const job = useSeennJob(seenn, jobId);
  const eta = useEtaCountdown(job);

  return (
    <View>
      <ProgressBar progress={job?.progress || 0} />

      {eta.remaining !== null && (
        <Text>~{eta.formatted} remaining</Text>
      )}

      {eta.isPastDue && (
        <Text style={{ color: '#f59e0b' }}>
          Taking longer than expected...
        </Text>
      )}

      {eta.confidence !== null && eta.confidence < 0.5 && (
        <Text style={{ opacity: 0.6 }}>
          (estimate may vary)
        </Text>
      )}
    </View>
  );
}
How ETA Works: The backend tracks completion times for each workflowId (or jobType as fallback). After 5+ completed jobs, ETA is calculated from historical data. Before that, the estimatedDuration from job creation is used. ETA is recalculated on every progress update based on current rate.

#ETA Fields on SeennJob

Field Type Description
estimatedCompletionAt string? ISO 8601 timestamp for estimated completion
workflowId string? ETA tracking key (defaults to jobType)
etaConfidence number? Confidence score (0.0 - 1.0), higher = more reliable
etaBasedOn number? Number of historical jobs used for calculation
startedAt string? When the job started running (for elapsed time display)

#Custom Backend

Using your own backend? Configure the SDK to point to your API:

Seenn.init({
  apiKey: 'pk_live_xxx',
  userId: 'user_123',
  config: {
    // Point to your own backend
    apiUrl: 'https://api.yourcompany.com',
    pollInterval: 5000,  // 5 seconds
  },
});
Open Source: The SDK is MIT licensed. Works with Seenn Cloud or any compatible backend. Your backend must implement the REST API spec.

#Standalone Mode (BYO Backend)

Already have your own backend with job state management and APNs push? Use standalone mode to control Live Activities without connecting to any Seenn server. No Seenn.init() required.

Self-Hosted vs Standalone — What's the difference?
  • Self-Hosted / Polling Mode: You implement Seenn's REST API spec (GET /v1/jobs/:id). The SDK polls your backend and manages state internally. Use Seenn.init() with your custom baseUrl.
  • Standalone Mode: You manage everything yourself — your own database, your own state management, your own APNs push. The SDK is just a thin native bridge to iOS Live Activity. No Seenn.init() needed.
Zero Configuration: The LiveActivity API works standalone out of the box. Import it and use it directly — no initialization needed.

#Basic Usage

import { LiveActivity } from '@seenn/react-native';

// No Seenn.init() needed!

// Start a Live Activity when job begins
await LiveActivity.start({
  jobId: 'job_123',
  title: 'Processing your video...',
  jobType: 'video-processing',
  initialProgress: 0,
});

// Update from your own state management
await LiveActivity.update({
  jobId: 'job_123',
  progress: 50,
  status: 'running',
  message: 'Encoding frames...',
});

// End with CTA button
await LiveActivity.end({
  jobId: 'job_123',
  finalStatus: 'completed',
  message: 'Video ready!',
  ctaButton: {
    text: 'Watch Now',
    deepLink: 'myapp://videos/job_123',
  },
});

#Push Token Handling

When your backend handles APNs push for Live Activity updates, you need to capture and send the push token:

import { LiveActivity } from '@seenn/react-native';
import { useEffect } from 'react';

function App() {
  useEffect(() => {
    // Listen for Live Activity push tokens
    const unsubscribe = LiveActivity.onPushToken((event) => {
      // Send token to your backend
      myApi.registerLiveActivityToken({
        jobId: event.jobId,
        token: event.token,
      });
    });

    return () => unsubscribe();
  }, []);

  return <YourApp />;
}

#APNs Payload Format

Your backend sends APNs push updates directly to iOS. The payload must match the Widget Extension's ContentState:

// Progress update
{
  "aps": {
    "timestamp": 1706500000,
    "event": "update",
    "content-state": {
      "progress": 75,
      "status": "running",
      "message": "Processing 3/4...",
      "stageName": "Encoding",
      "stageIndex": 3,
      "stageTotal": 4
    }
  }
}

// Completion with CTA button
{
  "aps": {
    "timestamp": 1706500120,
    "event": "end",
    "dismissal-date": 1706500420,
    "content-state": {
      "progress": 100,
      "status": "completed",
      "message": "Your video is ready!",
      "ctaButtonText": "Watch Now",
      "ctaDeepLink": "myapp://videos/123"
    }
  }
}

ContentState Fields

Field Type Description
progress Int Progress 0-100
status String pending | running | completed | failed
message String? Status message to display
stageName String? Current stage name
stageIndex Int? Current stage index (1-based)
stageTotal Int? Total number of stages
estimatedEndTime Int? ETA as Unix timestamp (seconds)
resultUrl String? Result URL for completed jobs
errorMessage String? Error message for failed jobs
ctaButtonText String? CTA button label
ctaDeepLink String? Deep link URL for CTA button

#LiveActivity API Reference

Method Description
LiveActivity.start(params) Start a new Live Activity
LiveActivity.update(params) Update an existing Live Activity
LiveActivity.end(params) End a Live Activity with optional CTA
LiveActivity.isSupported() Check if Live Activities are supported
LiveActivity.areActivitiesEnabled() Check if user has enabled Live Activities
LiveActivity.isActive(jobId) Check if a specific activity is active
LiveActivity.getActiveIds() Get all active Live Activity job IDs
LiveActivity.cancel(jobId) Cancel a specific activity immediately
LiveActivity.cancelAll() Cancel all active activities
LiveActivity.onPushToken(callback) Subscribe to push token events
Widget Extension Still Required: You still need the Widget Extension for Live Activities to display. Use npx @seenn/setup-widget init to generate the required files. See Widget Setup CLI.

#Error Handling

The SDK provides typed error handling:

import { useSeennJob, SeennError } from '@seenn/react-native';

function JobProgress({ jobId }: { jobId: string }) {
  const { job, error } = useSeennJob(jobId);

  if (error) {
    if (error instanceof SeennError) {
      switch (error.code) {
        case 'JOB_NOT_FOUND':
          return <Text>Job not found</Text>;
        case 'UNAUTHORIZED':
          // Refresh user token
          refreshToken();
          break;
        case 'RATE_LIMITED':
          return <Text>Too many requests, please wait</Text>;
      }
    }
    return <Text>Error: {error.message}</Text>;
  }

  // Render job...
}

#Error Codes

Code Description Action
JOB_NOT_FOUND Job doesn't exist Check jobId, show error message
UNAUTHORIZED Invalid/expired token Refresh user token
RATE_LIMITED Too many requests Retry after delay
CONNECTION_ERROR Connection failed Auto-reconnect in progress

#SDK Error Codes (v0.9.10+)

All Live Activity and JobNotification operations return a result with standardized error codes for programmatic handling:

import { LiveActivity, SeennErrorCode, SDK_VERSION } from '@seenn/react-native';

console.log('SDK Version:', SDK_VERSION); // '0.9.10'

const result = await LiveActivity.start({
  jobId: 'job_123',
  title: 'Processing...',
});

if (!result.success) {
  switch (result.code) {
    case SeennErrorCode.PLATFORM_NOT_SUPPORTED:
      console.log('Not on iOS');
      break;
    case SeennErrorCode.INVALID_JOB_ID:
      console.log('Invalid job ID');
      break;
    case SeennErrorCode.NATIVE_MODULE_NOT_FOUND:
      console.log('Native setup incomplete');
      break;
    default:
      console.log(`Error [${result.code}]: ${result.error}`);
  }
}
Code Description
PLATFORM_NOT_SUPPORTED Operation not supported on this platform (e.g., LiveActivity on Android)
NATIVE_MODULE_NOT_FOUND Native module not found - native setup incomplete
BRIDGE_NOT_REGISTERED Live Activity bridge not registered in AppDelegate
ACTIVITY_NOT_FOUND Live Activity not found for the given jobId
INVALID_JOB_ID Invalid or empty jobId
INVALID_PROGRESS Progress value out of range (must be 0-100)
INVALID_TITLE Invalid or empty title
INVALID_STATUS Invalid status value
UNKNOWN_ERROR Unknown error occurred

#useSeennPush Hook (v0.9.9+)

Convenience hook for managing push notifications with auto-refresh and debug mode:

import { useSeennPush } from '@seenn/react-native';

function App() {
  const {
    token,
    authorizationStatus,
    isLoading,
    requestProvisional,
    requestStandard,
    refreshToken,
  } = useSeennPush({
    debug: true,     // Enable detailed logging in __DEV__
    autoRefresh: true, // Auto-refresh token on mount
    onTokenReceived: async (token) => {
      await api.registerDevice({ userId, deviceToken: token });
    },
    onAuthorizationStatus: (status) => {
      console.log('Auth status:', status);
    },
    onError: (error) => {
      console.error('Push error:', error);
    },
  });

  useEffect(() => {
    if (authorizationStatus === 'notDetermined') {
      requestProvisional();
    }
  }, [authorizationStatus]);

  return <YourApp />;
}

#Advanced Usage

#Update User Token

// When token expires, update it
await Seenn.setUserToken(newToken);

#Manual Reconnect

// Force reconnect
await Seenn.reconnect();

#Cleanup

// Call when user logs out
await Seenn.dispose();