diff --git a/datalayer/phone-ui/api/current.api b/datalayer/phone-ui/api/current.api index be0b85995f..23a20adb6d 100644 --- a/datalayer/phone-ui/api/current.api +++ b/datalayer/phone-ui/api/current.api @@ -16,6 +16,7 @@ package com.google.android.horologist.datalayer.phone.ui.prompt.installapp { @com.google.android.horologist.annotations.ExperimentalHorologistApi public final class InstallAppPrompt { ctor public InstallAppPrompt(com.google.android.horologist.datalayer.phone.PhoneDataLayerAppHelper phoneDataLayerAppHelper); method public android.content.Intent getIntent(android.content.Context context, String appPackageName, @DrawableRes int image, String topMessage, String bottomMessage); + method public void performAction(android.content.Context context, String appPackageName); method public suspend Object? shouldDisplayPrompt(optional kotlin.jvm.functions.Function1? predicate, optional kotlin.coroutines.Continuation); } diff --git a/datalayer/phone-ui/src/main/java/com/google/android/horologist/datalayer/phone/ui/prompt/installapp/InstallAppBottomSheetActivity.kt b/datalayer/phone-ui/src/main/java/com/google/android/horologist/datalayer/phone/ui/prompt/installapp/InstallAppBottomSheetActivity.kt index a31c012b8d..d40c0b449c 100644 --- a/datalayer/phone-ui/src/main/java/com/google/android/horologist/datalayer/phone/ui/prompt/installapp/InstallAppBottomSheetActivity.kt +++ b/datalayer/phone-ui/src/main/java/com/google/android/horologist/datalayer/phone/ui/prompt/installapp/InstallAppBottomSheetActivity.kt @@ -30,7 +30,6 @@ import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.res.painterResource -import com.google.android.horologist.datalayer.phone.ui.play.launchPlay import kotlinx.coroutines.launch internal const val INSTALL_APP_KEY_APP_PACKAGE_NAME = "HOROLOGIST_INSTALL_APP_KEY_APP_PACKAGE_NAME" @@ -80,7 +79,7 @@ internal class InstallAppBottomSheetActivity : ComponentActivity() { } }, onConfirmation = { - this.launchPlay(appPackageName) + InstallAppPromptAction.run(context = this, appPackageName = appPackageName) setResult(RESULT_OK) finishWithoutAnimation() diff --git a/datalayer/phone-ui/src/main/java/com/google/android/horologist/datalayer/phone/ui/prompt/installapp/InstallAppPrompt.kt b/datalayer/phone-ui/src/main/java/com/google/android/horologist/datalayer/phone/ui/prompt/installapp/InstallAppPrompt.kt index d4e41dd8e3..55eb03fba4 100644 --- a/datalayer/phone-ui/src/main/java/com/google/android/horologist/datalayer/phone/ui/prompt/installapp/InstallAppPrompt.kt +++ b/datalayer/phone-ui/src/main/java/com/google/android/horologist/datalayer/phone/ui/prompt/installapp/InstallAppPrompt.kt @@ -94,4 +94,10 @@ public class InstallAppPrompt(private val phoneDataLayerAppHelper: PhoneDataLaye topMessage = topMessage, bottomMessage = bottomMessage, ) + + /** + * Performs the same action taken by the prompt when the user taps on "install". + */ + public fun performAction(context: Context, appPackageName: String): Unit = + InstallAppPromptAction.run(context = context, appPackageName = appPackageName) } diff --git a/datalayer/phone-ui/src/main/java/com/google/android/horologist/datalayer/phone/ui/prompt/installapp/InstallAppPromptAction.kt b/datalayer/phone-ui/src/main/java/com/google/android/horologist/datalayer/phone/ui/prompt/installapp/InstallAppPromptAction.kt new file mode 100644 index 0000000000..d5ca523dab --- /dev/null +++ b/datalayer/phone-ui/src/main/java/com/google/android/horologist/datalayer/phone/ui/prompt/installapp/InstallAppPromptAction.kt @@ -0,0 +1,26 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * 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. + */ + +package com.google.android.horologist.datalayer.phone.ui.prompt.installapp + +import android.content.Context +import com.google.android.horologist.datalayer.phone.ui.play.launchPlay + +internal object InstallAppPromptAction { + fun run(context: Context, appPackageName: String) { + context.launchPlay(appPackageName) + } +} diff --git a/datalayer/sample/phone/src/main/java/com/google/android/horologist/datalayer/sample/screens/Screen.kt b/datalayer/sample/phone/src/main/java/com/google/android/horologist/datalayer/sample/screens/Screen.kt index 2a7a69aae6..33a9983742 100644 --- a/datalayer/sample/phone/src/main/java/com/google/android/horologist/datalayer/sample/screens/Screen.kt +++ b/datalayer/sample/phone/src/main/java/com/google/android/horologist/datalayer/sample/screens/Screen.kt @@ -25,5 +25,6 @@ sealed class Screen( data object InstallAppPromptDemoScreen : Screen("installAppPromptDemoScreen") data object ReEngagePromptDemoScreen : Screen("reEngagePromptDemoScreen") data object SignInPromptDemoScreen : Screen("signInPromptDemoScreen") + data object InstallAppCustomPromptDemoScreen : Screen("installAppCustomPromptDemoScreen") data object CounterScreen : Screen("counterScreen") } diff --git a/datalayer/sample/phone/src/main/java/com/google/android/horologist/datalayer/sample/screens/inappprompts/custom/installapp/InstallAppCustomPromptDemoScreen.kt b/datalayer/sample/phone/src/main/java/com/google/android/horologist/datalayer/sample/screens/inappprompts/custom/installapp/InstallAppCustomPromptDemoScreen.kt new file mode 100644 index 0000000000..e4d62d16c1 --- /dev/null +++ b/datalayer/sample/phone/src/main/java/com/google/android/horologist/datalayer/sample/screens/inappprompts/custom/installapp/InstallAppCustomPromptDemoScreen.kt @@ -0,0 +1,179 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * 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. + */ + +package com.google.android.horologist.datalayer.sample.screens.inappprompts.custom.installapp + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Watch +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.google.android.horologist.datalayer.sample.R + +@Composable +fun InstallAppCustomPromptDemoScreen( + modifier: Modifier = Modifier, + viewModel: InstallAppPromptDemoViewModel = hiltViewModel(), +) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + + if (state == InstallAppCustomPromptDemoScreenState.Idle) { + SideEffect { + viewModel.initialize() + } + } + + val context = LocalContext.current + + InstallAppCustomPromptDemoScreen( + state = state, + onRunDemoClick = viewModel::onRunDemoClick, + onInstallPromptInstallClick = { + viewModel.installAppPrompt.performAction( + context = context, + appPackageName = context.packageName, + ) + viewModel.onInstallPromptInstallClick() + }, + onInstallPromptCancel = viewModel::onInstallPromptCancel, + modifier = modifier, + ) +} + +@Composable +fun InstallAppCustomPromptDemoScreen( + state: InstallAppCustomPromptDemoScreenState, + onRunDemoClick: () -> Unit, + onInstallPromptInstallClick: () -> Unit, + onInstallPromptCancel: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier.padding(all = 10.dp), + ) { + Text(text = stringResource(id = R.string.install_app_custom_prompt_api_call_demo_message)) + + Button( + onClick = onRunDemoClick, + modifier = Modifier + .padding(top = 10.dp) + .align(Alignment.CenterHorizontally), + enabled = state != InstallAppCustomPromptDemoScreenState.ApiNotAvailable, + ) { + Text(text = stringResource(id = R.string.install_app_custom_prompt_run_demo_button_label)) + } + + when (state) { + InstallAppCustomPromptDemoScreenState.Idle, + InstallAppCustomPromptDemoScreenState.Loaded, + -> { + /* do nothing */ + } + + InstallAppCustomPromptDemoScreenState.Loading -> { + CircularProgressIndicator() + } + + is InstallAppCustomPromptDemoScreenState.WatchFound -> { + AlertDialog( + icon = { + Icon(imageVector = Icons.Default.Watch, contentDescription = null) + }, + title = { + Text(text = stringResource(id = R.string.install_app_custom_prompt_demo_prompt_top_message)) + }, + text = { + Text(text = stringResource(id = R.string.install_app_custom_prompt_demo_prompt_bottom_message)) + }, + onDismissRequest = onInstallPromptCancel, + confirmButton = { + TextButton( + onClick = onInstallPromptInstallClick, + ) { + Text(text = stringResource(id = R.string.install_app_custom_prompt_demo_prompt_confirm_button_label)) + } + }, + dismissButton = { + TextButton( + onClick = onInstallPromptCancel, + ) { + Text(text = stringResource(id = R.string.install_app_custom_prompt_demo_prompt_dismiss_button_label)) + } + }, + ) + } + + InstallAppCustomPromptDemoScreenState.WatchNotFound -> { + Text( + stringResource( + id = R.string.install_app_custom_prompt_demo_result_label, + stringResource(id = R.string.install_app_custom_prompt_demo_no_watches_found_label), + ), + ) + } + + InstallAppCustomPromptDemoScreenState.InstallPromptInstallClicked -> { + Text( + stringResource( + id = R.string.install_app_custom_prompt_demo_result_label, + stringResource(id = R.string.install_app_custom_prompt_demo_prompt_install_result_label), + ), + ) + } + + InstallAppCustomPromptDemoScreenState.InstallPromptInstallCancelled -> { + Text( + stringResource( + id = R.string.install_app_custom_prompt_demo_result_label, + stringResource(id = R.string.install_app_custom_prompt_demo_prompt_cancel_result_label), + ), + ) + } + + InstallAppCustomPromptDemoScreenState.ApiNotAvailable -> { + Text(stringResource(id = R.string.wearable_message_api_unavailable)) + } + } + } +} + +@Preview(showBackground = true) +@Composable +fun InstallAppCustomPromptDemoScreenPreview() { + InstallAppCustomPromptDemoScreen( + state = InstallAppCustomPromptDemoScreenState.Idle, + onRunDemoClick = { }, + onInstallPromptInstallClick = { }, + onInstallPromptCancel = { }, + ) +} diff --git a/datalayer/sample/phone/src/main/java/com/google/android/horologist/datalayer/sample/screens/inappprompts/custom/installapp/InstallAppCustomPromptDemoViewModel.kt b/datalayer/sample/phone/src/main/java/com/google/android/horologist/datalayer/sample/screens/inappprompts/custom/installapp/InstallAppCustomPromptDemoViewModel.kt new file mode 100644 index 0000000000..309f952752 --- /dev/null +++ b/datalayer/sample/phone/src/main/java/com/google/android/horologist/datalayer/sample/screens/inappprompts/custom/installapp/InstallAppCustomPromptDemoViewModel.kt @@ -0,0 +1,92 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * 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. + */ + +package com.google.android.horologist.datalayer.sample.screens.inappprompts.custom.installapp + +import androidx.annotation.MainThread +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.google.android.horologist.datalayer.phone.PhoneDataLayerAppHelper +import com.google.android.horologist.datalayer.phone.ui.prompt.installapp.InstallAppPrompt +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class InstallAppPromptDemoViewModel + @Inject + constructor( + private val phoneDataLayerAppHelper: PhoneDataLayerAppHelper, + val installAppPrompt: InstallAppPrompt, + ) : ViewModel() { + + private var initializeCalled = false + + private val _uiState = + MutableStateFlow(InstallAppCustomPromptDemoScreenState.Idle) + public val uiState: StateFlow = _uiState + + @MainThread + fun initialize() { + if (initializeCalled) return + initializeCalled = true + + _uiState.value = InstallAppCustomPromptDemoScreenState.Loading + + viewModelScope.launch { + if (!phoneDataLayerAppHelper.isAvailable()) { + _uiState.value = InstallAppCustomPromptDemoScreenState.ApiNotAvailable + } else { + _uiState.value = InstallAppCustomPromptDemoScreenState.Loaded + } + } + } + + fun onRunDemoClick() { + _uiState.value = InstallAppCustomPromptDemoScreenState.Loading + + viewModelScope.launch { + val node = installAppPrompt.shouldDisplayPrompt() + + _uiState.value = if (node != null) { + InstallAppCustomPromptDemoScreenState.WatchFound + } else { + InstallAppCustomPromptDemoScreenState.WatchNotFound + } + } + } + + fun onInstallPromptInstallClick() { + _uiState.value = InstallAppCustomPromptDemoScreenState.InstallPromptInstallClicked + } + + fun onInstallPromptCancel() { + _uiState.value = InstallAppCustomPromptDemoScreenState.InstallPromptInstallCancelled + } + } + +sealed class InstallAppCustomPromptDemoScreenState { + data object Idle : InstallAppCustomPromptDemoScreenState() + data object Loading : InstallAppCustomPromptDemoScreenState() + data object Loaded : InstallAppCustomPromptDemoScreenState() + data object WatchFound : InstallAppCustomPromptDemoScreenState() + data object WatchNotFound : InstallAppCustomPromptDemoScreenState() + data object InstallPromptInstallClicked : InstallAppCustomPromptDemoScreenState() + data object InstallPromptInstallCancelled : InstallAppCustomPromptDemoScreenState() + data object ApiNotAvailable : InstallAppCustomPromptDemoScreenState() +} diff --git a/datalayer/sample/phone/src/main/java/com/google/android/horologist/datalayer/sample/screens/main/MainScreen.kt b/datalayer/sample/phone/src/main/java/com/google/android/horologist/datalayer/sample/screens/main/MainScreen.kt index a916e33d82..0b13910339 100644 --- a/datalayer/sample/phone/src/main/java/com/google/android/horologist/datalayer/sample/screens/main/MainScreen.kt +++ b/datalayer/sample/phone/src/main/java/com/google/android/horologist/datalayer/sample/screens/main/MainScreen.kt @@ -30,6 +30,7 @@ import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController import com.google.android.horologist.datalayer.sample.screens.Screen import com.google.android.horologist.datalayer.sample.screens.counter.CounterScreen +import com.google.android.horologist.datalayer.sample.screens.inappprompts.custom.installapp.InstallAppCustomPromptDemoScreen import com.google.android.horologist.datalayer.sample.screens.inappprompts.installapp.InstallAppPromptDemoScreen import com.google.android.horologist.datalayer.sample.screens.inappprompts.reengage.ReEngagePromptDemoScreen import com.google.android.horologist.datalayer.sample.screens.inappprompts.signin.SignInPromptDemoScreen @@ -75,6 +76,9 @@ fun MainScreen( composable(route = Screen.SignInPromptDemoScreen.route) { SignInPromptDemoScreen() } + composable(route = Screen.InstallAppCustomPromptDemoScreen.route) { + InstallAppCustomPromptDemoScreen() + } composable(route = Screen.CounterScreen.route) { CounterScreen() } diff --git a/datalayer/sample/phone/src/main/java/com/google/android/horologist/datalayer/sample/screens/menu/MenuScreen.kt b/datalayer/sample/phone/src/main/java/com/google/android/horologist/datalayer/sample/screens/menu/MenuScreen.kt index d94ffb3689..fee00fcea7 100644 --- a/datalayer/sample/phone/src/main/java/com/google/android/horologist/datalayer/sample/screens/menu/MenuScreen.kt +++ b/datalayer/sample/phone/src/main/java/com/google/android/horologist/datalayer/sample/screens/menu/MenuScreen.kt @@ -65,6 +65,10 @@ fun MenuScreen( Text(text = stringResource(id = R.string.menu_screen_signin_demo_item)) } + Button(onClick = { navController.navigate(Screen.InstallAppCustomPromptDemoScreen.route) }) { + Text(text = stringResource(id = R.string.menu_screen_install_app_custom_demo_item)) + } + Text( text = stringResource(id = R.string.menu_screen_datalayer_header), modifier = Modifier.padding(top = 10.dp), diff --git a/datalayer/sample/phone/src/main/res/values/strings.xml b/datalayer/sample/phone/src/main/res/values/strings.xml index 6e881e1ed5..b817230599 100644 --- a/datalayer/sample/phone/src/main/res/values/strings.xml +++ b/datalayer/sample/phone/src/main/res/values/strings.xml @@ -27,6 +27,7 @@ Install app Re-Engage Sign-in + Install app (custom) Data Layer Counter sample @@ -72,6 +73,18 @@ User tapped install on the prompt. User dismissed the prompt. + + This demo calls the Horologist API to check if there is a watch connected and the watch does not have the app installed.\nIt then displays a custom prompt asking the user to install the app.\nOnce the user taps install, it uses Horologist API to launch Google Play.\n\nGoogle Play won\'t find this app as it is not published. + Run demo + Test the interactions between the phone and the watch with the demo app. + Install the demo app on your Wear OS watch. + Install + Not now + Result: %1$s + No watches meeting the required conditions were found. + User tapped install on the prompt. + User dismissed the prompt. + This demo calls the Horologist API to show the re-engage prompt for the watch demo app. The API will only display the prompt if there is a watch connected and the watch has the app already installed. Run demo