Small WebRTC transport demo app for Android

This commit is contained in:
marcus-daily
2025-05-06 13:02:22 +01:00
committed by Marcus
parent a471f49b61
commit da0d2f0266
58 changed files with 2300 additions and 3 deletions

View File

@@ -6,11 +6,13 @@ on:
- main
paths:
- "examples/simple-chatbot/client/android/**"
- "examples/p2p-webrtc/video-transform/client/android/**"
pull_request:
branches:
- "**"
paths:
- "examples/simple-chatbot/client/android/**"
- "examples/p2p-webrtc/video-transform/client/android/**"
workflow_dispatch:
inputs:
sdk_git_ref:
@@ -23,7 +25,7 @@ concurrency:
jobs:
sdk:
name: "Simple chatbot demo"
name: "Demo apps"
runs-on: ubuntu-latest
steps:
- name: Checkout repo
@@ -37,12 +39,22 @@ jobs:
distribution: 'temurin'
java-version: '17'
- name: Build demo app
- name: Example app: Simple Chatbot
working-directory: examples/simple-chatbot/client/android
run: ./gradlew :simple-chatbot-client:assembleDebug
- name: Upload demo APK
- name: Upload Simple Chatbot APK
uses: actions/upload-artifact@v4
with:
name: Simple Chatbot Android Client
path: examples/simple-chatbot/client/android/simple-chatbot-client/build/outputs/apk/debug/simple-chatbot-client-debug.apk
- name: Example app: Small WebRTC Client
working-directory: examples/p2p-webrtc/video-transform/client/android
run: ./gradlew :small-webrtc-client:assembleDebug
- name: Upload Small WebRTC APK
uses: actions/upload-artifact@v4
with:
name: Small WebRTC Android Client
path: examples/p2p-webrtc/video-transform/client/android/small-webrtc-client/build/outputs/apk/debug/small-webrtc-client-debug.apk

View File

@@ -0,0 +1,10 @@
*.iml
.gradle
/local.properties
/.idea
.DS_Store
/build
/captures
.externalNativeBuild
.cxx
local.properties

View File

@@ -0,0 +1,24 @@
BSD 2-Clause License
Copyright (c) 20242025, Daily
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

View File

@@ -0,0 +1,12 @@
# Pipecat Small WebRTC Client for Android
Demo app which connects to the `video-transform` backend over the small WebRTC transport.
## How to run
```bash
./gradlew runDebug
```
Ensure that the `video-transform` server is running as described in the parent README.

View File

@@ -0,0 +1,5 @@
plugins {
alias(libs.plugins.jetbrains.kotlin.android) apply false
alias(libs.plugins.android.application) apply false
alias(libs.plugins.compose.compiler) apply false
}

View File

@@ -0,0 +1,23 @@
# Project-wide Gradle settings.
# IDE (e.g. Android Studio) users:
# Gradle settings configured through the IDE *will override*
# any settings specified in this file.
# For more details on how to configure your build environment visit
# http://www.gradle.org/docs/current/userguide/build_environment.html
# Specifies the JVM arguments used for the daemon process.
# The setting is particularly useful for tweaking memory settings.
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
# When configured, Gradle will run in incubating parallel mode.
# This option should only be used with decoupled projects. For more details, visit
# https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects
# org.gradle.parallel=true
# AndroidX package structure to make it clearer which packages are bundled with the
# Android operating system, and which are packaged with your app's APK
# https://developer.android.com/topic/libraries/support-library/androidx-rn
android.useAndroidX=true
# Kotlin code style for this project: "official" or "obsolete":
kotlin.code.style=official
# Enables namespacing of each library's R class so that its R class includes only the
# resources declared in the library itself and none from the library's dependencies,
# thereby reducing the size of the R class for that library
android.nonTransitiveRClass=true

View File

@@ -0,0 +1,34 @@
[versions]
accompanistPermissions = "0.34.0"
agp = "8.7.3"
constraintlayoutCompose = "1.1.0"
pipecatClient = "0.3.5"
kotlin = "2.0.20"
coreKtx = "1.15.0"
lifecycleRuntimeKtx = "2.8.7"
activityCompose = "1.9.3"
composeBom = "2024.12.01"
kotlinxSerializationJson = "1.7.1"
kotlinxSerializationPlugin = "2.0.20"
[libraries]
accompanist-permissions = { module = "com.google.accompanist:accompanist-permissions", version.ref = "accompanistPermissions" }
androidx-constraintlayout-compose = { module = "androidx.constraintlayout:constraintlayout-compose", version.ref = "constraintlayoutCompose" }
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
pipecat-client-smallwebrtc = { module = "ai.pipecat:small-webrtc-transport", version.ref = "pipecatClient" }
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
androidx-ui = { group = "androidx.compose.ui", name = "ui" }
androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
androidx-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
androidx-material3 = { group = "androidx.compose.material3", name = "material3" }
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" }
[plugins]
jetbrains-kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
android-application = { id = "com.android.application", version.ref = "agp" }
compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
jetbrains-kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlinxSerializationPlugin" }

View File

@@ -0,0 +1,6 @@
#Mon Aug 05 13:01:27 BST 2024
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-bin.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

View File

