- Unifies Location Flows and GeoHash Flows into one

- Make them react to changing location permissions
- Adds UI for when the location permission is rejected.
This commit is contained in:
Vitor Pamplona 2024-11-11 17:51:42 -05:00
parent 062e4af118
commit 37a92c25f0
10 changed files with 193 additions and 60 deletions

View File

@ -20,7 +20,6 @@
*/
package com.vitorpamplona.amethyst.model
import android.location.Location
import android.util.Log
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable
@ -29,10 +28,10 @@ import androidx.lifecycle.asLiveData
import androidx.lifecycle.liveData
import androidx.lifecycle.switchMap
import com.fasterxml.jackson.module.kotlin.readValue
import com.fonfon.kgeohash.toGeoHash
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.BuildConfig
import com.vitorpamplona.amethyst.service.FileHeader
import com.vitorpamplona.amethyst.service.LocationState
import com.vitorpamplona.amethyst.service.NostrLnZapPaymentResponseDataSource
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.tryAndWait
@ -170,7 +169,7 @@ class Account(
val listName: String,
val peopleList: StateFlow<NoteState> = MutableStateFlow(NoteState(Note(" "))),
val kind3: StateFlow<Account.LiveFollowList?> = MutableStateFlow(null),
val location: StateFlow<Location?> = MutableStateFlow(null),
val location: StateFlow<LocationState.LocationResult?> = MutableStateFlow(null),
)
val connectToRelaysFlow =
@ -541,7 +540,7 @@ class Account(
AROUND_ME ->
FeedsBaseFlows(
listName,
location = Amethyst.instance.locationManager.locationStateFlow,
location = Amethyst.instance.locationManager.geohashStateFlow,
)
else -> {
val note = LocalCache.checkGetOrCreateAddressableNote(listName)
@ -563,19 +562,19 @@ class Account(
listName: String,
kind3: LiveFollowList?,
noteState: NoteState,
location: Location?,
location: LocationState.LocationResult?,
): LiveFollowList? =
if (listName == GLOBAL_FOLLOWS) {
null
} else if (listName == KIND3_FOLLOWS) {
kind3
} else if (listName == AROUND_ME) {
val hash = location?.toGeoHash(com.vitorpamplona.amethyst.ui.actions.GeohashPrecision.KM_5_X_5.digits)
if (hash != null) {
val geohashResult = location ?: Amethyst.instance.locationManager.geohashStateFlow.value
if (geohashResult is LocationState.LocationResult.Success) {
// 2 neighbors deep = 25x25km
val hashes =
listOf(hash.toString()) +
hash.adjacent
listOf(geohashResult.geoHash.toString()) +
geohashResult.geoHash.adjacent
.map { listOf(it.toString()) + it.adjacent.map { it.toString() } }
.flatten()
.distinct()

View File

@ -26,21 +26,28 @@ import android.location.Geocoder
import android.location.Location
import android.location.LocationListener
import android.location.LocationManager
import android.os.Build
import android.os.Looper
import android.util.Log
import android.util.LruCache
import com.fonfon.kgeohash.GeoHash
import com.fonfon.kgeohash.toGeoHash
import com.vitorpamplona.amethyst.service.LocationState.Companion.MIN_DISTANCE
import com.vitorpamplona.amethyst.service.LocationState.Companion.MIN_TIME
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.transformLatest
import kotlinx.coroutines.launch
class LocationFlow(
@ -90,27 +97,48 @@ class LocationState(
const val MIN_DISTANCE: Float = 100.0f
}
private var latestLocation: Location = Location(LocationManager.NETWORK_PROVIDER)
sealed class LocationResult {
data class Success(
val geoHash: GeoHash,
) : LocationResult()
val locationStateFlow =
LocationFlow(context)
.get(MIN_TIME, MIN_DISTANCE)
.onEach {
latestLocation = it
object LackPermission : LocationResult()
object Loading : LocationResult()
}
private var hasLocationPermission = MutableStateFlow<Boolean>(false)
private var latestLocation: LocationResult = LocationResult.Loading
fun setLocationPermission(newValue: Boolean) {
if (newValue != hasLocationPermission.value) {
hasLocationPermission.tryEmit(newValue)
}
}
@OptIn(ExperimentalCoroutinesApi::class)
val geohashStateFlow =
hasLocationPermission
.transformLatest {
emitAll(
LocationFlow
(context)
.get(MIN_TIME, MIN_DISTANCE)
.map {
LocationResult.Success(it.toGeoHash(com.vitorpamplona.amethyst.ui.actions.GeohashPrecision.KM_5_X_5.digits)) as LocationResult
}.onEach {
latestLocation = it
}.catch { e ->
e.printStackTrace()
latestLocation = LocationResult.LackPermission
emit(LocationResult.LackPermission)
},
)
}.stateIn(
scope,
SharingStarted.WhileSubscribed(5000),
latestLocation,
)
val geohashStateFlow =
locationStateFlow
.map { it.toGeoHash(com.vitorpamplona.amethyst.ui.actions.GeohashPrecision.KM_5_X_5.digits).toString() }
.stateIn(
scope,
SharingStarted.WhileSubscribed(5000),
"",
)
}
object CachedGeoLocations {
@ -144,8 +172,11 @@ private class ReverseGeoLocationUtil {
): String? {
return try {
Geocoder(context)
.getFromLocation(location.latitude, location.longitude, 1)
?.firstOrNull()
.getFromLocation(
location.latitude,
location.longitude,
1,
)?.firstOrNull()
?.let { address ->
listOfNotNull(address.locality ?: address.subAdminArea, address.countryCode)
.joinToString(", ")
@ -157,3 +188,52 @@ private class ReverseGeoLocationUtil {
}
}
}
class ReverseGeoLocationFlow(
private val context: Context,
) {
@SuppressLint("MissingPermission")
fun get(location: Location): Flow<String?> =
callbackFlow {
val locationManager = Geocoder(context)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
val locationCallback =
(
Geocoder.GeocodeListener { addresses ->
launch {
send(
addresses.firstOrNull()?.let {
listOfNotNull(it.locality ?: it.subAdminArea, it.countryCode).joinToString(", ")
},
)
}
}
)
Log.d("GeoLocation Service", "LocationState Start")
locationManager
.getFromLocation(
location.latitude,
location.longitude,
1,
locationCallback,
)
} else {
launch {
send(
Geocoder(context)
.getFromLocation(
location.latitude,
location.longitude,
1,
)?.firstOrNull()
?.let { address ->
listOfNotNull(address.locality ?: address.subAdminArea, address.countryCode)
.joinToString(", ")
},
)
}
}
}
}

View File

@ -34,7 +34,6 @@ import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.input.TextFieldValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.fonfon.kgeohash.toGeoHash
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.compose.insertUrlAtCursor
@ -44,6 +43,7 @@ import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.FileHeader
import com.vitorpamplona.amethyst.service.LocationState
import com.vitorpamplona.amethyst.service.Nip96Uploader
import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource
import com.vitorpamplona.amethyst.ui.components.MediaCompressor
@ -75,13 +75,8 @@ import com.vitorpamplona.quartz.events.ZapSplitSetup
import com.vitorpamplona.quartz.events.findURLs
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.util.UUID
@ -166,7 +161,7 @@ open class NewPostViewModel : ViewModel() {
// GeoHash
var wantsToAddGeoHash by mutableStateOf(false)
var location: StateFlow<String?>? = null
var location: StateFlow<LocationState.LocationResult>? = null
// ZapRaiser
var canAddZapRaiser by mutableStateOf(false)
@ -535,7 +530,7 @@ open class NewPostViewModel : ViewModel() {
null
}
val geoHash = location?.value
val geoHash = (location?.value as? LocationState.LocationResult.Success)?.geoHash?.toString()
val localZapRaiserAmount = if (wantsZapraiser) zapRaiserAmount else null
nip95attachments.forEach {
@ -1259,13 +1254,9 @@ open class NewPostViewModel : ViewModel() {
contentToAddUrl = uri
}
@OptIn(ExperimentalCoroutinesApi::class)
fun locationFlow(): Flow<String?> {
fun locationFlow(): StateFlow<LocationState.LocationResult> {
if (location == null) {
location =
Amethyst.instance.locationManager.locationStateFlow
.mapLatest { it.toGeoHash(GeohashPrecision.KM_5_X_5.digits).toString() }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null)
location = Amethyst.instance.locationManager.geohashStateFlow
}
return location!!

View File

@ -20,6 +20,7 @@
*/
package com.vitorpamplona.amethyst.ui.dal
import com.vitorpamplona.amethyst.model.AROUND_ME
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.GLOBAL_FOLLOWS
import com.vitorpamplona.quartz.encoders.ATag
@ -33,6 +34,7 @@ import com.vitorpamplona.quartz.utils.TimeUtils
class FilterByListParams(
val isGlobal: Boolean,
val isHiddenList: Boolean,
val isAroundMe: Boolean,
val followLists: Account.LiveFollowList?,
val hiddenLists: Account.LiveHiddenUsers,
val now: Long = TimeUtils.oneMinuteFromNow(),
@ -43,6 +45,7 @@ class FilterByListParams(
fun isEventInList(noteEvent: Event): Boolean {
if (followLists == null) return false
if (isAroundMe && followLists.geotags.isEmpty() == true) return false
return if (noteEvent is LiveActivitiesEvent) {
noteEvent.participantsIntersect(followLists.authors) ||
@ -95,6 +98,7 @@ class FilterByListParams(
FilterByListParams(
isGlobal = selectedListName == GLOBAL_FOLLOWS,
isHiddenList = showHiddenKey(selectedListName, userHex),
isAroundMe = selectedListName == AROUND_ME,
followLists = followLists,
hiddenLists = hiddenUsers,
)

View File

@ -54,6 +54,7 @@ import com.google.accompanist.permissions.rememberPermissionState
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.service.LocationState
import com.vitorpamplona.amethyst.ui.actions.LoadingAnimation
import com.vitorpamplona.amethyst.ui.note.LoadCityName
import com.vitorpamplona.amethyst.ui.screen.AroundMeFeedDefinition
@ -71,7 +72,6 @@ import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.quartz.events.PeopleListEvent
import kotlinx.collections.immutable.ImmutableList
import kotlinx.coroutines.flow.map
@OptIn(ExperimentalPermissionsApi::class)
@Composable
@ -104,6 +104,12 @@ fun FeedFilterSpinner(
}
}
val locationPermissionState = rememberPermissionState(Manifest.permission.ACCESS_COARSE_LOCATION)
LaunchedEffect(locationPermissionState.status.isGranted) {
Amethyst.instance.locationManager.setLocationPermission(locationPermissionState.status.isGranted)
}
Box(
modifier = modifier,
contentAlignment = Alignment.Center,
@ -115,27 +121,45 @@ fun FeedFilterSpinner(
Text(currentText)
if (selected is AroundMeFeedDefinition) {
val locationPermissionState =
rememberPermissionState(
Manifest.permission.ACCESS_COARSE_LOCATION,
)
if (!locationPermissionState.status.isGranted) {
LaunchedEffect(locationPermissionState) { locationPermissionState.launchPermissionRequest() }
Text(
text = stringRes(R.string.lack_location_permissions),
fontSize = 12.sp,
lineHeight = 12.sp,
)
} else {
val location by Amethyst.instance.locationManager.geohashStateFlow
.collectAsStateWithLifecycle(null)
.collectAsStateWithLifecycle()
location?.let {
LoadCityName(
geohashStr = it,
onLoading = {
Spacer(modifier = StdHorzSpacer)
LoadingAnimation()
},
) { cityName ->
when (val myLocation = location) {
is LocationState.LocationResult.Success -> {
LoadCityName(
geohashStr = myLocation.geoHash.toString(),
onLoading = {
Spacer(modifier = StdHorzSpacer)
LoadingAnimation()
},
) { cityName ->
Text(
text = "($cityName)",
fontSize = 12.sp,
lineHeight = 12.sp,
)
}
}
LocationState.LocationResult.LackPermission -> {
Text(
text = "($cityName)",
text = stringRes(R.string.lack_location_permissions),
fontSize = 12.sp,
lineHeight = 12.sp,
)
}
LocationState.LocationResult.Loading -> {
Text(
text = stringRes(R.string.loading_location),
fontSize = 12.sp,
lineHeight = 12.sp,
)

View File

@ -215,6 +215,7 @@ fun LoadCityName(
CachedGeoLocations
.geoLocate(geohashStr, geoHash.toLocation(), context)
?.ifBlank { null }
if (newCityName != null && newCityName != cityName) {
cityName = newCityName
}

View File

@ -105,7 +105,7 @@ class FollowListState(
unpackList = listOf(MuteListEvent.blockListFor(account.userProfile().pubkeyHex)),
)
val defaultLists = persistentListOf(kind3Follow, globalFollow, aroundMe, muteListFollow)
val defaultLists = persistentListOf(kind3Follow, aroundMe, globalFollow, muteListFollow)
fun getPeopleLists(): List<FeedDefinition> =
account

View File

@ -128,10 +128,12 @@ import coil3.compose.AsyncImage
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberPermissionState
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.LocationState
import com.vitorpamplona.amethyst.service.Nip96MediaServers
import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource
import com.vitorpamplona.amethyst.ui.actions.LoadingAnimation
@ -1285,6 +1287,10 @@ fun LocationAsHash(postViewModel: NewPostViewModel) {
Manifest.permission.ACCESS_COARSE_LOCATION,
)
LaunchedEffect(locationPermissionState.status.isGranted) {
Amethyst.instance.locationManager.setLocationPermission(locationPermissionState.status.isGranted)
}
if (locationPermissionState.status.isGranted) {
Column(
modifier = Modifier.fillMaxWidth(),
@ -1334,9 +1340,28 @@ fun LocationAsHash(postViewModel: NewPostViewModel) {
@Composable
fun DisplayLocationObserver(postViewModel: NewPostViewModel) {
val location by postViewModel.locationFlow().collectAsStateWithLifecycle(null)
val location by postViewModel.locationFlow().collectAsStateWithLifecycle()
location?.let { DisplayLocationInTitle(geohash = it) }
when (val myLocation = location) {
is LocationState.LocationResult.Success -> {
DisplayLocationInTitle(geohash = myLocation.geoHash.toString())
}
LocationState.LocationResult.LackPermission -> {
Text(
text = stringRes(R.string.lack_location_permissions),
fontSize = 12.sp,
lineHeight = 12.sp,
)
}
LocationState.LocationResult.Loading -> {
Text(
text = stringRes(R.string.loading_location),
fontSize = 12.sp,
lineHeight = 12.sp,
)
}
}
}
@Composable

View File

@ -645,6 +645,9 @@
<string name="geohash_title">Expose Location as </string>
<string name="geohash_explainer">Adds a Geohash of your location to the post. The public will know you are within 5km (3mi) of the current location</string>
<string name="loading_location">Loading location</string>
<string name="lack_location_permissions">No Location Permissions</string>
<string name="add_sensitive_content_explainer">Adds sensitive content warning before showing your content. This is ideal for any NSFW content or content some people may find offensive or disturbing</string>
<string name="new_feature_nip17_might_not_be_available_title">New Feature</string>

View File

@ -280,7 +280,13 @@ open class Event(
return PoWRank.getCommited(id, commitedPoW)
}
override fun getGeoHash(): String? = tags.firstOrNull { it.size > 1 && it[0] == "g" }?.get(1)?.ifBlank { null }
override fun getGeoHash(): String? =
tags
.filter { it.size > 1 && it[0] == "g" }
.maxByOrNull {
it[1].length
}?.get(1)
?.ifBlank { null }
override fun getReward(): BigDecimal? =
try {