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
8 changes: 8 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,14 @@
<uses-permission android:name="android.permission.INTERNET" />
<!-- Runtime-requested on Android 13+ so the background poll can raise tray notifications. -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<!--
Runtime-requested, and only when the user taps "Use my location" in Settings,
to fill in the optional profile location. Coarse deliberately: those stored
coordinates drive city-level surfaces (the weather and location widgets), not
navigation, so the app never asks for ACCESS_FINE_LOCATION. Refusing it leaves
the whole app working — the coordinates can still be typed in, or left unset.
-->
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />

<application
android:name=".InterlinedListApplication"
Expand Down
5 changes: 4 additions & 1 deletion feature/profile/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,11 @@ dependencies {
debugImplementation(libs.androidx.compose.ui.tooling)
implementation(libs.androidx.lifecycle.viewmodel.compose)
implementation(libs.androidx.lifecycle.runtime.compose)
// Avatar picker uses rememberLauncherForActivityResult from activity-compose.
// Avatar picker and the location permission request use
// rememberLauncherForActivityResult from activity-compose.
implementation(libs.androidx.activity.compose)
// LocationManagerCompat backports one-shot location reads below API 30.
implementation(libs.androidx.core.ktx)

// This module owns its own Room cache (see DocumentsDatabase) — it must not
// reuse the shared :core:database, so it pulls Room in directly.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
package com.interlinedlist.android.feature.profile.ui

import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsEnabled
import androidx.compose.ui.test.assertIsNotEnabled
import androidx.compose.ui.test.assertTextContains
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performTextClearance
import androidx.compose.ui.test.performTextInput
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.google.common.truth.Truth.assertThat
import com.interlinedlist.android.core.designsystem.theme.InterlinedListTheme
import com.interlinedlist.android.feature.profile.domain.Coordinates
import com.interlinedlist.android.feature.profile.ui.settings.LocationNotice
import com.interlinedlist.android.feature.profile.ui.settings.LocationPermissionRationaleDialog
import com.interlinedlist.android.feature.profile.ui.settings.ProfileLocationGroup
import com.interlinedlist.android.feature.profile.ui.settings.ProfileLocationTestTags
import com.interlinedlist.android.feature.profile.ui.settings.ProfileLocationUiState
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith

/**
* The profile-location UI (issue #37). The section is rendered on its own, without the
* permission plumbing that wraps it, so these cover what the user sees and reports.
*/
@RunWith(AndroidJUnit4::class)
class ProfileLocationSectionTest {

@get:Rule
val composeRule = createComposeRule()

private fun setContent(
state: ProfileLocationUiState,
onUseDeviceLocation: () -> Unit = {},
onSaveLocation: (Double, Double) -> Unit = { _, _ -> },
onDismissNotice: () -> Unit = {},
) {
composeRule.setContent {
InterlinedListTheme {
ProfileLocationGroup(
state = state,
onUseDeviceLocation = onUseDeviceLocation,
onSaveLocation = onSaveLocation,
onDismissNotice = onDismissNotice,
)
}
}
}

@Test
fun storedLocation_isShownWithWhatHappensToIt() {
setContent(ProfileLocationUiState(coordinates = Coordinates(47.6062, -122.3321)))

composeRule.onNodeWithTag(ProfileLocationTestTags.GROUP).assertIsDisplayed()
composeRule.onNodeWithTag(ProfileLocationTestTags.VALUE)
.assertTextContains("Saved location: 47.6062, -122.3321")
// The privacy note is not optional decoration: it says where this ends up.
composeRule.onNodeWithTag(ProfileLocationTestTags.PRIVACY_NOTE).assertIsDisplayed()
}

@Test
fun storedLocation_explainsThatItCannotBeRemoved() {
// The API refuses every way of unsetting a location, so the button that would
// do it must never look pressable — and the reason has to be on screen, at the
// control, rather than left for the user to discover by failing.
setContent(ProfileLocationUiState(coordinates = Coordinates(47.6062, -122.3321)))

composeRule.onNodeWithTag(ProfileLocationTestTags.CLEAR).assertIsNotEnabled()
composeRule.onNodeWithTag(ProfileLocationTestTags.CANNOT_REMOVE).assertIsDisplayed()
}

@Test
fun storedLocation_canBeReplacedByTypingAnother() {
// Replacing is the only supported way to change a saved location.
var saved: Pair<Double, Double>? = null
setContent(
state = ProfileLocationUiState(coordinates = Coordinates(47.6062, -122.3321)),
onSaveLocation = { latitude, longitude -> saved = latitude to longitude },
)

composeRule.onNodeWithTag(ProfileLocationTestTags.LATITUDE).performTextClearance()
composeRule.onNodeWithTag(ProfileLocationTestTags.LATITUDE).performTextInput("45.52")
composeRule.onNodeWithTag(ProfileLocationTestTags.LONGITUDE).performTextClearance()
composeRule.onNodeWithTag(ProfileLocationTestTags.LONGITUDE).performTextInput("-122.68")
composeRule.onNodeWithTag(ProfileLocationTestTags.SAVE).performClick()

assertThat(saved).isEqualTo(45.52 to -122.68)
}

@Test
fun noStoredLocation_saysSoAndOffersNothingToClear() {
setContent(ProfileLocationUiState(coordinates = null))

composeRule.onNodeWithTag(ProfileLocationTestTags.VALUE)
.assertTextContains("No location saved.")
composeRule.onNodeWithTag(ProfileLocationTestTags.CLEAR).assertIsNotEnabled()
composeRule.onNodeWithTag(ProfileLocationTestTags.SAVE).assertIsNotEnabled()
// Nothing to remove, so nothing to explain about removal.
composeRule.onNodeWithTag(ProfileLocationTestTags.CANNOT_REMOVE).assertDoesNotExist()
}

@Test
fun typingCoordinates_andSaving_reportsThem() {
var saved: Pair<Double, Double>? = null
setContent(
state = ProfileLocationUiState(coordinates = null),
onSaveLocation = { latitude, longitude -> saved = latitude to longitude },
)

composeRule.onNodeWithTag(ProfileLocationTestTags.LATITUDE).performTextInput("47.6062")
composeRule.onNodeWithTag(ProfileLocationTestTags.LONGITUDE).performTextInput("-122.3321")
composeRule.onNodeWithTag(ProfileLocationTestTags.SAVE).performClick()

assertThat(saved).isEqualTo(47.6062 to -122.3321)
}

@Test
fun outOfRangeEntry_cannotBeSavedAndSaysWhy() {
var saved: Pair<Double, Double>? = null
setContent(
state = ProfileLocationUiState(coordinates = null),
onSaveLocation = { latitude, longitude -> saved = latitude to longitude },
)

composeRule.onNodeWithTag(ProfileLocationTestTags.LATITUDE).performTextInput("91")
composeRule.onNodeWithTag(ProfileLocationTestTags.LONGITUDE).performTextInput("0")

composeRule.onNodeWithTag(ProfileLocationTestTags.ENTRY_ERROR).assertIsDisplayed()
composeRule.onNodeWithTag(ProfileLocationTestTags.SAVE).assertIsNotEnabled()
composeRule.onNodeWithTag(ProfileLocationTestTags.SAVE).performClick()
assertThat(saved).isNull()
}

@Test
fun editingBackIntoRange_reEnablesSaving() {
setContent(ProfileLocationUiState(coordinates = null))

composeRule.onNodeWithTag(ProfileLocationTestTags.LATITUDE).performTextInput("991")
composeRule.onNodeWithTag(ProfileLocationTestTags.LONGITUDE).performTextInput("10")
composeRule.onNodeWithTag(ProfileLocationTestTags.SAVE).assertIsNotEnabled()

composeRule.onNodeWithTag(ProfileLocationTestTags.LATITUDE).performTextClearance()
composeRule.onNodeWithTag(ProfileLocationTestTags.LATITUDE).performTextInput("47.6")

composeRule.onNodeWithTag(ProfileLocationTestTags.SAVE).assertIsEnabled()
}

@Test
fun useMyLocation_reportsTheRequest() {
var asked = false
setContent(
state = ProfileLocationUiState(coordinates = null),
onUseDeviceLocation = { asked = true },
)

composeRule.onNodeWithTag(ProfileLocationTestTags.USE_DEVICE).performClick()

assertThat(asked).isTrue()
}

@Test
fun whileReadingTheDevice_theButtonIsBusy() {
setContent(ProfileLocationUiState(coordinates = null, isReadingDevice = true))

composeRule.onNodeWithTag(ProfileLocationTestTags.USE_DEVICE).assertIsNotEnabled()
}

@Test
fun aRefusedPermission_isShownAndTheFieldsStayUsable() {
var saved: Pair<Double, Double>? = null
setContent(
state = ProfileLocationUiState(
coordinates = null,
notice = LocationNotice("Location permission wasn't granted."),
),
onSaveLocation = { latitude, longitude -> saved = latitude to longitude },
)

composeRule.onNodeWithTag(ProfileLocationTestTags.NOTICE).assertIsDisplayed()
// Manual entry is the whole point of the fallback, so it must still work.
composeRule.onNodeWithTag(ProfileLocationTestTags.LATITUDE).performTextInput("47.6")
composeRule.onNodeWithTag(ProfileLocationTestTags.LONGITUDE).performTextInput("-122.3")
composeRule.onNodeWithTag(ProfileLocationTestTags.SAVE).performClick()

assertThat(saved).isEqualTo(47.6 to -122.3)
}

@Test
fun rationale_explainsBeforeAsking() {
var continued = false
var dismissed = false
composeRule.setContent {
InterlinedListTheme {
LocationPermissionRationaleDialog(
onContinue = { continued = true },
onDismiss = { dismissed = true },
)
}
}

composeRule.onNodeWithTag(ProfileLocationTestTags.RATIONALE).assertIsDisplayed()
composeRule.onNodeWithTag(ProfileLocationTestTags.RATIONALE_DISMISS).performClick()
assertThat(dismissed).isTrue()
assertThat(continued).isFalse()

composeRule.onNodeWithTag(ProfileLocationTestTags.RATIONALE_CONTINUE).performClick()
assertThat(continued).isTrue()
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package com.interlinedlist.android.feature.profile.data.location

import com.interlinedlist.android.feature.profile.domain.Coordinates

/** The outcome of one attempt to read this device's position. */
sealed interface DeviceLocationResult {

/** A fix was obtained. */
data class Available(val coordinates: Coordinates) : DeviceLocationResult

/**
* `ACCESS_COARSE_LOCATION` is not held, so nothing was read. Setting a location
* stays entirely optional, so this is an ordinary outcome and not an error.
*/
data object PermissionMissing : DeviceLocationResult

/**
* The permission is held but the device produced no fix — location services off,
* no usable provider, or nothing reported inside the time budget.
*/
data object Unavailable : DeviceLocationResult
}

/**
* Reads this device's approximate position, so Settings can offer "use my location"
* as an alternative to typing coordinates in.
*
* Abstracted (DIP) for two reasons: the view model stays unit-testable with no Android
* runtime, and there is exactly one place in the app that can touch the device's
* position — [currentCoordinates] — which is called only from an explicit user action.
* Nothing observes location; there is no background reader and no subscription.
*/
interface DeviceLocationSource {

/** True when `ACCESS_COARSE_LOCATION` is currently granted to the app. */
fun hasCoarsePermission(): Boolean

/**
* Takes a single reading. Answers [DeviceLocationResult.PermissionMissing] rather
* than throwing when the permission is absent, so a caller can never capture a
* position it was not granted.
*/
suspend fun currentCoordinates(): DeviceLocationResult
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
package com.interlinedlist.android.feature.profile.data.location

import android.Manifest
import android.annotation.SuppressLint
import android.content.Context
import android.content.pm.PackageManager
import android.location.Location
import android.location.LocationManager
import androidx.core.content.ContextCompat
import androidx.core.location.LocationManagerCompat
import androidx.core.os.CancellationSignal
import com.interlinedlist.android.core.common.dispatcher.DispatcherProvider
import com.interlinedlist.android.feature.profile.domain.Coordinates
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.coroutines.resume

/**
* Platform [DeviceLocationSource], backed by the framework's own [LocationManager] —
* no Play Services dependency, so the feature works on any device the app runs on.
*
* Only **coarse** providers are read. `GPS_PROVIDER` is deliberately absent: it needs
* `ACCESS_FINE_LOCATION`, which this app does not ask for and does not need, because
* the stored coordinates drive city-level surfaces (the weather and location widgets)
* rather than navigation.
*
* A reading is a one-shot with a time budget and no subscription: the app never
* watches the user's position, it answers a button press once and stops.
*/
@Singleton
class SystemDeviceLocationSource @Inject constructor(
@ApplicationContext private val context: Context,
private val dispatchers: DispatcherProvider,
) : DeviceLocationSource {

override fun hasCoarsePermission(): Boolean =
ContextCompat.checkSelfPermission(context, COARSE_LOCATION) ==
PackageManager.PERMISSION_GRANTED

override suspend fun currentCoordinates(): DeviceLocationResult =
withContext(dispatchers.io) {
// Re-checked here and not only in the UI: this is the single point that can
// read a position, so the guarantee belongs with it.
if (!hasCoarsePermission()) return@withContext DeviceLocationResult.PermissionMissing

val manager = context.getSystemService(Context.LOCATION_SERVICE) as? LocationManager
?: return@withContext DeviceLocationResult.Unavailable
val provider = manager.coarseProvider()
?: return@withContext DeviceLocationResult.Unavailable

// A fresh fix when the device can manage one inside the budget; otherwise
// whatever it already knows, which is plenty for a city-level location.
val fix = withTimeoutOrNull(FIX_TIMEOUT_MILLIS) { manager.awaitLocation(provider) }
?: manager.lastKnown(provider)

fix?.let { DeviceLocationResult.Available(Coordinates(it.latitude, it.longitude)) }
?: DeviceLocationResult.Unavailable
}

/**
* The coarse provider to read from, or null when the device has none enabled.
* `fused` (the platform's own, from Android 12) is preferred, then the network
* provider, then the passive one — never GPS.
*/
private fun LocationManager.coarseProvider(): String? = COARSE_PROVIDERS.firstOrNull {
it in allProviders && runCatching { isProviderEnabled(it) }.getOrDefault(false)
}

/** One fix from [provider], or null if the provider reports none. */
// Permission is verified in currentCoordinates() immediately before this runs.
@SuppressLint("MissingPermission")
private suspend fun LocationManager.awaitLocation(provider: String): Location? =
suspendCancellableCoroutine { continuation ->
val signal = CancellationSignal()
continuation.invokeOnCancellation { signal.cancel() }
LocationManagerCompat.getCurrentLocation(
this,
provider,
signal,
ContextCompat.getMainExecutor(context),
) { location: Location? ->
if (continuation.isActive) continuation.resume(location)
}
}

/** The provider's last known fix, if it has one and still permits reading it. */
@SuppressLint("MissingPermission")
private fun LocationManager.lastKnown(provider: String): Location? =
runCatching { getLastKnownLocation(provider) }.getOrNull()

private companion object {
val COARSE_LOCATION: String = Manifest.permission.ACCESS_COARSE_LOCATION

/**
* `LocationManager.FUSED_PROVIDER` is API 31, so its value is spelled out to
* keep this compiling against minSdk 26; the provider list is checked for it
* anyway, so an older device simply falls through to the next entry.
*/
val COARSE_PROVIDERS = listOf(
"fused",
LocationManager.NETWORK_PROVIDER,
LocationManager.PASSIVE_PROVIDER,
)

/** How long a press of "Use my location" may wait before giving up. */
const val FIX_TIMEOUT_MILLIS = 10_000L
}
}
Loading
Loading