From e3a65482ff664330dbc43d6490ef96cbc14befb0 Mon Sep 17 00:00:00 2001 From: Paul Peavyhouse Date: Fri, 24 Jan 2025 19:00:16 -0800 Subject: [PATCH] mobile: Add PTT UI, add MobileViewModel, handle remote PushToTalk Renamed: * mobile/MainActivity->MobileActivity * wear/MainActivity->WearActivity Start to add `shared` library module --- .gitignore | 2 + build.gradle.kts | 1 + gradle/libs.versions.toml | 8 + mobile/build.gradle.kts | 31 +- mobile/src/main/AndroidManifest.xml | 14 +- .../swooby/phonewearremote/MainActivity.kt | 56 ---- .../swooby/phonewearremote/MobileActivity.kt | 284 ++++++++++++++++++ .../swooby/phonewearremote/MobileViewModel.kt | 114 +++++++ .../java/com/swooby/phonewearremote/Utils.kt | 32 -- .../swooby/phonewearremote/ui/theme/Color.kt | 11 + .../swooby/phonewearremote/ui/theme/Theme.kt | 58 ++++ .../swooby/phonewearremote/ui/theme/Type.kt | 34 +++ .../src/main/res/drawable/baseline_mic_24.xml | 5 + .../main/res/drawable/baseline_mic_off_24.xml | 5 + mobile/src/main/res/raw/ptt_touch.wav | Bin 0 -> 3228 bytes mobile/src/main/res/values/strings.xml | 1 + settings.gradle.kts | 2 +- shared/.gitignore | 1 + shared/build.gradle.kts | 47 +++ shared/consumer-rules.pro | 0 shared/proguard-rules.pro | 21 ++ .../ExampleInstrumentedTest.kt | 24 ++ shared/src/main/AndroidManifest.xml | 4 + .../swooby/phonewearremote/SharedViewModel.kt | 137 +++++++++ .../java/com/swooby/phonewearremote/Utils.kt | 24 ++ .../swooby/phonewearremote/ExampleUnitTest.kt | 17 ++ wear/build.gradle.kts | 26 +- .../swooby/phonewearremote/WearViewModel.kt | 172 ++++++----- .../{MainActivity.kt => WearActivity.kt} | 101 +++---- wear/src/main/res/raw/ptt_touch.wav | Bin 0 -> 3228 bytes 30 files changed, 987 insertions(+), 245 deletions(-) delete mode 100644 mobile/src/main/java/com/swooby/phonewearremote/MainActivity.kt create mode 100644 mobile/src/main/java/com/swooby/phonewearremote/MobileActivity.kt create mode 100644 mobile/src/main/java/com/swooby/phonewearremote/MobileViewModel.kt delete mode 100644 mobile/src/main/java/com/swooby/phonewearremote/Utils.kt create mode 100644 mobile/src/main/java/com/swooby/phonewearremote/ui/theme/Color.kt create mode 100644 mobile/src/main/java/com/swooby/phonewearremote/ui/theme/Theme.kt create mode 100644 mobile/src/main/java/com/swooby/phonewearremote/ui/theme/Type.kt create mode 100644 mobile/src/main/res/drawable/baseline_mic_24.xml create mode 100644 mobile/src/main/res/drawable/baseline_mic_off_24.xml create mode 100644 mobile/src/main/res/raw/ptt_touch.wav create mode 100644 shared/.gitignore create mode 100644 shared/build.gradle.kts create mode 100644 shared/consumer-rules.pro create mode 100644 shared/proguard-rules.pro create mode 100644 shared/src/androidTest/java/com/swooby/phonewearremote/ExampleInstrumentedTest.kt create mode 100644 shared/src/main/AndroidManifest.xml create mode 100644 shared/src/main/java/com/swooby/phonewearremote/SharedViewModel.kt rename {wear => shared}/src/main/java/com/swooby/phonewearremote/Utils.kt (74%) create mode 100644 shared/src/test/java/com/swooby/phonewearremote/ExampleUnitTest.kt rename wear/src/main/java/com/swooby/phonewearremote/presentation/{MainActivity.kt => WearActivity.kt} (65%) create mode 100644 wear/src/main/res/raw/ptt_touch.wav diff --git a/.gitignore b/.gitignore index 9c87163..9417193 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,5 @@ /build /local.properties .DS_Store + +scratch/ diff --git a/build.gradle.kts b/build.gradle.kts index 952b930..5ea216f 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -3,4 +3,5 @@ plugins { alias(libs.plugins.android.application) apply false alias(libs.plugins.kotlin.android) apply false alias(libs.plugins.kotlin.compose) apply false + alias(libs.plugins.android.library) apply false } \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index a6bcd2b..e9eeac2 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -24,6 +24,9 @@ horologistComposeTools = "0.6.17" horologistTiles = "0.6.17" watchfaceComplicationsDataSourceKtx = "1.2.1" wearPhoneInteractions = "1.0.1" +runtimeAndroid = "1.7.6" +lifecycleRuntimeKtx = "2.8.7" +composeMaterialVersion = "1.4.0" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } @@ -56,9 +59,14 @@ horologist-tiles = { group = "com.google.android.horologist", name = "horologist androidx-watchface-complications-data-source-ktx = { group = "androidx.wear.watchface", name = "watchface-complications-data-source-ktx", version.ref = "watchfaceComplicationsDataSourceKtx" } androidx-wear-phone-interactions = { group = "androidx.wear", name = "wear-phone-interactions", version.ref = "wearPhoneInteractions" } kotlin-reflect = { module = "org.jetbrains.kotlin:kotlin-reflect", version.ref = "kotlin" } +androidx-runtime-android = { group = "androidx.compose.runtime", name = "runtime-android", version.ref = "runtimeAndroid" } +androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" } +androidx-material3 = { group = "androidx.compose.material3", name = "material3" } +compose-material = { group = "androidx.wear.compose", name = "compose-material", version.ref = "composeMaterialVersion" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } +android-library = { id = "com.android.library", version.ref = "agp" } diff --git a/mobile/build.gradle.kts b/mobile/build.gradle.kts index cd93e58..04e6cd0 100644 --- a/mobile/build.gradle.kts +++ b/mobile/build.gradle.kts @@ -1,6 +1,7 @@ plugins { alias(libs.plugins.android.application) alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.compose) } android { @@ -33,16 +34,36 @@ android { kotlinOptions { jvmTarget = "21" } + buildFeatures { + buildConfig = true + compose = true + } } dependencies { - implementation(libs.androidx.core.ktx) - implementation(libs.androidx.appcompat) - implementation(libs.play.services.wearable) - implementation(libs.material) implementation(libs.androidx.activity) + implementation(libs.androidx.activity.compose) + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.appcompat) implementation(libs.androidx.constraintlayout) + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.runtime.android) + implementation(libs.material) + implementation(libs.play.services.wearable) + implementation(libs.androidx.lifecycle.runtime.ktx) + implementation(libs.androidx.ui) + implementation(libs.androidx.ui.graphics) + implementation(libs.androidx.ui.tooling.preview) + implementation(libs.androidx.material3) + implementation(libs.compose.material) + implementation(project(":shared")) + testImplementation(libs.junit) - androidTestImplementation(libs.androidx.junit) + androidTestImplementation(libs.androidx.espresso.core) + androidTestImplementation(libs.androidx.junit) + androidTestImplementation(platform(libs.androidx.compose.bom)) + androidTestImplementation(libs.androidx.ui.test.junit4) + debugImplementation(libs.androidx.ui.tooling) + debugImplementation(libs.androidx.ui.test.manifest) } diff --git a/mobile/src/main/AndroidManifest.xml b/mobile/src/main/AndroidManifest.xml index a743f15..39a50d8 100644 --- a/mobile/src/main/AndroidManifest.xml +++ b/mobile/src/main/AndroidManifest.xml @@ -1,6 +1,8 @@ - + + android:theme="@style/Theme.PhoneWearRemote"> + android:label="@string/title_activity_mobile" + android:theme="@style/Theme.PhoneWearRemote" > diff --git a/mobile/src/main/java/com/swooby/phonewearremote/MainActivity.kt b/mobile/src/main/java/com/swooby/phonewearremote/MainActivity.kt deleted file mode 100644 index cf06aa1..0000000 --- a/mobile/src/main/java/com/swooby/phonewearremote/MainActivity.kt +++ /dev/null @@ -1,56 +0,0 @@ -package com.swooby.phonewearremote - -import android.os.Bundle -import android.util.Log -import androidx.activity.enableEdgeToEdge -import androidx.appcompat.app.AppCompatActivity -import androidx.core.view.ViewCompat -import androidx.core.view.WindowInsetsCompat -import com.google.android.gms.wearable.MessageClient -import com.google.android.gms.wearable.MessageEvent -import com.google.android.gms.wearable.Wearable -import com.swooby.phonewearremote.Utils.quote - -class MainActivity : AppCompatActivity(), MessageClient.OnMessageReceivedListener { - companion object { - private const val TAG = "MainActivity" - } - - private val messageClient by lazy { Wearable.getMessageClient(this) } - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - enableEdgeToEdge() - setContentView(R.layout.activity_main) - ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.main)) { v, insets -> - val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars()) - v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom) - insets - } - - messageClient.addListener(this) - } - - override fun onDestroy() { - super.onDestroy() - messageClient.removeListener(this) - } - - override fun onMessageReceived(messageEvent: MessageEvent) { - val path = messageEvent.path - when (path) { - "/pushToTalk" -> handlePushToTalkCommand(messageEvent) - else -> Log.d(TAG, "Unhandled messageEvent.path=${quote(path)}") - } - } - - private fun handlePushToTalkCommand(messageEvent: MessageEvent) { - val payload = messageEvent.data - val payloadString = String(payload) - Log.i(TAG, "handlePushToTalkCommand: PushToTalk command received! payloadString=${quote(payloadString)}") - - // TODO: Start your "PushToTalk" action: - // e.g., open a microphone, start voice recognition, etc. - // ... - } -} \ No newline at end of file diff --git a/mobile/src/main/java/com/swooby/phonewearremote/MobileActivity.kt b/mobile/src/main/java/com/swooby/phonewearremote/MobileActivity.kt new file mode 100644 index 0000000..cc1390f --- /dev/null +++ b/mobile/src/main/java/com/swooby/phonewearremote/MobileActivity.kt @@ -0,0 +1,284 @@ +package com.swooby.phonewearremote + +import android.os.Bundle +import android.util.Log +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.activity.viewModels +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown +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.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.CaretScope +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.Label +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.pointer.changedToUp +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.wear.compose.material.ContentAlpha +import com.swooby.phonewearremote.ui.theme.PhoneWearRemoteTheme + +class MobileActivity : ComponentActivity() { + private val mobileViewModel: MobileViewModel by viewModels() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + setContent { + MobileApp(mobileViewModel) + } + } + + override fun onResume() { + super.onResume() + mobileViewModel.init() + } + + override fun onPause() { + super.onPause() + mobileViewModel.close() + } +} + +@Preview(showBackground = true) +@Composable +fun GreetingPreview() { + MobileApp() +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun MobileApp( + mobileViewModel: MobileViewModel? = null +) { + PhoneWearRemoteTheme { + Scaffold( + modifier = Modifier.background(MaterialTheme.colorScheme.primary), + topBar = { + TopAppBar( + title = { Text("Phone") }, + modifier = Modifier + .fillMaxWidth() + ) + } + ) { innerPadding -> + KeepScreenOnComposable() + MainScreen( + mobileViewModel = mobileViewModel, + modifier = Modifier + .padding(innerPadding) + ) + } + } +} + +@Composable +fun MainScreen( + mobileViewModel: MobileViewModel? = null, + modifier: Modifier = Modifier +) { + + var isConnectingOrConnected by remember { mutableStateOf(true) } + var isConnected by remember { mutableStateOf(true) } + var isCancelingResponse by remember { mutableStateOf(false) } + + val wearAppNodeId by mobileViewModel + ?.remoteAppNodeId + ?.collectAsState() + ?: remember { mutableStateOf(null) } + + val disabledColor = MaterialTheme.colorScheme.onSurface.copy(alpha = ContentAlpha.disabled) + + Column( + modifier = modifier + //.border(1.dp, Color.Green) + , + verticalArrangement = Arrangement.Top, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + + Text( + modifier = Modifier + //.border(1.dp, Color.Red) + .fillMaxWidth(), + text = "Hello!", + textAlign = TextAlign.Center + ) + + TextField( + modifier = Modifier + //.border(1.dp, Color.Blue) + .fillMaxWidth(), + label = { Text("Wear App Node ID") }, + value = if (wearAppNodeId != null) "$wearAppNodeId" else "None", + readOnly = true, + onValueChange = {}, + ) + + Box( + modifier = Modifier + .size(150.dp) + , + contentAlignment = Alignment.Center + ) { + Box { + when { + isConnected -> { + CircularProgressIndicator( + progress = { 1f }, + color = Color.Green, + strokeWidth = 6.dp, + modifier = Modifier.size(150.dp) + ) + } + isConnectingOrConnected -> { + CircularProgressIndicator( + color = MaterialTheme.colorScheme.primary, + strokeWidth = 6.dp, + modifier = Modifier.size(150.dp) + ) + } + else -> { + CircularProgressIndicator( + progress = { 0f }, + color = disabledColor, + strokeWidth = 6.dp, + modifier = Modifier.size(150.dp) + ) + } + } + } + PushToTalkButton( + mobileViewModel = mobileViewModel, + enabled = isConnected && !isCancelingResponse, + onPushToTalkStart = { + mobileViewModel?.pushToTalk(true) + }, + onPushToTalkStop = { + mobileViewModel?.pushToTalk(false) + }, + ) + } + + } +} + +@Composable +fun PushToTalkButton( + mobileViewModel: MobileViewModel? = null, + enabled: Boolean = true, + modifier: Modifier = Modifier, + iconIdle: Int = R.drawable.baseline_mic_24, + iconPressed: Int = R.drawable.baseline_mic_24, + iconDisabled: Int = R.drawable.baseline_mic_off_24, + onPushToTalkStart: () -> Unit = {}, + onPushToTalkStop: () -> Unit = {} +) { + val pttState by mobileViewModel + ?.pushToTalkState + ?.collectAsState() + ?: remember { mutableStateOf(SharedViewModel.PttState.Idle) } + + val disabledColor = MaterialTheme.colorScheme.onSurface.copy(alpha = ContentAlpha.disabled) + val boxAlpha = if (enabled) 1.0f else 0.38f + + Box( + contentAlignment = Alignment.Center, + modifier = modifier + .size(120.dp) + .border(4.dp, if (enabled) MaterialTheme.colorScheme.primary else disabledColor, shape = CircleShape) + .background( + color = if (pttState == SharedViewModel.PttState.Pressed) Color.Green else Color.Transparent, + shape = CircleShape + ) + .let { baseModifier -> + if (enabled) { + baseModifier.pointerInput(Unit) { + awaitEachGesture { + awaitFirstDown() + onPushToTalkStart() + do { + val event = awaitPointerEvent() + } while (event.changes.any { !it.changedToUp() }) + onPushToTalkStop() + } + } + } else { + baseModifier.pointerInput(Unit) { + awaitEachGesture { + while (true) { + val event = awaitPointerEvent() + event.changes.forEach { it.consume() } + } + } + } + } + } + .then(Modifier.background(Color.Transparent)) + .let { + it.background(Color.Transparent).graphicsLayer { + this.alpha = boxAlpha + } + } + ) { + val iconRes = if (enabled) { + if (pttState == SharedViewModel.PttState.Pressed) { + iconPressed + } else { + iconIdle + } + } else { + iconDisabled + } + Icon( + painter = painterResource(id = iconRes), + contentDescription = "Microphone", + modifier = Modifier + .size(90.dp) + ) + } +} + +@Composable +fun KeepScreenOnComposable() { + val view = LocalView.current + DisposableEffect(Unit) { + view.keepScreenOn = true + onDispose { + view.keepScreenOn = false + } + } +} diff --git a/mobile/src/main/java/com/swooby/phonewearremote/MobileViewModel.kt b/mobile/src/main/java/com/swooby/phonewearremote/MobileViewModel.kt new file mode 100644 index 0000000..f05927f --- /dev/null +++ b/mobile/src/main/java/com/swooby/phonewearremote/MobileViewModel.kt @@ -0,0 +1,114 @@ +package com.swooby.phonewearremote + +import android.app.Application +import android.util.Log +import com.swooby.phonewearremote.Utils.playAudioResourceOnce + +class MobileViewModel(application: Application) : + SharedViewModel(application) { + override val TAG: String + get() = "MobileViewModel" + override val remoteTypeName: String + get() = "WEAR" + override val remoteCapabilityName: String + get() = "verify_remote_example_wear_app" + + override fun pushToTalk(on: Boolean, sourceNodeId: String?) { + Log.i(TAG, "pushToTalk(on=$on)") + if (on) { + if (_pushToTalkState.value != PttState.Pressed) { + _pushToTalkState.value = PttState.Pressed + playAudioResourceOnce(getApplication(), R.raw.ptt_touch) + //provideHapticFeedback(context) + } + } else { + if (_pushToTalkState.value != PttState.Idle) { + _pushToTalkState.value = PttState.Idle + playAudioResourceOnce(getApplication(), R.raw.ptt_touch) + //provideHapticFeedback(context) + } + } + + if (sourceNodeId == null) { + // request from local/mobile + Log.d(TAG, "pushToTalk: PTT $on **from** local/mobile...") + val remoteAppNodeId = _remoteAppNodeId.value + if (remoteAppNodeId != null) { + // tell remote/wear app that we are PTTing... + sendPushToTalkCommand(remoteAppNodeId, on) + } + //... + } else { + // request from remote/wear + _remoteAppNodeId.value = sourceNodeId + Log.d(TAG, "pushToTalk: PTT $on **from** remote/wear...") + //... + } + + pushToTalkLocal(on) + } + + override fun pushToTalkLocal(on: Boolean) { + super.pushToTalkLocal(on) + if (on) { + /* + pushToTalkViewModel?.realtimeClient?.also { realtimeClient -> + Log.d(TAG, "") + Log.d(TAG, "+onPushToTalkStart: pttState=$pttState") + // 1. Play the start sound + Log.d(TAG, "onPushToTalkStart: playing start sound") + playAudioResourceOnce( + context = pushToTalkViewModel.getApplication(), + audioResourceId = R.raw.quindar_nasa_apollo_intro, + volume = 0.2f, + ) { + // 2. Wait for the start sound to finish + Log.d(TAG, "onPushToTalkStart: start sound finished") + // 3. Open the mic + Log.d(TAG, "onPushToTalkStart: opening mic") + realtimeClient.setLocalAudioTrackMicrophoneEnabled(true) + Log.d(TAG, "onPushToTalkStart: mic opened") + // 4. Wait for the mic to open successfully + //... + Log.d(TAG, "-onPushToTalkStart") + Log.d(TAG, "") + } + } + */ + } else { + /* + pushToTalkViewModel?.realtimeClient?.also { realtimeClient -> + Log.d(TAG, "") + Log.d(TAG, "+onPushToTalkStop: pttState=$pttState") + // 1. Close the mic + Log.d(TAG, "onPushToTalkStop: closing mic") + realtimeClient.setLocalAudioTrackMicrophoneEnabled(false) + Log.d(TAG, "onPushToTalkStop: mic closed") + // 2. Wait for the mic to close successfully + //... + // 3. Send input_audio_buffer.commit + Log.d(TAG, "onPushToTalkStop: sending input_audio_buffer.commit") + realtimeClient.dataSendInputAudioBufferCommit() + Log.d(TAG, "onPushToTalkStop: input_audio_buffer.commit sent") + // 4. Send response.create + Log.d(TAG, "onPushToTalkStop: sending response.create") + realtimeClient.dataSendResponseCreate() + Log.d(TAG, "onPushToTalkStop: response.create sent") + // 5. Play the stop sound + Log.d(TAG, "onPushToTalkStop: playing stop sound") + playAudioResourceOnce( + context = pushToTalkViewModel.getApplication(), + audioResourceId = R.raw.quindar_nasa_apollo_outro, + volume = 0.2f, + ) { + // 6. Wait for the stop sound to finish + Log.d(TAG, "onPushToTalkStop: stop sound finished") + //... + Log.d(TAG, "-onPushToTalkStop") + Log.d(TAG, "") + } + } + */ + } + } +} \ No newline at end of file diff --git a/mobile/src/main/java/com/swooby/phonewearremote/Utils.kt b/mobile/src/main/java/com/swooby/phonewearremote/Utils.kt deleted file mode 100644 index 78c4313..0000000 --- a/mobile/src/main/java/com/swooby/phonewearremote/Utils.kt +++ /dev/null @@ -1,32 +0,0 @@ -package com.swooby.phonewearremote - -import kotlin.reflect.KClass - -object Utils { - fun getShortClassName(value: Any?): String { - return when (value) { - is KClass<*> -> value.simpleName ?: "null" - else -> value?.javaClass?.simpleName ?: "null" - } - } - - fun quote(value: Any?, typeOnly: Boolean = false): String { - if (value == null) { - return "null" - } - - if (typeOnly) { - return getShortClassName(value) - } - - if (value is String) { - return "\"$value\"" - } - - if (value is CharSequence) { - return "\"$value\"" - } - - return value.toString() - } -} \ No newline at end of file diff --git a/mobile/src/main/java/com/swooby/phonewearremote/ui/theme/Color.kt b/mobile/src/main/java/com/swooby/phonewearremote/ui/theme/Color.kt new file mode 100644 index 0000000..08ff033 --- /dev/null +++ b/mobile/src/main/java/com/swooby/phonewearremote/ui/theme/Color.kt @@ -0,0 +1,11 @@ +package com.swooby.phonewearremote.ui.theme + +import androidx.compose.ui.graphics.Color + +val Purple80 = Color(0xFFD0BCFF) +val PurpleGrey80 = Color(0xFFCCC2DC) +val Pink80 = Color(0xFFEFB8C8) + +val Purple40 = Color(0xFF6650a4) +val PurpleGrey40 = Color(0xFF625b71) +val Pink40 = Color(0xFF7D5260) \ No newline at end of file diff --git a/mobile/src/main/java/com/swooby/phonewearremote/ui/theme/Theme.kt b/mobile/src/main/java/com/swooby/phonewearremote/ui/theme/Theme.kt new file mode 100644 index 0000000..ce2ac01 --- /dev/null +++ b/mobile/src/main/java/com/swooby/phonewearremote/ui/theme/Theme.kt @@ -0,0 +1,58 @@ +package com.swooby.phonewearremote.ui.theme + +import android.app.Activity +import android.os.Build +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.dynamicDarkColorScheme +import androidx.compose.material3.dynamicLightColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalContext + +private val DarkColorScheme = darkColorScheme( + primary = Purple80, + secondary = PurpleGrey80, + tertiary = Pink80 +) + +private val LightColorScheme = lightColorScheme( + primary = Purple40, + secondary = PurpleGrey40, + tertiary = Pink40 + + /* Other default colors to override + background = Color(0xFFFFFBFE), + surface = Color(0xFFFFFBFE), + onPrimary = Color.White, + onSecondary = Color.White, + onTertiary = Color.White, + onBackground = Color(0xFF1C1B1F), + onSurface = Color(0xFF1C1B1F), + */ +) + +@Composable +fun PhoneWearRemoteTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + // Dynamic color is available on Android 12+ + dynamicColor: Boolean = true, + content: @Composable () -> Unit +) { + val colorScheme = when { + dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { + val context = LocalContext.current + if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) + } + + darkTheme -> DarkColorScheme + else -> LightColorScheme + } + + MaterialTheme( + colorScheme = colorScheme, + typography = Typography, + content = content + ) +} \ No newline at end of file diff --git a/mobile/src/main/java/com/swooby/phonewearremote/ui/theme/Type.kt b/mobile/src/main/java/com/swooby/phonewearremote/ui/theme/Type.kt new file mode 100644 index 0000000..2438f77 --- /dev/null +++ b/mobile/src/main/java/com/swooby/phonewearremote/ui/theme/Type.kt @@ -0,0 +1,34 @@ +package com.swooby.phonewearremote.ui.theme + +import androidx.compose.material3.Typography +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp + +// 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/mobile/src/main/res/drawable/baseline_mic_24.xml b/mobile/src/main/res/drawable/baseline_mic_24.xml new file mode 100644 index 0000000..c6ba416 --- /dev/null +++ b/mobile/src/main/res/drawable/baseline_mic_24.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/mobile/src/main/res/drawable/baseline_mic_off_24.xml b/mobile/src/main/res/drawable/baseline_mic_off_24.xml new file mode 100644 index 0000000..086e469 --- /dev/null +++ b/mobile/src/main/res/drawable/baseline_mic_off_24.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/mobile/src/main/res/raw/ptt_touch.wav b/mobile/src/main/res/raw/ptt_touch.wav new file mode 100644 index 0000000000000000000000000000000000000000..302fe766cd87208d95717f6146092c0e5d35eae2 GIT binary patch literal 3228 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 Co=xlk literal 0 HcmV?d00001 diff --git a/mobile/src/main/res/values/strings.xml b/mobile/src/main/res/values/strings.xml index cc354ca..f54c987 100644 --- a/mobile/src/main/res/values/strings.xml +++ b/mobile/src/main/res/values/strings.xml @@ -1,3 +1,4 @@ Phone Wear Remote + MobileActivity \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts index 3e80b3e..155bcb0 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -22,4 +22,4 @@ dependencyResolutionManagement { rootProject.name = "Phone Wear Remote" include(":mobile") include(":wear") - \ No newline at end of file +include(":shared") diff --git a/shared/.gitignore b/shared/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/shared/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts new file mode 100644 index 0000000..73d71d0 --- /dev/null +++ b/shared/build.gradle.kts @@ -0,0 +1,47 @@ +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.android) +} + +android { + namespace = "com.swooby.phonewearremote" + compileSdk = 35 + + defaultConfig { + minSdk = 34 + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + consumerProguardFiles("consumer-rules.pro") + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_21 + targetCompatibility = JavaVersion.VERSION_21 + } + kotlinOptions { + jvmTarget = "21" + } +} + +dependencies { + implementation(libs.androidx.appcompat) + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.wear.phone.interactions) + implementation(libs.kotlin.reflect) + implementation(libs.material) + implementation(libs.play.services.wearable) + + testImplementation(libs.junit) + + androidTestImplementation(libs.androidx.espresso.core) + androidTestImplementation(libs.androidx.junit) +} \ No newline at end of file diff --git a/shared/consumer-rules.pro b/shared/consumer-rules.pro new file mode 100644 index 0000000..e69de29 diff --git a/shared/proguard-rules.pro b/shared/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/shared/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/shared/src/androidTest/java/com/swooby/phonewearremote/ExampleInstrumentedTest.kt b/shared/src/androidTest/java/com/swooby/phonewearremote/ExampleInstrumentedTest.kt new file mode 100644 index 0000000..c36ad0f --- /dev/null +++ b/shared/src/androidTest/java/com/swooby/phonewearremote/ExampleInstrumentedTest.kt @@ -0,0 +1,24 @@ +package com.swooby.phonewearremote + +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.ext.junit.runners.AndroidJUnit4 + +import org.junit.Test +import org.junit.runner.RunWith + +import org.junit.Assert.* + +/** + * Instrumented test, which will execute on an Android device. + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +@RunWith(AndroidJUnit4::class) +class ExampleInstrumentedTest { + @Test + fun useAppContext() { + // Context of the app under test. + val appContext = InstrumentationRegistry.getInstrumentation().targetContext + assertEquals("com.swooby.phonewearremote.test", appContext.packageName) + } +} \ No newline at end of file diff --git a/shared/src/main/AndroidManifest.xml b/shared/src/main/AndroidManifest.xml new file mode 100644 index 0000000..a5918e6 --- /dev/null +++ b/shared/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/shared/src/main/java/com/swooby/phonewearremote/SharedViewModel.kt b/shared/src/main/java/com/swooby/phonewearremote/SharedViewModel.kt new file mode 100644 index 0000000..62160bb --- /dev/null +++ b/shared/src/main/java/com/swooby/phonewearremote/SharedViewModel.kt @@ -0,0 +1,137 @@ +package com.swooby.phonewearremote + +import android.app.Application +import android.util.Log +import androidx.lifecycle.AndroidViewModel +import com.google.android.gms.tasks.Task +import com.google.android.gms.wearable.CapabilityClient +import com.google.android.gms.wearable.MessageClient +import com.google.android.gms.wearable.MessageEvent +import com.google.android.gms.wearable.Node +import com.google.android.gms.wearable.Wearable +import com.swooby.phonewearremote.Utils.quote +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow + +abstract class SharedViewModel(application: Application): + AndroidViewModel(application) { + enum class PttState { + Idle, + Pressed + } + + protected abstract val TAG: String + protected abstract val remoteTypeName: String + protected abstract val remoteCapabilityName: String + + protected var _remoteAppNodeId = MutableStateFlow(null) // private mutable + val remoteAppNodeId = _remoteAppNodeId.asStateFlow() // public readonly + + protected var _pushToTalkState = MutableStateFlow(PttState.Idle) // private mutable + val pushToTalkState = _pushToTalkState.asStateFlow() // public readonly + + private val capabilityClient by lazy { Wearable.getCapabilityClient(application) } + private val messageClient by lazy { Wearable.getMessageClient(application) } + private val messageClientListener = MessageClient.OnMessageReceivedListener { + onMessageClientMessageReceived(it) + } + + fun init() { + messageClient.addListener(messageClientListener) + searchForRemoteAppNode() + } + + fun close() { + messageClient.removeListener(messageClientListener) + } + + private fun pickFirstNearbyNode(nodes: Set): Node? { + return nodes.firstOrNull { it.isNearby } ?: nodes.firstOrNull() + } + + private fun searchForRemoteAppNode() { + val capabilityClientTask = capabilityClient.getCapability( + remoteCapabilityName, + CapabilityClient.FILTER_REACHABLE + ) + capabilityClientTask.addOnCompleteListener { + if (it.isSuccessful) { + //Log.d(TAG, "Capability request succeeded"); + val nodeId = pickFirstNearbyNode(it.result.nodes)?.id + if (nodeId == null) { + Log.d(TAG, "searchForRemoteAppNode: Detected no $remoteTypeName app node") + } else { + Log.i(TAG, "searchForRemoteAppNode: Detected $remoteTypeName app nodeId=${quote(nodeId)}; Sending ping...") + sendPingCommand(nodeId) + } + } else { + Log.e(TAG, "searchForRemoteAppNode: Capability request failed to return any results"); + } + } + } + + private fun sendMessageToNode(nodeId: String, path: String, data: String): Task { + return sendMessageToNode(nodeId, path, data.toByteArray()) + } + + private fun sendMessageToNode(nodeId: String, path: String, data: ByteArray? = null): Task { + return messageClient + .sendMessage(nodeId, path, data) + .addOnFailureListener { e -> + Log.e(TAG, "sendMessageToNode: Message failed.", e) + _remoteAppNodeId.value = null + } + } + + private fun onMessageClientMessageReceived(messageEvent: MessageEvent) { + when (val path = messageEvent.path) { + "/ping" -> handlePingCommand(messageEvent) + "/pong" -> handlePongCommand(messageEvent) + "/pushToTalk" -> handlePushToTalkCommand(messageEvent) + else -> Log.d(TAG, "onMessageClientMessageReceived: Unhandled messageEvent.path=${quote(path)}") + } + } + + private fun sendPingCommand(nodeId: String): Task { + return sendMessageToNode(nodeId, "/ping") + } + + private fun handlePingCommand(messageEvent: MessageEvent) { + val nodeId = messageEvent.sourceNodeId + _remoteAppNodeId.value = nodeId + Log.i(TAG, "handlePingCommand: Got ping request from $remoteTypeName app nodeId=${quote(nodeId)}; Responding pong...") + sendPongCommand(nodeId) + } + + private fun sendPongCommand(nodeId: String): Task { + return sendMessageToNode(nodeId, "/pong") + } + + private fun handlePongCommand(messageEvent: MessageEvent) { + val nodeId = messageEvent.sourceNodeId + _remoteAppNodeId.value = nodeId + Log.i(TAG, "handlePongCommand: Got pong response from MOBILE app nodeId=${quote(nodeId)}") + } + + protected fun sendPushToTalkCommand(nodeId: String, on: Boolean): Task { + val path = "/pushToTalk" + val payload = (if (on) "on" else "off") + return sendMessageToNode(nodeId, path, payload) + } + + private fun handlePushToTalkCommand(messageEvent: MessageEvent) { + val payload = messageEvent.data + val payloadString = String(payload) + Log.i(TAG, "handlePushToTalkCommand: PushToTalk command received! payloadString=${quote(payloadString)}") + when (payloadString) { + "on" -> pushToTalk(true, sourceNodeId = messageEvent.sourceNodeId) + "off" -> pushToTalk(false, sourceNodeId = messageEvent.sourceNodeId) + } + } + + abstract fun pushToTalk(on: Boolean, sourceNodeId: String? = null) + + protected open fun pushToTalkLocal(on: Boolean) { + Log.i(TAG, "pushToTalkLocal(on=$on)") + } +} \ No newline at end of file diff --git a/wear/src/main/java/com/swooby/phonewearremote/Utils.kt b/shared/src/main/java/com/swooby/phonewearremote/Utils.kt similarity index 74% rename from wear/src/main/java/com/swooby/phonewearremote/Utils.kt rename to shared/src/main/java/com/swooby/phonewearremote/Utils.kt index 2cdcfd7..1a905c4 100644 --- a/wear/src/main/java/com/swooby/phonewearremote/Utils.kt +++ b/shared/src/main/java/com/swooby/phonewearremote/Utils.kt @@ -1,5 +1,8 @@ package com.swooby.phonewearremote +import android.content.Context +import android.media.MediaPlayer +import android.util.Log import androidx.wear.phone.interactions.PhoneTypeHelper import kotlin.reflect.KClass import kotlin.reflect.full.companionObject @@ -7,6 +10,8 @@ import kotlin.reflect.full.memberProperties import kotlin.reflect.full.staticProperties object Utils { + private const val TAG = "Utils" + /** * Creates a map of a class' field values to names */ @@ -66,4 +71,23 @@ object Utils { return value.toString() } + + fun playAudioResourceOnce( + context: Context, + audioResourceId: Int, + volume: Float = 0.7f, + state: Any? = null, + onCompletion: ((Any?) -> Unit)? = null + ) { + Log.d(TAG, "+playAudioResourceOnce(..., audioResourceId=$audioResourceId, ...)") + MediaPlayer.create(context, audioResourceId).apply { + setVolume(volume, volume) + setOnCompletionListener { + onCompletion?.invoke(state) + it.release() + Log.d(TAG, "-playAudioResourceOnce(..., audioResourceId=$audioResourceId, ...)") + } + start() + } + } } \ No newline at end of file diff --git a/shared/src/test/java/com/swooby/phonewearremote/ExampleUnitTest.kt b/shared/src/test/java/com/swooby/phonewearremote/ExampleUnitTest.kt new file mode 100644 index 0000000..df3ae54 --- /dev/null +++ b/shared/src/test/java/com/swooby/phonewearremote/ExampleUnitTest.kt @@ -0,0 +1,17 @@ +package com.swooby.phonewearremote + +import org.junit.Test + +import org.junit.Assert.* + +/** + * Example local unit test, which will execute on the development machine (host). + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +class ExampleUnitTest { + @Test + fun addition_isCorrect() { + assertEquals(4, 2 + 2) + } +} \ No newline at end of file diff --git a/wear/build.gradle.kts b/wear/build.gradle.kts index 7a9164c..bfff534 100644 --- a/wear/build.gradle.kts +++ b/wear/build.gradle.kts @@ -39,28 +39,28 @@ android { } dependencies { - implementation(libs.play.services.wearable) + 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.compose.material) implementation(libs.androidx.compose.foundation) - implementation(libs.androidx.wear.tooling.preview) - implementation(libs.androidx.activity.compose) + implementation(libs.androidx.compose.material) implementation(libs.androidx.core.splashscreen) implementation(libs.androidx.tiles) implementation(libs.androidx.tiles.material) implementation(libs.androidx.tiles.tooling.preview) - implementation(libs.horologist.compose.tools) - implementation(libs.horologist.tiles) + implementation(libs.androidx.ui) + implementation(libs.androidx.ui.graphics) + implementation(libs.androidx.ui.tooling.preview) implementation(libs.androidx.watchface.complications.data.source.ktx) implementation(libs.androidx.wear.phone.interactions) + implementation(libs.androidx.wear.tooling.preview) + implementation(libs.horologist.compose.tools) + implementation(libs.horologist.tiles) + implementation(libs.play.services.wearable) + implementation(project(":shared")) + androidTestImplementation(platform(libs.androidx.compose.bom)) androidTestImplementation(libs.androidx.ui.test.junit4) - debugImplementation(libs.androidx.ui.tooling) - debugImplementation(libs.androidx.ui.test.manifest) debugImplementation(libs.androidx.tiles.tooling) - - implementation(libs.kotlin.reflect) + debugImplementation(libs.androidx.ui.test.manifest) + debugImplementation(libs.androidx.ui.tooling) } diff --git a/wear/src/main/java/com/swooby/phonewearremote/WearViewModel.kt b/wear/src/main/java/com/swooby/phonewearremote/WearViewModel.kt index c6d9a55..0574d19 100644 --- a/wear/src/main/java/com/swooby/phonewearremote/WearViewModel.kt +++ b/wear/src/main/java/com/swooby/phonewearremote/WearViewModel.kt @@ -2,90 +2,116 @@ package com.swooby.phonewearremote import android.app.Application import android.util.Log -import androidx.lifecycle.AndroidViewModel -import com.google.android.gms.tasks.Task -import com.google.android.gms.wearable.CapabilityClient -import com.google.android.gms.wearable.MessageClient -import com.google.android.gms.wearable.MessageEvent -import com.google.android.gms.wearable.Node -import com.google.android.gms.wearable.Wearable -import com.swooby.phonewearremote.Utils.quote -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.asStateFlow +import com.swooby.phonewearremote.Utils.playAudioResourceOnce class WearViewModel(application: Application) : - AndroidViewModel(application) { - companion object { - private const val TAG = "WearViewModel" - } - - // setter - private var _phoneAppNodeId = MutableStateFlow(null) - // getter - val phoneAppNodeId = _phoneAppNodeId.asStateFlow() - - private val capabilityClient by lazy { Wearable.getCapabilityClient(application) } - - private val messageClient by lazy { Wearable.getMessageClient(application) } - private val messageClientListener = MessageClient.OnMessageReceivedListener { - onMessageClientMessageReceived(it) - } - - fun init() { - messageClient.addListener(messageClientListener) + SharedViewModel(application) { + override val TAG: String + get() = "WearViewModel" + override val remoteTypeName: String + get() = "MOBILE" + override val remoteCapabilityName: String + get() = "verify_remote_example_mobile_app" - val capabilityClientTask = capabilityClient.getCapability( - "verify_remote_example_mobile_app", - CapabilityClient.FILTER_REACHABLE - ) - capabilityClientTask.addOnCompleteListener { - if (it.isSuccessful) { - Log.d(TAG, "Capability request succeeded"); - _phoneAppNodeId.value = pickFirstNearbyNode(it.result.nodes)?.id - if (phoneAppNodeId.value == null) { - Log.d(TAG, "Detected no phone app node") - } else { - Log.i(TAG, "Detected phoneAppNodeId=${quote(phoneAppNodeId.value)}") - } - } else { - Log.e(TAG, "Capability request failed to return any results"); + override fun pushToTalk(on: Boolean, sourceNodeId: String?) { + Log.i(TAG, "pushToTalk(on=$on)") + if (on) { + if (_pushToTalkState.value != PttState.Pressed) { + _pushToTalkState.value = PttState.Pressed + playAudioResourceOnce(getApplication(), R.raw.ptt_touch) + //provideHapticFeedback(context) + } + } else { + if (_pushToTalkState.value != PttState.Idle) { + _pushToTalkState.value = PttState.Idle + playAudioResourceOnce(getApplication(), R.raw.ptt_touch) + //provideHapticFeedback(context) } } - } - fun close() { - messageClient.removeListener(messageClientListener) - } + var doPushToTalkLocal = true - private fun onMessageClientMessageReceived(messageEvent: MessageEvent) { - val path = messageEvent.path - val payload = String(messageEvent.data, Charsets.UTF_8) - // Handle incoming message from watch/phone - Log.d(TAG, "onMessageReceived: $path, $payload") - } - - private fun pickFirstNearbyNode(nodes: Set): Node? { - return nodes.firstOrNull { it.isNearby } ?: nodes.firstOrNull() - } + if (sourceNodeId == null) { + // request from local/wear + Log.d(TAG, "pushToTalk: PTT $on **from** local/wear...") + val remoteAppNodeId = _remoteAppNodeId.value + if (remoteAppNodeId != null) { + doPushToTalkLocal = false + // tell remote/mobile app to do the PTTing... + sendPushToTalkCommand(remoteAppNodeId, on) + } + } else { + // request from remote/mobile + _remoteAppNodeId.value = sourceNodeId + Log.d(TAG, "pushToTalk: PTT $on **from** remote/mobile...") + } - private fun sendMessageToNode(nodeId: String, path: String, data: String): Task { - return sendMessageToNode(nodeId, path, data.toByteArray()) + if (doPushToTalkLocal) { + pushToTalkLocal(on) + } } - private fun sendMessageToNode(nodeId: String, path: String, data: ByteArray): Task { - return messageClient - .sendMessage(nodeId, path, data) - .addOnSuccessListener { - Log.d(TAG, "sendMessageToNode: Message sent successfully to $nodeId") + override fun pushToTalkLocal(on: Boolean) { + super.pushToTalkLocal(on) + if (on) { + /* + pushToTalkViewModel?.realtimeClient?.also { realtimeClient -> + Log.d(TAG, "") + Log.d(TAG, "+onPushToTalkStart: pttState=$pttState") + // 1. Play the start sound + Log.d(TAG, "onPushToTalkStart: playing start sound") + playAudioResourceOnce( + context = pushToTalkViewModel.getApplication(), + audioResourceId = R.raw.quindar_nasa_apollo_intro, + volume = 0.2f, + ) { + // 2. Wait for the start sound to finish + Log.d(TAG, "onPushToTalkStart: start sound finished") + // 3. Open the mic + Log.d(TAG, "onPushToTalkStart: opening mic") + realtimeClient.setLocalAudioTrackMicrophoneEnabled(true) + Log.d(TAG, "onPushToTalkStart: mic opened") + // 4. Wait for the mic to open successfully + //... + Log.d(TAG, "-onPushToTalkStart") + Log.d(TAG, "") + } } - .addOnFailureListener { e -> - Log.e(TAG, "sendMessageToNode: Message failed.", e) + */ + } else { + /* + pushToTalkViewModel?.realtimeClient?.also { realtimeClient -> + Log.d(TAG, "") + Log.d(TAG, "+onPushToTalkStop: pttState=$pttState") + // 1. Close the mic + Log.d(TAG, "onPushToTalkStop: closing mic") + realtimeClient.setLocalAudioTrackMicrophoneEnabled(false) + Log.d(TAG, "onPushToTalkStop: mic closed") + // 2. Wait for the mic to close successfully + //... + // 3. Send input_audio_buffer.commit + Log.d(TAG, "onPushToTalkStop: sending input_audio_buffer.commit") + realtimeClient.dataSendInputAudioBufferCommit() + Log.d(TAG, "onPushToTalkStop: input_audio_buffer.commit sent") + // 4. Send response.create + Log.d(TAG, "onPushToTalkStop: sending response.create") + realtimeClient.dataSendResponseCreate() + Log.d(TAG, "onPushToTalkStop: response.create sent") + // 5. Play the stop sound + Log.d(TAG, "onPushToTalkStop: playing stop sound") + playAudioResourceOnce( + context = pushToTalkViewModel.getApplication(), + audioResourceId = R.raw.quindar_nasa_apollo_outro, + volume = 0.2f, + ) { + // 6. Wait for the stop sound to finish + Log.d(TAG, "onPushToTalkStop: stop sound finished") + //... + Log.d(TAG, "-onPushToTalkStop") + Log.d(TAG, "") + } } - } - - fun sendPushToTalkCommand(nodeId: String, on: Boolean): Task { - val path = "/pushToTalk" - val payload = (if (on) "on" else "off") - return sendMessageToNode(nodeId, path, payload) + */ + } } } \ No newline at end of file diff --git a/wear/src/main/java/com/swooby/phonewearremote/presentation/MainActivity.kt b/wear/src/main/java/com/swooby/phonewearremote/presentation/WearActivity.kt similarity index 65% rename from wear/src/main/java/com/swooby/phonewearremote/presentation/MainActivity.kt rename to wear/src/main/java/com/swooby/phonewearremote/presentation/WearActivity.kt index 3d0d883..3891408 100644 --- a/wear/src/main/java/com/swooby/phonewearremote/presentation/MainActivity.kt +++ b/wear/src/main/java/com/swooby/phonewearremote/presentation/WearActivity.kt @@ -7,6 +7,7 @@ package com.swooby.phonewearremote.presentation import android.os.Bundle import android.util.Log +import android.view.KeyEvent import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.viewModels @@ -20,17 +21,18 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.input.pointer.changedToUp import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalView import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -39,10 +41,14 @@ import androidx.wear.compose.material.MaterialTheme import androidx.wear.compose.material.Text import androidx.wear.compose.material.TimeText import androidx.wear.tooling.preview.devices.WearDevices +import com.swooby.phonewearremote.SharedViewModel import com.swooby.phonewearremote.WearViewModel import com.swooby.phonewearremote.presentation.theme.PhoneWearRemoteTheme class MainActivity : ComponentActivity() { + companion object { + private const val TAG = "MainActivity" + } private val wearViewModel: WearViewModel by viewModels() @@ -77,11 +83,6 @@ fun WearApp( targetNameDefault: String, wearViewModel: WearViewModel? = null, ) { - @Suppress("LocalVariableName") - val TAG = "WearApp" - - val phoneAppNodeId by wearViewModel?.phoneAppNodeId?.collectAsState() ?: remember { mutableStateOf(null) } - PhoneWearRemoteTheme { Box( modifier = Modifier @@ -90,56 +91,39 @@ fun WearApp( contentAlignment = Alignment.Center ) { TimeText() - RemoteButton( - targetName = if (phoneAppNodeId != null) "Phone" else targetNameDefault, + KeepScreenOnComposable() + PushToTalkButton( + wearViewModel = wearViewModel, + targetNameDefault = targetNameDefault, onPushToTalkStart = { - @Suppress("NAME_SHADOWING") - val phoneAppNodeId = phoneAppNodeId - if (phoneAppNodeId != null) { - Log.d(TAG, "onClick: PTT on phone...") - wearViewModel?.sendPushToTalkCommand(phoneAppNodeId, true) - } else { - Log.d(TAG, "onClick: TODO: PTT on local ...") - // ... - } - true + wearViewModel?.pushToTalk(true) }, onPushToTalkStop = { - @Suppress("NAME_SHADOWING") - val phoneAppNodeId = phoneAppNodeId - if (phoneAppNodeId != null) { - Log.d(TAG, "onClick: PTT off phone...") - wearViewModel?.sendPushToTalkCommand(phoneAppNodeId, false) - } else { - Log.d(TAG, "onClick: TODO: PTT off local ...") - // ... - } - true + wearViewModel?.pushToTalk(false) }, ) } } } -enum class PTTState { - Idle, - Pressed -} - @Composable -fun RemoteButton( - targetName: String, +fun PushToTalkButton( + wearViewModel: WearViewModel? = null, + targetNameDefault: String, enabled: Boolean = true, - onPushToTalkStart: (pttState: PTTState) -> Boolean = { - Log.d("PTT", "Push-to-Talk Start") - false - }, - onPushToTalkStop: (pttState: PTTState) -> Boolean = { - Log.d("PTT", "Push-to-Talk Stop") - false - } + onPushToTalkStart: () -> Unit = {}, + onPushToTalkStop: () -> Unit = {} ) { - var pttState by remember { mutableStateOf(PTTState.Idle) } + val pttState by wearViewModel + ?.pushToTalkState + ?.collectAsState() + ?: remember { mutableStateOf(SharedViewModel.PttState.Idle) } + + val phoneAppNodeId by wearViewModel + ?.remoteAppNodeId + ?.collectAsState() + ?: remember { mutableStateOf(null) } + val targetName = if (phoneAppNodeId != null) "Phone" else targetNameDefault val disabledColor = MaterialTheme.colors.onSurface.copy(alpha = 0.38f) val boxAlpha = if (enabled) 1.0f else 0.38f @@ -150,7 +134,7 @@ fun RemoteButton( .size(120.dp) .border(4.dp, if (enabled) MaterialTheme.colors.primary else disabledColor, shape = CircleShape) .background( - color = if (pttState == PTTState.Pressed) MaterialTheme.colors.primary else MaterialTheme.colors.surface, + color = if (pttState == SharedViewModel.PttState.Pressed) MaterialTheme.colors.primary else MaterialTheme.colors.surface, shape = CircleShape ) .let { baseModifier -> @@ -158,23 +142,11 @@ fun RemoteButton( baseModifier.pointerInput(Unit) { awaitEachGesture { awaitFirstDown() - if (pttState == PTTState.Idle) { - pttState = PTTState.Pressed - if (!onPushToTalkStart(pttState)) { - //provideHapticFeedback(context) - //provideAudibleFeedback(context, pttState) - } - } + onPushToTalkStart() do { val event = awaitPointerEvent() } while (event.changes.any { !it.changedToUp() }) - if (pttState == PTTState.Pressed) { - pttState = PTTState.Idle - if (!onPushToTalkStop(pttState)) { - //provideHapticFeedback(context) - //provideAudibleFeedback(context, pttState) - } - } + onPushToTalkStop() } } } else { @@ -202,3 +174,14 @@ fun RemoteButton( ) } } + +@Composable +fun KeepScreenOnComposable() { + val view = LocalView.current + DisposableEffect(Unit) { + view.keepScreenOn = true + onDispose { + view.keepScreenOn = false + } + } +} diff --git a/wear/src/main/res/raw/ptt_touch.wav b/wear/src/main/res/raw/ptt_touch.wav new file mode 100644 index 0000000000000000000000000000000000000000..302fe766cd87208d95717f6146092c0e5d35eae2 GIT binary patch literal 3228 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 Co=xlk literal 0 HcmV?d00001