LocationPickerController.kt 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559
  1. /*
  2. * Nextcloud Talk application
  3. *
  4. * @author Marcel Hibbe
  5. * Copyright (C) 2021 Marcel Hibbe <dev@mhibbe.de>
  6. *
  7. * This program is free software: you can redistribute it and/or modify
  8. * it under the terms of the GNU General Public License as published by
  9. * the Free Software Foundation, either version 3 of the License, or
  10. * at your option) any later version.
  11. *
  12. * This program is distributed in the hope that it will be useful,
  13. * but WITHOUT ANY WARRANTY; without even the implied warranty of
  14. * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  15. * GNU General Public License for more details.
  16. *
  17. * You should have received a copy of the GNU General Public License
  18. * along with this program. If not, see <http://www.gnu.org/licenses/>.
  19. */
  20. package com.nextcloud.talk.controllers
  21. import android.Manifest
  22. import android.app.SearchManager
  23. import android.content.Context
  24. import android.content.pm.PackageManager
  25. import android.graphics.drawable.ColorDrawable
  26. import android.location.Location
  27. import android.location.LocationListener
  28. import android.location.LocationManager
  29. import android.os.Build
  30. import android.os.Bundle
  31. import android.text.InputType
  32. import android.util.Log
  33. import android.view.Menu
  34. import android.view.MenuInflater
  35. import android.view.MenuItem
  36. import android.view.View
  37. import android.view.inputmethod.EditorInfo
  38. import android.widget.Toast
  39. import androidx.appcompat.widget.SearchView
  40. import androidx.core.content.PermissionChecker
  41. import androidx.core.content.res.ResourcesCompat
  42. import androidx.core.view.MenuItemCompat
  43. import androidx.preference.PreferenceManager
  44. import autodagger.AutoInjector
  45. import com.bluelinelabs.conductor.RouterTransaction
  46. import com.bluelinelabs.conductor.changehandler.HorizontalChangeHandler
  47. import com.nextcloud.talk.R
  48. import com.nextcloud.talk.api.NcApi
  49. import com.nextcloud.talk.application.NextcloudTalkApplication
  50. import com.nextcloud.talk.controllers.base.NewBaseController
  51. import com.nextcloud.talk.controllers.util.viewBinding
  52. import com.nextcloud.talk.databinding.ControllerLocationBinding
  53. import com.nextcloud.talk.models.json.generic.GenericOverall
  54. import com.nextcloud.talk.users.UserManager
  55. import com.nextcloud.talk.utils.ApiUtils
  56. import com.nextcloud.talk.utils.DisplayUtils
  57. import com.nextcloud.talk.utils.bundle.BundleKeys
  58. import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_ROOM_TOKEN
  59. import fr.dudie.nominatim.client.TalkJsonNominatimClient
  60. import fr.dudie.nominatim.model.Address
  61. import io.reactivex.Observer
  62. import io.reactivex.android.schedulers.AndroidSchedulers
  63. import io.reactivex.disposables.Disposable
  64. import io.reactivex.schedulers.Schedulers
  65. import kotlinx.coroutines.CoroutineScope
  66. import kotlinx.coroutines.Dispatchers
  67. import kotlinx.coroutines.launch
  68. import kotlinx.coroutines.withContext
  69. import okhttp3.OkHttpClient
  70. import org.osmdroid.config.Configuration.getInstance
  71. import org.osmdroid.events.DelayedMapListener
  72. import org.osmdroid.events.MapListener
  73. import org.osmdroid.events.ScrollEvent
  74. import org.osmdroid.events.ZoomEvent
  75. import org.osmdroid.tileprovider.tilesource.TileSourceFactory
  76. import org.osmdroid.util.GeoPoint
  77. import org.osmdroid.views.overlay.CopyrightOverlay
  78. import org.osmdroid.views.overlay.mylocation.GpsMyLocationProvider
  79. import org.osmdroid.views.overlay.mylocation.MyLocationNewOverlay
  80. import javax.inject.Inject
  81. @AutoInjector(NextcloudTalkApplication::class)
  82. class LocationPickerController(args: Bundle) :
  83. NewBaseController(
  84. R.layout.controller_location,
  85. args
  86. ),
  87. SearchView.OnQueryTextListener,
  88. LocationListener,
  89. GeocodingController.GeocodingResultListener {
  90. private val binding: ControllerLocationBinding by viewBinding(ControllerLocationBinding::bind)
  91. @Inject
  92. lateinit var ncApi: NcApi
  93. @Inject
  94. lateinit var userManager: UserManager
  95. @Inject
  96. lateinit var okHttpClient: OkHttpClient
  97. var nominatimClient: TalkJsonNominatimClient? = null
  98. var roomToken: String?
  99. var myLocation: GeoPoint = GeoPoint(COORDINATE_ZERO, COORDINATE_ZERO)
  100. private var locationManager: LocationManager? = null
  101. private lateinit var locationOverlay: MyLocationNewOverlay
  102. var moveToCurrentLocationWasClicked: Boolean = true
  103. var readyToShareLocation: Boolean = false
  104. var searchItem: MenuItem? = null
  105. var searchView: SearchView? = null
  106. var receivedChosenGeocodingResult: Boolean = false
  107. var geocodedLat: Double = 0.0
  108. var geocodedLon: Double = 0.0
  109. var geocodedName: String = ""
  110. init {
  111. setHasOptionsMenu(true)
  112. NextcloudTalkApplication.sharedApplication!!.componentApplication.inject(this)
  113. getInstance().load(context, PreferenceManager.getDefaultSharedPreferences(context))
  114. roomToken = args.getString(KEY_ROOM_TOKEN)
  115. }
  116. override fun onAttach(view: View) {
  117. super.onAttach(view)
  118. initMap()
  119. }
  120. @Suppress("Detekt.TooGenericExceptionCaught")
  121. override fun onDetach(view: View) {
  122. super.onDetach(view)
  123. try {
  124. locationManager!!.removeUpdates(this)
  125. } catch (e: Exception) {
  126. Log.e(TAG, "error when trying to remove updates for location Manager", e)
  127. }
  128. locationOverlay.disableMyLocation()
  129. }
  130. override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
  131. super.onCreateOptionsMenu(menu, inflater)
  132. inflater.inflate(R.menu.menu_locationpicker, menu)
  133. searchItem = menu.findItem(R.id.location_action_search)
  134. initSearchView()
  135. }
  136. override fun onPrepareOptionsMenu(menu: Menu) {
  137. super.onPrepareOptionsMenu(menu)
  138. actionBar?.setIcon(ColorDrawable(resources!!.getColor(android.R.color.transparent)))
  139. actionBar?.title = context!!.getString(R.string.nc_share_location)
  140. }
  141. override val title: String
  142. get() =
  143. resources!!.getString(R.string.nc_share_location)
  144. override fun onViewBound(view: View) {
  145. setLocationDescription(false, receivedChosenGeocodingResult)
  146. binding.shareLocation.isClickable = false
  147. binding.shareLocation.setOnClickListener {
  148. if (readyToShareLocation) {
  149. shareLocation(
  150. binding.map.mapCenter?.latitude,
  151. binding.map.mapCenter?.longitude,
  152. binding.placeName.text.toString()
  153. )
  154. } else {
  155. Log.w(TAG, "readyToShareLocation was false while user tried to share location.")
  156. }
  157. }
  158. }
  159. private fun initSearchView() {
  160. if (activity != null) {
  161. val searchManager = activity!!.getSystemService(Context.SEARCH_SERVICE) as SearchManager
  162. if (searchItem != null) {
  163. searchView = MenuItemCompat.getActionView(searchItem) as SearchView
  164. searchView?.maxWidth = Int.MAX_VALUE
  165. searchView?.inputType = InputType.TYPE_TEXT_VARIATION_FILTER
  166. var imeOptions = EditorInfo.IME_ACTION_DONE or EditorInfo.IME_FLAG_NO_FULLSCREEN
  167. if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && appPreferences!!.isKeyboardIncognito) {
  168. imeOptions = imeOptions or EditorInfo.IME_FLAG_NO_PERSONALIZED_LEARNING
  169. }
  170. searchView?.imeOptions = imeOptions
  171. searchView?.queryHint = resources!!.getString(R.string.nc_search)
  172. searchView?.setSearchableInfo(searchManager.getSearchableInfo(activity!!.componentName))
  173. searchView?.setOnQueryTextListener(this)
  174. }
  175. }
  176. }
  177. override fun onQueryTextSubmit(query: String?): Boolean {
  178. if (!query.isNullOrEmpty()) {
  179. val bundle = Bundle()
  180. bundle.putString(BundleKeys.KEY_GEOCODING_QUERY, query)
  181. bundle.putString(BundleKeys.KEY_ROOM_TOKEN, roomToken)
  182. router.pushController(
  183. RouterTransaction.with(GeocodingController(bundle, this))
  184. .pushChangeHandler(HorizontalChangeHandler())
  185. .popChangeHandler(HorizontalChangeHandler())
  186. )
  187. }
  188. return true
  189. }
  190. override fun onQueryTextChange(newText: String?): Boolean {
  191. return true
  192. }
  193. @Suppress("Detekt.TooGenericExceptionCaught", "Detekt.ComplexMethod")
  194. private fun initMap() {
  195. binding.map.setTileSource(TileSourceFactory.MAPNIK)
  196. binding.map.onResume()
  197. locationManager = activity!!.getSystemService(Context.LOCATION_SERVICE) as LocationManager
  198. if (!isLocationPermissionsGranted()) {
  199. requestLocationPermissions()
  200. } else {
  201. requestLocationUpdates()
  202. }
  203. val copyrightOverlay = CopyrightOverlay(context)
  204. binding.map.overlays?.add(copyrightOverlay)
  205. binding.map.setMultiTouchControls(true)
  206. binding.map.isTilesScaledToDpi = true
  207. locationOverlay = MyLocationNewOverlay(GpsMyLocationProvider(context), binding.map)
  208. locationOverlay.enableMyLocation()
  209. locationOverlay.setPersonHotspot(PERSON_HOT_SPOT_X, PERSON_HOT_SPOT_Y)
  210. locationOverlay.setPersonIcon(
  211. DisplayUtils.getBitmap(
  212. ResourcesCompat.getDrawable(resources!!, R.drawable.current_location_circle, null)
  213. )
  214. )
  215. binding.map.overlays?.add(locationOverlay)
  216. val mapController = binding.map.controller
  217. if (receivedChosenGeocodingResult) {
  218. mapController?.setZoom(ZOOM_LEVEL_RECEIVED_RESULT)
  219. } else {
  220. mapController?.setZoom(ZOOM_LEVEL_DEFAULT)
  221. }
  222. val zoomToCurrentPositionOnFirstFix = !receivedChosenGeocodingResult
  223. locationOverlay.runOnFirstFix {
  224. if (locationOverlay.myLocation != null) {
  225. myLocation = locationOverlay.myLocation
  226. if (zoomToCurrentPositionOnFirstFix) {
  227. activity!!.runOnUiThread {
  228. mapController?.setZoom(ZOOM_LEVEL_DEFAULT)
  229. mapController?.setCenter(myLocation)
  230. }
  231. }
  232. } else {
  233. // locationOverlay.myLocation was null. might be an osmdroid bug?
  234. // However that seems to be okay because runOnFirstFix is called twice somehow and the second time
  235. // locationOverlay.myLocation is not null.
  236. }
  237. }
  238. if (receivedChosenGeocodingResult && geocodedLat != COORDINATE_ZERO && geocodedLon != COORDINATE_ZERO) {
  239. mapController?.setCenter(GeoPoint(geocodedLat, geocodedLon))
  240. }
  241. binding.centerMapButton.setOnClickListener {
  242. if (myLocation.latitude == COORDINATE_ZERO && myLocation.longitude == COORDINATE_ZERO) {
  243. Toast.makeText(context, context?.getString(R.string.nc_location_unknown), Toast.LENGTH_LONG).show()
  244. } else {
  245. mapController?.animateTo(myLocation)
  246. moveToCurrentLocationWasClicked = true
  247. }
  248. }
  249. binding.map.addMapListener(
  250. delayedMapListener()
  251. )
  252. }
  253. private fun delayedMapListener() = DelayedMapListener(
  254. object : MapListener {
  255. @Suppress("Detekt.TooGenericExceptionCaught")
  256. override fun onScroll(paramScrollEvent: ScrollEvent): Boolean {
  257. try {
  258. when {
  259. moveToCurrentLocationWasClicked -> {
  260. setLocationDescription(isGpsLocation = true, isGeocodedResult = false)
  261. moveToCurrentLocationWasClicked = false
  262. }
  263. receivedChosenGeocodingResult -> {
  264. binding.shareLocation.isClickable = true
  265. setLocationDescription(isGpsLocation = false, isGeocodedResult = true)
  266. receivedChosenGeocodingResult = false
  267. }
  268. else -> {
  269. binding.shareLocation.isClickable = true
  270. setLocationDescription(isGpsLocation = false, isGeocodedResult = false)
  271. }
  272. }
  273. } catch (e: NullPointerException) {
  274. Log.d(TAG, "UI already closed")
  275. }
  276. readyToShareLocation = true
  277. return true
  278. }
  279. override fun onZoom(event: ZoomEvent): Boolean {
  280. return false
  281. }
  282. })
  283. @Suppress("Detekt.TooGenericExceptionCaught")
  284. private fun requestLocationUpdates() {
  285. try {
  286. when {
  287. locationManager!!.isProviderEnabled(LocationManager.NETWORK_PROVIDER) -> {
  288. locationManager!!.requestLocationUpdates(
  289. LocationManager.NETWORK_PROVIDER,
  290. MIN_LOCATION_UPDATE_TIME,
  291. MIN_LOCATION_UPDATE_DISTANCE,
  292. this
  293. )
  294. }
  295. locationManager!!.isProviderEnabled(LocationManager.GPS_PROVIDER) -> {
  296. locationManager!!.requestLocationUpdates(
  297. LocationManager.GPS_PROVIDER,
  298. MIN_LOCATION_UPDATE_TIME,
  299. MIN_LOCATION_UPDATE_DISTANCE,
  300. this
  301. )
  302. Log.d(TAG, "LocationManager.NETWORK_PROVIDER falling back to LocationManager.GPS_PROVIDER")
  303. }
  304. else -> {
  305. Log.e(
  306. TAG,
  307. "Error requesting location updates. Probably this is a phone without google services" +
  308. " and there is no alternative like UnifiedNlp installed. Furthermore no GPS is " +
  309. "supported."
  310. )
  311. Toast.makeText(context, context?.getString(R.string.nc_location_unknown), Toast.LENGTH_LONG)
  312. .show()
  313. }
  314. }
  315. } catch (e: SecurityException) {
  316. Log.e(TAG, "Error when requesting location updates. Permissions may be missing.", e)
  317. Toast.makeText(context, context?.getString(R.string.nc_location_unknown), Toast.LENGTH_LONG).show()
  318. } catch (e: Exception) {
  319. Log.e(TAG, "Error when requesting location updates.", e)
  320. Toast.makeText(context, context?.getString(R.string.nc_common_error_sorry), Toast.LENGTH_LONG).show()
  321. }
  322. }
  323. private fun setLocationDescription(isGpsLocation: Boolean, isGeocodedResult: Boolean) {
  324. when {
  325. isGpsLocation -> {
  326. binding.shareLocationDescription.text = context!!.getText(R.string.nc_share_current_location)
  327. binding.placeName.visibility = View.GONE
  328. binding.placeName.text = ""
  329. }
  330. isGeocodedResult -> {
  331. binding.shareLocationDescription.text = context!!.getText(R.string.nc_share_this_location)
  332. binding.placeName.visibility = View.VISIBLE
  333. binding.placeName.text = geocodedName
  334. }
  335. else -> {
  336. binding.shareLocationDescription.text = context!!.getText(R.string.nc_share_this_location)
  337. binding.placeName.visibility = View.GONE
  338. binding.placeName.text = ""
  339. }
  340. }
  341. }
  342. private fun shareLocation(selectedLat: Double?, selectedLon: Double?, locationName: String?) {
  343. if (selectedLat != null || selectedLon != null) {
  344. val name = locationName
  345. if (name.isNullOrEmpty()) {
  346. initGeocoder()
  347. searchPlaceNameForCoordinates(selectedLat!!, selectedLon!!)
  348. } else {
  349. executeShareLocation(selectedLat, selectedLon, locationName)
  350. }
  351. }
  352. }
  353. private fun executeShareLocation(selectedLat: Double?, selectedLon: Double?, locationName: String?) {
  354. val objectId = "geo:$selectedLat,$selectedLon"
  355. var locationNameToShare = locationName
  356. if (locationNameToShare.isNullOrBlank()) {
  357. locationNameToShare = resources?.getString(R.string.nc_shared_location)
  358. }
  359. val metaData: String =
  360. "{\"type\":\"geo-location\",\"id\":\"geo:$selectedLat,$selectedLon\",\"latitude\":\"$selectedLat\"," +
  361. "\"longitude\":\"$selectedLon\",\"name\":\"$locationNameToShare\"}"
  362. val currentUser = userManager.currentUser.blockingGet()
  363. val apiVersion = ApiUtils.getChatApiVersion(currentUser, intArrayOf(1))
  364. ncApi.sendLocation(
  365. ApiUtils.getCredentials(currentUser?.username, currentUser?.token),
  366. ApiUtils.getUrlToSendLocation(apiVersion, currentUser?.baseUrl, roomToken),
  367. "geo-location",
  368. objectId,
  369. metaData
  370. )
  371. .subscribeOn(Schedulers.io())
  372. .observeOn(AndroidSchedulers.mainThread())
  373. .subscribe(object : Observer<GenericOverall> {
  374. override fun onSubscribe(d: Disposable) {
  375. // unused atm
  376. }
  377. override fun onNext(t: GenericOverall) {
  378. router.popCurrentController()
  379. }
  380. override fun onError(e: Throwable) {
  381. Log.e(TAG, "error when trying to share location", e)
  382. Toast.makeText(context, R.string.nc_common_error_sorry, Toast.LENGTH_LONG).show()
  383. router.popCurrentController()
  384. }
  385. override fun onComplete() {
  386. // unused atm
  387. }
  388. })
  389. }
  390. private fun isLocationPermissionsGranted(): Boolean {
  391. fun isCoarseLocationGranted(): Boolean {
  392. return PermissionChecker.checkSelfPermission(
  393. context!!,
  394. Manifest.permission.ACCESS_COARSE_LOCATION
  395. ) == PermissionChecker.PERMISSION_GRANTED
  396. }
  397. fun isFineLocationGranted(): Boolean {
  398. return PermissionChecker.checkSelfPermission(
  399. context!!,
  400. Manifest.permission.ACCESS_FINE_LOCATION
  401. ) == PermissionChecker.PERMISSION_GRANTED
  402. }
  403. return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
  404. isCoarseLocationGranted() && isFineLocationGranted()
  405. } else {
  406. true
  407. }
  408. }
  409. private fun requestLocationPermissions() {
  410. requestPermissions(
  411. arrayOf(
  412. Manifest.permission.ACCESS_FINE_LOCATION,
  413. Manifest.permission.ACCESS_COARSE_LOCATION
  414. ),
  415. REQUEST_PERMISSIONS_REQUEST_CODE
  416. )
  417. }
  418. override fun onRequestPermissionsResult(
  419. requestCode: Int,
  420. permissions: Array<out String>,
  421. grantResults: IntArray
  422. ) {
  423. fun areAllGranted(grantResults: IntArray): Boolean {
  424. grantResults.forEach {
  425. if (it == PackageManager.PERMISSION_DENIED) return false
  426. }
  427. return grantResults.isNotEmpty()
  428. }
  429. if (requestCode == REQUEST_PERMISSIONS_REQUEST_CODE && areAllGranted(grantResults)) {
  430. initMap()
  431. } else {
  432. Toast.makeText(context, context!!.getString(R.string.nc_location_permission_required), Toast.LENGTH_LONG)
  433. .show()
  434. }
  435. }
  436. override fun receiveChosenGeocodingResult(lat: Double, lon: Double, name: String) {
  437. receivedChosenGeocodingResult = true
  438. geocodedLat = lat
  439. geocodedLon = lon
  440. geocodedName = name
  441. }
  442. private fun initGeocoder() {
  443. val baseUrl = context!!.getString(R.string.osm_geocoder_url)
  444. val email = context!!.getString(R.string.osm_geocoder_contact)
  445. nominatimClient = TalkJsonNominatimClient(baseUrl, okHttpClient, email)
  446. }
  447. private fun searchPlaceNameForCoordinates(lat: Double, lon: Double): Boolean {
  448. CoroutineScope(Dispatchers.IO).launch {
  449. executeGeocodingRequest(lat, lon)
  450. }
  451. return true
  452. }
  453. @Suppress("Detekt.TooGenericExceptionCaught")
  454. private suspend fun executeGeocodingRequest(lat: Double, lon: Double) {
  455. var address: Address? = null
  456. try {
  457. address = nominatimClient!!.getAddress(lon, lat)
  458. } catch (e: Exception) {
  459. Log.e(TAG, "Failed to get geocoded addresses", e)
  460. Toast.makeText(context, R.string.nc_common_error_sorry, Toast.LENGTH_LONG).show()
  461. }
  462. updateResultOnMainThread(lat, lon, address?.displayName)
  463. }
  464. private suspend fun updateResultOnMainThread(lat: Double, lon: Double, addressName: String?) {
  465. withContext(Dispatchers.Main) {
  466. executeShareLocation(lat, lon, addressName)
  467. }
  468. }
  469. override fun onLocationChanged(location: Location) {
  470. myLocation = GeoPoint(location)
  471. }
  472. override fun onStatusChanged(provider: String?, status: Int, extras: Bundle?) {
  473. // empty
  474. }
  475. override fun onProviderEnabled(provider: String) {
  476. // empty
  477. }
  478. override fun onProviderDisabled(provider: String) {
  479. // empty
  480. }
  481. companion object {
  482. private const val TAG = "LocPicker"
  483. private const val REQUEST_PERMISSIONS_REQUEST_CODE = 1
  484. private const val PERSON_HOT_SPOT_X: Float = 20.0F
  485. private const val PERSON_HOT_SPOT_Y: Float = 20.0F
  486. private const val ZOOM_LEVEL_RECEIVED_RESULT: Double = 14.0
  487. private const val ZOOM_LEVEL_DEFAULT: Double = 14.0
  488. private const val COORDINATE_ZERO: Double = 0.0
  489. private const val MIN_LOCATION_UPDATE_TIME: Long = 30 * 1000L
  490. private const val MIN_LOCATION_UPDATE_DISTANCE: Float = 0f
  491. }
  492. }