Add OSM search provider (#611)

* add openstreetmaps module in data

* fix injection

* retrofit2 implementation

* tokenization

* partial rewrite of OpeningTime.fromOverpassElement

* finish rewrite of OpeningTime.fromOverpassElement

* fix merge x)

* configurable search radius

* add settings section and disable setting explicit timeout values for http-client to alleviate issues during debugging

* settings screen localization

* enable radius slider only when locations are enabled

* fix dayRange parsing, add barebones UI

* add files to git

* add location listener in SearchableItemVM that gets activated by LocationItem

* add heading listener

* Calculations, UI additions

* rename settings to LocationsSettings

* use android location library for bearing calculations

* location fix

* rotation fix, demo UI

* working buttons for launching map and website (if available)

* finish botched UI

* improve overpass query by utilizing regex for fuzzy search results

* add link to documentation for further reference

* localization comments

* remove wikipedia minification setting

* schema version, default radius 1.5km

* move osm-specific opening-time parsing to OsmLocation

* refactor with callbackFlow

* remember flow and set minimum distance update to 1m

* refactor for replacementIcon, add imperial unit option

* 'open until' UI fix

* implement serializers

* catch errors in deserializer

* hacky live sorting by distance

* give max priority to bestmatch determined by SearchVM

* add yards as additional step to metersToLocalizzedString

* move http-client from serializers of osmlocation to companion object for cache updating

* add setting for custom URL

* round yards to int

* add botched map preview

* unbotch map tiles, draw user location in map (proof of concept)

* - create MapTiles Composable
- add border around map
- add indicators for location and userlocation, when on map

* fix default imperial units setting

* fix tint color

* add OSM attribution string

* display loading animation when tiles can't be shown yet

* create compose preview of maptiles

* UI work

* being glad that API's just return null instead of throwing information

* tryStartActivity

* aniimate card row placement

* Text alignment, padding

* Rotation -PI/PI wrap fix

* fix direction arrow rotation when screen is upside down

* more icons

* icons, settings, localization
- consider other tags than "amenity" when determining location category
- add many more location categories with corresponding icons
- add settings to disable map theming and hide search results with
  LocationCategory.OTHER
- add default localizations for settings

* catch errors when deserializing location category

* move location and heading functions to Context.kt in extensions

* fix hideUncategorized criterion

* add pre-sorting by distance for location results in SearchVM by injecting Context into search()

* specify receiver parameters in ktx.Context lambdas

* move pose logic and context dependency to new module devicepose with DevicePoseProvider

* git, add the frickin' module

* search overpass for nodes and ways
include category for parcel_locker
already start searching for queries extending length 2

* make openingTimes immutable

* OsmRepository changes
- include telephone number
- don't try to repeatedly update cache if there is no value to be
  updated to
- deduplicate results with same label by category and distance (100m)
- include fixmeurl to point to openstreetmaps.org/fixthemap

* ask for center in overpass API to compute center coordinates of ways

* search for brand

* add chemist location category

* restaurant / fastfood icon shenanigans

* actually add the icons :|

* add leisure tag for leisure:fitness_centre

* return to 'open until'/'opens in'/'open next'

* adding missing UI features
- bug report dialog
- call button if phone number exists
- grid item popup

* refactor to handle 24/7 locations more comfortably

* hide hours in 'opens_in' when they are zero

* show maptiles such that user is always in view

* drawing adjustments

* cache previous zoom level to speed up tile coordinate calculations

* using remember

* using MutableIntState

* fix logic that determines whether tiles are loading

* fix for numTiles == 9

* one plus one is two plus one makes three quick maths

* animate user location indicator, remember calculations

* fix off by one when determining next opening hour

* second attempt to fix upside down arrow rotation (probably fine now)

* logging

* reconsider declination, inject samplingPeriod

* undoing the merge undo

* move localization string to i18n

* revert reordering by distance

* refactor .distanceTo

* make Location abstract class to override compareTo with cached distance to correct sort order in search results

* when it is if when you could use when

* replace Pair with dataclass

* condition check order

* not creating objects with undefined locations, removing suspend from getCategory()

* inject permissionsmanager as constructor parameter

* Store OSM settings in decentralized datastore

