diff --git a/mobile/src/main/AndroidManifest.xml b/mobile/src/main/AndroidManifest.xml index ac19bd0..57bd0b4 100644 --- a/mobile/src/main/AndroidManifest.xml +++ b/mobile/src/main/AndroidManifest.xml @@ -16,7 +16,9 @@ android:roundIcon="@mipmap/ic_launcher_round" android:supportsRtl="true" android:theme="@style/Theme.AlfredAI" + android:enableOnBackInvokedCallback="true" > + + \ No newline at end of file diff --git a/mobile/src/main/java/com/swooby/alfredai/AlfredAiApp.kt b/mobile/src/main/java/com/swooby/alfredai/AlfredAiApp.kt index 2427b19..aaa56ea 100644 --- a/mobile/src/main/java/com/swooby/alfredai/AlfredAiApp.kt +++ b/mobile/src/main/java/com/swooby/alfredai/AlfredAiApp.kt @@ -1,13 +1,42 @@ package com.swooby.alfredai import android.app.Application +import androidx.activity.ComponentActivity +import androidx.annotation.MainThread +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelLazy +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.ViewModelStore +import androidx.lifecycle.ViewModelStoreOwner + +@MainThread +inline fun ComponentActivity.appViewModels(): Lazy { + return ViewModelLazy( + VM::class, + { (application as AlfredAiApp).viewModelStore }, + { ViewModelProvider.AndroidViewModelFactory.getInstance(application) }, + { defaultViewModelCreationExtras } + ) +} + +class AlfredAiApp : Application(), ViewModelStoreOwner +{ + override val viewModelStore = ViewModelStore() + + val mobileViewModel by lazy { + ViewModelProvider( + this, + ViewModelProvider.AndroidViewModelFactory.getInstance(this) + )[MobileViewModel::class.java] + } -class AlfredAiApp : Application() { override fun onCreate() { super.onCreate() + mobileViewModel.init() } override fun onTerminate() { super.onTerminate() + mobileViewModel.close() } } diff --git a/mobile/src/main/java/com/swooby/alfredai/MobileActivity.kt b/mobile/src/main/java/com/swooby/alfredai/MobileActivity.kt index 05dae73..feb920c 100644 --- a/mobile/src/main/java/com/swooby/alfredai/MobileActivity.kt +++ b/mobile/src/main/java/com/swooby/alfredai/MobileActivity.kt @@ -67,6 +67,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color 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 @@ -122,15 +123,14 @@ class MobileActivity : ComponentActivity() { private const val TAG = "PushToTalkActivity" } - private val mobileViewModel: MobileViewModel by viewModels() + private val mobileViewModel: MobileViewModel by appViewModels() override fun onCreate(savedInstanceState: Bundle?) { Log.d(TAG, "onCreate()") super.onCreate(savedInstanceState) - enableEdgeToEdge() setContent { - PushToTalkScreen(mobileViewModel) + MobileApp(mobileViewModel) } } @@ -150,9 +150,9 @@ enum class ConversationSpeaker { @OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) @Composable -fun PushToTalkScreen(mobileViewModel: MobileViewModel? = null) { +fun MobileApp(mobileViewModel: MobileViewModel? = null) { @Suppress("LocalVariableName") - val TAG = "PushToTalkScreen" + val TAG = "MobileApp" @Suppress( "SimplifyBooleanWithConstants", @@ -973,6 +973,9 @@ fun PushToTalkScreen(mobileViewModel: MobileViewModel? = null) { , contentAlignment = Alignment.Center ) { + if (isConnected) { + KeepScreenOnComposable() + } Box { when { isConnected -> { @@ -1086,13 +1089,24 @@ fun PushToTalkScreen(mobileViewModel: MobileViewModel? = null) { // } +@Composable +fun KeepScreenOnComposable() { + val view = LocalView.current + DisposableEffect(Unit) { + view.keepScreenOn = true + onDispose { + view.keepScreenOn = false + } + } +} + @Preview( uiMode = Configuration.UI_MODE_NIGHT_NO, showBackground = true ) @Composable fun PushToTalkButtonActivityPreviewLight() { - PushToTalkScreen() + MobileApp() } @Preview( @@ -1101,5 +1115,5 @@ fun PushToTalkButtonActivityPreviewLight() { ) @Composable fun PushToTalkButtonActivityPreviewDark() { - PushToTalkScreen() + MobileApp() } diff --git a/mobile/src/main/java/com/swooby/alfredai/MobileViewModel.kt b/mobile/src/main/java/com/swooby/alfredai/MobileViewModel.kt index 9b05db6..bfd2a6d 100644 --- a/mobile/src/main/java/com/swooby/alfredai/MobileViewModel.kt +++ b/mobile/src/main/java/com/swooby/alfredai/MobileViewModel.kt @@ -59,7 +59,7 @@ class MobileViewModel(application: Application) : override val remoteTypeName: String get() = "MOBILE" override val remoteCapabilityName: String - get() = "verify_remote_example_wear_app" + get() = "verify_remote_alfredai_wear_app" private val prefs = PushToTalkPreferences(application) @@ -231,7 +231,38 @@ class MobileViewModel(application: Application) : } override fun pushToTalk(on: Boolean, sourceNodeId: String?) { - TODO("Not yet implemented") + Log.i(TAG, "pushToTalk(on=$on)") + if (on) { + if (pushToTalkState.value != PttState.Pressed) { + setPushToTalkState(PttState.Pressed) + playAudioResourceOnce(getApplication(), R.raw.quindar_nasa_apollo_intro) + //provideHapticFeedback(context) + } + } else { + if (pushToTalkState.value != PttState.Idle) { + setPushToTalkState(PttState.Idle) + playAudioResourceOnce(getApplication(), R.raw.quindar_nasa_apollo_outro) + //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) { diff --git a/mobile/src/main/res/values/wear.xml b/mobile/src/main/res/values/wear.xml index 38efa61..9ff8fc8 100644 --- a/mobile/src/main/res/values/wear.xml +++ b/mobile/src/main/res/values/wear.xml @@ -4,6 +4,6 @@ tools:keep="@array/android_wear_capabilities" > - verify_remote_example_phone_app + verify_remote_alfredai_mobile_app \ No newline at end of file diff --git a/shared/src/main/java/com/swooby/alfredai/SharedViewModel.kt b/shared/src/main/java/com/swooby/alfredai/SharedViewModel.kt index 2bfc56c..e9069ef 100644 --- a/shared/src/main/java/com/swooby/alfredai/SharedViewModel.kt +++ b/shared/src/main/java/com/swooby/alfredai/SharedViewModel.kt @@ -25,10 +25,18 @@ abstract class SharedViewModel(application: Application) : protected abstract val remoteTypeName: String protected abstract val remoteCapabilityName: String - protected var _remoteAppNodeId = MutableStateFlow(null) // private mutable + private fun setRemoteAppNodeId(nodeId: String?) { + Log.i(TAG, "setRemoteAppNodeId(nodeId=${quote(nodeId)})") + _remoteAppNodeId.value = nodeId + } + private var _remoteAppNodeId = MutableStateFlow(null) val remoteAppNodeId = _remoteAppNodeId.asStateFlow() // public readonly - protected var _pushToTalkState = MutableStateFlow(PttState.Idle) // private mutable + protected fun setPushToTalkState(state: PttState) { + Log.i(TAG, "setPushToTalkState(state=$state)") + _pushToTalkState.value = state + } + private var _pushToTalkState = MutableStateFlow(PttState.Idle) val pushToTalkState = _pushToTalkState.asStateFlow() // public readonly private val capabilityClient by lazy { Wearable.getCapabilityClient(application) } @@ -79,8 +87,8 @@ abstract class SharedViewModel(application: Application) : return messageClient .sendMessage(nodeId, path, data) .addOnFailureListener { e -> - Log.e(TAG, "sendMessageToNode: Message failed.", e) - _remoteAppNodeId.value = null + Log.e(TAG, "sendMessageToNode: Message failed", e) + setRemoteAppNodeId(null) } } @@ -99,8 +107,8 @@ abstract class SharedViewModel(application: Application) : 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...") + Log.i(TAG, "handlePingCommand: Ping request from $remoteTypeName app nodeId=${quote(nodeId)}; Responding pong...") + setRemoteAppNodeId(nodeId) sendPongCommand(nodeId) } @@ -110,8 +118,8 @@ abstract class SharedViewModel(application: Application) : 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)}") + Log.i(TAG, "handlePongCommand: Pong response from $remoteTypeName app nodeId=${quote(nodeId)}") + setRemoteAppNodeId(nodeId) } protected fun sendPushToTalkCommand(nodeId: String, on: Boolean): Task { @@ -121,12 +129,13 @@ abstract class SharedViewModel(application: Application) : } private fun handlePushToTalkCommand(messageEvent: MessageEvent) { - val payload = messageEvent.data - val payloadString = String(payload) - Log.i(TAG, "handlePushToTalkCommand: PushToTalk command received! payloadString=${quote(payloadString)}") + val nodeId = messageEvent.sourceNodeId + val payloadString = String(messageEvent.data) + Log.i(TAG, "handlePushToTalkCommand: PushToTalk request from $remoteTypeName app nodeId=${quote(nodeId)}! payloadString=${quote(payloadString)}") + setRemoteAppNodeId(nodeId) when (payloadString) { - "on" -> pushToTalk(true, sourceNodeId = messageEvent.sourceNodeId) - "off" -> pushToTalk(false, sourceNodeId = messageEvent.sourceNodeId) + "on" -> pushToTalk(true, sourceNodeId = nodeId) + "off" -> pushToTalk(false, sourceNodeId = nodeId) } } diff --git a/wear/src/main/AndroidManifest.xml b/wear/src/main/AndroidManifest.xml index 891f8c9..7c76d3a 100644 --- a/wear/src/main/AndroidManifest.xml +++ b/wear/src/main/AndroidManifest.xml @@ -11,8 +11,11 @@ + android:theme="@android:style/Theme.DeviceDefault" + > + - @@ -37,7 +39,6 @@ - @@ -58,12 +59,14 @@ android:name=".presentation.WearActivity" android:exported="true" android:taskAffinity="" - android:theme="@style/MainActivityTheme.Starting"> + android:theme="@style/MainActivityTheme.Starting" + > + \ No newline at end of file diff --git a/wear/src/main/java/com/swooby/alfredai/AlfredAiApp.kt b/wear/src/main/java/com/swooby/alfredai/AlfredAiApp.kt new file mode 100644 index 0000000..3319e9c --- /dev/null +++ b/wear/src/main/java/com/swooby/alfredai/AlfredAiApp.kt @@ -0,0 +1,41 @@ +package com.swooby.alfredai + +import android.app.Application +import androidx.activity.ComponentActivity +import androidx.annotation.MainThread +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelLazy +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.ViewModelStore +import androidx.lifecycle.ViewModelStoreOwner + +@MainThread +inline fun ComponentActivity.appViewModels(): Lazy { + return ViewModelLazy( + VM::class, + { (application as AlfredAiApp).viewModelStore }, + { ViewModelProvider.AndroidViewModelFactory.getInstance(application) }, + { defaultViewModelCreationExtras } + ) +} + +class AlfredAiApp : Application(), ViewModelStoreOwner { + override val viewModelStore = ViewModelStore() + + val wearViewModel by lazy { + ViewModelProvider( + this, + ViewModelProvider.AndroidViewModelFactory.getInstance(this) + )[WearViewModel::class.java] + } + + override fun onCreate() { + super.onCreate() + wearViewModel.init() + } + + override fun onTerminate() { + super.onTerminate() + wearViewModel.close() + } +} diff --git a/wear/src/main/java/com/swooby/alfredai/WearViewModel.kt b/wear/src/main/java/com/swooby/alfredai/WearViewModel.kt index 17f50d1..285d902 100644 --- a/wear/src/main/java/com/swooby/alfredai/WearViewModel.kt +++ b/wear/src/main/java/com/swooby/alfredai/WearViewModel.kt @@ -9,6 +9,7 @@ import com.google.android.gms.wearable.DataItem import com.google.android.gms.wearable.PutDataMapRequest import com.google.android.gms.wearable.PutDataRequest import com.google.android.gms.wearable.Wearable +import com.swooby.alfredai.Utils.playAudioResourceOnce class WearViewModel(application: Application) : SharedViewModel(application) @@ -18,7 +19,7 @@ class WearViewModel(application: Application) : override val remoteTypeName: String get() = "MOBILE" override val remoteCapabilityName: String - get() = "verify_remote_example_mobile_app" + get() = "verify_remote_alfredai_mobile_app" private val dataClient = Wearable.getDataClient(application) private val dataClientListener = DataClient.OnDataChangedListener { @@ -54,11 +55,50 @@ class WearViewModel(application: Application) : } override fun pushToTalk(on: Boolean, sourceNodeId: String?) { - //... + Log.i(TAG, "pushToTalk(on=$on)") + if (on) { + if (pushToTalkState.value != PttState.Pressed) { + setPushToTalkState(PttState.Pressed) + playAudioResourceOnce(getApplication(), R.raw.quindar_nasa_apollo_intro) + //provideHapticFeedback(context) + } + } else { + if (pushToTalkState.value != PttState.Idle) { + setPushToTalkState(PttState.Idle) + playAudioResourceOnce(getApplication(), R.raw.quindar_nasa_apollo_outro) + //provideHapticFeedback(context) + } + } + + var doPushToTalkLocal = true + + 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...") + doPushToTalkLocal = false + } + + if (doPushToTalkLocal) { + pushToTalkLocal(on) + } } override fun pushToTalkLocal(on: Boolean) { super.pushToTalkLocal(on) - //... + if (on) { + TODO("Not yet implemented") + } else { + TODO("Not yet implemented") + } } } diff --git a/wear/src/main/java/com/swooby/alfredai/presentation/WearActivity.kt b/wear/src/main/java/com/swooby/alfredai/presentation/WearActivity.kt index 9988816..a060b8b 100644 --- a/wear/src/main/java/com/swooby/alfredai/presentation/WearActivity.kt +++ b/wear/src/main/java/com/swooby/alfredai/presentation/WearActivity.kt @@ -4,6 +4,7 @@ import android.os.Bundle import android.util.Log import androidx.activity.ComponentActivity import androidx.activity.compose.setContent +import androidx.activity.viewModels import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.gestures.awaitEachGesture @@ -13,6 +14,8 @@ import androidx.compose.foundation.layout.fillMaxSize 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.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -24,6 +27,7 @@ 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.res.painterResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -35,9 +39,11 @@ import androidx.wear.compose.material.Icon import androidx.wear.compose.material.MaterialTheme import androidx.wear.compose.material.TimeText import androidx.wear.tooling.preview.devices.WearDevices +import com.swooby.alfredai.AlfredAiApp import com.swooby.alfredai.R import com.swooby.alfredai.SharedViewModel import com.swooby.alfredai.WearViewModel +import com.swooby.alfredai.appViewModels import com.swooby.alfredai.presentation.theme.AlfredAITheme // TODO: If phone app is not running: @@ -49,17 +55,13 @@ class WearActivity : ComponentActivity() { private const val TAG = "MainActivity" } - private lateinit var wearViewModel: WearViewModel + private val wearViewModel: WearViewModel by appViewModels() override fun onCreate(savedInstanceState: Bundle?) { Log.d(TAG, "onCreate()") installSplashScreen() super.onCreate(savedInstanceState) - - wearViewModel = ViewModelProvider(this)[WearViewModel::class.java] - setTheme(android.R.style.Theme_DeviceDefault) - setContent { WearApp("Wear", wearViewModel) } @@ -68,29 +70,34 @@ class WearActivity : ComponentActivity() { override fun onDestroy() { Log.d(TAG, "onDestroy()") super.onDestroy() - wearViewModel.close() } } @Preview(device = WearDevices.SMALL_ROUND, showSystemUi = true) @Composable fun DefaultPreview() { - WearApp("Preview Wear") + WearApp("Preview") } @Composable fun WearApp( - greetingName: String, + targetNameDefault: String, wearViewModel: WearViewModel? = null, ) { @Suppress("LocalVariableName") val TAG = "WearApp" - val phoneAppNodeId by wearViewModel?.remoteAppNodeId?.collectAsState() ?: remember { mutableStateOf(null) } + val phoneAppNodeId by wearViewModel + ?.remoteAppNodeId + ?.collectAsState() + ?: remember { mutableStateOf(null) } + Log.d(TAG, "phoneAppNodeId is: $phoneAppNodeId") + LaunchedEffect(phoneAppNodeId) { + Log.d(TAG, "phoneAppNodeId changed to: $phoneAppNodeId") + } var isConnectingOrConnected by remember { mutableStateOf(false) } - var isConnected = phoneAppNodeId != null - // by remember { mutableStateOf(false || nodeList.isNotEmpty()) } + val isConnected = phoneAppNodeId != null val disabledColor = MaterialTheme.colors.onSurface.copy(alpha = ContentAlpha.disabled) @@ -102,6 +109,9 @@ fun WearApp( contentAlignment = Alignment.Center ) { TimeText() + if (isConnected) { + KeepScreenOnComposable() + } Box { when { isConnected -> { @@ -112,6 +122,7 @@ fun WearApp( modifier = Modifier.size(150.dp) ) } + isConnectingOrConnected -> { CircularProgressIndicator( indicatorColor = MaterialTheme.colors.primary, @@ -119,6 +130,7 @@ fun WearApp( modifier = Modifier.size(150.dp) ) } + else -> { CircularProgressIndicator( progress = 0f, @@ -204,9 +216,11 @@ fun PushToTalkButton( } .then(Modifier.background(Color.Transparent)) .let { - it.background(Color.Transparent).graphicsLayer { - this.alpha = boxAlpha - } + it + .background(Color.Transparent) + .graphicsLayer { + this.alpha = boxAlpha + } } ) { val iconRes = if (enabled) { @@ -226,3 +240,14 @@ fun PushToTalkButton( ) } } + +@Composable +fun KeepScreenOnComposable() { + val view = LocalView.current + DisposableEffect(Unit) { + view.keepScreenOn = true + onDispose { + view.keepScreenOn = false + } + } +} diff --git a/wear/src/main/res/raw/quindar_nasa_apollo_intro.wav b/wear/src/main/res/raw/quindar_nasa_apollo_intro.wav new file mode 100644 index 0000000..378e02c Binary files /dev/null and b/wear/src/main/res/raw/quindar_nasa_apollo_intro.wav differ diff --git a/wear/src/main/res/raw/quindar_nasa_apollo_outro.wav b/wear/src/main/res/raw/quindar_nasa_apollo_outro.wav new file mode 100644 index 0000000..284b71f Binary files /dev/null and b/wear/src/main/res/raw/quindar_nasa_apollo_outro.wav differ diff --git a/wear/src/main/res/values/wear.xml b/wear/src/main/res/values/wear.xml index 5565869..f767c1b 100644 --- a/wear/src/main/res/values/wear.xml +++ b/wear/src/main/res/values/wear.xml @@ -4,6 +4,6 @@ tools:keep="@array/android_wear_capabilities" > - verify_remote_example_wear_app + verify_remote_alfredai_wear_app \ No newline at end of file