mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2025-03-26 17:52:29 +01:00
- 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:
parent
062e4af118
commit
37a92c25f0
@ -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()
|
||||
|
@ -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(", ")
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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!!
|
||||
|
@ -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,
|
||||
)
|
||||
|
@ -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,
|
||||
)
|
||||
|
@ -215,6 +215,7 @@ fun LoadCityName(
|
||||
CachedGeoLocations
|
||||
.geoLocate(geohashStr, geoHash.toLocation(), context)
|
||||
?.ifBlank { null }
|
||||
|
||||
if (newCityName != null && newCityName != cityName) {
|
||||
cityName = newCityName
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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>
|
||||
|
@ -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 {
|
||||
|
Loading…
x
Reference in New Issue
Block a user