@@ -0,0 +1,185 @@
#!/usr/bin/env sh
#
# Copyright 2015 the original author or authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
##############################################################################
##
## Gradle start up script for UN*X
##
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
PRG="$0"
# Need this for relative symlinks.
while [ -h "$PRG" ] ; do
ls=`ls -ld "$PRG"`
link=`expr "$ls" : '.*-> \(.*\)$'`
if expr "$link" : '/.*' > /dev/null; then
PRG="$link"
else
PRG=`dirname "$PRG"`"/$link"
fi
done
SAVED="`pwd`"
cd "`dirname \"$PRG\"`/" >/dev/null
APP_HOME="`pwd -P`"
cd "$SAVED" >/dev/null
APP_NAME="Gradle"
APP_BASE_NAME=`basename "$0"`
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD="maximum"
warn () {
echo "$*"
}
die () {
echo
echo "$*"
echo
exit 1
}
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "`uname`" in
CYGWIN* )
cygwin=true
;;
Darwin* )
darwin=true
;;
MINGW* )
msys=true
;;
NONSTOP* )
nonstop=true
;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD="$JAVA_HOME/jre/sh/java"
else
JAVACMD="$JAVA_HOME/bin/java"
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD="java"
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
# Increase the maximum file descriptors if we can.
if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
MAX_FD_LIMIT=`ulimit -H -n`
if [ $? -eq 0 ] ; then
if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
MAX_FD="$MAX_FD_LIMIT"
fi
ulimit -n $MAX_FD
if [ $? -ne 0 ] ; then
warn "Could not set maximum file descriptor limit: $MAX_FD"
fi
else
warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
fi
fi
# For Darwin, add options to specify how the application appears in the dock
if $darwin; then
GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
fi
# For Cygwin or MSYS, switch paths to Windows format before running java
if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then
APP_HOME=`cygpath --path --mixed "$APP_HOME"`
CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
JAVACMD=`cygpath --unix "$JAVACMD"`
# We build the pattern for arguments to be converted via cygpath
ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
SEP=""
for dir in $ROOTDIRSRAW ; do
ROOTDIRS="$ROOTDIRS$SEP$dir"
SEP="|"
done
OURCYGPATTERN="(^($ROOTDIRS))"
# Add a user-defined pattern to the cygpath arguments
if [ "$GRADLE_CYGPATTERN" != "" ] ; then
OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
fi
# Now convert the arguments - kludge to limit ourselves to /bin/sh
i=0
for arg in "$@" ; do
CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
else
eval `echo args$i`="\"$arg\""
fi
i=`expr $i + 1`
done
case $i in
0) set -- ;;
1) set -- "$args0" ;;
2) set -- "$args0" "$args1" ;;
3) set -- "$args0" "$args1" "$args2" ;;
4) set -- "$args0" "$args1" "$args2" "$args3" ;;
5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
esac
fi
# Escape application args
save () {
for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
echo " "
}
APP_ARGS=`save "$@"`
# Collect all arguments for the java command, following the shell quoting and substitution rules
eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
exec "$JAVACMD" "$@"

View File

@@ -0,0 +1,89 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@if "%DEBUG%" == "" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%" == "" set DIRNAME=.
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if "%ERRORLEVEL%" == "0" goto execute
echo.
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo.
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
:end
@rem End local scope for the variables with windows NT shell
if "%ERRORLEVEL%"=="0" goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
exit /b 1
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega

View File

@@ -0,0 +1,24 @@
pluginManagement {
repositories {
google {
content {
includeGroupByRegex("com\\.android.*")
includeGroupByRegex("com\\.google.*")
includeGroupByRegex("androidx.*")
}
}
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
mavenLocal()
}
}
rootProject.name = "Pipecat Small WebRTC Client"
include(":small-webrtc-client")

View File

@@ -0,0 +1 @@
/build

View File

@@ -0,0 +1,75 @@
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.jetbrains.kotlin.android)
alias(libs.plugins.jetbrains.kotlin.serialization)
alias(libs.plugins.compose.compiler)
}
android {
namespace = "ai.pipecat.small_webrtc_client"
compileSdk = 35
defaultConfig {
applicationId = "ai.pipecat.small_webrtc_client"
minSdk = 26
targetSdk = 35
versionCode = 1
versionName = "1.0"
vectorDrawables {
useSupportLibrary = true
}
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = "1.8"
}
buildFeatures {
compose = true
buildConfig = true
}
composeOptions {
kotlinCompilerExtensionVersion = "1.5.1"
}
packaging {
resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
}
}
}
dependencies {
implementation(libs.pipecat.client.smallwebrtc)
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.activity.compose)
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.ui)
implementation(libs.androidx.ui.graphics)
implementation(libs.androidx.ui.tooling.preview)
implementation(libs.androidx.material3)
implementation(libs.accompanist.permissions)
implementation(libs.androidx.constraintlayout.compose)
implementation(libs.kotlinx.serialization.json)
androidTestImplementation(platform(libs.androidx.compose.bom))
debugImplementation(libs.androidx.ui.tooling)
debugImplementation(libs.androidx.ui.test.manifest)
}

View File

@@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile

View File

@@ -0,0 +1,37 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-feature
android:name="android.hardware.camera"
android:required="false" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<application
android:name=".RTVIApplication"
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:usesCleartextTraffic="true"
android:theme="@style/Theme.RTVIClient">
<activity
android:name=".MainActivity"
android:exported="true"
android:windowSoftInputMode="adjustResize"
android:configChanges="keyboard|keyboardHidden|orientation|screenSize"
android:theme="@style/Theme.RTVIClient">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>

View File