* Update searchable content in database on launch

* Refactor, add mechanism to load updated searchable data lazily

* Cache all OSM data in launcher database

* Add pin to favorites button to location results

* Add sealed class UpdateResult
that is returned by awaiting updatedSelf of DeferredSearchable

- update on success
- set flag on temporarily unavailable (TODO add some UI indication)
- delete and invalidate VM on permanently unavailable (Display some
  message window to user?)

* Move sorting of Locations from OsmRepository
to SearchVM using cached location in DevicePoseProvider, if available

* make use of cached location in code

* make use of DevicePoseProvider in WeatherRepository

* inject via koin

* increase getLocation().timeout() to 10 minutes since we are asking for locations only every hour, so 10 minutes seem reasonable (?)

* poll new location every time

* add icon for cached results where results are temporarily unavailable

* Refactor DeferredSearchable to UpdatableSearchable
that receives a closure to retrieve an updated self.

- moved timestamp (formerly `updatedAt`) to UpdatableSearchable
- moved logic whether to update searchable to `requestUpdatedSearchable`
  in SearchableItemVM, which gets triggered every time the details of
  the item are shown
- keep track in SearchableVM whether we should retry updating, possibly
  bypassing a timestamp value that is not old enough
- show toast upon permanently unavailable
- animate "cached_searchable" icon
- make "cached_searchable" icon clickable to show toast explaining the
  situation

* logging on PermanentlyUnavailable

* refactor OsmRepository.update()

* MapTheming adjustments. There is now darkmode, hooray!

* remove outdated comment

* code tidying

* remove unnecessary LaunchedEffect

* make outdated badge only clickable when actually outdated

* deserialize opening schedule

* set deserialized props to null if strings are blank

* also consider contact:* tagging scheme for website & phone

* tweaks

* git add Result.kt

* don't search for locations if network is not allowed

* merge fixes

* Move location search settings to preferences module

* Change wording and order of location search preferences

* Limit location search results

* Order location search results by distance

* Use a sequence

* Android Studio's suggestion wasn't as fleshed out as one would hope

* Add proguard rules

* Rename TileMapRepository to MapTileLoader

---------

