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.