@@ -0,0 +1,257 @@
package ai.pipecat.small_webrtc_client
import ai.pipecat.small_webrtc_client.ui.InCallLayout
import ai.pipecat.small_webrtc_client.ui.PermissionScreen
import ai.pipecat.small_webrtc_client.ui.theme.Colors
import ai.pipecat.small_webrtc_client.ui.theme.RTVIClientTheme
import ai.pipecat.small_webrtc_client.ui.theme.TextStyles
import ai.pipecat.small_webrtc_client.ui.theme.textFieldColors
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.annotation.DrawableRes
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
val voiceClientManager = VoiceClientManager(this)
setContent {
RTVIClientTheme {
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
Box(
Modifier
.fillMaxSize()
.padding(innerPadding)
) {
PermissionScreen()
val vcState = voiceClientManager.state.value
if (vcState != null) {
InCallLayout(voiceClientManager)
} else {
ConnectSettings(voiceClientManager)
}
voiceClientManager.errors.firstOrNull()?.let { errorText ->
val dismiss: () -> Unit = { voiceClientManager.errors.removeAt(0) }
AlertDialog(
onDismissRequest = dismiss,
confirmButton = {
Button(onClick = dismiss) {
Text(
text = "OK",
fontSize = 14.sp,
fontWeight = FontWeight.W700,
color = Color.White,
style = TextStyles.base
)
}
},
containerColor = Color.White,
title = {
Text(
text = "Error",
fontSize = 22.sp,
fontWeight = FontWeight.W600,
color = Color.Black,
style = TextStyles.base
)
},
text = {
Text(
text = errorText.message,
fontSize = 16.sp,
fontWeight = FontWeight.W400,
color = Color.Black,
style = TextStyles.base
)
}
)
}
}
}
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ConnectSettings(
voiceClientManager: VoiceClientManager,
) {
val scrollState = rememberScrollState()
val start = {
val backendUrl = Preferences.backendUrl.value
voiceClientManager.start(baseUrl = backendUrl!!)
}
Box(
modifier = Modifier
.fillMaxSize()
.verticalScroll(scrollState)
.imePadding()
.padding(20.dp),
contentAlignment = Alignment.Center
) {
Box(
Modifier
.fillMaxWidth()
.shadow(2.dp, RoundedCornerShape(16.dp))
.clip(RoundedCornerShape(16.dp))
.background(Colors.mainSurfaceBackground)
) {
Column(
Modifier
.fillMaxWidth()
.padding(
vertical = 24.dp,
horizontal = 28.dp
)
) {
Spacer(modifier = Modifier.height(12.dp))
Text(
modifier = Modifier.align(Alignment.CenterHorizontally),
text = "Connect to WebRTC server",
fontSize = 22.sp,
fontWeight = FontWeight.W700,
style = TextStyles.base
)
Spacer(modifier = Modifier.height(36.dp))
Text(
text = "Backend URL",
fontSize = 16.sp,
fontWeight = FontWeight.W400,
style = TextStyles.base
)
Spacer(modifier = Modifier.height(12.dp))
TextField(
modifier = Modifier
.fillMaxWidth()
.border(1.dp, Colors.textFieldBorder, RoundedCornerShape(12.dp)),
value = Preferences.backendUrl.value ?: "",
onValueChange = { Preferences.backendUrl.value = it },
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Uri,
imeAction = ImeAction.Next
),
colors = textFieldColors(),
shape = RoundedCornerShape(12.dp)
)
Spacer(modifier = Modifier.height(36.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(16.dp)
) {
ConnectDialogButton(
modifier = Modifier.weight(1f),
onClick = start,
text = "Connect",
foreground = Color.White,
background = Colors.buttonNormal,
border = Colors.buttonNormal
)
}
}
}
}
}
@Composable
private fun ConnectDialogButton(
onClick: () -> Unit,
text: String,
foreground: Color,
background: Color,
border: Color,
modifier: Modifier = Modifier,
@DrawableRes icon: Int? = null,
) {
val shape = RoundedCornerShape(8.dp)
Row(
modifier
.border(1.dp, border, shape)
.clip(shape)
.background(background)
.clickable(onClick = onClick)
.padding(vertical = 10.dp, horizontal = 24.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center
) {
if (icon != null) {
Icon(
modifier = Modifier.size(24.dp),
painter = painterResource(icon),
tint = foreground,
contentDescription = null
)
Spacer(modifier = Modifier.width(8.dp))
}
Text(
text = text,
fontSize = 16.sp,
fontWeight = FontWeight.W500,
color = foreground
)
}
}

View File

@@ -0,0 +1,75 @@
package ai.pipecat.small_webrtc_client
import android.content.Context
import android.content.SharedPreferences
import androidx.compose.runtime.mutableStateOf
import kotlinx.serialization.KSerializer
import kotlinx.serialization.json.Json
private val JSON_INSTANCE = Json { ignoreUnknownKeys = true }
object Preferences {
private const val PREF_BACKEND_URL = "backend_url"
private lateinit var prefs: SharedPreferences
fun initAppStart(context: Context) {
prefs = context.applicationContext.getSharedPreferences("prefs", Context.MODE_PRIVATE)
listOf(backendUrl).forEach { it.init() }
}
private fun getString(key: String): String? = prefs.getString(key, null)
interface BasePref {
fun init()
}
class StringPref(private val key: String): BasePref {
private val cachedValue = mutableStateOf<String?>(null)
override fun init() {
cachedValue.value = getString(key)
prefs.registerOnSharedPreferenceChangeListener { _, changedKey ->
if (key == changedKey) {
cachedValue.value = getString(key)
}
}
}
var value: String?
get() = cachedValue.value
set(newValue) {
cachedValue.value = newValue
prefs.edit().putString(key, newValue).apply()
}
}
class JsonPref<E>(private val key: String, private var serializer: KSerializer<E>): BasePref {
private val cachedValue = mutableStateOf<E?>(null)
private fun lookupValue(): E? =
getString(key)?.let { JSON_INSTANCE.decodeFromString(serializer, it) }
override fun init() {
cachedValue.value = lookupValue()
prefs.registerOnSharedPreferenceChangeListener { _, changedKey ->
if (key == changedKey) {
cachedValue.value = lookupValue()
}
}
}
var value: E?
get() = cachedValue.value
set(newValue) {
cachedValue.value = newValue
prefs.edit()
.putString(key, newValue?.let { JSON_INSTANCE.encodeToString(serializer, it) })
.apply()
}
}
val backendUrl = StringPref(PREF_BACKEND_URL)
}

View File

@@ -0,0 +1,10 @@
package ai.pipecat.small_webrtc_client
import android.app.Application
class RTVIApplication : Application() {
override fun onCreate() {
super.onCreate()
Preferences.initAppStart(this)
}
}

View File

@@ -0,0 +1,191 @@
package ai.pipecat.small_webrtc_client
import ai.pipecat.client.RTVIClient
import ai.pipecat.client.RTVIClientOptions
import ai.pipecat.client.RTVIClientParams
import ai.pipecat.client.RTVIEventCallbacks
import ai.pipecat.client.result.Future
import ai.pipecat.client.result.RTVIError
import ai.pipecat.client.result.Result
import ai.pipecat.client.small_webrtc_transport.SmallWebRTCTransport
import ai.pipecat.client.types.ActionDescription
import ai.pipecat.client.types.Participant
import ai.pipecat.client.types.PipecatMetrics
import ai.pipecat.client.types.ServiceConfig
import ai.pipecat.client.types.Tracks
import ai.pipecat.client.types.Transcript
import ai.pipecat.client.types.TransportState
import ai.pipecat.small_webrtc_client.utils.Timestamp
import android.content.Context
import android.util.Log
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
@Immutable
data class Error(val message: String)
@Stable
class VoiceClientManager(private val context: Context) {
companion object {
private const val TAG = "VoiceClientManager"
}
private val client = mutableStateOf<RTVIClient?>(null)
val state = mutableStateOf<TransportState?>(null)
val errors = mutableStateListOf<Error>()
val actionDescriptions =
mutableStateOf<Result<List<ActionDescription>, RTVIError>?>(null)
val expiryTime = mutableStateOf<Timestamp?>(null)
val botReady = mutableStateOf(false)
val botIsTalking = mutableStateOf(false)
val userIsTalking = mutableStateOf(false)
val botAudioLevel = mutableFloatStateOf(0f)
val userAudioLevel = mutableFloatStateOf(0f)
val mic = mutableStateOf(false)
val camera = mutableStateOf(false)
val tracks = mutableStateOf<Tracks?>(null)
private fun <E> Future<E, RTVIError>.displayErrors() = withErrorCallback {
Log.e(TAG, "Future resolved with error: ${it.description}", it.exception)
errors.add(Error(it.description))
}
fun start(baseUrl: String) {
if (client.value != null) {
return
}
val options = RTVIClientOptions(
params = RTVIClientParams(baseUrl = null),
enableMic = true,
enableCam = true
)
state.value = TransportState.Disconnected
val callbacks = object : RTVIEventCallbacks() {
override fun onTransportStateChanged(state: TransportState) {
this@VoiceClientManager.state.value = state
}
override fun onBackendError(message: String) {
"Error from backend: $message".let {
Log.e(TAG, it)
errors.add(Error(it))
}
}
override fun onBotReady(version: String, config: List<ServiceConfig>) {
Log.i(TAG, "Bot ready. Version $version, config: $config")
botReady.value = true
client.value?.describeActions()?.withCallback {
actionDescriptions.value = it
}
}
override fun onPipecatMetrics(data: PipecatMetrics) {
Log.i(TAG, "Pipecat metrics: $data")
}
override fun onUserTranscript(data: Transcript) {
Log.i(TAG, "User transcript: $data")
}
override fun onBotTranscript(text: String) {
Log.i(TAG, "Bot transcript: $text")
}
override fun onBotStartedSpeaking() {
Log.i(TAG, "Bot started speaking")
botIsTalking.value = true
}
override fun onBotStoppedSpeaking() {
Log.i(TAG, "Bot stopped speaking")
botIsTalking.value = false
}
override fun onUserStartedSpeaking() {
Log.i(TAG, "User started speaking")
userIsTalking.value = true
}
override fun onUserStoppedSpeaking() {
Log.i(TAG, "User stopped speaking")
userIsTalking.value = false
}
override fun onTracksUpdated(tracks: Tracks) {
this@VoiceClientManager.tracks.value = tracks
}
override fun onInputsUpdated(camera: Boolean, mic: Boolean) {
this@VoiceClientManager.camera.value = camera
this@VoiceClientManager.mic.value = mic
}
override fun onConnected() {
expiryTime.value = client.value?.expiry?.let(Timestamp::ofEpochSecs)
}
override fun onDisconnected() {
expiryTime.value = null
actionDescriptions.value = null
botIsTalking.value = false
userIsTalking.value = false
state.value = null
actionDescriptions.value = null
botReady.value = false
tracks.value = null
client.value?.release()
client.value = null
}
override fun onUserAudioLevel(level: Float) {
userAudioLevel.floatValue = level
}
override fun onRemoteAudioLevel(level: Float, participant: Participant) {
botAudioLevel.floatValue = level
}
}
val client = RTVIClient(SmallWebRTCTransport.Factory(context, baseUrl), callbacks, options)
client.connect().displayErrors().withErrorCallback {
callbacks.onDisconnected()
}
this.client.value = client
}
fun enableCamera(enabled: Boolean) {
client.value?.enableCam(enabled)?.displayErrors()
}
fun enableMic(enabled: Boolean) {
client.value?.enableMic(enabled)?.displayErrors()
}
fun toggleCamera() = enableCamera(!camera.value)
fun toggleMic() = enableMic(!mic.value)
fun stop() {
client.value?.disconnect()?.displayErrors()
}
}

View File

@@ -0,0 +1,70 @@
package ai.pipecat.small_webrtc_client.ui
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Canvas
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.semantics.clearAndSetSemantics
@Composable
fun ListeningAnimation(
modifier: Modifier,
active: Boolean,
level: Float,
color: Color,
) {
val infiniteTransition = rememberInfiniteTransition("listeningAnimation")
val loopState by infiniteTransition.animateFloat(
initialValue = 0f,
targetValue = Math.PI.toFloat() * 2f,
animationSpec = infiniteRepeatable(tween(durationMillis = 1000, easing = LinearEasing)),
label = "listeningAnimationLoopState"
)
val activeFraction by animateFloatAsState(
if (active) {
Math.pow(level.toDouble(), 0.3).toFloat()
} else {
0f
}
)
Canvas(modifier.clearAndSetSemantics { }) {
val strokeWidthPx = size.width / 12
val lineCount = 5
for (i in 1..lineCount) {
val sine = Math.sin(loopState + 0.9 * i)
val fraction = activeFraction * ((sine + 1) / 2).toFloat()
val x = (size.width / (lineCount + 1)) * i
val yMax = size.height * 0.25f
val yMin = size.height * 0.5f
val y = yMin + (yMax - yMin) * fraction
val yEnd = size.height - y
this.drawLine(
start = Offset(x, y),
end = Offset(x, yEnd),
color = color,
strokeWidth = strokeWidthPx,
cap = StrokeCap.Round
)
}
}
}

View File

@@ -0,0 +1,122 @@
package ai.pipecat.small_webrtc_client.ui
import ai.pipecat.client.small_webrtc_transport.views.VideoView
import ai.pipecat.client.types.Tracks
import ai.pipecat.small_webrtc_client.ui.theme.Colors
import android.view.ViewGroup.LayoutParams
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.animateColorAsState
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.runtime.Composable
import androidx.compose.runtime.FloatState
import androidx.compose.runtime.State
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
@Composable
fun BotIndicator(
modifier: Modifier,
isReady: Boolean,
isTalking: State<Boolean>,
audioLevel: FloatState,
tracks: State<Tracks?>
) {
Box(
modifier = modifier.padding(15.dp),
contentAlignment = Alignment.Center
) {
val color by animateColorAsState(if (isTalking.value || !isReady) {
Color.Black
} else {
Colors.botIndicatorBackground
})
Box(
Modifier
.aspectRatio(1f)
.fillMaxSize()
.shadow(20.dp, CircleShape)
.border(12.dp, Color.White, CircleShape)
.border(1.dp, Colors.lightGrey, CircleShape)
.clip(CircleShape)
.background(color)
.padding(50.dp),
contentAlignment = Alignment.Center,
) {
AnimatedContent(
targetState = isReady
) { isReadyVal ->
if (isReadyVal) {
val botVideoTrack by remember { derivedStateOf {
tracks.value?.bot?.video
}}
if (botVideoTrack != null) {
AndroidView(
modifier = Modifier.fillMaxSize(),
factory = { context ->
VideoView(context).apply {
layoutParams = LayoutParams(
LayoutParams.MATCH_PARENT,
LayoutParams.MATCH_PARENT
)
}
},
update = {
it.track = botVideoTrack
}
)
} else {
ListeningAnimation(
modifier = Modifier.fillMaxSize(),
active = isTalking.value,
level = audioLevel.floatValue,
color = Color.White
)
}
} else {
CircularProgressIndicator(
modifier = Modifier.size(180.dp),
color = Color.White,
strokeWidth = 12.dp,
strokeCap = StrokeCap.Round,
trackColor = color
)
}
}
}
}
}
@Composable
@Preview
fun PreviewBotIndicator() {
BotIndicator(
modifier = Modifier,
isReady = false,
isTalking = remember { mutableStateOf(true) },
audioLevel = remember { mutableFloatStateOf(1.0f) },
tracks = remember { mutableStateOf(null) }
)
}

View File

@@ -0,0 +1,89 @@
package ai.pipecat.small_webrtc_client.ui
import ai.pipecat.small_webrtc_client.R
import ai.pipecat.small_webrtc_client.ui.theme.Colors
import androidx.annotation.DrawableRes
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@Composable
private fun FooterButton(
modifier: Modifier,
onClick: () -> Unit,
@DrawableRes icon: Int,
text: String,
foreground: Color,
background: Color,
border: Color,
) {
val shape = RoundedCornerShape(12.dp)
Row(
modifier
.border(1.dp, border, shape)
.clip(shape)
.background(background)
.clickable(onClick = onClick)
.padding(vertical = 10.dp, horizontal = 18.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center
) {
Icon(
modifier = Modifier.size(24.dp),
painter = painterResource(icon),
tint = foreground,
contentDescription = null
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = text,
fontSize = 14.sp,
fontWeight = FontWeight.W600,
color = foreground
)
}
}
@Composable
fun ColumnScope.InCallFooter(
onClickEnd: () -> Unit,
) {
Row(Modifier
.fillMaxWidth(0.5f)
.padding(15.dp)
.align(Alignment.CenterHorizontally)
) {
FooterButton(
modifier = Modifier.weight(1f),
onClick = onClickEnd,
icon = R.drawable.phone_hangup,
text = "End",
foreground = Color.White,
background = Colors.endButton,
border = Colors.endButton
)
}
}

View File

@@ -0,0 +1,49 @@
package ai.pipecat.small_webrtc_client.ui
import ai.pipecat.small_webrtc_client.utils.Timestamp
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.constraintlayout.compose.ConstraintLayout
@Composable
fun InCallHeader(
expiryTime: Timestamp?
) {
ConstraintLayout(
Modifier
.fillMaxWidth()
.padding(vertical = 15.dp)
) {
val refTimer = createRef()
AnimatedContent(
modifier = Modifier.constrainAs(refTimer) {
top.linkTo(parent.top)
bottom.linkTo(parent.bottom)
end.linkTo(parent.end)
},
targetState = expiryTime,
transitionSpec = { fadeIn() togetherWith fadeOut() }
) { expiryTimeVal ->
if (expiryTimeVal != null) {
Timer(expiryTime = expiryTimeVal, modifier = Modifier)
}
}
}
}
@Composable
@Preview
fun PreviewInCallHeader() {
InCallHeader(
Timestamp.now() + java.time.Duration.ofMinutes(3)
)
}

View File

@@ -0,0 +1,71 @@
package ai.pipecat.small_webrtc_client.ui
import ai.pipecat.small_webrtc_client.VoiceClientManager
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
@Composable
fun InCallLayout(voiceClientManager: VoiceClientManager) {
val localCam by remember { derivedStateOf { voiceClientManager.tracks.value?.local?.video } }
Column(Modifier.fillMaxSize()) {
InCallHeader(expiryTime = voiceClientManager.expiryTime.value)
Box(
modifier = Modifier
.weight(1f)
.fillMaxWidth(),
contentAlignment = Alignment.Center
) {
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(12.dp, Alignment.CenterVertically)
) {
BotIndicator(
modifier = Modifier,
isReady = voiceClientManager.botReady.value,
isTalking = voiceClientManager.botIsTalking,
audioLevel = voiceClientManager.botAudioLevel,
tracks = voiceClientManager.tracks
)
Row(
verticalAlignment = Alignment.CenterVertically
) {
UserMicButton(
onClick = voiceClientManager::toggleMic,
micEnabled = voiceClientManager.mic.value,
modifier = Modifier,
isTalking = voiceClientManager.userIsTalking,
audioLevel = voiceClientManager.userAudioLevel
)
UserCamButton(
onClick = voiceClientManager::toggleCamera,
camEnabled = voiceClientManager.camera.value,
camTrackId = localCam,
modifier = Modifier
)
}
}
}
InCallFooter(
onClickEnd = voiceClientManager::stop
)
}
}

View File

@@ -0,0 +1,99 @@
package ai.pipecat.small_webrtc_client.ui
import ai.pipecat.small_webrtc_client.ui.theme.Colors
import ai.pipecat.small_webrtc_client.ui.theme.TextStyles
import android.Manifest
import android.util.Log
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberPermissionState
@OptIn(ExperimentalPermissionsApi::class)
@Composable
fun PermissionScreen() {
val cameraPermission = rememberPermissionState(Manifest.permission.CAMERA)
val micPermission = rememberPermissionState(Manifest.permission.RECORD_AUDIO)
val requestPermissionLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestMultiplePermissions()
) { isGranted ->
Log.i("MainActivity", "Permissions granted: $isGranted")
}
if (!cameraPermission.status.isGranted || !micPermission.status.isGranted) {
Dialog(
onDismissRequest = {},
) {
val dialogShape = RoundedCornerShape(16.dp)
Column(
Modifier
.shadow(6.dp, dialogShape)
.border(2.dp, Colors.logoBorder, dialogShape)
.clip(dialogShape)
.background(Color.White)
.padding(28.dp)
) {
Text(
text = "Permissions",
fontSize = 24.sp,
fontWeight = FontWeight.W700,
style = TextStyles.base
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Please grant camera and mic permissions to continue",
fontSize = 18.sp,
fontWeight = FontWeight.W400,
style = TextStyles.base
)
Spacer(modifier = Modifier.height(36.dp))
Button(
modifier = Modifier.align(Alignment.End),
shape = RoundedCornerShape(12.dp),
onClick = {
requestPermissionLauncher.launch(
arrayOf(
Manifest.permission.CAMERA,
Manifest.permission.RECORD_AUDIO
)
)
}
) {
Text(
text = "Grant permissions",
fontSize = 16.sp,
fontWeight = FontWeight.W700,
style = TextStyles.base
)
}
}
}
}
}

View File

@@ -0,0 +1,72 @@
package ai.pipecat.small_webrtc_client.ui
import ai.pipecat.small_webrtc_client.R
import ai.pipecat.small_webrtc_client.ui.theme.Colors
import ai.pipecat.small_webrtc_client.utils.Timestamp
import ai.pipecat.small_webrtc_client.utils.formatTimer
import ai.pipecat.small_webrtc_client.utils.rtcStateSecs
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import java.time.Duration
@Composable
fun Timer(
expiryTime: Timestamp,
modifier: Modifier,
) {
val now by rtcStateSecs()
val shape = RoundedCornerShape(
topStart = 12.dp,
bottomStart = 12.dp,
)
Row(
modifier = modifier
.widthIn(min = 100.dp)
.clip(shape)
.background(Colors.lightGrey)
.padding(top = 12.dp, bottom = 12.dp, start = 12.dp, end = 16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
painter = painterResource(id = R.drawable.timer_outline),
contentDescription = null,
modifier = Modifier.size(20.dp),
tint = Colors.expiryTimerForeground
)
Spacer(Modifier.width(8.dp))
Text(
text = formatTimer(duration = expiryTime - now),
fontSize = 16.sp,
fontWeight = FontWeight.W600,
color = Colors.expiryTimerForeground
)
}
}
@Composable
@Preview
fun PreviewExpiryTimer() {
Timer(Timestamp.now() + Duration.ofMinutes(5), Modifier)
}

View File

@@ -0,0 +1,110 @@
package ai.pipecat.small_webrtc_client.ui
import ai.pipecat.client.small_webrtc_transport.views.VideoView
import ai.pipecat.client.types.MediaTrackId
import ai.pipecat.small_webrtc_client.R
import ai.pipecat.small_webrtc_client.ui.theme.Colors
import androidx.compose.animation.animateColorAsState
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Icon
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
@Composable
fun UserCamButton(
onClick: () -> Unit,
camEnabled: Boolean,
camTrackId: MediaTrackId?,
modifier: Modifier,
) {
Box(
modifier = modifier.padding(15.dp).size(96.dp),
contentAlignment = Alignment.Center
) {
val color by animateColorAsState(
if (camEnabled) {
Colors.unmutedMicBackground
} else {
Colors.mutedMicBackground
}
)
Box(
Modifier
.fillMaxSize()
.shadow(3.dp, CircleShape)
.border(6.dp, Color.White, CircleShape)
.border(1.dp, Colors.lightGrey, CircleShape)
.clip(CircleShape)
.background(color)
.clickable(onClick = onClick),
contentAlignment = Alignment.Center,
) {
if (camTrackId != null) {
AndroidView(
factory = { context ->
VideoView(context)
},
update = { view ->
view.track = camTrackId
}
)
} else {
Icon(
modifier = Modifier.size(30.dp),
painter = painterResource(
if (camEnabled) {
R.drawable.video
} else {
R.drawable.video_off
}
),
tint = Color.White,
contentDescription = if (camEnabled) {
"Disable camera"
} else {
"Enable camera"
},
)
}
}
}
}
@Composable
@Preview
fun PreviewUserCamButton() {
UserCamButton(
onClick = {},
camTrackId = null,
camEnabled = true,
modifier = Modifier,
)
}
@Composable
@Preview
fun PreviewUserCamButtonMuted() {
UserCamButton(
onClick = {},
camTrackId = null,
camEnabled = false,
modifier = Modifier,
)
}

View File

@@ -0,0 +1,114 @@
package ai.pipecat.small_webrtc_client.ui
import ai.pipecat.small_webrtc_client.R
import ai.pipecat.small_webrtc_client.ui.theme.Colors
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Icon
import androidx.compose.runtime.Composable
import androidx.compose.runtime.FloatState
import androidx.compose.runtime.State
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
@Composable
fun UserMicButton(
onClick: () -> Unit,
micEnabled: Boolean,
modifier: Modifier,
isTalking: State<Boolean>,
audioLevel: FloatState,
) {
Box(
modifier = modifier.padding(15.dp),
contentAlignment = Alignment.Center
) {
val borderThickness by animateDpAsState(
if (isTalking.value) {
(24.dp * Math.pow(audioLevel.floatValue.toDouble(), 0.3).toFloat()) + 3.dp
} else {
6.dp
}
)
val color by animateColorAsState(
if (!micEnabled) {
Colors.mutedMicBackground
} else if (isTalking.value) {
Color.Black
} else {
Colors.unmutedMicBackground
}
)
Box(
Modifier
.shadow(3.dp, CircleShape)
.border(borderThickness, Color.White, CircleShape)
.border(1.dp, Colors.lightGrey, CircleShape)
.clip(CircleShape)
.background(color)
.clickable(onClick = onClick)
.padding(36.dp),
contentAlignment = Alignment.Center,
) {
Icon(
modifier = Modifier.size(48.dp),
painter = painterResource(
if (micEnabled) {
R.drawable.microphone
} else {
R.drawable.microphone_off
}
),
tint = Color.White,
contentDescription = if (micEnabled) {
"Mute microphone"
} else {
"Unmute microphone"
},
)
}
}
}
@Composable
@Preview
fun PreviewUserMicButton() {
UserMicButton(
onClick = {},
micEnabled = true,
modifier = Modifier,
isTalking = remember { mutableStateOf(false) },
audioLevel = remember { mutableFloatStateOf(1.0f) }
)
}
@Composable
@Preview
fun PreviewUserMicButtonMuted() {
UserMicButton(
onClick = {},
micEnabled = false,
modifier = Modifier,
isTalking = remember { mutableStateOf(false) },
audioLevel = remember { mutableFloatStateOf(1.0f) }
)
}

View File

@@ -0,0 +1,22 @@
package ai.pipecat.small_webrtc_client.ui.theme
import androidx.compose.ui.graphics.Color
object Colors {
val buttonNormal = Color(0xFF374151)
val buttonWarning = Color(0xFFE53935)
val buttonSection = Color(0xFFDFF1FF)
val activityBackground = Color(0xFFF9FAFB)
val mainSurfaceBackground = Color.White
val lightGrey = Color(0x7FE5E7EB)
val expiryTimerForeground = Color.Black
val logoBorder = Color(0xFFE2E8F0)
val endButton = Color(0xFF0F172A)
val textFieldBorder = Color(0xFFDFE6EF)
val botIndicatorBackground = Color(0xFF374151)
val mutedMicBackground = Color(0xFFF04A4A)
val unmutedMicBackground = Color(0xFF616978)
}

View File

@@ -0,0 +1,36 @@
package ai.pipecat.small_webrtc_client.ui.theme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
private val LightColorScheme = lightColorScheme(
primary = Colors.buttonNormal,
secondary = Colors.buttonWarning,
background = Colors.activityBackground,
surface = Colors.mainSurfaceBackground
)
@Composable
fun RTVIClientTheme(
content: @Composable () -> Unit
) {
val colorScheme = LightColorScheme
MaterialTheme(
colorScheme = colorScheme,
typography = Typography,
content = content
)
}
@Composable
fun textFieldColors() = TextFieldDefaults.colors().copy(
unfocusedContainerColor = Colors.activityBackground,
focusedContainerColor = Colors.activityBackground,
focusedIndicatorColor = Color.Transparent,
disabledIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
)

View File

@@ -0,0 +1,40 @@
package ai.pipecat.small_webrtc_client.ui.theme
import ai.pipecat.small_webrtc_client.R
import androidx.compose.material3.Typography
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
object TextStyles {
val base = TextStyle(fontFamily = FontFamily(Font(R.font.inter)))
}
// Set of Material typography styles to start with
val Typography = Typography(
bodyLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 16.sp,
lineHeight = 24.sp,
letterSpacing = 0.5.sp
)
/* Other default text styles to override
titleLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 22.sp,
lineHeight = 28.sp,
letterSpacing = 0.sp
),
labelSmall = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Medium,
fontSize = 11.sp,
lineHeight = 16.sp,
letterSpacing = 0.5.sp
)
*/
)