Co-authored-by: MM20 <15646950+MM2-0@users.noreply.github.com>
This commit is contained in:
Christoph
2024-03-26 19:06:19 +01:00
committed by GitHub
co-authored by MM20
parent 4b30b13266
commit cba387c832
133 changed files with 4123 additions and 79 deletions
+1
View File
@@ -0,0 +1 @@
/build
+48
View File
@@ -0,0 +1,48 @@
@Suppress("DSL_SCOPE_VIOLATION") // TODO: Remove once KTIJ-19369 is fixed
plugins {
alias(libs.plugins.android.library)
alias(libs.plugins.kotlin.android)
}
android {
namespace = "de.mm20.launcher2.location"
compileSdk = 34
defaultConfig {
minSdk = 21
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
consumerProguardFiles("consumer-rules.pro")
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = "1.8"
}
}
dependencies {
implementation(libs.androidx.core)
implementation(libs.androidx.appcompat)
implementation(libs.materialcomponents.core)
implementation(libs.koin.android)
implementation(project(":core:ktx"))
testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)
}
View File
+21
View File
@@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
</manifest>
@@ -0,0 +1,160 @@
package de.mm20.launcher2.devicepose
import android.Manifest
import android.content.Context
import android.hardware.GeomagneticField
import android.hardware.Sensor
import android.hardware.SensorEvent
import android.hardware.SensorEventListener
import android.hardware.SensorManager
import android.location.Location
import android.location.LocationListener
import android.location.LocationManager
import android.util.Log
import androidx.core.content.getSystemService
import de.mm20.launcher2.ktx.PI
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.channelFlow
import de.mm20.launcher2.ktx.checkPermission
import kotlinx.coroutines.flow.combine
class DevicePoseProvider internal constructor(
private val context: Context
) {
var lastLocation: Location? = null
private set
private var declination: Float? = null
private fun updateDeclination(location: Location) {
declination = GeomagneticField(
location.latitude.toFloat(),
location.longitude.toFloat(),
location.altitude.toFloat(),
location.time
).declination
}
fun getLocation(minTimeMs: Long = 1000, minDistanceM: Float = 1f) = channelFlow {
val locationCallback = LocationListener {
lastLocation = it
updateDeclination(it)
trySend(it)
}
context.getSystemService<LocationManager>()
?.runCatching {
val hasFineAccess =
context.checkPermission(Manifest.permission.ACCESS_FINE_LOCATION)
val hasCoarseAccess =
context.checkPermission(Manifest.permission.ACCESS_COARSE_LOCATION)
val location =
(if (hasFineAccess) this@runCatching.getLastKnownLocation(LocationManager.GPS_PROVIDER) else null)
?: if (hasCoarseAccess) this@runCatching.getLastKnownLocation(LocationManager.NETWORK_PROVIDER) else null
if (location != null) {
lastLocation = location
updateDeclination(location)
trySend(location)
}
if (hasFineAccess) {
this@runCatching.requestLocationUpdates(
LocationManager.GPS_PROVIDER,
minTimeMs,
minDistanceM,
locationCallback
)
}
if (hasCoarseAccess) {
this@runCatching.requestLocationUpdates(
LocationManager.NETWORK_PROVIDER,
minTimeMs,
minDistanceM,
locationCallback
)
}
}?.onFailure {
Log.e("SearchableItemVM", "Failed to register location listener", it)
}
awaitClose {
context.getSystemService<LocationManager>()?.removeUpdates(locationCallback)
}
}
fun getAzimuthDegrees(samplingPeriodUs: Int = SensorManager.SENSOR_DELAY_UI): Flow<Float> = callbackFlow {
val azimuthCallback = object : SensorEventListener {
override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) {}
override fun onSensorChanged(event: SensorEvent?) {
if (event?.sensor?.type != Sensor.TYPE_ROTATION_VECTOR)
return
val rotationMatrix = FloatArray(9)
val orientationAngles = FloatArray(3)
SensorManager.getRotationMatrixFromVector(rotationMatrix, event.values)
SensorManager.getOrientation(rotationMatrix, orientationAngles)
trySend(orientationAngles[0] * 180f / Float.PI + (declination ?: 0f))
}
}
context
.getSystemService<SensorManager>()
?.runCatching {
this@runCatching.registerListener(
azimuthCallback,
this@runCatching.getDefaultSensor(Sensor.TYPE_ROTATION_VECTOR)
?: return@runCatching,
samplingPeriodUs
)
}?.onFailure {
Log.e("DevicePoseProvider", "Failed to register ROTATION_VECTOR listener", it)
}
awaitClose {
context.getSystemService<SensorManager>()?.unregisterListener(azimuthCallback)
}
}
fun getHeadingToDegrees(headingEastwardDegrees: Float, samplingPeriodUs: Int = SensorManager.SENSOR_DELAY_UI): Flow<Float> = combine(
getAzimuthDegrees(samplingPeriodUs),
callbackFlow {
val upsideDownCallback = object : SensorEventListener {
override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) {}
override fun onSensorChanged(event: SensorEvent?) {
if (event?.sensor?.type != Sensor.TYPE_GRAVITY)
return
val (_, _, z) = event.values
trySend(z < 0f)
}
}
context
.getSystemService<SensorManager>()
?.runCatching {
this@runCatching.registerListener(
upsideDownCallback,
this@runCatching.getDefaultSensor(Sensor.TYPE_GRAVITY)
?: return@runCatching,
samplingPeriodUs
)
}?.onFailure {
Log.e("SearchableItemVM", "Failed to register GRAVITY listener", it)
}
awaitClose {
context.getSystemService<SensorManager>()?.unregisterListener(upsideDownCallback)
}
}) { azimuthDegrees, isUpsideDown ->
if (isUpsideDown) {
azimuthDegrees - headingEastwardDegrees
} else {
headingEastwardDegrees - azimuthDegrees
}
}
}
@@ -0,0 +1,8 @@
package de.mm20.launcher2.devicepose
import org.koin.android.ext.koin.androidContext
import org.koin.dsl.module
val devicePoseModule = module {
single<DevicePoseProvider> { DevicePoseProvider(androidContext()) }
}