From 069e359a8d5b13846f2649370395d022d6ebfa64 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 2 Sep 2026 06:58:54 +0000 Subject: [PATCH 1/2] Fail release build when signing secrets are missing Confirmed via CI logs (release.yml run for v1.0.0.41) that KEYSTORE_FILE/KEYSTORE_PASSWORD/KEY_ALIAS/KEY_PASSWORD are all empty in this repo, so app/build.gradle's release build falls back to debug signing. Each GitHub Actions job runs on a fresh, ephemeral VM with no persisted ~/.android/debug.keystore, so that fallback generates a new, different debug keystore on every run - meaning every published GitHub Release APK is signed with a different certificate than the previous one. Android refuses to install an update whose signing certificate doesn't match the currently-installed app's, so the in-app "Update to latest" flow always downloads the new APK successfully and then always fails at install time, regardless of network conditions or device. Add a guard step that fails the workflow with a clear error instead of silently publishing another release nobody can update to, until the real signing secrets are configured in the repo. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01147MnP2Vf5sDY2BT7UhBbn --- .github/workflows/release.yml | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ff5d70e..36f1b86 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -26,6 +26,22 @@ jobs: - name: Grant execute permission for gradlew run: chmod +x gradlew + - name: Verify release signing secrets are configured + run: | + # app/build.gradle silently falls back to debug signing when KEYSTORE_FILE isn't set, + # which lets `./gradlew assembleRelease` still work on a dev machine. But on this + # ephemeral GitHub Actions runner that fallback creates a brand-new, random debug + # keystore on every single run (there's no persisted ~/.android/debug.keystore to + # reuse) - so each GitHub Release APK ends up signed with a *different* certificate. + # Android refuses to install an update whose signing certificate doesn't match the + # currently-installed app's, so the in-app "Update to latest" flow + # (UpdateRepositoryImpl/AppUpdateInstaller) always downloads fine and then always fails + # to install. Fail loudly here instead of silently publishing another broken update. + if [ -z "${{ secrets.KEYSTORE_FILE }}" ]; then + echo "::error::KEYSTORE_FILE (and KEYSTORE_PASSWORD/KEY_ALIAS/KEY_PASSWORD) repository secrets are not set. Without them, this workflow falls back to a fresh debug keystore on every run, so each release gets a different signing certificate and in-app updates fail to install. Add the real signing secrets under Settings > Secrets and variables > Actions before releasing." + exit 1 + fi + - name: Build and Sign APK env: BUILD_NUMBER: ${{ github.run_number }} From 06e3e810b041b35b7f4c2cc9d0545ad41a267cc9 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 2 Sep 2026 07:24:55 +0000 Subject: [PATCH 2/2] Add Glovo, hidden Diagnostics gesture, and food provider picker - Comparison configuration section in Settings (above Updates): pick exactly 2 of Uber Eats / Bolt Food / Glovo to compare on "Search Food". Tapping a third option swaps out whichever of the current 2 was picked least recently, so the pair never drops below or exceeds 2. Persisted via a new ComparisonConfigRepository (SharedPreferences), defaulting to Uber Eats + Bolt Food. - Added Glovo as a third food delivery provider: package name (com.glovo) confirmed via the Play Store listing, search deep link is a best-effort guess (documented, along with everything else known about these deep links, in docs/DEEP_LINKS.md #5) since no shipped manifest was available to verify a host against, unlike Bolt Food. - Generalized the food search plumbing (FoodDeliveryProvider enum, FoodDeepLinks.createSearchLink, MainViewModel, MainActivity) from a hardcoded Uber Eats/Bolt Food pair to whichever 2 providers are currently selected. - Fixed a latent package-visibility bug found while wiring this up: the manifest's block only listed the ride apps, so PackageManager.getPackageInfo() for the food apps (isAppInstalled) would throw NameNotFoundException on API 30+ regardless of whether they were actually installed. Added all three food package names. - Diagnostics (Bolt Link Lab) in Settings is now hidden until the Settings title is tapped 10 times, keeping it out of regular users' way while still reachable for on-device deep-link debugging. - Changed the main screen's primary button text from "Compare" to "Compare Trips". Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01147MnP2Vf5sDY2BT7UhBbn --- .../compareapp/MainActivityEspressoTest.kt | 4 +- app/src/main/AndroidManifest.xml | 9 ++ .../neteinstein/compareapp/MainActivity.kt | 75 +++++------ .../data/repository/AppRepository.kt | 1 - .../data/repository/AppRepositoryImpl.kt | 8 -- .../repository/ComparisonConfigRepository.kt | 15 +++ .../ComparisonConfigRepositoryImpl.kt | 47 +++++++ .../compareapp/di/RepositoryModule.kt | 8 ++ .../compareapp/ui/screens/CompareScreen.kt | 19 ++- .../compareapp/ui/screens/MainViewModel.kt | 53 ++++---- .../compareapp/ui/screens/SettingsScreen.kt | 93 +++++++++++--- .../ui/screens/SettingsViewModel.kt | 57 ++++++++- .../compareapp/utils/FoodDeepLinks.kt | 41 +++---- .../compareapp/utils/FoodDeliveryProvider.kt | 20 +++ app/src/main/res/values-pt/strings.xml | 11 +- app/src/main/res/values/strings.xml | 11 +- .../compareapp/MainViewModelFoodSearchTest.kt | 54 +++++--- .../compareapp/SettingsViewModelTest.kt | 116 ++++++++++++++++++ .../ComparisonConfigRepositoryImplTest.kt | 64 ++++++++++ .../helpers/TestViewModelFactory.kt | 23 +++- .../compareapp/utils/FoodDeepLinksTest.kt | 38 ++++-- docs/DEEP_LINKS.md | 62 ++++++++-- 22 files changed, 656 insertions(+), 173 deletions(-) create mode 100644 app/src/main/java/org/neteinstein/compareapp/data/repository/ComparisonConfigRepository.kt create mode 100644 app/src/main/java/org/neteinstein/compareapp/data/repository/ComparisonConfigRepositoryImpl.kt create mode 100644 app/src/main/java/org/neteinstein/compareapp/utils/FoodDeliveryProvider.kt create mode 100644 app/src/test/java/org/neteinstein/compareapp/SettingsViewModelTest.kt create mode 100644 app/src/test/java/org/neteinstein/compareapp/data/repository/ComparisonConfigRepositoryImplTest.kt diff --git a/app/src/androidTest/java/org/neteinstein/compareapp/MainActivityEspressoTest.kt b/app/src/androidTest/java/org/neteinstein/compareapp/MainActivityEspressoTest.kt index 93a50aa..ffbe580 100644 --- a/app/src/androidTest/java/org/neteinstein/compareapp/MainActivityEspressoTest.kt +++ b/app/src/androidTest/java/org/neteinstein/compareapp/MainActivityEspressoTest.kt @@ -37,7 +37,7 @@ class MainActivityEspressoTest { @Test fun testCompareButtonIsDisplayedButDisabled_whenAppsNotInstalled() { // The compare button should be visible but disabled if apps aren't installed - val compareButton = composeTestRule.onNodeWithText("Compare") + val compareButton = composeTestRule.onNodeWithText("Compare Trips") compareButton.assertIsDisplayed() // Note: This may fail if Uber/Bolt are actually installed on test device } @@ -129,6 +129,6 @@ class MainActivityEspressoTest { composeTestRule.onNodeWithText("CompareApp").assertIsDisplayed() composeTestRule.onNodeWithText("Pickup Location").assertIsDisplayed() composeTestRule.onNodeWithText("Dropoff Location").assertIsDisplayed() - composeTestRule.onNodeWithText("Compare").assertIsDisplayed() + composeTestRule.onNodeWithText("Compare Trips").assertIsDisplayed() } } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index a2a342a..66aa9eb 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -83,8 +83,17 @@ + + + + diff --git a/app/src/main/java/org/neteinstein/compareapp/MainActivity.kt b/app/src/main/java/org/neteinstein/compareapp/MainActivity.kt index 389f3b5..7c9eb56 100644 --- a/app/src/main/java/org/neteinstein/compareapp/MainActivity.kt +++ b/app/src/main/java/org/neteinstein/compareapp/MainActivity.kt @@ -27,7 +27,7 @@ import org.neteinstein.compareapp.ui.screens.BoltLinkLabRoute import org.neteinstein.compareapp.ui.screens.CompareScreen import org.neteinstein.compareapp.ui.screens.SettingsRoute import org.neteinstein.compareapp.ui.theme.CompareAppTheme -import org.neteinstein.compareapp.utils.FoodDeepLinks +import org.neteinstein.compareapp.utils.FoodDeliveryProvider @AndroidEntryPoint class MainActivity : ComponentActivity() { @@ -79,9 +79,7 @@ class MainActivity : ComponentActivity() { onOpenDeepLinks = { uberDeepLink, boltDeepLink, boltDeepLinkWeb -> openInSplitScreen(uberDeepLink, boltDeepLink, boltDeepLinkWeb) }, - onOpenFoodSearch = { uberEatsLink, boltFoodLink -> - openFoodSearch(uberEatsLink, boltFoodLink) - } + onOpenFoodSearch = { links -> openFoodSearch(links) } ) } } @@ -148,49 +146,38 @@ class MainActivity : ComponentActivity() { } } - private fun openFoodSearch(uberEatsLink: String, boltFoodLink: String) { + /** + * Opens each provider's search link in turn (staggered by [SPLIT_SCREEN_DELAY_MS], same as + * [openInSplitScreen]) so both land side by side in split screen. [links] always has exactly 2 + * entries - the pair currently selected under Settings > Comparison configuration - ordered by + * [FoodDeliveryProvider]'s declaration order (see [MainViewModel.prepareFoodSearchLinks]). + */ + private fun openFoodSearch(links: Map) { lifecycleScope.launch { - try { - openLinkWithAppFallback( - startApp = { - val intent = Intent(Intent.ACTION_VIEW, Uri.parse(uberEatsLink)) - intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_LAUNCH_ADJACENT - intent.setPackage(FoodDeepLinks.UBER_EATS_PACKAGE) - startActivity(intent) - }, - startBrowser = { - val intent = Intent(Intent.ACTION_VIEW, Uri.parse(uberEatsLink)) - intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_LAUNCH_ADJACENT - startActivity(intent) - } - ) - } catch (e: Exception) { - Log.e("MainActivity", "Could not open Uber Eats: ${e.message}") - withContext(Dispatchers.Main) { - Toast.makeText(this@MainActivity, getString(R.string.error_uber_eats), Toast.LENGTH_SHORT).show() + links.entries.forEachIndexed { index, (provider, link) -> + if (index > 0) { + kotlinx.coroutines.delay(SPLIT_SCREEN_DELAY_MS) } - } - - kotlinx.coroutines.delay(SPLIT_SCREEN_DELAY_MS) - - try { - openLinkWithAppFallback( - startApp = { - val intent = Intent(Intent.ACTION_VIEW, Uri.parse(boltFoodLink)) - intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_LAUNCH_ADJACENT - intent.setPackage(FoodDeepLinks.BOLT_FOOD_PACKAGE) - startActivity(intent) - }, - startBrowser = { - val intent = Intent(Intent.ACTION_VIEW, Uri.parse(boltFoodLink)) - intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_LAUNCH_ADJACENT - startActivity(intent) + try { + openLinkWithAppFallback( + startApp = { + val intent = Intent(Intent.ACTION_VIEW, Uri.parse(link)) + intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_LAUNCH_ADJACENT + intent.setPackage(provider.packageName) + startActivity(intent) + }, + startBrowser = { + val intent = Intent(Intent.ACTION_VIEW, Uri.parse(link)) + intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_LAUNCH_ADJACENT + startActivity(intent) + } + ) + } catch (e: Exception) { + Log.e("MainActivity", "Could not open ${provider.displayName}: ${e.message}") + withContext(Dispatchers.Main) { + val message = getString(R.string.error_food_provider, provider.displayName) + Toast.makeText(this@MainActivity, message, Toast.LENGTH_SHORT).show() } - ) - } catch (e: Exception) { - Log.e("MainActivity", "Could not open Bolt Food: ${e.message}") - withContext(Dispatchers.Main) { - Toast.makeText(this@MainActivity, getString(R.string.error_bolt_food), Toast.LENGTH_SHORT).show() } } } diff --git a/app/src/main/java/org/neteinstein/compareapp/data/repository/AppRepository.kt b/app/src/main/java/org/neteinstein/compareapp/data/repository/AppRepository.kt index 7aeb3cc..b3adeca 100644 --- a/app/src/main/java/org/neteinstein/compareapp/data/repository/AppRepository.kt +++ b/app/src/main/java/org/neteinstein/compareapp/data/repository/AppRepository.kt @@ -3,5 +3,4 @@ package org.neteinstein.compareapp.data.repository interface AppRepository { fun isAppInstalled(packageName: String): Boolean fun checkRequiredApps(): Pair - fun checkFoodApps(): Pair } diff --git a/app/src/main/java/org/neteinstein/compareapp/data/repository/AppRepositoryImpl.kt b/app/src/main/java/org/neteinstein/compareapp/data/repository/AppRepositoryImpl.kt index 974ce7b..e20e4d0 100644 --- a/app/src/main/java/org/neteinstein/compareapp/data/repository/AppRepositoryImpl.kt +++ b/app/src/main/java/org/neteinstein/compareapp/data/repository/AppRepositoryImpl.kt @@ -15,8 +15,6 @@ class AppRepositoryImpl @Inject constructor( companion object { private const val UBER_PACKAGE_NAME = "com.ubercab" private const val BOLT_PACKAGE_NAME = "ee.mtakso.client" - private const val UBER_EATS_PACKAGE_NAME = "com.ubercab.eats" - private const val BOLT_FOOD_PACKAGE_NAME = "com.bolt.deliveryclient" } override fun isAppInstalled(packageName: String): Boolean { @@ -34,10 +32,4 @@ class AppRepositoryImpl @Inject constructor( val isBoltInstalled = isAppInstalled(BOLT_PACKAGE_NAME) return Pair(isUberInstalled, isBoltInstalled) } - - override fun checkFoodApps(): Pair { - val isUberEatsInstalled = isAppInstalled(UBER_EATS_PACKAGE_NAME) - val isBoltFoodInstalled = isAppInstalled(BOLT_FOOD_PACKAGE_NAME) - return Pair(isUberEatsInstalled, isBoltFoodInstalled) - } } diff --git a/app/src/main/java/org/neteinstein/compareapp/data/repository/ComparisonConfigRepository.kt b/app/src/main/java/org/neteinstein/compareapp/data/repository/ComparisonConfigRepository.kt new file mode 100644 index 0000000..51c5477 --- /dev/null +++ b/app/src/main/java/org/neteinstein/compareapp/data/repository/ComparisonConfigRepository.kt @@ -0,0 +1,15 @@ +package org.neteinstein.compareapp.data.repository + +import kotlinx.coroutines.flow.StateFlow +import org.neteinstein.compareapp.utils.FoodDeliveryProvider + +/** + * Persists which pair of food delivery apps (see [FoodDeliveryProvider]) "Search Food" compares - + * set from Settings > Comparison configuration. Always exactly 2 providers; enforced by + * [setSelectedFoodProviders] rather than left to callers. + */ +interface ComparisonConfigRepository { + val selectedFoodProviders: StateFlow> + + fun setSelectedFoodProviders(providers: Set) +} diff --git a/app/src/main/java/org/neteinstein/compareapp/data/repository/ComparisonConfigRepositoryImpl.kt b/app/src/main/java/org/neteinstein/compareapp/data/repository/ComparisonConfigRepositoryImpl.kt new file mode 100644 index 0000000..e8d05e7 --- /dev/null +++ b/app/src/main/java/org/neteinstein/compareapp/data/repository/ComparisonConfigRepositoryImpl.kt @@ -0,0 +1,47 @@ +package org.neteinstein.compareapp.data.repository + +import android.content.Context +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import org.neteinstein.compareapp.utils.FoodDeliveryProvider +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class ComparisonConfigRepositoryImpl @Inject constructor( + @ApplicationContext context: Context +) : ComparisonConfigRepository { + + private val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + + private val _selectedFoodProviders = MutableStateFlow(loadSelectedFoodProviders()) + override val selectedFoodProviders: StateFlow> = _selectedFoodProviders.asStateFlow() + + override fun setSelectedFoodProviders(providers: Set) { + require(providers.size == 2) { + "Exactly 2 food delivery providers must be selected, got ${providers.size}: $providers" + } + prefs.edit() + .putStringSet(KEY_FOOD_PROVIDERS, providers.mapTo(mutableSetOf()) { it.name }) + .apply() + _selectedFoodProviders.value = providers + } + + private fun loadSelectedFoodProviders(): Set { + val storedNames = prefs.getStringSet(KEY_FOOD_PROVIDERS, null) ?: return DEFAULT_FOOD_PROVIDERS + val parsed = storedNames.mapNotNullTo(mutableSetOf()) { name -> + FoodDeliveryProvider.entries.find { it.name == name } + } + // Falls back to the default pair if prefs are missing, corrupted, or (after an app update + // that removes a provider) no longer resolve to exactly 2 valid entries. + return if (parsed.size == 2) parsed else DEFAULT_FOOD_PROVIDERS + } + + private companion object { + const val PREFS_NAME = "comparison_config" + const val KEY_FOOD_PROVIDERS = "selected_food_providers" + val DEFAULT_FOOD_PROVIDERS = setOf(FoodDeliveryProvider.UBER_EATS, FoodDeliveryProvider.BOLT_FOOD) + } +} diff --git a/app/src/main/java/org/neteinstein/compareapp/di/RepositoryModule.kt b/app/src/main/java/org/neteinstein/compareapp/di/RepositoryModule.kt index 69bccfa..ca43981 100644 --- a/app/src/main/java/org/neteinstein/compareapp/di/RepositoryModule.kt +++ b/app/src/main/java/org/neteinstein/compareapp/di/RepositoryModule.kt @@ -6,6 +6,8 @@ import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent import org.neteinstein.compareapp.data.repository.AppRepository import org.neteinstein.compareapp.data.repository.AppRepositoryImpl +import org.neteinstein.compareapp.data.repository.ComparisonConfigRepository +import org.neteinstein.compareapp.data.repository.ComparisonConfigRepositoryImpl import org.neteinstein.compareapp.data.repository.LocationRepository import org.neteinstein.compareapp.data.repository.LocationRepositoryImpl import org.neteinstein.compareapp.data.repository.UpdateRepository @@ -33,4 +35,10 @@ abstract class RepositoryModule { abstract fun bindUpdateRepository( updateRepositoryImpl: UpdateRepositoryImpl ): UpdateRepository + + @Binds + @Singleton + abstract fun bindComparisonConfigRepository( + comparisonConfigRepositoryImpl: ComparisonConfigRepositoryImpl + ): ComparisonConfigRepository } diff --git a/app/src/main/java/org/neteinstein/compareapp/ui/screens/CompareScreen.kt b/app/src/main/java/org/neteinstein/compareapp/ui/screens/CompareScreen.kt index 772ccbd..a6e2f46 100644 --- a/app/src/main/java/org/neteinstein/compareapp/ui/screens/CompareScreen.kt +++ b/app/src/main/java/org/neteinstein/compareapp/ui/screens/CompareScreen.kt @@ -71,6 +71,7 @@ import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.compose.LocalLifecycleOwner import org.neteinstein.compareapp.R import org.neteinstein.compareapp.utils.DeepLinkLocationParser +import org.neteinstein.compareapp.utils.FoodDeliveryProvider import org.neteinstein.compareapp.utils.FoodSearchMode import org.neteinstein.compareapp.utils.MapsShareLinkResolver @@ -81,7 +82,7 @@ fun CompareScreen( incomingLocationUri: Uri? = null, onOpenSettings: () -> Unit = {}, onOpenDeepLinks: (uberDeepLink: String, boltDeepLink: String, boltDeepLinkWeb: String) -> Unit, - onOpenFoodSearch: (uberEatsLink: String, boltFoodLink: String) -> Unit = { _, _ -> } + onOpenFoodSearch: (links: Map) -> Unit = {} ) { val uiState by viewModel.uiState.collectAsState() val context = LocalContext.current @@ -182,14 +183,14 @@ fun CompareScreen( "Warning: ${missingApps.joinToString(" and ")} ${if (missingApps.size == 1) "app is" else "apps are"} required for this to work" } - val foodWarningMessage = if (uiState.isUberEatsInstalled && uiState.isBoltFoodInstalled) { + val missingFoodApps = uiState.selectedFoodProviders - uiState.installedFoodProviders + val foodWarningMessage = if (missingFoodApps.isEmpty()) { null } else { - val missingApps = buildList { - if (!uiState.isUberEatsInstalled) add("Uber Eats") - if (!uiState.isBoltFoodInstalled) add("Bolt Food") - } - "Warning: ${missingApps.joinToString(" and ")} ${if (missingApps.size == 1) "app is" else "apps are"} required for this to work" + val missingAppNames = FoodDeliveryProvider.entries + .filter { it in missingFoodApps } + .map { it.displayName } + "Warning: ${missingAppNames.joinToString(" and ")} ${if (missingAppNames.size == 1) "app is" else "apps are"} required for this to work" } val loadingText = stringResource(R.string.loading) @@ -497,9 +498,7 @@ fun CompareScreen( Button( onClick = { viewModel.prepareFoodSearchLinks( - onSuccess = { uberEatsLink, boltFoodLink -> - onOpenFoodSearch(uberEatsLink, boltFoodLink) - }, + onSuccess = { links -> onOpenFoodSearch(links) }, onError = { Toast.makeText(context, foodValidationMessageText, Toast.LENGTH_SHORT).show() } diff --git a/app/src/main/java/org/neteinstein/compareapp/ui/screens/MainViewModel.kt b/app/src/main/java/org/neteinstein/compareapp/ui/screens/MainViewModel.kt index be19bb9..f843163 100644 --- a/app/src/main/java/org/neteinstein/compareapp/ui/screens/MainViewModel.kt +++ b/app/src/main/java/org/neteinstein/compareapp/ui/screens/MainViewModel.kt @@ -12,8 +12,10 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import org.neteinstein.compareapp.data.repository.AddressSuggestion import org.neteinstein.compareapp.data.repository.AppRepository +import org.neteinstein.compareapp.data.repository.ComparisonConfigRepository import org.neteinstein.compareapp.data.repository.LocationRepository import org.neteinstein.compareapp.utils.FoodDeepLinks +import org.neteinstein.compareapp.utils.FoodDeliveryProvider import org.neteinstein.compareapp.utils.FoodSearchMode import java.net.URLEncoder import java.util.Locale @@ -22,7 +24,8 @@ import javax.inject.Inject @HiltViewModel class MainViewModel @Inject constructor( private val locationRepository: LocationRepository, - private val appRepository: AppRepository + private val appRepository: AppRepository, + private val comparisonConfigRepository: ComparisonConfigRepository ) : ViewModel() { private val _uiState = MutableStateFlow(CompareUiState()) @@ -33,6 +36,7 @@ class MainViewModel @Inject constructor( init { checkInstalledApps() + checkFoodAppsInstalled() } fun checkInstalledApps() { @@ -46,18 +50,20 @@ class MainViewModel @Inject constructor( Log.d("MainViewModel", "Uber installed: $isUberInstalled, Bolt installed: $isBoltInstalled") } - // Separate from checkInstalledApps() so existing tests that construct MainViewModel with a - // custom AppRepository mock (stubbing only checkRequiredApps(), called from init) don't need - // to also stub checkFoodApps() - callers that care about food app state opt in explicitly. + /** + * Re-reads the selected pair from [ComparisonConfigRepository] (a plain synchronous + * [kotlinx.coroutines.flow.StateFlow] read, not a suspend call) and checks which of that pair + * is actually installed. Called from [init] for the first render, and again by + * [org.neteinstein.compareapp.ui.screens.CompareScreen] on `ON_RESUME` - since Settings and + * this screen share the same [MainViewModel] instance (no Navigation-Compose back stack, just + * a local screen flag - see [org.neteinstein.compareapp.MainActivity]), resuming here after + * changing the pair in Settings is exactly when a stale selection would otherwise linger. + */ fun checkFoodAppsInstalled() { - val (isUberEatsInstalled, isBoltFoodInstalled) = appRepository.checkFoodApps() - _uiState.update { - it.copy( - isUberEatsInstalled = isUberEatsInstalled, - isBoltFoodInstalled = isBoltFoodInstalled - ) - } - Log.d("MainViewModel", "Uber Eats installed: $isUberEatsInstalled, Bolt Food installed: $isBoltFoodInstalled") + val selected = comparisonConfigRepository.selectedFoodProviders.value + val installed = selected.filter { appRepository.isAppInstalled(it.packageName) }.toSet() + _uiState.update { it.copy(selectedFoodProviders = selected, installedFoodProviders = installed) } + Log.d("MainViewModel", "Installed food providers: ${installed.map { it.displayName }}") } fun updatePickup(value: String) { @@ -338,13 +344,15 @@ class MainViewModel @Inject constructor( } /** - * Builds the Uber Eats / Bolt Food search links from the current query + location - see - * [FoodDeepLinks] for why these are unverified best-effort guesses rather than a confirmed - * format. No geocoding needed here (unlike [prepareDeepLinks]): both links take free-text - * search terms, not coordinates. + * Builds the search links for the currently selected pair of food providers (Settings > + * Comparison configuration) from the current query + location - see [FoodDeepLinks] for why + * these are unverified best-effort guesses rather than a confirmed format. No geocoding needed + * here (unlike [prepareDeepLinks]): all these links take free-text search terms, not + * coordinates. Ordered by [FoodDeliveryProvider]'s declaration order so which app opens first + * is stable regardless of the order the pair was selected in. */ fun prepareFoodSearchLinks( - onSuccess: (uberEatsLink: String, boltFoodLink: String) -> Unit, + onSuccess: (links: Map) -> Unit, onError: () -> Unit = {} ) { val currentState = _uiState.value @@ -353,9 +361,10 @@ class MainViewModel @Inject constructor( return } - val uberEatsLink = FoodDeepLinks.createUberEatsSearchLink(currentState.foodQuery, currentState.foodLocation) - val boltFoodLink = FoodDeepLinks.createBoltFoodSearchLink(currentState.foodQuery, currentState.foodLocation) - onSuccess(uberEatsLink, boltFoodLink) + val links = FoodDeliveryProvider.entries + .filter { it in currentState.selectedFoodProviders } + .associateWith { FoodDeepLinks.createSearchLink(it, currentState.foodQuery, currentState.foodLocation) } + onSuccess(links) } companion object { @@ -379,6 +388,6 @@ data class CompareUiState( val foodQuery: String = "", val foodLocation: String = "", val foodSearchMode: FoodSearchMode = FoodSearchMode.RESTAURANT, - val isUberEatsInstalled: Boolean = false, - val isBoltFoodInstalled: Boolean = false + val selectedFoodProviders: Set = emptySet(), + val installedFoodProviders: Set = emptySet() ) diff --git a/app/src/main/java/org/neteinstein/compareapp/ui/screens/SettingsScreen.kt b/app/src/main/java/org/neteinstein/compareapp/ui/screens/SettingsScreen.kt index a6496ed..220b5e1 100644 --- a/app/src/main/java/org/neteinstein/compareapp/ui/screens/SettingsScreen.kt +++ b/app/src/main/java/org/neteinstein/compareapp/ui/screens/SettingsScreen.kt @@ -4,6 +4,7 @@ import android.content.Intent import android.net.Uri import android.os.Build import android.provider.Settings +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -19,6 +20,7 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material3.Button +import androidx.compose.material3.Checkbox import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon @@ -42,6 +44,7 @@ import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import org.neteinstein.compareapp.R +import org.neteinstein.compareapp.utils.FoodDeliveryProvider /** * Stateful entry point: wires [SettingsViewModel] to [SettingsScreen]. @@ -78,9 +81,11 @@ fun SettingsRoute( SettingsScreen( uiState = uiState, onBack = onBack, + onTitleClicked = viewModel::onTitleClicked, onUpdateClicked = viewModel::onUpdateClicked, onEnableSideloadingClicked = viewModel::onEnableSideloadingClicked, onChangeLanguageClicked = onChangeLanguageClicked, + onFoodProviderToggled = viewModel::onFoodProviderToggled, onOpenBoltLinkLab = onOpenBoltLinkLab, modifier = modifier ) @@ -88,8 +93,10 @@ fun SettingsRoute( /** * Stateless, preview-friendly screen: title bar up top, a scrollable body ordered - * Language -> Updates. Language is omitted entirely when [onChangeLanguageClicked] is `null` - * (below API 33 - see [SettingsRoute]). + * Language -> Comparison configuration -> Updates -> (hidden) Diagnostics. Language is omitted + * entirely when [onChangeLanguageClicked] is `null` (below API 33 - see [SettingsRoute]). + * Diagnostics only appears once [uiState].diagnosticsUnlocked is true, which [onTitleClicked] + * (wired to the title text below) works towards - see [SettingsViewModel.onTitleClicked]. */ @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -97,16 +104,23 @@ fun SettingsScreen( uiState: SettingsUiState, onBack: () -> Unit, modifier: Modifier = Modifier, + onTitleClicked: () -> Unit = {}, onUpdateClicked: () -> Unit = {}, onEnableSideloadingClicked: () -> Unit = {}, onChangeLanguageClicked: (() -> Unit)? = null, + onFoodProviderToggled: (FoodDeliveryProvider) -> Unit = {}, onOpenBoltLinkLab: () -> Unit = {} ) { Scaffold( modifier = modifier.fillMaxSize(), topBar = { TopAppBar( - title = { Text(text = stringResource(R.string.settings_title)) }, + title = { + Text( + text = stringResource(R.string.settings_title), + modifier = Modifier.clickable(onClick = onTitleClicked) + ) + }, navigationIcon = { IconButton(onClick = onBack) { Icon( @@ -135,6 +149,17 @@ fun SettingsScreen( Spacer(modifier = Modifier.height(24.dp)) } + Text( + text = stringResource(R.string.settings_comparison_section_title), + style = MaterialTheme.typography.titleMedium + ) + Spacer(modifier = Modifier.height(12.dp)) + ComparisonConfigurationSection( + selectedFoodProviders = uiState.selectedFoodProviders, + onFoodProviderToggled = onFoodProviderToggled + ) + Spacer(modifier = Modifier.height(24.dp)) + Text( text = stringResource(R.string.settings_update_section_title), style = MaterialTheme.typography.titleMedium @@ -146,26 +171,64 @@ fun SettingsScreen( onEnableSideloadingClicked = onEnableSideloadingClicked ) - Spacer(modifier = Modifier.height(24.dp)) - Text( - text = "Diagnostics", - style = MaterialTheme.typography.titleMedium - ) - Spacer(modifier = Modifier.height(12.dp)) - Column(modifier = Modifier.fillMaxWidth()) { + if (uiState.diagnosticsUnlocked) { + Spacer(modifier = Modifier.height(24.dp)) Text( - text = "Try out candidate Bolt deep-link formats against the installed Bolt app.", - style = MaterialTheme.typography.bodyMedium, - modifier = Modifier.padding(bottom = 8.dp) + text = "Diagnostics", + style = MaterialTheme.typography.titleMedium ) - TextButton(onClick = onOpenBoltLinkLab) { - Text(text = "Open Bolt Link Lab") + Spacer(modifier = Modifier.height(12.dp)) + Column(modifier = Modifier.fillMaxWidth()) { + Text( + text = "Try out candidate Bolt deep-link formats against the installed Bolt app.", + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(bottom = 8.dp) + ) + TextButton(onClick = onOpenBoltLinkLab) { + Text(text = "Open Bolt Link Lab") + } } } } } } +/** + * Which pair of food delivery apps "Search Food" compares - see + * [SettingsViewModel.onFoodProviderToggled] for the exactly-2 swap behavior each checkbox drives. + */ +@Composable +private fun ComparisonConfigurationSection( + selectedFoodProviders: List, + onFoodProviderToggled: (FoodDeliveryProvider) -> Unit, + modifier: Modifier = Modifier +) { + Column(modifier = modifier.fillMaxWidth()) { + Text( + text = stringResource(R.string.settings_comparison_food_subtitle), + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Text( + text = stringResource(R.string.settings_comparison_food_info), + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(top = 4.dp, bottom = 8.dp) + ) + FoodDeliveryProvider.entries.forEach { provider -> + val isSelected = provider in selectedFoodProviders + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { onFoodProviderToggled(provider) }, + verticalAlignment = Alignment.CenterVertically + ) { + Checkbox(checked = isSelected, onCheckedChange = { onFoodProviderToggled(provider) }) + Text(text = provider.displayName, style = MaterialTheme.typography.bodyLarge) + } + } + } +} + /** Info text + button deep-linking into the system per-app language screen (see [SettingsRoute]). */ @Composable private fun LanguageSection( diff --git a/app/src/main/java/org/neteinstein/compareapp/ui/screens/SettingsViewModel.kt b/app/src/main/java/org/neteinstein/compareapp/ui/screens/SettingsViewModel.kt index 15631a1..2d4f204 100644 --- a/app/src/main/java/org/neteinstein/compareapp/ui/screens/SettingsViewModel.kt +++ b/app/src/main/java/org/neteinstein/compareapp/ui/screens/SettingsViewModel.kt @@ -7,9 +7,11 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import org.neteinstein.compareapp.data.repository.AppUpdate +import org.neteinstein.compareapp.data.repository.ComparisonConfigRepository import org.neteinstein.compareapp.data.repository.UpdateCheckResult import org.neteinstein.compareapp.data.repository.UpdateRepository import org.neteinstein.compareapp.utils.AppUpdateInstaller +import org.neteinstein.compareapp.utils.FoodDeliveryProvider import javax.inject.Inject /** @@ -44,18 +46,34 @@ sealed class UpdateStatus { } data class SettingsUiState( - val updateStatus: UpdateStatus = UpdateStatus.Idle + val updateStatus: UpdateStatus = UpdateStatus.Idle, + /** + * The currently selected pair of food delivery apps, ordered oldest-selection-first - see + * [SettingsViewModel.onFoodProviderToggled] for why the order matters. Always exactly 2 once + * initialized from [ComparisonConfigRepository]. + */ + val selectedFoodProviders: List = emptyList(), + /** Diagnostics (Bolt Link Lab) only shows once [SettingsViewModel.onTitleClicked] fires 10 times. */ + val diagnosticsUnlocked: Boolean = false ) @HiltViewModel class SettingsViewModel @Inject constructor( private val updateRepository: UpdateRepository, - private val appUpdateInstaller: AppUpdateInstaller + private val appUpdateInstaller: AppUpdateInstaller, + private val comparisonConfigRepository: ComparisonConfigRepository ) : ViewModel() { - private val _uiState = MutableStateFlow(SettingsUiState()) + private val _uiState = MutableStateFlow( + SettingsUiState( + selectedFoodProviders = FoodDeliveryProvider.entries + .filter { it in comparisonConfigRepository.selectedFoodProviders.value } + ) + ) val uiState = _uiState.asStateFlow() + private var titleTapCount = 0 + /** * Runs a background update check when Settings is entered so the "Update to latest" button can * reflect availability immediately (green when a release is found), without auto-downloading. @@ -101,6 +119,35 @@ class SettingsViewModel @Inject constructor( appUpdateInstaller.openInstallPermissionSettings() } + /** + * Hidden gesture that reveals the Diagnostics section (Bolt Link Lab) after the Settings + * title has been tapped 10 times in one visit to this screen - keeps it out of the way of + * regular users while still reachable for on-device deep-link debugging. + */ + fun onTitleClicked() { + titleTapCount++ + if (titleTapCount >= DIAGNOSTICS_UNLOCK_TAP_COUNT) { + _uiState.value = _uiState.value.copy(diagnosticsUnlocked = true) + } + } + + /** + * Toggles [provider] in the food comparison pair, keeping it at exactly 2 selections at all + * times: + * - Tapping an already-selected provider is a no-op - unselecting it would drop the pair to 1. + * - Tapping the unselected provider swaps it in for whichever of the current 2 was selected + * least recently, so the pair stays at 2 without the user ever having to explicitly + * deselect anything. + */ + fun onFoodProviderToggled(provider: FoodDeliveryProvider) { + val current = _uiState.value.selectedFoodProviders + if (provider in current) return + + val updated = listOf(current[1], provider) + _uiState.value = _uiState.value.copy(selectedFoodProviders = updated) + comparisonConfigRepository.setSelectedFoodProviders(updated.toSet()) + } + private suspend fun downloadAndInstall(update: AppUpdate) { if (!appUpdateInstaller.canInstallPackages()) { _uiState.value = _uiState.value.copy(updateStatus = UpdateStatus.SideloadingBlocked) @@ -120,4 +167,8 @@ class SettingsViewModel @Inject constructor( _uiState.value = _uiState.value.copy(updateStatus = UpdateStatus.Failed("Download failed")) } } + + private companion object { + const val DIAGNOSTICS_UNLOCK_TAP_COUNT = 10 + } } diff --git a/app/src/main/java/org/neteinstein/compareapp/utils/FoodDeepLinks.kt b/app/src/main/java/org/neteinstein/compareapp/utils/FoodDeepLinks.kt index 4b29a7a..f4028ef 100644 --- a/app/src/main/java/org/neteinstein/compareapp/utils/FoodDeepLinks.kt +++ b/app/src/main/java/org/neteinstein/compareapp/utils/FoodDeepLinks.kt @@ -4,8 +4,8 @@ import java.net.URLEncoder /** * What to search for on each food app: a restaurant/place name, or a specific dish. - * Both modes hit the same search endpoint - only the query text differs - since neither - * app's search exposes a separate "restaurant vs dish" deep-link parameter. + * Both modes hit the same search endpoint - only the query text differs - since none of these + * apps' search exposes a separate "restaurant vs dish" deep-link parameter. */ enum class FoodSearchMode { RESTAURANT, @@ -13,30 +13,29 @@ enum class FoodSearchMode { } /** - * Best-effort search deep links for Uber Eats and Bolt Food. + * Best-effort search deep links for the food delivery apps in [FoodDeliveryProvider]. * - * Neither company publishes a documented deep-link API for search (same situation as Bolt's ride - * app - see [BoltDeepLinkCandidates]), so these are unverified: they're built from Uber Eats' - * public website URL structure and from Bolt Food's own AndroidManifest.xml - * (`com.bolt.deliveryclient`), which declares a verified `https://food.bolt.eu` App Link with a - * `/search` path. Both links target the app's package explicitly so they open the app directly - * rather than a browser tab (same trick as [org.neteinstein.compareapp.openBoltWebLink]), but - * whether the app actually pre-fills the search box from the `q` param hasn't been confirmed on - * a device - if it doesn't, the app still opens, just not pre-searched. + * None of these companies publish a documented deep-link API for search (same situation as + * Bolt's ride app - see [BoltDeepLinkCandidates]), so these are unverified: Uber Eats' and Bolt + * Food's are built from Uber Eats' public website URL structure and from Bolt Food's own + * AndroidManifest.xml (`com.bolt.deliveryclient`), which declares a verified `https://food.bolt.eu` + * App Link with a `/search` path - see docs/DEEP_LINKS.md for the full writeup, including why + * Glovo's is the shakiest of the three (its web app is city/locale-scoped rather than a flat + * `?q=` search page, and no shipped manifest was available to verify a native scheme against). + * All three links target the app's package explicitly so they open the app directly rather than a + * browser tab (same trick as [org.neteinstein.compareapp.openBoltWebLink]), but whether the app + * actually pre-fills the search box from the query param hasn't been confirmed on a device - if it + * doesn't, the app still opens, just not pre-searched. */ object FoodDeepLinks { - const val UBER_EATS_PACKAGE = "com.ubercab.eats" - const val BOLT_FOOD_PACKAGE = "com.bolt.deliveryclient" - - fun createUberEatsSearchLink(query: String, location: String): String { - val encoded = URLEncoder.encode(combinedQuery(query, location), "UTF-8") - return "https://www.ubereats.com/search?q=$encoded" - } - - fun createBoltFoodSearchLink(query: String, location: String): String { + fun createSearchLink(provider: FoodDeliveryProvider, query: String, location: String): String { val encoded = URLEncoder.encode(combinedQuery(query, location), "UTF-8") - return "https://food.bolt.eu/search?q=$encoded" + return when (provider) { + FoodDeliveryProvider.UBER_EATS -> "https://www.ubereats.com/search?q=$encoded" + FoodDeliveryProvider.BOLT_FOOD -> "https://food.bolt.eu/search?q=$encoded" + FoodDeliveryProvider.GLOVO -> "https://glovoapp.com/search/?query=$encoded" + } } private fun combinedQuery(query: String, location: String): String { diff --git a/app/src/main/java/org/neteinstein/compareapp/utils/FoodDeliveryProvider.kt b/app/src/main/java/org/neteinstein/compareapp/utils/FoodDeliveryProvider.kt new file mode 100644 index 0000000..0fc88d6 --- /dev/null +++ b/app/src/main/java/org/neteinstein/compareapp/utils/FoodDeliveryProvider.kt @@ -0,0 +1,20 @@ +package org.neteinstein.compareapp.utils + +/** + * The food delivery apps this app knows how to search across. Exactly two of these are active at + * once - the user picks which pair under Settings > Comparison configuration (see + * [org.neteinstein.compareapp.data.repository.ComparisonConfigRepository]) - and that pair is what + * [FoodDeepLinks] builds search links for and [org.neteinstein.compareapp.MainActivity.openFoodSearch] + * opens when "Search Food" is tapped. + * + * [displayName] is used only in unlocalized UI copy (warning banners, error toasts) - same + * convention as the hardcoded "Uber Eats"/"Bolt Food" strings this replaces in `CompareScreen.kt`. + */ +enum class FoodDeliveryProvider(val packageName: String, val displayName: String) { + UBER_EATS("com.ubercab.eats", "Uber Eats"), + BOLT_FOOD("com.bolt.deliveryclient", "Bolt Food"), + + // Package name confirmed via the Play Store listing ("Glovo: Food & Grocery Delivery", + // id=com.glovo). See docs/DEEP_LINKS.md #5 for what is/isn't confirmed about its deep links. + GLOVO("com.glovo", "Glovo") +} diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index bc5e2e2..9a4a567 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -6,12 +6,11 @@ Local de Entrega Por favor, insira os locais de recolha e entrega Carregando… - Comparar + Comparar Viagens Não foi possível abrir o aplicativo Bolt Não foi possível abrir o aplicativo Uber Não foi possível preparar os dados de localização. Verifique os endereços. - Não foi possível abrir o aplicativo Uber Eats - Não foi possível abrir o aplicativo Bolt Food + Não foi possível abrir o %1$s Usar localização atual Permissão de localização negada Não foi possível obter a localização atual @@ -27,6 +26,10 @@ Altere o idioma de exibição deste aplicativo nas configurações do seu dispositivo. Alterar idioma + Configuração de comparação + Comida + Escolha exatamente 2 aplicativos para comparar em "Pesquisar Comida". Tocar num terceiro aplicativo substitui o que foi escolhido há mais tempo entre os 2 atuais. + Atualizações Verifica a página de releases deste aplicativo no GitHub em busca de uma versão mais recente e a baixa de lá - sem precisar da Play Store. Atualizar para a mais recente @@ -46,5 +49,5 @@ Local (cidade, bairro) Pesquisar Comida Por favor, insira um restaurante ou prato para pesquisar - Nem o Uber Eats nem o Bolt Food publicam um formato de link direto para pesquisa, então isso é uma tentativa - ambos os aplicativos abrirão, mas a pesquisa pode não ser preenchida automaticamente. + Nenhum destes aplicativos de entrega de comida publica um formato de link direto para pesquisa, então isso é uma tentativa - os aplicativos abrirão, mas a pesquisa pode não ser preenchida automaticamente. diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e20a6cb..b0e3872 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -6,12 +6,11 @@ Dropoff Location Please enter both pickup and dropoff locations Loading… - Compare + Compare Trips Could not open Bolt app Could not open Uber app Could not prepare location data. Please check your addresses. - Could not open Uber Eats app - Could not open Bolt Food app + Could not open %1$s Use current location Location permission denied Could not get current location @@ -27,6 +26,10 @@ Change the language this app is displayed in from your device\'s Settings. Change language + Comparison configuration + Food + Pick exactly 2 apps to compare on "Search Food". Tapping a third app swaps out whichever of the current 2 was picked longest ago. + Updates Checks this app\'s GitHub releases page for a newer version and downloads it from there - no Play Store needed. Update to latest @@ -46,5 +49,5 @@ Location (city, area) Search Food Please enter a restaurant or dish to search for - Neither Uber Eats nor Bolt Food publish a search deep-link format, so this is a best-effort guess - both apps will open, but the search may not pre-fill. + None of these food delivery apps publish a search deep-link format, so this is a best-effort guess - both apps will open, but the search may not pre-fill. diff --git a/app/src/test/java/org/neteinstein/compareapp/MainViewModelFoodSearchTest.kt b/app/src/test/java/org/neteinstein/compareapp/MainViewModelFoodSearchTest.kt index 0d66c63..df65378 100644 --- a/app/src/test/java/org/neteinstein/compareapp/MainViewModelFoodSearchTest.kt +++ b/app/src/test/java/org/neteinstein/compareapp/MainViewModelFoodSearchTest.kt @@ -16,16 +16,18 @@ import org.mockito.Mockito import org.mockito.Mockito.`when` import org.neteinstein.compareapp.data.repository.AppRepository import org.neteinstein.compareapp.data.repository.LocationRepository +import org.neteinstein.compareapp.helpers.FakeComparisonConfigRepository import org.neteinstein.compareapp.helpers.TestViewModelFactory import org.neteinstein.compareapp.ui.screens.MainViewModel +import org.neteinstein.compareapp.utils.FoodDeliveryProvider import org.neteinstein.compareapp.utils.FoodSearchMode import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config /** - * Tests for the food search (Uber Eats / Bolt Food) side of [MainViewModel] - the query/location - * fields, restaurant-vs-dish mode toggle, and building the two search links. Unlike - * [MainViewModel.prepareDeepLinks], this never geocodes - both links take free-text search terms. + * Tests for the food search side of [MainViewModel] - the query/location fields, restaurant-vs-dish + * mode toggle, and building search links for the currently selected pair of food providers. Unlike + * [MainViewModel.prepareDeepLinks], this never geocodes - all these links take free-text search terms. */ @RunWith(RobolectricTestRunner::class) @Config(sdk = [33]) @@ -79,7 +81,7 @@ class MainViewModelFoodSearchTest { var errorCalled = false var successCalled = false viewModel.prepareFoodSearchLinks( - onSuccess = { _, _ -> successCalled = true }, + onSuccess = { successCalled = true }, onError = { errorCalled = true } ) @@ -88,19 +90,16 @@ class MainViewModelFoodSearchTest { } @Test - fun testPrepareFoodSearchLinks_buildsBothLinksFromQueryAndLocation() { + fun testPrepareFoodSearchLinks_buildsLinksForBothSelectedProviders() { viewModel.updateFoodQuery("Tacos") viewModel.updateFoodLocation("Madrid") - var uberEatsLink: String? = null - var boltFoodLink: String? = null - viewModel.prepareFoodSearchLinks( - onSuccess = { uber, bolt -> - uberEatsLink = uber - boltFoodLink = bolt - } - ) + var links: Map? = null + viewModel.prepareFoodSearchLinks(onSuccess = { links = it }) + val uberEatsLink = links?.get(FoodDeliveryProvider.UBER_EATS) + val boltFoodLink = links?.get(FoodDeliveryProvider.BOLT_FOOD) + assertEquals(2, links?.size) assertTrue(uberEatsLink?.startsWith("https://www.ubereats.com/search?q=") == true) assertTrue(uberEatsLink?.contains("Tacos+Madrid") == true) assertTrue(boltFoodLink?.startsWith("https://food.bolt.eu/search?q=") == true) @@ -108,10 +107,31 @@ class MainViewModelFoodSearchTest { } @Test - fun testCheckFoodAppsInstalled_updatesInstalledFlags() { + fun testPrepareFoodSearchLinks_respectsSelectedProviders() { + val appRepository = Mockito.mock(AppRepository::class.java) + `when`(appRepository.checkRequiredApps()).thenReturn(Pair(true, true)) + val configRepository = FakeComparisonConfigRepository( + initial = setOf(FoodDeliveryProvider.BOLT_FOOD, FoodDeliveryProvider.GLOVO) + ) + val vm = TestViewModelFactory.createTestViewModel( + Mockito.mock(LocationRepository::class.java), + appRepository, + configRepository + ) + vm.updateFoodQuery("Sushi") + + var links: Map? = null + vm.prepareFoodSearchLinks(onSuccess = { links = it }) + + assertEquals(setOf(FoodDeliveryProvider.BOLT_FOOD, FoodDeliveryProvider.GLOVO), links?.keys) + } + + @Test + fun testCheckFoodAppsInstalled_updatesInstalledProviders() { val appRepository = Mockito.mock(AppRepository::class.java) `when`(appRepository.checkRequiredApps()).thenReturn(Pair(true, true)) - `when`(appRepository.checkFoodApps()).thenReturn(Pair(false, true)) + `when`(appRepository.isAppInstalled(FoodDeliveryProvider.UBER_EATS.packageName)).thenReturn(false) + `when`(appRepository.isAppInstalled(FoodDeliveryProvider.BOLT_FOOD.packageName)).thenReturn(true) val vm = TestViewModelFactory.createTestViewModel( Mockito.mock(LocationRepository::class.java), appRepository @@ -120,7 +140,7 @@ class MainViewModelFoodSearchTest { vm.checkFoodAppsInstalled() val state = vm.uiState.value - assertFalse(state.isUberEatsInstalled) - assertTrue(state.isBoltFoodInstalled) + assertFalse(FoodDeliveryProvider.UBER_EATS in state.installedFoodProviders) + assertTrue(FoodDeliveryProvider.BOLT_FOOD in state.installedFoodProviders) } } diff --git a/app/src/test/java/org/neteinstein/compareapp/SettingsViewModelTest.kt b/app/src/test/java/org/neteinstein/compareapp/SettingsViewModelTest.kt new file mode 100644 index 0000000..bf5fea7 --- /dev/null +++ b/app/src/test/java/org/neteinstein/compareapp/SettingsViewModelTest.kt @@ -0,0 +1,116 @@ +package org.neteinstein.compareapp + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mockito +import org.mockito.Mockito.`when` +import org.neteinstein.compareapp.data.repository.UpdateCheckResult +import org.neteinstein.compareapp.data.repository.UpdateRepository +import org.neteinstein.compareapp.helpers.FakeComparisonConfigRepository +import org.neteinstein.compareapp.ui.screens.SettingsViewModel +import org.neteinstein.compareapp.utils.AppUpdateInstaller +import org.neteinstein.compareapp.utils.FoodDeliveryProvider +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * Tests for [SettingsViewModel]'s two Settings-only additions: the hidden Diagnostics tap gesture + * and the exactly-2-of-3 food provider swap logic. Update-check behavior already predates this and + * isn't re-tested here. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [33]) +@OptIn(ExperimentalCoroutinesApi::class) +class SettingsViewModelTest { + + private lateinit var configRepository: FakeComparisonConfigRepository + private lateinit var viewModel: SettingsViewModel + + @Before + fun setup() { + Dispatchers.setMain(UnconfinedTestDispatcher()) + val updateRepository = Mockito.mock(UpdateRepository::class.java) + runBlocking { `when`(updateRepository.checkForUpdate()).thenReturn(UpdateCheckResult.UpToDate("1.0.0")) } + configRepository = FakeComparisonConfigRepository() + val appUpdateInstaller = Mockito.mock(AppUpdateInstaller::class.java) + viewModel = SettingsViewModel(updateRepository, appUpdateInstaller, configRepository) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun testInitialSelectedFoodProviders_matchesRepositoryDefault() { + assertEquals( + listOf(FoodDeliveryProvider.UBER_EATS, FoodDeliveryProvider.BOLT_FOOD), + viewModel.uiState.value.selectedFoodProviders + ) + } + + @Test + fun testOnTitleClicked_diagnosticsStayHidden_beforeTenthTap() { + repeat(9) { viewModel.onTitleClicked() } + + assertFalse(viewModel.uiState.value.diagnosticsUnlocked) + } + + @Test + fun testOnTitleClicked_diagnosticsUnlock_onTenthTap() { + repeat(10) { viewModel.onTitleClicked() } + + assertTrue(viewModel.uiState.value.diagnosticsUnlocked) + } + + @Test + fun testOnFoodProviderToggled_alreadySelected_isNoOp() { + viewModel.onFoodProviderToggled(FoodDeliveryProvider.UBER_EATS) + + assertEquals( + listOf(FoodDeliveryProvider.UBER_EATS, FoodDeliveryProvider.BOLT_FOOD), + viewModel.uiState.value.selectedFoodProviders + ) + } + + @Test + fun testOnFoodProviderToggled_unselected_swapsOutOldestSelection() { + viewModel.onFoodProviderToggled(FoodDeliveryProvider.GLOVO) + + val selected = viewModel.uiState.value.selectedFoodProviders + assertEquals(2, selected.size) + assertEquals(listOf(FoodDeliveryProvider.BOLT_FOOD, FoodDeliveryProvider.GLOVO), selected) + } + + @Test + fun testOnFoodProviderToggled_persistsToRepository() { + viewModel.onFoodProviderToggled(FoodDeliveryProvider.GLOVO) + + assertEquals( + setOf(FoodDeliveryProvider.BOLT_FOOD, FoodDeliveryProvider.GLOVO), + configRepository.selectedFoodProviders.value + ) + } + + @Test + fun testOnFoodProviderToggled_secondSwapEvictsNewOldest() { + viewModel.onFoodProviderToggled(FoodDeliveryProvider.GLOVO) // [BOLT_FOOD, GLOVO] + viewModel.onFoodProviderToggled(FoodDeliveryProvider.UBER_EATS) // evicts BOLT_FOOD + + assertEquals( + listOf(FoodDeliveryProvider.GLOVO, FoodDeliveryProvider.UBER_EATS), + viewModel.uiState.value.selectedFoodProviders + ) + } +} diff --git a/app/src/test/java/org/neteinstein/compareapp/data/repository/ComparisonConfigRepositoryImplTest.kt b/app/src/test/java/org/neteinstein/compareapp/data/repository/ComparisonConfigRepositoryImplTest.kt new file mode 100644 index 0000000..9433cd0 --- /dev/null +++ b/app/src/test/java/org/neteinstein/compareapp/data/repository/ComparisonConfigRepositoryImplTest.kt @@ -0,0 +1,64 @@ +package org.neteinstein.compareapp.data.repository + +import androidx.test.core.app.ApplicationProvider +import org.junit.Assert.assertEquals +import org.junit.Assert.assertThrows +import org.junit.Test +import org.junit.runner.RunWith +import org.neteinstein.compareapp.utils.FoodDeliveryProvider +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28]) +class ComparisonConfigRepositoryImplTest { + + private fun newRepository() = ComparisonConfigRepositoryImpl(ApplicationProvider.getApplicationContext()) + + @Test + fun testSelectedFoodProviders_defaultsToUberEatsAndBoltFood() { + val repository = newRepository() + + assertEquals( + setOf(FoodDeliveryProvider.UBER_EATS, FoodDeliveryProvider.BOLT_FOOD), + repository.selectedFoodProviders.value + ) + } + + @Test + fun testSetSelectedFoodProviders_updatesStateFlow() { + val repository = newRepository() + + repository.setSelectedFoodProviders(setOf(FoodDeliveryProvider.BOLT_FOOD, FoodDeliveryProvider.GLOVO)) + + assertEquals( + setOf(FoodDeliveryProvider.BOLT_FOOD, FoodDeliveryProvider.GLOVO), + repository.selectedFoodProviders.value + ) + } + + @Test + fun testSetSelectedFoodProviders_persistsAcrossInstances() { + val repository = newRepository() + repository.setSelectedFoodProviders(setOf(FoodDeliveryProvider.UBER_EATS, FoodDeliveryProvider.GLOVO)) + + val reloaded = newRepository() + + assertEquals( + setOf(FoodDeliveryProvider.UBER_EATS, FoodDeliveryProvider.GLOVO), + reloaded.selectedFoodProviders.value + ) + } + + @Test + fun testSetSelectedFoodProviders_rejectsWrongCount() { + val repository = newRepository() + + assertThrows(IllegalArgumentException::class.java) { + repository.setSelectedFoodProviders(setOf(FoodDeliveryProvider.UBER_EATS)) + } + assertThrows(IllegalArgumentException::class.java) { + repository.setSelectedFoodProviders(FoodDeliveryProvider.entries.toSet()) + } + } +} diff --git a/app/src/test/java/org/neteinstein/compareapp/helpers/TestViewModelFactory.kt b/app/src/test/java/org/neteinstein/compareapp/helpers/TestViewModelFactory.kt index 12051a8..40e5ae0 100644 --- a/app/src/test/java/org/neteinstein/compareapp/helpers/TestViewModelFactory.kt +++ b/app/src/test/java/org/neteinstein/compareapp/helpers/TestViewModelFactory.kt @@ -1,15 +1,33 @@ package org.neteinstein.compareapp.helpers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow import org.mockito.Mockito import org.mockito.Mockito.`when` import org.neteinstein.compareapp.data.repository.AppRepository +import org.neteinstein.compareapp.data.repository.ComparisonConfigRepository import org.neteinstein.compareapp.data.repository.LocationRepository import org.neteinstein.compareapp.ui.screens.MainViewModel +import org.neteinstein.compareapp.utils.FoodDeliveryProvider + +/** In-memory [ComparisonConfigRepository] for tests - avoids needing a real Context/SharedPreferences. */ +class FakeComparisonConfigRepository( + initial: Set = setOf(FoodDeliveryProvider.UBER_EATS, FoodDeliveryProvider.BOLT_FOOD) +) : ComparisonConfigRepository { + private val state = MutableStateFlow(initial) + override val selectedFoodProviders: StateFlow> = state + + override fun setSelectedFoodProviders(providers: Set) { + require(providers.size == 2) { "Exactly 2 food delivery providers must be selected, got ${providers.size}" } + state.value = providers + } +} object TestViewModelFactory { fun createTestViewModel( locationRepository: LocationRepository? = null, - appRepository: AppRepository? = null + appRepository: AppRepository? = null, + comparisonConfigRepository: ComparisonConfigRepository? = null ): MainViewModel { val mockLocationRepo = locationRepository ?: Mockito.mock(LocationRepository::class.java) @@ -19,7 +37,8 @@ object TestViewModelFactory { val mockAppRepo = appRepository ?: Mockito.mock(AppRepository::class.java).also { `when`(it.checkRequiredApps()).thenReturn(Pair(true, true)) } + val configRepo = comparisonConfigRepository ?: FakeComparisonConfigRepository() - return MainViewModel(mockLocationRepo, mockAppRepo) + return MainViewModel(mockLocationRepo, mockAppRepo, configRepo) } } diff --git a/app/src/test/java/org/neteinstein/compareapp/utils/FoodDeepLinksTest.kt b/app/src/test/java/org/neteinstein/compareapp/utils/FoodDeepLinksTest.kt index a173f6d..f23623f 100644 --- a/app/src/test/java/org/neteinstein/compareapp/utils/FoodDeepLinksTest.kt +++ b/app/src/test/java/org/neteinstein/compareapp/utils/FoodDeepLinksTest.kt @@ -7,38 +7,54 @@ import org.junit.Test class FoodDeepLinksTest { @Test - fun testCreateUberEatsSearchLink_combinesQueryAndLocation() { - val link = FoodDeepLinks.createUberEatsSearchLink("Sushi Place", "Downtown") + fun testCreateSearchLink_uberEats_combinesQueryAndLocation() { + val link = FoodDeepLinks.createSearchLink(FoodDeliveryProvider.UBER_EATS, "Sushi Place", "Downtown") assertTrue(link.startsWith("https://www.ubereats.com/search?q=")) assertTrue(link.contains("Sushi+Place+Downtown") || link.contains("Sushi%20Place%20Downtown")) } @Test - fun testCreateUberEatsSearchLink_withoutLocationUsesQueryOnly() { - val link = FoodDeepLinks.createUberEatsSearchLink("Sushi Place", "") + fun testCreateSearchLink_uberEats_withoutLocationUsesQueryOnly() { + val link = FoodDeepLinks.createSearchLink(FoodDeliveryProvider.UBER_EATS, "Sushi Place", "") assertEquals("https://www.ubereats.com/search?q=Sushi+Place", link) } @Test - fun testCreateBoltFoodSearchLink_combinesQueryAndLocation() { - val link = FoodDeepLinks.createBoltFoodSearchLink("Pizza", "Lisbon") + fun testCreateSearchLink_boltFood_combinesQueryAndLocation() { + val link = FoodDeepLinks.createSearchLink(FoodDeliveryProvider.BOLT_FOOD, "Pizza", "Lisbon") assertTrue(link.startsWith("https://food.bolt.eu/search?q=")) assertTrue(link.contains("Pizza+Lisbon")) } @Test - fun testCreateBoltFoodSearchLink_withoutLocationUsesQueryOnly() { - val link = FoodDeepLinks.createBoltFoodSearchLink("Pizza", " ") + fun testCreateSearchLink_boltFood_withoutLocationUsesQueryOnly() { + val link = FoodDeepLinks.createSearchLink(FoodDeliveryProvider.BOLT_FOOD, "Pizza", " ") assertEquals("https://food.bolt.eu/search?q=Pizza", link) } @Test - fun testPackageConstants_matchRealPackageNames() { - assertEquals("com.ubercab.eats", FoodDeepLinks.UBER_EATS_PACKAGE) - assertEquals("com.bolt.deliveryclient", FoodDeepLinks.BOLT_FOOD_PACKAGE) + fun testCreateSearchLink_glovo_combinesQueryAndLocation() { + val link = FoodDeepLinks.createSearchLink(FoodDeliveryProvider.GLOVO, "Burger", "Barcelona") + + assertTrue(link.startsWith("https://glovoapp.com/search/?query=")) + assertTrue(link.contains("Burger+Barcelona")) + } + + @Test + fun testCreateSearchLink_glovo_withoutLocationUsesQueryOnly() { + val link = FoodDeepLinks.createSearchLink(FoodDeliveryProvider.GLOVO, "Burger", "") + + assertEquals("https://glovoapp.com/search/?query=Burger", link) + } + + @Test + fun testPackageNames_matchRealPackageNames() { + assertEquals("com.ubercab.eats", FoodDeliveryProvider.UBER_EATS.packageName) + assertEquals("com.bolt.deliveryclient", FoodDeliveryProvider.BOLT_FOOD.packageName) + assertEquals("com.glovo", FoodDeliveryProvider.GLOVO.packageName) } } diff --git a/docs/DEEP_LINKS.md b/docs/DEEP_LINKS.md index e4e90f1..1dc5388 100644 --- a/docs/DEEP_LINKS.md +++ b/docs/DEEP_LINKS.md @@ -1,9 +1,11 @@ # Deep Links Reference -Everything this project knows about the deep-link formats for Uber, Uber Eats, Bolt (rides), and -Bolt Food, gathered while debugging the "Bolt opens with no destination set" issue (see PRs #63, -#79, #80, #81). Keep this updated as new evidence comes in - it's the source of truth for the next -person (or the next AI session) working on any of these integrations. +Everything this project knows about the deep-link formats for Uber, Uber Eats, Bolt (rides), Bolt +Food, and Glovo, gathered while debugging the "Bolt opens with no destination set" issue (see PRs +#63, #79, #80, #81) and while adding Glovo as a third food delivery option (see the Comparison +configuration section in Settings, and `ComparisonConfigRepository`). Keep this updated as new +evidence comes in - it's the source of truth for the next person (or the next AI session) working +on any of these integrations. ## Confidence legend @@ -187,6 +189,41 @@ still unconfirmed on-device. --- +## 5. Glovo — `com.glovo` — ❓ UNVERIFIED GUESS + +**Source**: no official documentation found (same situation as Uber Eats). No shipped +`AndroidManifest.xml` was available to inspect for this one either (unlike Bolt/Bolt Food, where a +real manifest was shared during those tasks) - both the network fetches attempted while researching +this and a manifest teardown were unavailable in that session's environment, so *nothing* below the +package name is host/param-verified. This is the least-confident entry in this document; treat it +as a starting point for on-device testing, not a trustworthy format. + +**Package name**: `com.glovo` (confirmed via web search against the Play Store listing for "Glovo: +Food & Grocery Delivery" - note this is distinct from the separate courier/partner apps +`com.logistics.rider.glovo` and `com.deliveryhero.glovopartner`, and from `com.glovoapp23`, which +came up while researching this but does not appear to be the current consumer app's id). + +**Why it's shakier than Uber Eats/Bolt Food**: both of those have a flat, locale-independent +`https:///search?q=` page. Glovo's web app is locale/city-scoped instead +(`https://glovoapp.com///...`, e.g. `.../en/es/map/cities`) - there's no confirmed +flat search URL. A `links.glovoapp.com` domain exists (likely a dynamic-link/attribution host, +similar to Bolt's `*.sng.link` App Links) but its host/path contract wasn't reachable to inspect. + +**What this app implements** (`FoodDeepLinks.createSearchLink()` for `FoodDeliveryProvider.GLOVO` +in `FoodDeepLinks.kt`): +``` +https://glovoapp.com/search/?query= +``` +This guesses that, launched from inside the already-installed app via an explicit-package intent +(same trick used for the other two providers), Glovo's own in-app session already knows the user's +city/locale, so the URL's lack of a locale/country path segment may not matter the way it would for +a plain browser visit - but this is **unconfirmed**. If the app doesn't handle this path at all, the +explicit-package intent throws and `MainActivity.openLinkWithAppFallback()` falls back to a browser +tab (same behavior as the other two providers), so the worst case is "opens a browser to a page that +doesn't resolve either" rather than a crash. + +--- + ## Where this lives in the codebase | Concern | File | @@ -194,9 +231,11 @@ still unconfirmed on-device. | Uber ride link builder | `app/src/main/java/org/neteinstein/compareapp/ui/screens/MainViewModel.kt` (`createUberDeepLink`) | | Bolt ride link builder | same file (`createBoltDeepLink`, `createBoltDeepLinkWeb`) | | Bolt ride candidate formats (for on-device testing) | `app/src/main/java/org/neteinstein/compareapp/utils/BoltDeepLinkCandidates.kt` | -| Bolt Link Lab UI (Settings → Diagnostics) | `app/src/main/java/org/neteinstein/compareapp/ui/screens/BoltLinkLabScreen.kt` / `BoltLinkLabViewModel.kt` | -| Food search link builders (Uber Eats / Bolt Food) | `app/src/main/java/org/neteinstein/compareapp/utils/FoodDeepLinks.kt` | -| Launch mechanics (native → web/browser fallback, explicit package targeting) | `app/src/main/java/org/neteinstein/compareapp/MainActivity.kt` (`launchBoltWithFallback`, `openBoltWebLink`, `openLinkWithAppFallback`) | +| Bolt Link Lab UI (Settings → Diagnostics, tap the title 10 times to reveal) | `app/src/main/java/org/neteinstein/compareapp/ui/screens/BoltLinkLabScreen.kt` / `BoltLinkLabViewModel.kt` | +| Food providers (package names, display names) | `app/src/main/java/org/neteinstein/compareapp/utils/FoodDeliveryProvider.kt` | +| Food search link builders (Uber Eats / Bolt Food / Glovo) | `app/src/main/java/org/neteinstein/compareapp/utils/FoodDeepLinks.kt` | +| Which 2 of 3 food providers are active (Settings → Comparison configuration) | `app/src/main/java/org/neteinstein/compareapp/data/repository/ComparisonConfigRepository.kt` / `ComparisonConfigRepositoryImpl.kt` | +| Launch mechanics (native → web/browser fallback, explicit package targeting) | `app/src/main/java/org/neteinstein/compareapp/MainActivity.kt` (`launchBoltWithFallback`, `openBoltWebLink`, `openLinkWithAppFallback`, `openFoodSearch`) | ## Open items / suggested next steps @@ -211,6 +250,11 @@ still unconfirmed on-device. pattern can be used for a specific restaurant once/if a restaurant-ID lookup becomes available. 4. **Uber Eats**: no verified path exists; the search link is the best available guess. Revisit if Uber ever publishes Eats deep-link docs. -5. **"Same restaurant" across Uber Eats / Bolt Food**: not implemented. Neither platform exposes a - shared restaurant identifier or a public search API, so matching would require an extra +5. **Glovo**: highest priority follow-up of the three food providers - get a device with Glovo + installed (or the shipped APK's `AndroidManifest.xml`) and confirm whether `com.glovo` declares + any App Link host at all, and whether `glovoapp.com/search/?query=` (or any URL) actually opens + the app rather than falling back to browser. Right now this is a guess with no host verification, + unlike Bolt Food's confirmed `/search` App Link. +6. **"Same restaurant" across food providers**: not implemented. None of the three platforms expose + a shared restaurant identifier or a public search API, so matching would require an extra search-and-compare step (fuzzy match by name + location) rather than a direct deep link.