View File

@@ -0,0 +1,21 @@
package ai.pipecat.small_webrtc_client.utils
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.flow
private val rtcFlowSecs = flow {
while(true) {
val now = Timestamp.now().toEpochMilli()
val rounded = ((now + 500) / 1000) * 1000
emit(Timestamp.ofEpochMilli(rounded))
val target = rounded + 1000
delay(target - now)
}
}
@Composable
fun rtcStateSecs() = rtcFlowSecs.collectAsState(initial = Timestamp.now())

View File

@@ -0,0 +1,64 @@
package ai.pipecat.small_webrtc_client.utils
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import java.time.Duration
import java.time.Instant
import java.time.format.DateTimeFormatter
import java.util.Date
// Wrapper for Compose stability
@Immutable
@JvmInline
value class Timestamp(
val value: Instant
) : Comparable<Timestamp> {
val isInPast: Boolean
get() = value < Instant.now()
val isInFuture: Boolean
get() = value > Instant.now()
fun toEpochMilli() = value.toEpochMilli()
operator fun plus(duration: Duration) = Timestamp(value + duration)
operator fun minus(duration: Duration) = Timestamp(value - duration)
operator fun minus(rhs: Timestamp) = Duration.between(rhs.value, value)
override operator fun compareTo(other: Timestamp) = value.compareTo(other.value)
fun toISOString(): String = DateTimeFormatter.ISO_INSTANT.format(value)
override fun toString() = toISOString()
companion object {
fun now() = Timestamp(Instant.now())
fun ofEpochMilli(value: Long) = Timestamp(Instant.ofEpochMilli(value))
fun ofEpochSecs(value: Long) = ofEpochMilli(value * 1000)
fun parse(value: CharSequence) = Timestamp(Instant.parse(value))
fun from(date: Date) = Timestamp(date.toInstant())
}
}
@Composable
fun formatTimer(duration: Duration): String {
if (duration.seconds < 0) {
return "0s"
}
val mins = duration.seconds / 60
val secs = duration.seconds % 60
return if (mins == 0L) {
"${secs}s"
} else {
"${mins}m ${secs}s"
}
}

