talk-android/app/src/main/java/com/nextcloud/talk/controllers/LocationPickerController.kt
Marcel Hibbe f813688a12
remove hardcoded api version for location sharing
Signed-off-by: Marcel Hibbe <dev@mhibbe.de>
2021-07-15 19:16:50 +02:00

550 lines
21 KiB
Kotlin

/*
* Nextcloud Talk application
*
* @author Marcel Hibbe
* Copyright (C) 2021 Marcel Hibbe <dev@mhibbe.de>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package com.nextcloud.talk.controllers
import android.Manifest
import android.app.SearchManager
import android.content.Context
import android.content.pm.PackageManager
import android.graphics.drawable.ColorDrawable
import android.location.Location
import android.location.LocationListener
import android.location.LocationManager
import android.os.Build
import android.os.Bundle
import android.text.InputType
import android.util.Log
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import android.view.View
import android.view.inputmethod.EditorInfo
import android.widget.Toast
import androidx.appcompat.widget.SearchView
import androidx.core.content.PermissionChecker
import androidx.core.content.res.ResourcesCompat
import androidx.core.view.MenuItemCompat
import androidx.preference.PreferenceManager
import autodagger.AutoInjector
import com.bluelinelabs.conductor.RouterTransaction
import com.bluelinelabs.conductor.changehandler.HorizontalChangeHandler
import com.nextcloud.talk.R
import com.nextcloud.talk.api.NcApi
import com.nextcloud.talk.application.NextcloudTalkApplication
import com.nextcloud.talk.controllers.base.NewBaseController
import com.nextcloud.talk.controllers.util.viewBinding
import com.nextcloud.talk.databinding.ControllerLocationBinding
import com.nextcloud.talk.models.json.generic.GenericOverall
import com.nextcloud.talk.utils.ApiUtils
import com.nextcloud.talk.utils.DisplayUtils
import com.nextcloud.talk.utils.bundle.BundleKeys
import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_ROOM_TOKEN
import com.nextcloud.talk.utils.database.user.UserUtils
import fr.dudie.nominatim.client.TalkJsonNominatimClient
import fr.dudie.nominatim.model.Address
import io.reactivex.Observer
import io.reactivex.android.schedulers.AndroidSchedulers
import io.reactivex.disposables.Disposable
import io.reactivex.schedulers.Schedulers
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import org.osmdroid.config.Configuration.getInstance
import org.osmdroid.events.DelayedMapListener
import org.osmdroid.events.MapListener
import org.osmdroid.events.ScrollEvent
import org.osmdroid.events.ZoomEvent
import org.osmdroid.tileprovider.tilesource.TileSourceFactory
import org.osmdroid.util.GeoPoint
import org.osmdroid.views.overlay.CopyrightOverlay
import org.osmdroid.views.overlay.mylocation.GpsMyLocationProvider
import org.osmdroid.views.overlay.mylocation.MyLocationNewOverlay
import javax.inject.Inject
@AutoInjector(NextcloudTalkApplication::class)
class LocationPickerController(args: Bundle) :
NewBaseController(
R.layout.controller_location,
args
),
SearchView.OnQueryTextListener,
LocationListener,
GeocodingController.GeocodingResultListener {
private val binding: ControllerLocationBinding by viewBinding(ControllerLocationBinding::bind)
@Inject
lateinit var ncApi: NcApi
@Inject
lateinit var userUtils: UserUtils
@Inject
lateinit var okHttpClient: OkHttpClient
var nominatimClient: TalkJsonNominatimClient? = null
var roomToken: String?
var myLocation: GeoPoint = GeoPoint(COORDINATE_ZERO, COORDINATE_ZERO)
private var locationManager: LocationManager? = null
private lateinit var locationOverlay: MyLocationNewOverlay
var moveToCurrentLocationWasClicked: Boolean = true
var readyToShareLocation: Boolean = false
var searchItem: MenuItem? = null
var searchView: SearchView? = null
var receivedChosenGeocodingResult: Boolean = false
var geocodedLat: Double = 0.0
var geocodedLon: Double = 0.0
var geocodedName: String = ""
init {
setHasOptionsMenu(true)
NextcloudTalkApplication.sharedApplication!!.componentApplication.inject(this)
getInstance().load(context, PreferenceManager.getDefaultSharedPreferences(context))
roomToken = args.getString(KEY_ROOM_TOKEN)
}
override fun onAttach(view: View) {
super.onAttach(view)
initMap()
}
@Suppress("Detekt.TooGenericExceptionCaught")
override fun onDetach(view: View) {
super.onDetach(view)
try {
locationManager!!.removeUpdates(this)
} catch (e: Exception) {
Log.e(TAG, "error when trying to remove updates for location Manager", e)
}
locationOverlay.disableMyLocation()
}
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
super.onCreateOptionsMenu(menu, inflater)
inflater.inflate(R.menu.menu_locationpicker, menu)
searchItem = menu.findItem(R.id.location_action_search)
initSearchView()
}
override fun onPrepareOptionsMenu(menu: Menu) {
super.onPrepareOptionsMenu(menu)
actionBar?.setIcon(ColorDrawable(resources!!.getColor(android.R.color.transparent)))
actionBar?.title = context!!.getString(R.string.nc_share_location)
}
override val title: String
get() =
resources!!.getString(R.string.nc_share_location)
override fun onViewBound(view: View) {
setLocationDescription(false, receivedChosenGeocodingResult)
binding.shareLocation.isClickable = false
binding.shareLocation.setOnClickListener {
if (readyToShareLocation) {
shareLocation(
binding.map.mapCenter?.latitude,
binding.map.mapCenter?.longitude,
binding.placeName.text.toString()
)
} else {
Log.w(TAG, "readyToShareLocation was false while user tried to share location.")
}
}
}
private fun initSearchView() {
if (activity != null) {
val searchManager = activity!!.getSystemService(Context.SEARCH_SERVICE) as SearchManager
if (searchItem != null) {
searchView = MenuItemCompat.getActionView(searchItem) as SearchView
searchView?.maxWidth = Int.MAX_VALUE
searchView?.inputType = InputType.TYPE_TEXT_VARIATION_FILTER
var imeOptions = EditorInfo.IME_ACTION_DONE or EditorInfo.IME_FLAG_NO_FULLSCREEN
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && appPreferences!!.isKeyboardIncognito) {
imeOptions = imeOptions or EditorInfo.IME_FLAG_NO_PERSONALIZED_LEARNING
}
searchView?.imeOptions = imeOptions
searchView?.queryHint = resources!!.getString(R.string.nc_search)
searchView?.setSearchableInfo(searchManager.getSearchableInfo(activity!!.componentName))
searchView?.setOnQueryTextListener(this)
}
}
}
override fun onQueryTextSubmit(query: String?): Boolean {
if (!query.isNullOrEmpty()) {
val bundle = Bundle()
bundle.putString(BundleKeys.KEY_GEOCODING_QUERY, query)
bundle.putString(BundleKeys.KEY_ROOM_TOKEN, roomToken)
router.pushController(
RouterTransaction.with(GeocodingController(bundle, this))
.pushChangeHandler(HorizontalChangeHandler())
.popChangeHandler(HorizontalChangeHandler())
)
}
return true
}
override fun onQueryTextChange(newText: String?): Boolean {
return true
}
private fun initMap() {
binding.map.setTileSource(TileSourceFactory.MAPNIK)
binding.map.onResume()
locationManager = activity!!.getSystemService(Context.LOCATION_SERVICE) as LocationManager
if (!isLocationPermissionsGranted()) {
requestLocationPermissions()
} else {
try {
when {
locationManager!!.isProviderEnabled(LocationManager.NETWORK_PROVIDER) -> {
locationManager!!.requestLocationUpdates(
LocationManager.NETWORK_PROVIDER,
MIN_LOCATION_UPDATE_TIME,
MIN_LOCATION_UPDATE_DISTANCE,
this
)
}
locationManager!!.isProviderEnabled(LocationManager.GPS_PROVIDER) -> {
locationManager!!.requestLocationUpdates(
LocationManager.GPS_PROVIDER,
MIN_LOCATION_UPDATE_TIME,
MIN_LOCATION_UPDATE_DISTANCE,
this
)
Log.d(TAG, "LocationManager.NETWORK_PROVIDER falling back to LocationManager.GPS_PROVIDER")
}
else -> {
Log.e(
TAG,
"Error requesting location updates. Probably this is a phone without google services" +
" and there is no alternative like UnifiedNlp installed. Furthermore no GPS is " +
"supported."
)
Toast.makeText(context, context?.getString(R.string.nc_location_unknown), Toast.LENGTH_LONG)
.show()
}
}
} catch (e: SecurityException) {
Log.e(TAG, "Error when requesting location updates. Permissions may be missing.", e)
Toast.makeText(context, context?.getString(R.string.nc_location_unknown), Toast.LENGTH_LONG).show()
} catch (e: Exception) {
Log.e(TAG, "Error when requesting location updates.", e)
Toast.makeText(context, context?.getString(R.string.nc_common_error_sorry), Toast.LENGTH_LONG).show()
}
}
val copyrightOverlay = CopyrightOverlay(context)
binding.map.overlays?.add(copyrightOverlay)
binding.map.setMultiTouchControls(true)
binding.map.isTilesScaledToDpi = true
locationOverlay = MyLocationNewOverlay(GpsMyLocationProvider(context), binding.map)
locationOverlay.enableMyLocation()
locationOverlay.setPersonHotspot(PERSON_HOT_SPOT_X, PERSON_HOT_SPOT_Y)
locationOverlay.setPersonIcon(
DisplayUtils.getBitmap(
ResourcesCompat.getDrawable(resources!!, R.drawable.current_location_circle, null)
)
)
binding.map.overlays?.add(locationOverlay)
val mapController = binding.map.controller
if (receivedChosenGeocodingResult) {
mapController?.setZoom(ZOOM_LEVEL_RECEIVED_RESULT)
} else {
mapController?.setZoom(ZOOM_LEVEL_DEFAULT)
}
val zoomToCurrentPositionOnFirstFix = !receivedChosenGeocodingResult
locationOverlay.runOnFirstFix {
if (locationOverlay.myLocation != null) {
myLocation = locationOverlay.myLocation
if (zoomToCurrentPositionOnFirstFix) {
activity!!.runOnUiThread {
mapController?.setZoom(ZOOM_LEVEL_DEFAULT)
mapController?.setCenter(myLocation)
}
}
} else {
// locationOverlay.myLocation was null. might be an osmdroid bug?
// However that seems to be okay because runOnFirstFix is called twice somehow and the second time
// locationOverlay.myLocation is not null.
}
}
if (receivedChosenGeocodingResult && geocodedLat != COORDINATE_ZERO && geocodedLon != COORDINATE_ZERO) {
mapController?.setCenter(GeoPoint(geocodedLat, geocodedLon))
}
binding.centerMapButton.setOnClickListener {
if (myLocation.latitude == COORDINATE_ZERO && myLocation.longitude == COORDINATE_ZERO) {
Toast.makeText(context, context?.getString(R.string.nc_location_unknown), Toast.LENGTH_LONG).show()
} else {
mapController?.animateTo(myLocation)
moveToCurrentLocationWasClicked = true
}
}
binding.map.addMapListener(
DelayedMapListener(
object : MapListener {
@Suppress("Detekt.TooGenericExceptionCaught")
override fun onScroll(paramScrollEvent: ScrollEvent): Boolean {
try {
when {
moveToCurrentLocationWasClicked -> {
setLocationDescription(isGpsLocation = true, isGeocodedResult = false)
moveToCurrentLocationWasClicked = false
}
receivedChosenGeocodingResult -> {
binding.shareLocation.isClickable = true
setLocationDescription(isGpsLocation = false, isGeocodedResult = true)
receivedChosenGeocodingResult = false
}
else -> {
binding.shareLocation.isClickable = true
setLocationDescription(isGpsLocation = false, isGeocodedResult = false)
}
}
} catch (e: NullPointerException) {
Log.d(TAG, "UI already closed")
}
readyToShareLocation = true
return true
}
override fun onZoom(event: ZoomEvent): Boolean {
return false
}
})
)
}
private fun setLocationDescription(isGpsLocation: Boolean, isGeocodedResult: Boolean) {
when {
isGpsLocation -> {
binding.shareLocationDescription.text = context!!.getText(R.string.nc_share_current_location)
binding.placeName.visibility = View.GONE
binding.placeName.text = ""
}
isGeocodedResult -> {
binding.shareLocationDescription.text = context!!.getText(R.string.nc_share_this_location)
binding.placeName.visibility = View.VISIBLE
binding.placeName.text = geocodedName
}
else -> {
binding.shareLocationDescription.text = context!!.getText(R.string.nc_share_this_location)
binding.placeName.visibility = View.GONE
binding.placeName.text = ""
}
}
}
private fun shareLocation(selectedLat: Double?, selectedLon: Double?, locationName: String?) {
if (selectedLat != null || selectedLon != null) {
val name = locationName
if (name.isNullOrEmpty()) {
initGeocoder()
searchPlaceNameForCoordinates(selectedLat!!, selectedLon!!)
} else {
executeShareLocation(selectedLat, selectedLon, locationName)
}
}
}
private fun executeShareLocation(selectedLat: Double?, selectedLon: Double?, locationName: String?) {
val objectId = "geo:$selectedLat,$selectedLon"
var locationNameToShare = locationName
if (locationNameToShare.isNullOrBlank()) {
locationNameToShare = resources?.getString(R.string.nc_shared_location)
}
val metaData: String =
"{\"type\":\"geo-location\",\"id\":\"geo:$selectedLat,$selectedLon\",\"latitude\":\"$selectedLat\"," +
"\"longitude\":\"$selectedLon\",\"name\":\"$locationNameToShare\"}"
val apiVersion = ApiUtils.getChatApiVersion(userUtils.currentUser, intArrayOf(1))
ncApi.sendLocation(
ApiUtils.getCredentials(userUtils.currentUser?.username, userUtils.currentUser?.token),
ApiUtils.getUrlToSendLocation(apiVersion, userUtils.currentUser?.baseUrl, roomToken),
"geo-location",
objectId,
metaData
)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(object : Observer<GenericOverall> {
override fun onSubscribe(d: Disposable) {
// unused atm
}
override fun onNext(t: GenericOverall) {
router.popCurrentController()
}
override fun onError(e: Throwable) {
Log.e(TAG, "error when trying to share location", e)
Toast.makeText(context, R.string.nc_common_error_sorry, Toast.LENGTH_LONG).show()
router.popCurrentController()
}
override fun onComplete() {
// unused atm
}
})
}
private fun isLocationPermissionsGranted(): Boolean {
fun isCoarseLocationGranted(): Boolean {
return PermissionChecker.checkSelfPermission(
context!!,
Manifest.permission.ACCESS_COARSE_LOCATION
) == PermissionChecker.PERMISSION_GRANTED
}
fun isFineLocationGranted(): Boolean {
return PermissionChecker.checkSelfPermission(
context!!,
Manifest.permission.ACCESS_FINE_LOCATION
) == PermissionChecker.PERMISSION_GRANTED
}
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
isCoarseLocationGranted() && isFineLocationGranted()
} else {
true
}
}
private fun requestLocationPermissions() {
requestPermissions(
arrayOf(
Manifest.permission.ACCESS_FINE_LOCATION,
Manifest.permission.ACCESS_COARSE_LOCATION
),
REQUEST_PERMISSIONS_REQUEST_CODE
)
}
override fun onRequestPermissionsResult(
requestCode: Int,
permissions: Array<out String>,
grantResults: IntArray
) {
fun areAllGranted(grantResults: IntArray): Boolean {
if (grantResults.isEmpty()) return false
grantResults.forEach {
if (it == PackageManager.PERMISSION_DENIED) return false
}
return true
}
if (requestCode == REQUEST_PERMISSIONS_REQUEST_CODE && areAllGranted(grantResults)) {
initMap()
} else {
Toast.makeText(context, context!!.getString(R.string.nc_location_permission_required), Toast.LENGTH_LONG)
.show()
}
}
override fun receiveChosenGeocodingResult(lat: Double, lon: Double, name: String) {
receivedChosenGeocodingResult = true
geocodedLat = lat
geocodedLon = lon
geocodedName = name
}
private fun initGeocoder() {
val baseUrl = context!!.getString(R.string.osm_geocoder_url)
val email = context!!.getString(R.string.osm_geocoder_contact)
nominatimClient = TalkJsonNominatimClient(baseUrl, okHttpClient, email)
}
private fun searchPlaceNameForCoordinates(lat: Double, lon: Double): Boolean {
CoroutineScope(Dispatchers.IO).launch {
executeGeocodingRequest(lat, lon)
}
return true
}
@Suppress("Detekt.TooGenericExceptionCaught")
private suspend fun executeGeocodingRequest(lat: Double, lon: Double) {
var address: Address? = null
try {
address = nominatimClient!!.getAddress(lon, lat)
} catch (e: Exception) {
Log.e(TAG, "Failed to get geocoded addresses", e)
Toast.makeText(context, R.string.nc_common_error_sorry, Toast.LENGTH_LONG).show()
}
updateResultOnMainThread(lat, lon, address?.displayName)
}
private suspend fun updateResultOnMainThread(lat: Double, lon: Double, addressName: String?) {
withContext(Dispatchers.Main) {
executeShareLocation(lat, lon, addressName)
}
}
override fun onLocationChanged(location: Location?) {
myLocation = GeoPoint(location)
}
override fun onStatusChanged(provider: String?, status: Int, extras: Bundle?) {
// empty
}
override fun onProviderEnabled(provider: String?) {
// empty
}
override fun onProviderDisabled(provider: String?) {
// empty
}
companion object {
private const val TAG = "LocPicker"
private const val REQUEST_PERMISSIONS_REQUEST_CODE = 1
private const val PERSON_HOT_SPOT_X: Float = 20.0F
private const val PERSON_HOT_SPOT_Y: Float = 20.0F
private const val ZOOM_LEVEL_RECEIVED_RESULT: Double = 14.0
private const val ZOOM_LEVEL_DEFAULT: Double = 14.0
private const val COORDINATE_ZERO: Double = 0.0
private const val MIN_LOCATION_UPDATE_TIME: Long = 30 * 1000L
private const val MIN_LOCATION_UPDATE_DISTANCE: Float = 0f
}
}