diff --git a/.github/workflows/android.yaml b/.github/workflows/android.yaml new file mode 100644 index 000000000..28e0b2bb9 --- /dev/null +++ b/.github/workflows/android.yaml @@ -0,0 +1,44 @@ +name: android + +on: + push: + branches: + - main + pull_request: + branches: + - "**" + workflow_dispatch: + inputs: + sdk_git_ref: + type: string + description: "Which git ref of the app to build" + +concurrency: + group: build-android-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + sdk: + name: "Simple chatbot demo" + runs-on: ubuntu-latest + steps: + - name: Checkout repo + uses: actions/checkout@v4 + with: + ref: ${{ github.event.inputs.sdk_git_ref || github.ref }} + + - name: "Install Java" + uses: actions/setup-java@v4 + with: + distribution: 'temurin' + java-version: '17' + + - name: Build demo app + working-directory: examples/simple-chatbot/examples/android + run: ./gradlew :simple-chatbot-client:assembleDebug + + - name: Upload demo APK + uses: actions/upload-artifact@v4 + with: + name: Simple Chatbot Android Client + path: examples/simple-chatbot/examples/android/simple-chatbot-client/build/outputs/apk/debug/simple-chatbot-client-debug.apk diff --git a/examples/simple-chatbot/examples/android/.gitignore b/examples/simple-chatbot/examples/android/.gitignore new file mode 100644 index 000000000..10cfdbfaf --- /dev/null +++ b/examples/simple-chatbot/examples/android/.gitignore @@ -0,0 +1,10 @@ +*.iml +.gradle +/local.properties +/.idea +.DS_Store +/build +/captures +.externalNativeBuild +.cxx +local.properties diff --git a/examples/simple-chatbot/examples/android/LICENSE b/examples/simple-chatbot/examples/android/LICENSE new file mode 100644 index 000000000..cd6220df2 --- /dev/null +++ b/examples/simple-chatbot/examples/android/LICENSE @@ -0,0 +1,24 @@ +BSD 2-Clause License + +Copyright (c) 2024, 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. diff --git a/examples/simple-chatbot/examples/android/README.md b/examples/simple-chatbot/examples/android/README.md new file mode 100644 index 000000000..69eba8b64 --- /dev/null +++ b/examples/simple-chatbot/examples/android/README.md @@ -0,0 +1,20 @@ +# Pipecat Simple Chatbot Client for Android + +Demo app which connects to the `simple-chatbot` backend over RTVI. + +## Screenshot + +screenshot + +## How to run + +```bash +./gradlew runDebug +``` + +Ensure that the `simple-chatbot` server is running as described in the parent README. + +For a full walkthrough, describing how to set up the backend and forward the connection +to your Android device, please see the following blog post: + +https://www.daily.co/blog/build-a-voice-agent-for-android-with-gemini-multimodal-live/ diff --git a/examples/simple-chatbot/examples/android/build.gradle.kts b/examples/simple-chatbot/examples/android/build.gradle.kts new file mode 100644 index 000000000..861303b1f --- /dev/null +++ b/examples/simple-chatbot/examples/android/build.gradle.kts @@ -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 +} diff --git a/examples/simple-chatbot/examples/android/files/screenshot.jpg b/examples/simple-chatbot/examples/android/files/screenshot.jpg new file mode 100644 index 000000000..c234e5b0b Binary files /dev/null and b/examples/simple-chatbot/examples/android/files/screenshot.jpg differ diff --git a/examples/simple-chatbot/examples/android/gradle.properties b/examples/simple-chatbot/examples/android/gradle.properties new file mode 100644 index 000000000..20e2a0152 --- /dev/null +++ b/examples/simple-chatbot/examples/android/gradle.properties @@ -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 \ No newline at end of file diff --git a/examples/simple-chatbot/examples/android/gradle/libs.versions.toml b/examples/simple-chatbot/examples/android/gradle/libs.versions.toml new file mode 100644 index 000000000..6e945b370 --- /dev/null +++ b/examples/simple-chatbot/examples/android/gradle/libs.versions.toml @@ -0,0 +1,34 @@ +[versions] +accompanistPermissions = "0.34.0" +agp = "8.5.2" +constraintlayoutCompose = "1.0.1" +pipecatClientDaily = "0.3.2" +kotlin = "2.0.20" +coreKtx = "1.13.1" +lifecycleRuntimeKtx = "2.8.6" +activityCompose = "1.9.2" +composeBom = "2024.09.03" +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-daily = { module = "ai.pipecat:daily-transport", version.ref = "pipecatClientDaily" } +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" } diff --git a/examples/simple-chatbot/examples/android/gradle/wrapper/gradle-wrapper.jar b/examples/simple-chatbot/examples/android/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 000000000..e708b1c02 Binary files /dev/null and b/examples/simple-chatbot/examples/android/gradle/wrapper/gradle-wrapper.jar differ diff --git a/examples/simple-chatbot/examples/android/gradle/wrapper/gradle-wrapper.properties b/examples/simple-chatbot/examples/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 000000000..35c800ac7 --- /dev/null +++ b/examples/simple-chatbot/examples/android/gradle/wrapper/gradle-wrapper.properties @@ -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.7-bin.zip +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/examples/simple-chatbot/examples/android/gradlew b/examples/simple-chatbot/examples/android/gradlew new file mode 100755 index 000000000..4f906e0c8 --- /dev/null +++ b/examples/simple-chatbot/examples/android/gradlew @@ -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" "$@" diff --git a/examples/simple-chatbot/examples/android/gradlew.bat b/examples/simple-chatbot/examples/android/gradlew.bat new file mode 100644 index 000000000..107acd32c --- /dev/null +++ b/examples/simple-chatbot/examples/android/gradlew.bat @@ -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 diff --git a/examples/simple-chatbot/examples/android/settings.gradle.kts b/examples/simple-chatbot/examples/android/settings.gradle.kts new file mode 100644 index 000000000..03bf7ef96 --- /dev/null +++ b/examples/simple-chatbot/examples/android/settings.gradle.kts @@ -0,0 +1,23 @@ +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() + } +} + +rootProject.name = "Pipecat Simple Chatbot Client" +include(":simple-chatbot-client") diff --git a/examples/simple-chatbot/examples/android/simple-chatbot-client/.gitignore b/examples/simple-chatbot/examples/android/simple-chatbot-client/.gitignore new file mode 100644 index 000000000..42afabfd2 --- /dev/null +++ b/examples/simple-chatbot/examples/android/simple-chatbot-client/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/examples/simple-chatbot/examples/android/simple-chatbot-client/build.gradle.kts b/examples/simple-chatbot/examples/android/simple-chatbot-client/build.gradle.kts new file mode 100644 index 000000000..ac22dae0b --- /dev/null +++ b/examples/simple-chatbot/examples/android/simple-chatbot-client/build.gradle.kts @@ -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.simple_chatbot_client" + compileSdk = 34 + + defaultConfig { + applicationId = "ai.pipecat.simple_chatbot_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.daily) + 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) +} diff --git a/examples/simple-chatbot/examples/android/simple-chatbot-client/proguard-rules.pro b/examples/simple-chatbot/examples/android/simple-chatbot-client/proguard-rules.pro new file mode 100644 index 000000000..481bb4348 --- /dev/null +++ b/examples/simple-chatbot/examples/android/simple-chatbot-client/proguard-rules.pro @@ -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 \ No newline at end of file diff --git a/examples/simple-chatbot/examples/android/simple-chatbot-client/src/main/AndroidManifest.xml b/examples/simple-chatbot/examples/android/simple-chatbot-client/src/main/AndroidManifest.xml new file mode 100644 index 000000000..33b74741c --- /dev/null +++ b/examples/simple-chatbot/examples/android/simple-chatbot-client/src/main/AndroidManifest.xml @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/examples/simple-chatbot/examples/android/simple-chatbot-client/src/main/java/ai/pipecat/simple_chatbot_client/MainActivity.kt b/examples/simple-chatbot/examples/android/simple-chatbot-client/src/main/java/ai/pipecat/simple_chatbot_client/MainActivity.kt new file mode 100644 index 000000000..8c4292692 --- /dev/null +++ b/examples/simple-chatbot/examples/android/simple-chatbot-client/src/main/java/ai/pipecat/simple_chatbot_client/MainActivity.kt @@ -0,0 +1,257 @@ +package ai.pipecat.simple_chatbot_client + +import ai.pipecat.simple_chatbot_client.ui.InCallLayout +import ai.pipecat.simple_chatbot_client.ui.PermissionScreen +import ai.pipecat.simple_chatbot_client.ui.theme.Colors +import ai.pipecat.simple_chatbot_client.ui.theme.RTVIClientTheme +import ai.pipecat.simple_chatbot_client.ui.theme.TextStyles +import ai.pipecat.simple_chatbot_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 an RTVI 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 + ) + } +} diff --git a/examples/simple-chatbot/examples/android/simple-chatbot-client/src/main/java/ai/pipecat/simple_chatbot_client/Preferences.kt b/examples/simple-chatbot/examples/android/simple-chatbot-client/src/main/java/ai/pipecat/simple_chatbot_client/Preferences.kt new file mode 100644 index 000000000..cbde9fff6 --- /dev/null +++ b/examples/simple-chatbot/examples/android/simple-chatbot-client/src/main/java/ai/pipecat/simple_chatbot_client/Preferences.kt @@ -0,0 +1,76 @@ +package ai.pipecat.simple_chatbot_client + +import android.content.Context +import android.content.SharedPreferences +import androidx.compose.runtime.mutableStateOf +import kotlinx.serialization.KSerializer +import kotlinx.serialization.Serializable +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(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(private val key: String, private var serializer: KSerializer): BasePref { + private val cachedValue = mutableStateOf(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) +} \ No newline at end of file diff --git a/examples/simple-chatbot/examples/android/simple-chatbot-client/src/main/java/ai/pipecat/simple_chatbot_client/RTVIApplication.kt b/examples/simple-chatbot/examples/android/simple-chatbot-client/src/main/java/ai/pipecat/simple_chatbot_client/RTVIApplication.kt new file mode 100644 index 000000000..de07ab327 --- /dev/null +++ b/examples/simple-chatbot/examples/android/simple-chatbot-client/src/main/java/ai/pipecat/simple_chatbot_client/RTVIApplication.kt @@ -0,0 +1,10 @@ +package ai.pipecat.simple_chatbot_client + +import android.app.Application + +class RTVIApplication : Application() { + override fun onCreate() { + super.onCreate() + Preferences.initAppStart(this) + } +} \ No newline at end of file diff --git a/examples/simple-chatbot/examples/android/simple-chatbot-client/src/main/java/ai/pipecat/simple_chatbot_client/VoiceClientManager.kt b/examples/simple-chatbot/examples/android/simple-chatbot-client/src/main/java/ai/pipecat/simple_chatbot_client/VoiceClientManager.kt new file mode 100644 index 000000000..e6d22baad --- /dev/null +++ b/examples/simple-chatbot/examples/android/simple-chatbot-client/src/main/java/ai/pipecat/simple_chatbot_client/VoiceClientManager.kt @@ -0,0 +1,196 @@ +package ai.pipecat.simple_chatbot_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.daily.DailyTransport +import ai.pipecat.client.result.Future +import ai.pipecat.client.result.RTVIError +import ai.pipecat.client.result.Result +import ai.pipecat.client.types.ActionDescription +import ai.pipecat.client.types.Option +import ai.pipecat.client.types.Participant +import ai.pipecat.client.types.PipecatMetrics +import ai.pipecat.client.types.RTVIURLEndpoints +import ai.pipecat.client.types.ServiceConfig +import ai.pipecat.client.types.ServiceRegistration +import ai.pipecat.client.types.Tracks +import ai.pipecat.client.types.Transcript +import ai.pipecat.client.types.TransportState +import ai.pipecat.client.types.Value +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 +import ai.pipecat.simple_chatbot_client.utils.Timestamp + +@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(null) + + val state = mutableStateOf(null) + + val errors = mutableStateListOf() + + val actionDescriptions = + mutableStateOf, RTVIError>?>(null) + + val expiryTime = mutableStateOf(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(null) + + private fun Future.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 = baseUrl, + endpoints = RTVIURLEndpoints(), + ) + ) + + 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) { + + 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(DailyTransport.Factory(context), 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() + } +} \ No newline at end of file diff --git a/examples/simple-chatbot/examples/android/simple-chatbot-client/src/main/java/ai/pipecat/simple_chatbot_client/ui/AudioIndicator.kt b/examples/simple-chatbot/examples/android/simple-chatbot-client/src/main/java/ai/pipecat/simple_chatbot_client/ui/AudioIndicator.kt new file mode 100644 index 000000000..6667f1559 --- /dev/null +++ b/examples/simple-chatbot/examples/android/simple-chatbot-client/src/main/java/ai/pipecat/simple_chatbot_client/ui/AudioIndicator.kt @@ -0,0 +1,70 @@ +package ai.pipecat.simple_chatbot_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 + ) + } + } +} diff --git a/examples/simple-chatbot/examples/android/simple-chatbot-client/src/main/java/ai/pipecat/simple_chatbot_client/ui/BotIndicator.kt b/examples/simple-chatbot/examples/android/simple-chatbot-client/src/main/java/ai/pipecat/simple_chatbot_client/ui/BotIndicator.kt new file mode 100644 index 000000000..10351bac7 --- /dev/null +++ b/examples/simple-chatbot/examples/android/simple-chatbot-client/src/main/java/ai/pipecat/simple_chatbot_client/ui/BotIndicator.kt @@ -0,0 +1,93 @@ +package ai.pipecat.simple_chatbot_client.ui + +import ai.pipecat.simple_chatbot_client.ui.theme.Colors +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.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 + +@Composable +fun BotIndicator( + modifier: Modifier, + isReady: Boolean, + isTalking: State, + audioLevel: FloatState, +) { + 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) { + 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) } + ) +} \ No newline at end of file diff --git a/examples/simple-chatbot/examples/android/simple-chatbot-client/src/main/java/ai/pipecat/simple_chatbot_client/ui/InCallFooter.kt b/examples/simple-chatbot/examples/android/simple-chatbot-client/src/main/java/ai/pipecat/simple_chatbot_client/ui/InCallFooter.kt new file mode 100644 index 000000000..31b50dc17 --- /dev/null +++ b/examples/simple-chatbot/examples/android/simple-chatbot-client/src/main/java/ai/pipecat/simple_chatbot_client/ui/InCallFooter.kt @@ -0,0 +1,89 @@ +package ai.pipecat.simple_chatbot_client.ui + +import ai.pipecat.simple_chatbot_client.R +import ai.pipecat.simple_chatbot_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 + ) + } +} diff --git a/examples/simple-chatbot/examples/android/simple-chatbot-client/src/main/java/ai/pipecat/simple_chatbot_client/ui/InCallHeader.kt b/examples/simple-chatbot/examples/android/simple-chatbot-client/src/main/java/ai/pipecat/simple_chatbot_client/ui/InCallHeader.kt new file mode 100644 index 000000000..68a5c1e8f --- /dev/null +++ b/examples/simple-chatbot/examples/android/simple-chatbot-client/src/main/java/ai/pipecat/simple_chatbot_client/ui/InCallHeader.kt @@ -0,0 +1,49 @@ +package ai.pipecat.simple_chatbot_client.ui + +import ai.pipecat.simple_chatbot_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) + ) +} \ No newline at end of file diff --git a/examples/simple-chatbot/examples/android/simple-chatbot-client/src/main/java/ai/pipecat/simple_chatbot_client/ui/InCallLayout.kt b/examples/simple-chatbot/examples/android/simple-chatbot-client/src/main/java/ai/pipecat/simple_chatbot_client/ui/InCallLayout.kt new file mode 100644 index 000000000..7d450d6ab --- /dev/null +++ b/examples/simple-chatbot/examples/android/simple-chatbot-client/src/main/java/ai/pipecat/simple_chatbot_client/ui/InCallLayout.kt @@ -0,0 +1,70 @@ +package ai.pipecat.simple_chatbot_client.ui + +import ai.pipecat.simple_chatbot_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 + ) + + 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 + ) + } +} diff --git a/examples/simple-chatbot/examples/android/simple-chatbot-client/src/main/java/ai/pipecat/simple_chatbot_client/ui/PermissionScreen.kt b/examples/simple-chatbot/examples/android/simple-chatbot-client/src/main/java/ai/pipecat/simple_chatbot_client/ui/PermissionScreen.kt new file mode 100644 index 000000000..686586709 --- /dev/null +++ b/examples/simple-chatbot/examples/android/simple-chatbot-client/src/main/java/ai/pipecat/simple_chatbot_client/ui/PermissionScreen.kt @@ -0,0 +1,99 @@ +package ai.pipecat.simple_chatbot_client.ui + +import ai.pipecat.simple_chatbot_client.ui.theme.Colors +import ai.pipecat.simple_chatbot_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 + ) + } + } + } + } +} \ No newline at end of file diff --git a/examples/simple-chatbot/examples/android/simple-chatbot-client/src/main/java/ai/pipecat/simple_chatbot_client/ui/Timer.kt b/examples/simple-chatbot/examples/android/simple-chatbot-client/src/main/java/ai/pipecat/simple_chatbot_client/ui/Timer.kt new file mode 100644 index 000000000..88bb5fb12 --- /dev/null +++ b/examples/simple-chatbot/examples/android/simple-chatbot-client/src/main/java/ai/pipecat/simple_chatbot_client/ui/Timer.kt @@ -0,0 +1,72 @@ +package ai.pipecat.simple_chatbot_client.ui + +import ai.pipecat.simple_chatbot_client.R +import ai.pipecat.simple_chatbot_client.ui.theme.Colors +import ai.pipecat.simple_chatbot_client.utils.Timestamp +import ai.pipecat.simple_chatbot_client.utils.formatTimer +import ai.pipecat.simple_chatbot_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) +} \ No newline at end of file diff --git a/examples/simple-chatbot/examples/android/simple-chatbot-client/src/main/java/ai/pipecat/simple_chatbot_client/ui/UserCamButton.kt b/examples/simple-chatbot/examples/android/simple-chatbot-client/src/main/java/ai/pipecat/simple_chatbot_client/ui/UserCamButton.kt new file mode 100644 index 000000000..2529eea58 --- /dev/null +++ b/examples/simple-chatbot/examples/android/simple-chatbot-client/src/main/java/ai/pipecat/simple_chatbot_client/ui/UserCamButton.kt @@ -0,0 +1,111 @@ +package ai.pipecat.simple_chatbot_client.ui + +import ai.pipecat.simple_chatbot_client.R +import ai.pipecat.simple_chatbot_client.ui.theme.Colors +import ai.pipecat.client.daily.VoiceClientVideoView +import ai.pipecat.client.types.MediaTrackId +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.State +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 -> + VoiceClientVideoView(context) + }, + update = { view -> + view.voiceClientTrack = 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, + ) +} \ No newline at end of file diff --git a/examples/simple-chatbot/examples/android/simple-chatbot-client/src/main/java/ai/pipecat/simple_chatbot_client/ui/UserMicButton.kt b/examples/simple-chatbot/examples/android/simple-chatbot-client/src/main/java/ai/pipecat/simple_chatbot_client/ui/UserMicButton.kt new file mode 100644 index 000000000..8c7eb7d5e --- /dev/null +++ b/examples/simple-chatbot/examples/android/simple-chatbot-client/src/main/java/ai/pipecat/simple_chatbot_client/ui/UserMicButton.kt @@ -0,0 +1,114 @@ +package ai.pipecat.simple_chatbot_client.ui + +import ai.pipecat.simple_chatbot_client.R +import ai.pipecat.simple_chatbot_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, + 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) } + ) +} \ No newline at end of file diff --git a/examples/simple-chatbot/examples/android/simple-chatbot-client/src/main/java/ai/pipecat/simple_chatbot_client/ui/theme/Color.kt b/examples/simple-chatbot/examples/android/simple-chatbot-client/src/main/java/ai/pipecat/simple_chatbot_client/ui/theme/Color.kt new file mode 100644 index 000000000..5ac1ee190 --- /dev/null +++ b/examples/simple-chatbot/examples/android/simple-chatbot-client/src/main/java/ai/pipecat/simple_chatbot_client/ui/theme/Color.kt @@ -0,0 +1,22 @@ +package ai.pipecat.simple_chatbot_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) +} \ No newline at end of file diff --git a/examples/simple-chatbot/examples/android/simple-chatbot-client/src/main/java/ai/pipecat/simple_chatbot_client/ui/theme/Theme.kt b/examples/simple-chatbot/examples/android/simple-chatbot-client/src/main/java/ai/pipecat/simple_chatbot_client/ui/theme/Theme.kt new file mode 100644 index 000000000..e6341638d --- /dev/null +++ b/examples/simple-chatbot/examples/android/simple-chatbot-client/src/main/java/ai/pipecat/simple_chatbot_client/ui/theme/Theme.kt @@ -0,0 +1,36 @@ +package ai.pipecat.simple_chatbot_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, +) \ No newline at end of file diff --git a/examples/simple-chatbot/examples/android/simple-chatbot-client/src/main/java/ai/pipecat/simple_chatbot_client/ui/theme/Type.kt b/examples/simple-chatbot/examples/android/simple-chatbot-client/src/main/java/ai/pipecat/simple_chatbot_client/ui/theme/Type.kt new file mode 100644 index 000000000..58085a30d --- /dev/null +++ b/examples/simple-chatbot/examples/android/simple-chatbot-client/src/main/java/ai/pipecat/simple_chatbot_client/ui/theme/Type.kt @@ -0,0 +1,40 @@ +package ai.pipecat.simple_chatbot_client.ui.theme + +import ai.pipecat.simple_chatbot_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 + ) + */ +) \ No newline at end of file diff --git a/examples/simple-chatbot/examples/android/simple-chatbot-client/src/main/java/ai/pipecat/simple_chatbot_client/utils/RealTimeClock.kt b/examples/simple-chatbot/examples/android/simple-chatbot-client/src/main/java/ai/pipecat/simple_chatbot_client/utils/RealTimeClock.kt new file mode 100644 index 000000000..b67f1605f --- /dev/null +++ b/examples/simple-chatbot/examples/android/simple-chatbot-client/src/main/java/ai/pipecat/simple_chatbot_client/utils/RealTimeClock.kt @@ -0,0 +1,21 @@ +package ai.pipecat.simple_chatbot_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()) \ No newline at end of file diff --git a/examples/simple-chatbot/examples/android/simple-chatbot-client/src/main/java/ai/pipecat/simple_chatbot_client/utils/TimeUtils.kt b/examples/simple-chatbot/examples/android/simple-chatbot-client/src/main/java/ai/pipecat/simple_chatbot_client/utils/TimeUtils.kt new file mode 100644 index 000000000..46841446b --- /dev/null +++ b/examples/simple-chatbot/examples/android/simple-chatbot-client/src/main/java/ai/pipecat/simple_chatbot_client/utils/TimeUtils.kt @@ -0,0 +1,64 @@ +package ai.pipecat.simple_chatbot_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 { + 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" + } +} diff --git a/examples/simple-chatbot/examples/android/simple-chatbot-client/src/main/res/drawable/chevron_down.xml b/examples/simple-chatbot/examples/android/simple-chatbot-client/src/main/res/drawable/chevron_down.xml new file mode 100644 index 000000000..89a68f423 --- /dev/null +++ b/examples/simple-chatbot/examples/android/simple-chatbot-client/src/main/res/drawable/chevron_down.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/examples/simple-chatbot/examples/android/simple-chatbot-client/src/main/res/drawable/chevron_right.xml b/examples/simple-chatbot/examples/android/simple-chatbot-client/src/main/res/drawable/chevron_right.xml new file mode 100644 index 000000000..ea7d41847 --- /dev/null +++ b/examples/simple-chatbot/examples/android/simple-chatbot-client/src/main/res/drawable/chevron_right.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/examples/simple-chatbot/examples/android/simple-chatbot-client/src/main/res/drawable/cog.xml b/examples/simple-chatbot/examples/android/simple-chatbot-client/src/main/res/drawable/cog.xml new file mode 100644 index 000000000..04fcde338 --- /dev/null +++ b/examples/simple-chatbot/examples/android/simple-chatbot-client/src/main/res/drawable/cog.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/examples/simple-chatbot/examples/android/simple-chatbot-client/src/main/res/drawable/console.xml b/examples/simple-chatbot/examples/android/simple-chatbot-client/src/main/res/drawable/console.xml new file mode 100644 index 000000000..ced885b0a --- /dev/null +++ b/examples/simple-chatbot/examples/android/simple-chatbot-client/src/main/res/drawable/console.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/examples/simple-chatbot/examples/android/simple-chatbot-client/src/main/res/drawable/ic_launcher_background.xml b/examples/simple-chatbot/examples/android/simple-chatbot-client/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 000000000..07d5da9cb --- /dev/null +++ b/examples/simple-chatbot/examples/android/simple-chatbot-client/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/examples/simple-chatbot/examples/android/simple-chatbot-client/src/main/res/drawable/ic_launcher_foreground.xml b/examples/simple-chatbot/examples/android/simple-chatbot-client/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 000000000..2b068d114 --- /dev/null +++ b/examples/simple-chatbot/examples/android/simple-chatbot-client/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/examples/simple-chatbot/examples/android/simple-chatbot-client/src/main/res/drawable/microphone.xml b/examples/simple-chatbot/examples/android/simple-chatbot-client/src/main/res/drawable/microphone.xml new file mode 100644 index 000000000..ed375ff21 --- /dev/null +++ b/examples/simple-chatbot/examples/android/simple-chatbot-client/src/main/res/drawable/microphone.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/examples/simple-chatbot/examples/android/simple-chatbot-client/src/main/res/drawable/microphone_off.xml b/examples/simple-chatbot/examples/android/simple-chatbot-client/src/main/res/drawable/microphone_off.xml new file mode 100644 index 000000000..bbeb43c59 --- /dev/null +++ b/examples/simple-chatbot/examples/android/simple-chatbot-client/src/main/res/drawable/microphone_off.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/examples/simple-chatbot/examples/android/simple-chatbot-client/src/main/res/drawable/phone_hangup.xml b/examples/simple-chatbot/examples/android/simple-chatbot-client/src/main/res/drawable/phone_hangup.xml new file mode 100644 index 000000000..a637344c3 --- /dev/null +++ b/examples/simple-chatbot/examples/android/simple-chatbot-client/src/main/res/drawable/phone_hangup.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/examples/simple-chatbot/examples/android/simple-chatbot-client/src/main/res/drawable/timer_outline.xml b/examples/simple-chatbot/examples/android/simple-chatbot-client/src/main/res/drawable/timer_outline.xml new file mode 100644 index 000000000..ef0ca41fe --- /dev/null +++ b/examples/simple-chatbot/examples/android/simple-chatbot-client/src/main/res/drawable/timer_outline.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/examples/simple-chatbot/examples/android/simple-chatbot-client/src/main/res/drawable/video.xml b/examples/simple-chatbot/examples/android/simple-chatbot-client/src/main/res/drawable/video.xml new file mode 100644 index 000000000..bce7a9ed5 --- /dev/null +++ b/examples/simple-chatbot/examples/android/simple-chatbot-client/src/main/res/drawable/video.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/examples/simple-chatbot/examples/android/simple-chatbot-client/src/main/res/drawable/video_off.xml b/examples/simple-chatbot/examples/android/simple-chatbot-client/src/main/res/drawable/video_off.xml new file mode 100644 index 000000000..c06bc38a3 --- /dev/null +++ b/examples/simple-chatbot/examples/android/simple-chatbot-client/src/main/res/drawable/video_off.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/examples/simple-chatbot/examples/android/simple-chatbot-client/src/main/res/font/inter.ttf b/examples/simple-chatbot/examples/android/simple-chatbot-client/src/main/res/font/inter.ttf new file mode 100644 index 000000000..e31b51e3e Binary files /dev/null and b/examples/simple-chatbot/examples/android/simple-chatbot-client/src/main/res/font/inter.ttf differ diff --git a/examples/simple-chatbot/examples/android/simple-chatbot-client/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/examples/simple-chatbot/examples/android/simple-chatbot-client/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 000000000..6f3b755bf --- /dev/null +++ b/examples/simple-chatbot/examples/android/simple-chatbot-client/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/examples/simple-chatbot/examples/android/simple-chatbot-client/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/examples/simple-chatbot/examples/android/simple-chatbot-client/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 000000000..6f3b755bf --- /dev/null +++ b/examples/simple-chatbot/examples/android/simple-chatbot-client/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/examples/simple-chatbot/examples/android/simple-chatbot-client/src/main/res/mipmap-hdpi/ic_launcher.webp b/examples/simple-chatbot/examples/android/simple-chatbot-client/src/main/res/mipmap-hdpi/ic_launcher.webp new file mode 100644 index 000000000..c209e78ec Binary files /dev/null and b/examples/simple-chatbot/examples/android/simple-chatbot-client/src/main/res/mipmap-hdpi/ic_launcher.webp differ diff --git a/examples/simple-chatbot/examples/android/simple-chatbot-client/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/examples/simple-chatbot/examples/android/simple-chatbot-client/src/main/res/mipmap-hdpi/ic_launcher_round.webp new file mode 100644 index 000000000..b2dfe3d1b Binary files /dev/null and b/examples/simple-chatbot/examples/android/simple-chatbot-client/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ diff --git a/examples/simple-chatbot/examples/android/simple-chatbot-client/src/main/res/mipmap-mdpi/ic_launcher.webp b/examples/simple-chatbot/examples/android/simple-chatbot-client/src/main/res/mipmap-mdpi/ic_launcher.webp new file mode 100644 index 000000000..4f0f1d64e Binary files /dev/null and b/examples/simple-chatbot/examples/android/simple-chatbot-client/src/main/res/mipmap-mdpi/ic_launcher.webp differ diff --git a/examples/simple-chatbot/examples/android/simple-chatbot-client/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/examples/simple-chatbot/examples/android/simple-chatbot-client/src/main/res/mipmap-mdpi/ic_launcher_round.webp new file mode 100644 index 000000000..62b611da0 Binary files /dev/null and b/examples/simple-chatbot/examples/android/simple-chatbot-client/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ diff --git a/examples/simple-chatbot/examples/android/simple-chatbot-client/src/main/res/mipmap-xhdpi/ic_launcher.webp b/examples/simple-chatbot/examples/android/simple-chatbot-client/src/main/res/mipmap-xhdpi/ic_launcher.webp new file mode 100644 index 000000000..948a3070f Binary files /dev/null and b/examples/simple-chatbot/examples/android/simple-chatbot-client/src/main/res/mipmap-xhdpi/ic_launcher.webp differ diff --git a/examples/simple-chatbot/examples/android/simple-chatbot-client/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/examples/simple-chatbot/examples/android/simple-chatbot-client/src/main/res/mipmap-xhdpi/ic_launcher_round.webp new file mode 100644 index 000000000..1b9a6956b Binary files /dev/null and b/examples/simple-chatbot/examples/android/simple-chatbot-client/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ diff --git a/examples/simple-chatbot/examples/android/simple-chatbot-client/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/examples/simple-chatbot/examples/android/simple-chatbot-client/src/main/res/mipmap-xxhdpi/ic_launcher.webp new file mode 100644 index 000000000..28d4b77f9 Binary files /dev/null and b/examples/simple-chatbot/examples/android/simple-chatbot-client/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ diff --git a/examples/simple-chatbot/examples/android/simple-chatbot-client/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/examples/simple-chatbot/examples/android/simple-chatbot-client/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp new file mode 100644 index 000000000..9287f5083 Binary files /dev/null and b/examples/simple-chatbot/examples/android/simple-chatbot-client/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ diff --git a/examples/simple-chatbot/examples/android/simple-chatbot-client/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/examples/simple-chatbot/examples/android/simple-chatbot-client/src/main/res/mipmap-xxxhdpi/ic_launcher.webp new file mode 100644 index 000000000..aa7d6427e Binary files /dev/null and b/examples/simple-chatbot/examples/android/simple-chatbot-client/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ diff --git a/examples/simple-chatbot/examples/android/simple-chatbot-client/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/examples/simple-chatbot/examples/android/simple-chatbot-client/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp new file mode 100644 index 000000000..9126ae37c Binary files /dev/null and b/examples/simple-chatbot/examples/android/simple-chatbot-client/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ diff --git a/examples/simple-chatbot/examples/android/simple-chatbot-client/src/main/res/values/strings.xml b/examples/simple-chatbot/examples/android/simple-chatbot-client/src/main/res/values/strings.xml new file mode 100644 index 000000000..d6b0d9702 --- /dev/null +++ b/examples/simple-chatbot/examples/android/simple-chatbot-client/src/main/res/values/strings.xml @@ -0,0 +1,3 @@ + + Pipecat Simple Chatbot Client + diff --git a/examples/simple-chatbot/examples/android/simple-chatbot-client/src/main/res/values/themes.xml b/examples/simple-chatbot/examples/android/simple-chatbot-client/src/main/res/values/themes.xml new file mode 100644 index 000000000..3675aa6ba --- /dev/null +++ b/examples/simple-chatbot/examples/android/simple-chatbot-client/src/main/res/values/themes.xml @@ -0,0 +1,5 @@ + + + +