View File

@@ -0,0 +1,170 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#3DDC84"
android:pathData="M0,0h108v108h-108z" />
<path
android:fillColor="#00000000"
android:pathData="M9,0L9,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,0L19,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,0L29,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,0L39,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,0L49,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,0L59,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,0L69,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,0L79,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M89,0L89,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M99,0L99,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,9L108,9"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,19L108,19"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,29L108,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,39L108,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,49L108,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,59L108,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,69L108,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,79L108,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,89L108,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,99L108,99"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,29L89,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,39L89,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,49L89,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,59L89,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,69L89,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,79L89,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,19L29,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,19L39,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,19L49,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,19L59,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,19L69,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,19L79,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
</vector>

View File

@@ -0,0 +1,30 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
<aapt:attr name="android:fillColor">
<gradient
android:endX="85.84757"
android:endY="92.4963"
android:startX="42.9492"
android:startY="49.59793"
android:type="linear">
<item
android:color="#44000000"
android:offset="0.0" />
<item
android:color="#00000000"
android:offset="1.0" />
</gradient>
</aapt:attr>
</path>
<path
android:fillColor="#FFFFFF"
android:fillType="nonZero"
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
android:strokeWidth="1"
android:strokeColor="#00000000" />
</vector>

View File

@@ -0,0 +1 @@
<!-- drawable/microphone.xml --><vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:width="24dp" android:viewportWidth="24" android:viewportHeight="24"><path android:fillColor="#000000" android:pathData="M12,2A3,3 0 0,1 15,5V11A3,3 0 0,1 12,14A3,3 0 0,1 9,11V5A3,3 0 0,1 12,2M19,11C19,14.53 16.39,17.44 13,17.93V21H11V17.93C7.61,17.44 5,14.53 5,11H7A5,5 0 0,0 12,16A5,5 0 0,0 17,11H19Z" /></vector>

