diff --git a/.github/workflows/mobile_eas_build.yaml b/.github/workflows/mobile_eas_build.yaml new file mode 100644 index 000000000..4a6c40b15 --- /dev/null +++ b/.github/workflows/mobile_eas_build.yaml @@ -0,0 +1,71 @@ +name: Mobile EAS Build +on: + pull_request: + types: [labeled] + +permissions: + contents: read + pull-requests: write + +jobs: + build: + if: ${{ github.event.label.name == 'eas build' }} + runs-on: macOS-latest + steps: + - name: 🏗 Setup repo + uses: actions/checkout@v4 + with: + submodules: recursive + + - name: 🏗 Setup Node + uses: actions/setup-node@v4 + with: + node-version: 22.x + cache: npm + + - name: 🏗 Setup EAS + uses: expo/expo-github-action@v8 + with: + eas-version: latest + token: ${{ secrets.EXPO_TOKEN }} + + - name: Use corepack + run: corepack enable + + - name: 📦 Install dependencies + run: yarn && yarn build + + - name: 🚀 Build app + run: eas build --non-interactive --platform=all --profile production + working-directory: examples/mobile-client/fishjam-chat + + - name: 🛫 Submit iOS app to TestFlight + run: eas submit --non-interactive --platform=ios --latest + working-directory: examples/mobile-client/fishjam-chat + + - name: ⛓️‍💥 Get iOS archive url and version + id: ios_build + run: eas build:list --json --non-interactive | jq -r '[.[] | select(.platform=="IOS")][0] | "version=\(.appVersion) (\(.appBuildVersion))"' >> $GITHUB_OUTPUT + working-directory: examples/mobile-client/fishjam-chat + + - name: ⛓️‍💥 Get Android archive url and version + id: android_build + run: eas build:list --json --non-interactive | jq -r '[.[] | select(.platform=="ANDROID")][0] | "url=\(.artifacts.applicationArchiveUrl)\nversion=\(.appVersion) (\(.appBuildVersion))"' >> $GITHUB_OUTPUT + working-directory: examples/mobile-client/fishjam-chat + + - name: 📱 Get App Store Connect app ID + id: asc_app + run: echo "id=$(jq -r '.submit.production.ios.ascAppId' eas.json)" >> $GITHUB_OUTPUT + working-directory: examples/mobile-client/fishjam-chat + + - name: 💬 Add comment with build links + uses: actions/github-script@v7 + with: + github-token: ${{secrets.GITHUB_TOKEN}} + script: | + github.rest.issues.createComment({ + issue_number: context.issue.number, + owner: context.repo.owner, + repo: context.repo.repo, + body: '**Download links**\nAndroid - ${{ steps.android_build.outputs.version }}: ${{ steps.android_build.outputs.url }}\niOS - ${{ steps.ios_build.outputs.version }}: https://testflight.apple.com/v1/app/${{ steps.asc_app.outputs.id }}' + }) diff --git a/examples/mobile-client/fishjam-chat/.env.example b/examples/mobile-client/fishjam-chat/.env.example new file mode 100644 index 000000000..975cf0cb2 --- /dev/null +++ b/examples/mobile-client/fishjam-chat/.env.example @@ -0,0 +1,3 @@ +EXPO_PUBLIC_FISHJAM_ID= +EXPO_PUBLIC_VIDEOROOM_STAGING_SANDBOX_URL= +EXPO_PUBLIC_SANDBOX_API_URL= \ No newline at end of file diff --git a/examples/mobile-client/fishjam-chat/.eslintrc.js b/examples/mobile-client/fishjam-chat/.eslintrc.js new file mode 100644 index 000000000..9d8402ae0 --- /dev/null +++ b/examples/mobile-client/fishjam-chat/.eslintrc.js @@ -0,0 +1,18 @@ +module.exports = { + root: true, + extends: ['expo'], + ignorePatterns: [ + 'dist/*', + 'node_modules/*', + 'coverage/*', + 'build/*', + 'ios/*', + 'android/*', + '.eslintrc.js', + 'prettier.config.js', + 'global.d.ts', + ], + rules: { + 'import/no-unresolved': 'off', + }, +}; diff --git a/examples/mobile-client/fishjam-chat/.gitignore b/examples/mobile-client/fishjam-chat/.gitignore new file mode 100644 index 000000000..99491cc9c --- /dev/null +++ b/examples/mobile-client/fishjam-chat/.gitignore @@ -0,0 +1,41 @@ +# dependencies +node_modules/ + +# Expo +.expo/ +dist/ +web-build/ +expo-env.d.ts + +# Native +.kotlin/ +*.orig.* +*.jks +*.p8 +*.p12 +*.key +*.mobileprovision + +# Metro +.metro-health-check* + +# debug +npm-debug.* +yarn-debug.* +yarn-error.* + +# macOS +.DS_Store +*.pem + +# local env files +.env*.local + +# typescript +*.tsbuildinfo + +app-example + +# generated native folders +/ios +/android diff --git a/examples/mobile-client/fishjam-chat/README.md b/examples/mobile-client/fishjam-chat/README.md new file mode 100644 index 000000000..4ca4cdbce --- /dev/null +++ b/examples/mobile-client/fishjam-chat/README.md @@ -0,0 +1,84 @@ +# React Native Fishjam example + +## Prerequisites + +Copy `.env.example` to `.env` in the `examples/mobile-client/fishjam-chat` directory and fill in the required value: + +- `EXPO_PUBLIC_FISHJAM_ID` - Fishjam ID for connecting to fishjam platform +- `EXPO_PUBLIC_SANDBOX_API_URL` - Sandbox API URL used by `useSandbox` to create rooms and fetch peer, viewer, and streamer tokens + +You can find these values in the Fishjam dashboard: + +- `Fishjam ID` is available at [fishjam.io/app](https://fishjam.io/app). +- `Sandbox API url` is available at [fishjam.io/app/sandbox](https://fishjam.io/app/sandbox). + +There also exists this additional environment variable, which is used for internal testing purposes: + +- `EXPO_PUBLIC_VIDEOROOM_STAGING_SANDBOX_URL` - Sandbox URL for VideoRoom staging environment + +## Example Overview + +The app has 2 tabs showing different ways to connect to Fishjam video calls: + +**VideoRoom** - Connect to VideoRoom (Fishjam's demo service, something like Google Meet) by entering a room name and username. The app automatically creates the room and generates tokens for you. + +**Livestream** - Join existing livestreams or create your own livestream. + +## Project setup + +1. Clone the repository: + +``` +git clone https://github.com/fishjam-cloud/web-client-sdk.git +cd web-client-sdk +``` + +2. Install dependencies and build project: + +```sh +yarn +yarn build +``` + +> [!IMPORTANT] +> Before prebuilding, replace all occurrences of `io.fishjam.example.fishjamchat` in `app.json` with your own bundle identifier: +> +> - **iOS bundle identifier** — `expo.ios.bundleIdentifier` +> - **Android package name** — `expo.android.package` +> +> For example, if your bundle ID is `com.yourcompany.yourapp`: +> +> - iOS & Android: `com.yourcompany.yourapp` +> - ScreenBroadcastExtension: `com.yourcompany.yourapp.ScreenBroadcastExtension` +> - App group: `group.com.yourcompany.yourapp` + +3. Prebuild native files in example directory: + +```sh +cd examples/mobile-client/fishjam-chat +npx expo prebuild --clean +``` + +> [!NOTE] +> Be sure to run `npx expo prebuild` and not `yarn prebuild` as there's an issue with path generation for the `ios/.xcode.env.local` file + +4. Build app: + +``` +yarn ios +yarn android +``` + +## Development + +1. Whenever you make changes in the `packages` directory, make sure to build the app in the root directory (not in `examples/mobile-client/fishjam-chat`). This ensures that all related workspaces are also built: + +```sh +yarn build +``` + +2. Linter (run in the root directory): + +```sh +yarn lint +``` diff --git a/examples/mobile-client/fishjam-chat/app.json b/examples/mobile-client/fishjam-chat/app.json new file mode 100644 index 000000000..a60680eb1 --- /dev/null +++ b/examples/mobile-client/fishjam-chat/app.json @@ -0,0 +1,102 @@ +{ + "expo": { + "name": "fishjam-chat", + "slug": "fishjam-chat", + "version": "1.0.0", + "orientation": "portrait", + "icon": "./assets/images/icon.png", + "scheme": "fishjamchat", + "userInterfaceStyle": "automatic", + "newArchEnabled": true, + "ios": { + "supportsTablet": true, + "bundleIdentifier": "io.fishjam.example.fishjamchat", + "infoPlist": { + "NSCameraUsageDescription": "Allow $(PRODUCT_NAME) to access your camera.", + "NSMicrophoneUsageDescription": "Allow $(PRODUCT_NAME) to access your microphone.", + "ITSAppUsesNonExemptEncryption": false + } + }, + "android": { + "adaptiveIcon": { + "foregroundImage": "./assets/images/adaptive-icon.png", + "monochromeImage": "./assets/images/adaptive-icon.png", + "backgroundColor": "#ffffff" + }, + "edgeToEdgeEnabled": true, + "predictiveBackGestureEnabled": false, + "permissions": [ + "android.permission.CAMERA", + "android.permission.RECORD_AUDIO", + "android.permission.MODIFY_AUDIO_SETTINGS", + "android.permission.ACCESS_NETWORK_STATE", + "android.permission.ACCESS_WIFI_STATE", + "android.permission.FOREGROUND_SERVICE", + "android.permission.FOREGROUND_SERVICE_MEDIA_PROJECTION", + "android.permission.FOREGROUND_SERVICE_CAMERA", + "android.permission.FOREGROUND_SERVICE_MICROPHONE", + "android.permission.POST_NOTIFICATIONS" + ], + "package": "io.fishjam.example.fishjamchat" + }, + "web": { + "output": "static", + "favicon": "./assets/images/favicon.png" + }, + "plugins": [ + "expo-router", + [ + "@fishjam-cloud/react-native-client", + { + "android": { + "supportsPictureInPicture": true, + "enableForegroundService": true, + "enableScreensharing": true + }, + "ios": { + "enableScreensharing": true, + "enableVoIPBackgroundMode": true, + "supportsPictureInPicture": true + } + } + ], + [ + "expo-splash-screen", + { + "image": "./assets/images/splash.png", + "resizeMode": "contain", + "backgroundColor": "#ffffff", + "dark": { + "backgroundColor": "#000000" + } + } + ] + ], + "experiments": { + "typedRoutes": true + }, + "extra": { + "eas": { + "projectId": "3cb3251a-603a-4c13-ab69-6fac3249072d", + "build": { + "experimental": { + "ios": { + "appExtensions": [ + { + "targetName": "ScreenBroadcastExtension", + "bundleIdentifier": "io.fishjam.example.fishjamchat.ScreenBroadcastExtension", + "entitlements": { + "com.apple.security.application-groups": [ + "group.io.fishjam.example.fishjamchat" + ] + } + } + ] + } + } + } + } + }, + "owner": "fishjam-cloud" + } +} diff --git a/examples/mobile-client/fishjam-chat/app/(tabs)/_layout.tsx b/examples/mobile-client/fishjam-chat/app/(tabs)/_layout.tsx new file mode 100644 index 000000000..4b006f292 --- /dev/null +++ b/examples/mobile-client/fishjam-chat/app/(tabs)/_layout.tsx @@ -0,0 +1,34 @@ +import { Ionicons } from '@expo/vector-icons'; +import { Tabs } from 'expo-router'; + +import { BrandColors } from '../../utils/Colors'; + +export default function TabLayout() { + return ( + + ( + + ), + }} + /> + ( + + ), + }} + /> + + ); +} diff --git a/examples/mobile-client/fishjam-chat/app/(tabs)/livestream.tsx b/examples/mobile-client/fishjam-chat/app/(tabs)/livestream.tsx new file mode 100644 index 000000000..c2d643179 --- /dev/null +++ b/examples/mobile-client/fishjam-chat/app/(tabs)/livestream.tsx @@ -0,0 +1,145 @@ +import { router } from 'expo-router'; +import React, { useState } from 'react'; +import { + Dimensions, + Image, + Keyboard, + KeyboardAvoidingView, + StyleSheet, + Text, +} from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; + +import { Button, DismissKeyboard, TextInput } from '../../components'; + +// eslint-disable-next-line @typescript-eslint/no-require-imports +const FishjamLogo = require('../../assets/images/fishjam-logo.png'); + +export default function LivestreamScreen() { + const [connectionError, setConnectionError] = useState(null); + + const [fishjamId, setFishjamId] = useState( + process.env.EXPO_PUBLIC_FISHJAM_ID ?? '', + ); + const [roomName, setRoomName] = useState(''); + + const validateInputs = () => { + if (!fishjamId) { + throw new Error('Fishjam ID is required'); + } + + if (!roomName) { + throw new Error('Room name is required'); + } + }; + + const onTapConnectViewerButton = async () => { + try { + validateInputs(); + setConnectionError(null); + Keyboard.dismiss(); + router.push({ + pathname: '/livestream/viewer', + params: { fishjamId, roomName }, + }); + } catch (e) { + const message = + 'message' in (e as Error) ? (e as Error).message : 'Unknown error'; + setConnectionError(message); + } + }; + + const onTapConnectStreamerButton = async () => { + try { + validateInputs(); + setConnectionError(null); + Keyboard.dismiss(); + router.push({ + pathname: '/livestream/streamer', + params: { fishjamId, roomName }, + }); + } catch (e) { + const message = + 'message' in (e as Error) ? (e as Error).message : 'Unknown error'; + setConnectionError(message); + } + }; + + const onTapConnectScreenSharingButton = async () => { + try { + validateInputs(); + setConnectionError(null); + Keyboard.dismiss(); + router.push({ + pathname: '/livestream/screen-sharing', + params: { fishjamId, roomName }, + }); + } catch (e) { + const message = + 'message' in (e as Error) ? (e as Error).message : 'Unknown error'; + setConnectionError(message); + } + }; + + return ( + + + + {connectionError && ( + {connectionError} + )} + + + +