Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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()
}
}
9 changes: 9 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -83,8 +83,17 @@
</intent-filter>
</receiver>
</application>
<!-- Package visibility (API 30+): without a <queries> entry, PackageManager.getPackageInfo()
(AppRepository.isAppInstalled) throws NameNotFoundException for any package not listed
here - not just implicit intent resolution - so every app this screen checks/targets by
package (ride comparison + food search) needs an entry, or "app not installed" warnings
become permanently, silently wrong on modern Android regardless of what's actually
installed. See FoodDeliveryProvider.kt for the food package names. -->
<queries>
<package android:name="com.ubercab" />
<package android:name="ee.mtakso.client" />
<package android:name="com.ubercab.eats" />
<package android:name="com.bolt.deliveryclient" />
<package android:name="com.glovo" />
</queries>
</manifest>
75 changes: 31 additions & 44 deletions app/src/main/java/org/neteinstein/compareapp/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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) }
)
}
}
Expand Down Expand Up @@ -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<FoodDeliveryProvider, String>) {
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()
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,4 @@ package org.neteinstein.compareapp.data.repository
interface AppRepository {
fun isAppInstalled(packageName: String): Boolean
fun checkRequiredApps(): Pair<Boolean, Boolean>
fun checkFoodApps(): Pair<Boolean, Boolean>
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -34,10 +32,4 @@ class AppRepositoryImpl @Inject constructor(
val isBoltInstalled = isAppInstalled(BOLT_PACKAGE_NAME)
return Pair(isUberInstalled, isBoltInstalled)
}

override fun checkFoodApps(): Pair<Boolean, Boolean> {
val isUberEatsInstalled = isAppInstalled(UBER_EATS_PACKAGE_NAME)
val isBoltFoodInstalled = isAppInstalled(BOLT_FOOD_PACKAGE_NAME)
return Pair(isUberEatsInstalled, isBoltFoodInstalled)
}
}
Original file line number Diff line number Diff line change
@@ -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<Set<FoodDeliveryProvider>>

fun setSelectedFoodProviders(providers: Set<FoodDeliveryProvider>)
}
Original file line number Diff line number Diff line change
@@ -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<Set<FoodDeliveryProvider>> = _selectedFoodProviders.asStateFlow()

override fun setSelectedFoodProviders(providers: Set<FoodDeliveryProvider>) {
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<FoodDeliveryProvider> {
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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -33,4 +35,10 @@ abstract class RepositoryModule {
abstract fun bindUpdateRepository(
updateRepositoryImpl: UpdateRepositoryImpl
): UpdateRepository

@Binds
@Singleton
abstract fun bindComparisonConfigRepository(
comparisonConfigRepositoryImpl: ComparisonConfigRepositoryImpl
): ComparisonConfigRepository
}
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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<FoodDeliveryProvider, String>) -> Unit = {}
) {
val uiState by viewModel.uiState.collectAsState()
val context = LocalContext.current
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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())
Expand All @@ -33,6 +36,7 @@ class MainViewModel @Inject constructor(

init {
checkInstalledApps()
checkFoodAppsInstalled()
}

fun checkInstalledApps() {
Expand All @@ -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) {
Expand Down Expand Up @@ -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<FoodDeliveryProvider, String>) -> Unit,
onError: () -> Unit = {}
) {
val currentState = _uiState.value
Expand All @@ -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 {
Expand All @@ -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<FoodDeliveryProvider> = emptySet(),
val installedFoodProviders: Set<FoodDeliveryProvider> = emptySet()
)
Loading
Loading