View File

@@ -0,0 +1 @@
<!-- drawable/microphone_off.xml --><vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:width="24dp" android:viewportWidth="24" android:viewportHeight="24"><path android:fillColor="#000000" android:pathData="M19,11C19,12.19 18.66,13.3 18.1,14.28L16.87,13.05C17.14,12.43 17.3,11.74 17.3,11H19M15,11.16L9,5.18V5A3,3 0 0,1 12,2A3,3 0 0,1 15,5V11L15,11.16M4.27,3L21,19.73L19.73,21L15.54,16.81C14.77,17.27 13.91,17.58 13,17.72V21H11V17.72C7.72,17.23 5,14.41 5,11H6.7C6.7,14 9.24,16.1 12,16.1C12.81,16.1 13.6,15.91 14.31,15.58L12.65,13.92L12,14A3,3 0 0,1 9,11V10.28L3,4.27L4.27,3Z" /></vector>

View File

@@ -0,0 +1 @@
<!-- drawable/phone_hangup.xml --><vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:width="24dp" android:viewportWidth="24" android:viewportHeight="24"><path android:fillColor="#000000" android:pathData="M12,9C10.4,9 8.85,9.25 7.4,9.72V12.82C7.4,13.22 7.17,13.56 6.84,13.72C5.86,14.21 4.97,14.84 4.17,15.57C4,15.75 3.75,15.86 3.5,15.86C3.2,15.86 2.95,15.74 2.77,15.56L0.29,13.08C0.11,12.9 0,12.65 0,12.38C0,12.1 0.11,11.85 0.29,11.67C3.34,8.77 7.46,7 12,7C16.54,7 20.66,8.77 23.71,11.67C23.89,11.85 24,12.1 24,12.38C24,12.65 23.89,12.9 23.71,13.08L21.23,15.56C21.05,15.74 20.8,15.86 20.5,15.86C20.25,15.86 20,15.75 19.82,15.57C19.03,14.84 18.14,14.21 17.16,13.72C16.83,13.56 16.6,13.22 16.6,12.82V9.72C15.15,9.25 13.6,9 12,9Z" /></vector>

