diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 46b5b013c..b1a05cf02 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -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 = MutableStateFlow(NoteState(Note(" "))), val kind3: StateFlow = MutableStateFlow(null), - val location: StateFlow = MutableStateFlow(null), + val location: StateFlow = 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() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/LocationState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/LocationState.kt index aa367873a..97ed4ae06 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/LocationState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/LocationState.kt @@ -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,26 +97,47 @@ 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 - }.stateIn( - scope, - SharingStarted.WhileSubscribed(5000), - latestLocation, - ) + object LackPermission : LocationResult() + + object Loading : LocationResult() + } + + private var hasLocationPermission = MutableStateFlow(false) + private var latestLocation: LocationResult = LocationResult.Loading + + fun setLocationPermission(newValue: Boolean) { + if (newValue != hasLocationPermission.value) { + hasLocationPermission.tryEmit(newValue) + } + } + @OptIn(ExperimentalCoroutinesApi::class) val geohashStateFlow = - locationStateFlow - .map { it.toGeoHash(com.vitorpamplona.amethyst.ui.actions.GeohashPrecision.KM_5_X_5.digits).toString() } - .stateIn( + 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, ) } @@ -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 = + 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(", ") + }, + ) + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt index 8491b4ae1..b65e9c13e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt @@ -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? = null + var location: StateFlow? = 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 { + fun locationFlow(): StateFlow { 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!! diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/FilterByListParams.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/FilterByListParams.kt index be8680f15..e5e64d1fc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/FilterByListParams.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/FilterByListParams.kt @@ -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, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/FeedFilterSpinner.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/FeedFilterSpinner.kt index a1855640d..f14e03041 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/FeedFilterSpinner.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/FeedFilterSpinner.kt @@ -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) - - location?.let { - LoadCityName( - geohashStr = it, - onLoading = { - Spacer(modifier = StdHorzSpacer) - LoadingAnimation() - }, - ) { cityName -> + .collectAsStateWithLifecycle() + + 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 = stringRes(R.string.lack_location_permissions), + fontSize = 12.sp, + lineHeight = 12.sp, + ) + } + LocationState.LocationResult.Loading -> { Text( - text = "($cityName)", + text = stringRes(R.string.loading_location), fontSize = 12.sp, lineHeight = 12.sp, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/Loaders.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/Loaders.kt index 8cc0d2c7e..d6007fb01 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/Loaders.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/Loaders.kt @@ -215,6 +215,7 @@ fun LoadCityName( CachedGeoLocations .geoLocate(geohashStr, geoHash.toLocation(), context) ?.ifBlank { null } + if (newCityName != null && newCityName != cityName) { cityName = newCityName } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/FollowListState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/FollowListState.kt index 11f61d91b..023386c92 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/FollowListState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/FollowListState.kt @@ -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 = account diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/NewPostScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/NewPostScreen.kt index 5bb1eaed5..8af27c984 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/NewPostScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/NewPostScreen.kt @@ -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() + + when (val myLocation = location) { + is LocationState.LocationResult.Success -> { + DisplayLocationInTitle(geohash = myLocation.geoHash.toString()) + } - location?.let { DisplayLocationInTitle(geohash = it) } + 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 diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 2a85da27a..09bd89729 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -645,6 +645,9 @@ Expose Location as Adds a Geohash of your location to the post. The public will know you are within 5km (3mi) of the current location + Loading location + No Location Permissions + Adds sensitive content warning before showing your content. This is ideal for any NSFW content or content some people may find offensive or disturbing New Feature diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/Event.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/Event.kt index 438d059b9..708045c0f 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/Event.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/Event.kt @@ -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 {