Replace Places SDK with HTTP API
@ -1,128 +0,0 @@
|
||||
package org.tasks.location
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import com.google.android.gms.maps.model.LatLng
|
||||
import com.google.android.gms.maps.model.LatLngBounds
|
||||
import com.google.android.libraries.places.api.Places
|
||||
import com.google.android.libraries.places.api.model.AutocompletePrediction
|
||||
import com.google.android.libraries.places.api.model.AutocompleteSessionToken
|
||||
import com.google.android.libraries.places.api.model.Place.Field
|
||||
import com.google.android.libraries.places.api.model.RectangularBounds
|
||||
import com.google.android.libraries.places.api.net.*
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.tasks.R
|
||||
import org.tasks.data.Place
|
||||
import org.tasks.data.Place.Companion.newPlace
|
||||
import kotlin.coroutines.suspendCoroutine
|
||||
|
||||
class PlaceSearchGoogle(private val context: Context) : PlaceSearch {
|
||||
private var token: AutocompleteSessionToken? = null
|
||||
private var placesClient: PlacesClient? = null
|
||||
|
||||
override fun restoreState(savedInstanceState: Bundle?) {
|
||||
token = savedInstanceState?.getParcelable(EXTRA_SESSION_TOKEN)
|
||||
}
|
||||
|
||||
override fun saveState(outState: Bundle) {
|
||||
outState.putParcelable(EXTRA_SESSION_TOKEN, token)
|
||||
}
|
||||
|
||||
override fun getAttributionRes(dark: Boolean): Int {
|
||||
return if (dark) R.drawable.places_powered_by_google_dark else R.drawable.places_powered_by_google_light
|
||||
}
|
||||
|
||||
override suspend fun search(query: String, bias: MapPosition?): List<PlaceSearchResult> =
|
||||
withContext(Dispatchers.IO) {
|
||||
suspendCoroutine { cont ->
|
||||
if (!Places.isInitialized()) {
|
||||
Places.initialize(context, context.getString(R.string.google_key))
|
||||
}
|
||||
if (placesClient == null) {
|
||||
placesClient = Places.createClient(context)
|
||||
}
|
||||
if (token == null) {
|
||||
token = AutocompleteSessionToken.newInstance()
|
||||
}
|
||||
val request = FindAutocompletePredictionsRequest.builder().setSessionToken(token).setQuery(query)
|
||||
if (bias != null) {
|
||||
request.locationBias =
|
||||
RectangularBounds.newInstance(
|
||||
LatLngBounds.builder()
|
||||
.include(LatLng(bias.latitude, bias.longitude))
|
||||
.build())
|
||||
}
|
||||
placesClient!!
|
||||
.findAutocompletePredictions(request.build())
|
||||
.addOnSuccessListener { response: FindAutocompletePredictionsResponse ->
|
||||
val places = toSearchResults(response.autocompletePredictions)
|
||||
cont.resumeWith(Result.success(places))
|
||||
}
|
||||
.addOnFailureListener { e: Exception ->
|
||||
cont.resumeWith(Result.failure(e))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun fetch(placeSearchResult: PlaceSearchResult): Place =
|
||||
withContext(Dispatchers.IO) {
|
||||
suspendCoroutine { cont ->
|
||||
placesClient!!
|
||||
.fetchPlace(
|
||||
FetchPlaceRequest.builder(
|
||||
placeSearchResult.id,
|
||||
listOf(
|
||||
Field.ID,
|
||||
Field.LAT_LNG,
|
||||
Field.ADDRESS,
|
||||
Field.WEBSITE_URI,
|
||||
Field.NAME,
|
||||
Field.PHONE_NUMBER))
|
||||
.setSessionToken(token)
|
||||
.build())
|
||||
.addOnSuccessListener { result: FetchPlaceResponse ->
|
||||
cont.resumeWith(Result.success(toPlace(result)))
|
||||
}
|
||||
.addOnFailureListener { e: Exception ->
|
||||
cont.resumeWith(Result.failure(e))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private fun toSearchResults(predictions: List<AutocompletePrediction>): List<PlaceSearchResult> {
|
||||
return predictions.map {
|
||||
PlaceSearchResult(
|
||||
it.placeId,
|
||||
it.getPrimaryText(null).toString(),
|
||||
it.getSecondaryText(null).toString())
|
||||
}
|
||||
}
|
||||
|
||||
private fun toPlace(fetchPlaceResponse: FetchPlaceResponse): Place {
|
||||
val place = fetchPlaceResponse.place
|
||||
val result = newPlace()
|
||||
result.name = place.name
|
||||
val address: CharSequence? = place.address
|
||||
if (address != null) {
|
||||
result.address = place.address
|
||||
}
|
||||
val phoneNumber: CharSequence? = place.phoneNumber
|
||||
if (phoneNumber != null) {
|
||||
result.phone = phoneNumber.toString()
|
||||
}
|
||||
val uri = place.websiteUri
|
||||
if (uri != null) {
|
||||
result.url = uri.toString()
|
||||
}
|
||||
val latLng = place.latLng
|
||||
result.latitude = latLng!!.latitude
|
||||
result.longitude = latLng.longitude
|
||||
return result
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val EXTRA_SESSION_TOKEN = "extra_session_token"
|
||||
}
|
||||
}
|
@ -0,0 +1,24 @@
|
||||
package org.tasks.injection
|
||||
|
||||
import dagger.Lazy
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.components.ViewModelComponent
|
||||
import dagger.hilt.android.scopes.ViewModelScoped
|
||||
import org.tasks.billing.Inventory
|
||||
import org.tasks.location.PlaceSearch
|
||||
import org.tasks.location.PlaceSearchGoogle
|
||||
import org.tasks.location.PlaceSearchMapbox
|
||||
|
||||
@Module
|
||||
@InstallIn(ViewModelComponent::class)
|
||||
class ViewModelModule {
|
||||
@Provides
|
||||
@ViewModelScoped
|
||||
fun getPlaceSearchProvider(
|
||||
inventory: Inventory,
|
||||
google: Lazy<PlaceSearchGoogle>,
|
||||
mapbox: Lazy<PlaceSearchMapbox>
|
||||
): PlaceSearch = if (inventory.hasTasksSubscription) google.get() else mapbox.get()
|
||||
}
|
@ -0,0 +1,146 @@
|
||||
package org.tasks.location
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import com.google.gson.JsonObject
|
||||
import com.google.gson.JsonParser
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.Request
|
||||
import org.tasks.R
|
||||
import org.tasks.data.CaldavAccount.Companion.TYPE_TASKS
|
||||
import org.tasks.data.CaldavDao
|
||||
import org.tasks.data.Place
|
||||
import org.tasks.data.Place.Companion.newPlace
|
||||
import org.tasks.http.HttpClientFactory
|
||||
import org.tasks.http.HttpException
|
||||
import timber.log.Timber
|
||||
import java.util.*
|
||||
import javax.inject.Inject
|
||||
|
||||
class PlaceSearchGoogle @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val httpClientFactory: HttpClientFactory,
|
||||
private val caldavDao: CaldavDao
|
||||
) : PlaceSearch {
|
||||
private val url = context.getString(R.string.tasks_places_url)
|
||||
private var token: String? = null
|
||||
|
||||
override fun restoreState(savedInstanceState: Bundle?) {
|
||||
token = savedInstanceState?.getParcelable(EXTRA_SESSION_TOKEN)
|
||||
}
|
||||
|
||||
override fun saveState(outState: Bundle) {
|
||||
outState.putString(EXTRA_SESSION_TOKEN, token)
|
||||
}
|
||||
|
||||
override fun getAttributionRes(dark: Boolean) = if (dark) {
|
||||
R.drawable.powered_by_google_on_non_white
|
||||
} else {
|
||||
R.drawable.powered_by_google_on_white
|
||||
}
|
||||
|
||||
override suspend fun search(query: String, bias: MapPosition?): List<PlaceSearchResult> {
|
||||
if (token == null) {
|
||||
token = UUID.randomUUID().toString()
|
||||
}
|
||||
val proximity = bias?.let {
|
||||
"&location=${bias.latitude},${bias.longitude}&radius=25000"
|
||||
}
|
||||
val jsonObject = execute(
|
||||
"${this.url}/maps/api/place/queryautocomplete/json?input=$query&sessiontoken=$token$proximity"
|
||||
)
|
||||
return toSearchResults(jsonObject)
|
||||
}
|
||||
|
||||
override suspend fun fetch(placeSearchResult: PlaceSearchResult): Place {
|
||||
val jsonObject = execute(
|
||||
"${this.url}/maps/api/place/details/json?place_id=${placeSearchResult.id}&fields=$FIELDS&sessiontoken=$token"
|
||||
)
|
||||
return toPlace(jsonObject)
|
||||
}
|
||||
|
||||
private suspend fun execute(url: String): JsonObject = withContext(Dispatchers.IO) {
|
||||
Timber.d(url)
|
||||
val account = caldavDao.getAccounts(TYPE_TASKS).firstOrNull()
|
||||
?: throw IllegalStateException(
|
||||
context.getString(R.string.tasks_org_account_required)
|
||||
)
|
||||
val client = httpClientFactory
|
||||
.newBuilder(
|
||||
foreground = true,
|
||||
username = account.username,
|
||||
encryptedPassword = account.password
|
||||
)
|
||||
.build()
|
||||
val response = client.newCall(Request.Builder().get().url(url).build()).execute()
|
||||
if (response.isSuccessful) {
|
||||
response.body?.string()?.toJson()?.apply { checkResult(this) }
|
||||
?: throw IllegalStateException("Request failed")
|
||||
} else {
|
||||
throw HttpException(response.code, response.message)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val EXTRA_SESSION_TOKEN = "extra_session_token"
|
||||
private val FIELDS =
|
||||
listOf(
|
||||
"place_id",
|
||||
"geometry/location",
|
||||
"formatted_address",
|
||||
"website",
|
||||
"name",
|
||||
"international_phone_number"
|
||||
).joinToString(",")
|
||||
|
||||
internal fun String.toJson(): JsonObject = JsonParser.parseString(this).asJsonObject
|
||||
|
||||
private fun checkResult(json: JsonObject) {
|
||||
val status = json.get("status").asString
|
||||
when {
|
||||
status == "OK" -> return
|
||||
json.has("error_message") ->
|
||||
throw IllegalStateException(json.get("error_message").asString)
|
||||
else ->
|
||||
throw IllegalStateException(status)
|
||||
}
|
||||
}
|
||||
|
||||
internal fun toSearchResults(json: JsonObject): List<PlaceSearchResult> =
|
||||
json.get("predictions")
|
||||
.asJsonArray
|
||||
.map { it.asJsonObject }
|
||||
.filter { it.has("place_id") }
|
||||
.map { toSearchEntry(it) }
|
||||
|
||||
private fun toSearchEntry(json: JsonObject): PlaceSearchResult {
|
||||
val place = json.get("structured_formatting").asJsonObject
|
||||
return PlaceSearchResult(
|
||||
json.get("place_id").asString,
|
||||
place.get("main_text").asString,
|
||||
place.get("secondary_text").asString
|
||||
)
|
||||
}
|
||||
|
||||
internal fun toPlace(json: JsonObject): Place {
|
||||
val result = json.get("result").asJsonObject
|
||||
val location = result.get("geometry").asJsonObject.get("location").asJsonObject
|
||||
return newPlace().apply {
|
||||
name = result.get("name").asString
|
||||
address = result.getString("formatted_address")
|
||||
phone = result.getString("international_phone_number")
|
||||
url = result.getString("website")
|
||||
latitude = location.get("lat").asDouble
|
||||
longitude = location.get("lng").asDouble
|
||||
}
|
||||
}
|
||||
|
||||
private fun JsonObject.getString(field: String): String? = if (has(field)) {
|
||||
get(field).asString
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
After Width: | Height: | Size: 2.2 KiB |
After Width: | Height: | Size: 3.3 KiB |
After Width: | Height: | Size: 1.1 KiB |
After Width: | Height: | Size: 1.6 KiB |
After Width: | Height: | Size: 1.4 KiB |
After Width: | Height: | Size: 2.1 KiB |
After Width: | Height: | Size: 3.1 KiB |
After Width: | Height: | Size: 4.4 KiB |
After Width: | Height: | Size: 4.7 KiB |
After Width: | Height: | Size: 6.7 KiB |
After Width: | Height: | Size: 6.0 KiB |
After Width: | Height: | Size: 9.1 KiB |
@ -0,0 +1,49 @@
|
||||
package org.tasks.location
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
import org.tasks.TestUtilities.readFile
|
||||
import org.tasks.location.PlaceSearchGoogle.Companion.toJson
|
||||
import org.tasks.location.PlaceSearchGoogle.Companion.toPlace
|
||||
import org.tasks.location.PlaceSearchGoogle.Companion.toSearchResults
|
||||
|
||||
class PlaceSearchGoogleTest {
|
||||
@Test
|
||||
fun placeSearchWithMultipleResults() {
|
||||
val results = toSearchResults(readFile("google_places/search.json").toJson())
|
||||
|
||||
assertEquals(
|
||||
listOf(
|
||||
"ChIJfQQVCCMzjoARce0POzONI8I",
|
||||
"ChIJQXqgXsiAj4ARqtl6U4GD-Cw",
|
||||
"ChIJCWNdVNgr3YAR4pLlOt8CfEk",
|
||||
"ChIJhTEH6lev3IARDMKC_pGF6nI"
|
||||
),
|
||||
results.map { it.id }
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun validatePlace() {
|
||||
val result = toSearchResults(readFile("google_places/search.json").toJson())[2]
|
||||
|
||||
assertEquals("ChIJCWNdVNgr3YAR4pLlOt8CfEk", result.id)
|
||||
assertEquals("Portillo's Hot Dogs", result.name)
|
||||
assertEquals("La Palma Avenue, Buena Park, CA, USA", result.address)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun fetchPlace() {
|
||||
val result = toPlace(readFile("google_places/fetch.json").toJson())
|
||||
|
||||
assertEquals("Magic Kingdom Park", result.name)
|
||||
assertEquals("1180 Seven Seas Drive, Lake Buena Vista, FL 32836, USA", result.address)
|
||||
assertEquals(28.417663, result.latitude, 0.0)
|
||||
assertEquals(-81.581212, result.longitude, 0.0)
|
||||
assertEquals("+1 407-939-5277", result.phone)
|
||||
assertEquals(
|
||||
"https://disneyworld.disney.go.com/destinations/magic-kingdom/?CMP=OKC-80007944_GM_WDW_destination_magickingdompark_NA",
|
||||
result.url
|
||||
)
|
||||
}
|
||||
}
|
@ -0,0 +1,17 @@
|
||||
{
|
||||
"html_attributions": [],
|
||||
"result": {
|
||||
"formatted_address": "1180 Seven Seas Drive, Lake Buena Vista, FL 32836, USA",
|
||||
"geometry": {
|
||||
"location": {
|
||||
"lat": 28.417663,
|
||||
"lng": -81.581212
|
||||
}
|
||||
},
|
||||
"international_phone_number": "+1 407-939-5277",
|
||||
"name": "Magic Kingdom Park",
|
||||
"place_id": "ChIJgUulalN-3YgRGoTaWM2LawY",
|
||||
"website": "https://disneyworld.disney.go.com/destinations/magic-kingdom/?CMP=OKC-80007944_GM_WDW_destination_magickingdompark_NA"
|
||||
},
|
||||
"status": "OK"
|
||||
}
|
@ -0,0 +1,225 @@
|
||||
{
|
||||
"predictions": [
|
||||
{
|
||||
"description": "portillo's hot dogs",
|
||||
"matched_substrings": [
|
||||
{
|
||||
"length": 19,
|
||||
"offset": 0
|
||||
}
|
||||
],
|
||||
"structured_formatting": {
|
||||
"main_text": "portillo's hot dogs",
|
||||
"main_text_matched_substrings": [
|
||||
{
|
||||
"length": 19,
|
||||
"offset": 0
|
||||
}
|
||||
]
|
||||
},
|
||||
"terms": [
|
||||
{
|
||||
"offset": 0,
|
||||
"value": "portillo's hot dogs"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"description": "Portillo Diesel, Leo Avenue, San Jose, CA, USA",
|
||||
"matched_substrings": [
|
||||
{
|
||||
"length": 15,
|
||||
"offset": 0
|
||||
}
|
||||
],
|
||||
"place_id": "ChIJfQQVCCMzjoARce0POzONI8I",
|
||||
"reference": "ChIJfQQVCCMzjoARce0POzONI8I",
|
||||
"structured_formatting": {
|
||||
"main_text": "Portillo Diesel",
|
||||
"main_text_matched_substrings": [
|
||||
{
|
||||
"length": 15,
|
||||
"offset": 0
|
||||
}
|
||||
],
|
||||
"secondary_text": "Leo Avenue, San Jose, CA, USA"
|
||||
},
|
||||
"terms": [
|
||||
{
|
||||
"offset": 0,
|
||||
"value": "Portillo Diesel"
|
||||
},
|
||||
{
|
||||
"offset": 17,
|
||||
"value": "Leo Avenue"
|
||||
},
|
||||
{
|
||||
"offset": 29,
|
||||
"value": "San Jose"
|
||||
},
|
||||
{
|
||||
"offset": 39,
|
||||
"value": "CA"
|
||||
},
|
||||
{
|
||||
"offset": 43,
|
||||
"value": "USA"
|
||||
}
|
||||
],
|
||||
"types": [
|
||||
"car_repair",
|
||||
"point_of_interest",
|
||||
"establishment"
|
||||
]
|
||||
},
|
||||
{
|
||||
"description": "Portillo's Trucking, Franklin Street, Oakland, CA, USA",
|
||||
"matched_substrings": [
|
||||
{
|
||||
"length": 19,
|
||||
"offset": 0
|
||||
}
|
||||
],
|
||||
"place_id": "ChIJQXqgXsiAj4ARqtl6U4GD-Cw",
|
||||
"reference": "ChIJQXqgXsiAj4ARqtl6U4GD-Cw",
|
||||
"structured_formatting": {
|
||||
"main_text": "Portillo's Trucking",
|
||||
"main_text_matched_substrings": [
|
||||
{
|
||||
"length": 19,
|
||||
"offset": 0
|
||||
}
|
||||
],
|
||||
"secondary_text": "Franklin Street, Oakland, CA, USA"
|
||||
},
|
||||
"terms": [
|
||||
{
|
||||
"offset": 0,
|
||||
"value": "Portillo's Trucking"
|
||||
},
|
||||
{
|
||||
"offset": 21,
|
||||
"value": "Franklin Street"
|
||||
},
|
||||
{
|
||||
"offset": 38,
|
||||
"value": "Oakland"
|
||||
},
|
||||
{
|
||||
"offset": 47,
|
||||
"value": "CA"
|
||||
},
|
||||
{
|
||||
"offset": 51,
|
||||
"value": "USA"
|
||||
}
|
||||
],
|
||||
"types": [
|
||||
"moving_company",
|
||||
"point_of_interest",
|
||||
"establishment"
|
||||
]
|
||||
},
|
||||
{
|
||||
"description": "Portillo's Hot Dogs, La Palma Avenue, Buena Park, CA, USA",
|
||||
"matched_substrings": [
|
||||
{
|
||||
"length": 19,
|
||||
"offset": 0
|
||||
}
|
||||
],
|
||||
"place_id": "ChIJCWNdVNgr3YAR4pLlOt8CfEk",
|
||||
"reference": "ChIJCWNdVNgr3YAR4pLlOt8CfEk",
|
||||
"structured_formatting": {
|
||||
"main_text": "Portillo's Hot Dogs",
|
||||
"main_text_matched_substrings": [
|
||||
{
|
||||
"length": 19,
|
||||
"offset": 0
|
||||
}
|
||||
],
|
||||
"secondary_text": "La Palma Avenue, Buena Park, CA, USA"
|
||||
},
|
||||
"terms": [
|
||||
{
|
||||
"offset": 0,
|
||||
"value": "Portillo's Hot Dogs"
|
||||
},
|
||||
{
|
||||
"offset": 21,
|
||||
"value": "La Palma Avenue"
|
||||
},
|
||||
{
|
||||
"offset": 38,
|
||||
"value": "Buena Park"
|
||||
},
|
||||
{
|
||||
"offset": 50,
|
||||
"value": "CA"
|
||||
},
|
||||
{
|
||||
"offset": 54,
|
||||
"value": "USA"
|
||||
}
|
||||
],
|
||||
"types": [
|
||||
"meal_takeaway",
|
||||
"restaurant",
|
||||
"food",
|
||||
"point_of_interest",
|
||||
"establishment"
|
||||
]
|
||||
},
|
||||
{
|
||||
"description": "Portillo's Hot Dogs, Day Street, Moreno Valley, CA, USA",
|
||||
"matched_substrings": [
|
||||
{
|
||||
"length": 19,
|
||||
"offset": 0
|
||||
}
|
||||
],
|
||||
"place_id": "ChIJhTEH6lev3IARDMKC_pGF6nI",
|
||||
"reference": "ChIJhTEH6lev3IARDMKC_pGF6nI",
|
||||
"structured_formatting": {
|
||||
"main_text": "Portillo's Hot Dogs",
|
||||
"main_text_matched_substrings": [
|
||||
{
|
||||
"length": 19,
|
||||
"offset": 0
|
||||
}
|
||||
],
|
||||
"secondary_text": "Day Street, Moreno Valley, CA, USA"
|
||||
},
|
||||
"terms": [
|
||||
{
|
||||
"offset": 0,
|
||||
"value": "Portillo's Hot Dogs"
|
||||
},
|
||||
{
|
||||
"offset": 21,
|
||||
"value": "Day Street"
|
||||
},
|
||||
{
|
||||
"offset": 33,
|
||||
"value": "Moreno Valley"
|
||||
},
|
||||
{
|
||||
"offset": 48,
|
||||
"value": "CA"
|
||||
},
|
||||
{
|
||||
"offset": 52,
|
||||
"value": "USA"
|
||||
}
|
||||
],
|
||||
"types": [
|
||||
"meal_takeaway",
|
||||
"restaurant",
|
||||
"food",
|
||||
"point_of_interest",
|
||||
"establishment"
|
||||
]
|
||||
}
|
||||
],
|
||||
"status": "OK"
|
||||
}
|