View File

@@ -0,0 +1 @@
<!-- drawable/timer_outline.xml --><vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:width="24dp" android:viewportWidth="24" android:viewportHeight="24"><path android:fillColor="#000000" android:pathData="M12,20A7,7 0 0,1 5,13A7,7 0 0,1 12,6A7,7 0 0,1 19,13A7,7 0 0,1 12,20M19.03,7.39L20.45,5.97C20,5.46 19.55,5 19.04,4.56L17.62,6C16.07,4.74 14.12,4 12,4A9,9 0 0,0 3,13A9,9 0 0,0 12,22C17,22 21,17.97 21,13C21,10.88 20.26,8.93 19.03,7.39M11,14H13V8H11M15,1H9V3H15V1Z" /></vector>

View File

@@ -0,0 +1 @@
<!-- drawable/video.xml --><vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:width="24dp" android:viewportWidth="24" android:viewportHeight="24"><path android:fillColor="#000000" android:pathData="M17,10.5V7A1,1 0 0,0 16,6H4A1,1 0 0,0 3,7V17A1,1 0 0,0 4,18H16A1,1 0 0,0 17,17V13.5L21,17.5V6.5L17,10.5Z" /></vector>

View File

@@ -0,0 +1 @@
<!-- drawable/video_off.xml --><vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:width="24dp" android:viewportWidth="24" android:viewportHeight="24"><path android:fillColor="#000000" android:pathData="M3.27,2L2,3.27L4.73,6H4A1,1 0 0,0 3,7V17A1,1 0 0,0 4,18H16C16.2,18 16.39,17.92 16.54,17.82L19.73,21L21,19.73M21,6.5L17,10.5V7A1,1 0 0,0 16,6H9.82L21,17.18V6.5Z" /></vector>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

View File

@@ -0,0 +1,3 @@
<resources>
<string name="app_name">Pipecat Small WebRTC Client</string>
</resources>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.RTVIClient" parent="android:Theme.Material.Light.NoActionBar" />
</resources>

View File

@@ -9,6 +9,7 @@
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<application
android:name=".RTVIApplication"