LocationPickerController.kt 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549
  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.utils.ApiUtils
  55. import com.nextcloud.talk.utils.DisplayUtils
  56. import com.nextcloud.talk.utils.bundle.BundleKeys
  57. import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_ROOM_TOKEN
  58. import com.nextcloud.talk.utils.database.user.UserUtils
  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 userUtils: UserUtils
  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. private fun initMap() {
  194. binding.map.setTileSource(TileSourceFactory.MAPNIK)
  195. binding.map.onResume()
  196. locationManager = activity!!.getSystemService(Context.LOCATION_SERVICE) as LocationManager
  197. if (!isLocationPermissionsGranted()) {
  198. requestLocationPermissions()
  199. } else {
  200. try {
  201. when {
  202. locationManager!!.isProviderEnabled(LocationManager.NETWORK_PROVIDER) -> {
  203. locationManager!!.requestLocationUpdates(
  204. LocationManager.NETWORK_PROVIDER,
  205. MIN_LOCATION_UPDATE_TIME,
  206. MIN_LOCATION_UPDATE_DISTANCE,
  207. this
  208. )
  209. }
  210. locationManager!!.isProviderEnabled(LocationManager.GPS_PROVIDER) -> {
  211. locationManager!!.requestLocationUpdates(
  212. LocationManager.GPS_PROVIDER,
  213. MIN_LOCATION_UPDATE_TIME,
  214. MIN_LOCATION_UPDATE_DISTANCE,
  215. this
  216. )
  217. Log.d(TAG, "LocationManager.NETWORK_PROVIDER falling back to LocationManager.GPS_PROVIDER")
  218. }
  219. else -> {
  220. Log.e(
  221. TAG,
  222. "Error requesting location updates. Probably this is a phone without google services" +
  223. " and there is no alternative like UnifiedNlp installed. Furthermore no GPS is " +
  224. "supported."
  225. )
  226. Toast.makeText(context, context?.getString(R.string.nc_location_unknown), Toast.LENGTH_LONG)
  227. .show()
  228. }
  229. }
  230. } catch (e: SecurityException) {
  231. Log.e(TAG, "Error when requesting location updates. Permissions may be missing.", e)
  232. Toast.makeText(context, context?.getString(R.string.nc_location_unknown), Toast.LENGTH_LONG).show()
  233. } catch (e: Exception) {
  234. Log.e(TAG, "Error when requesting location updates.", e)
  235. Toast.makeText(context, context?.getString(R.string.nc_common_error_sorry), Toast.LENGTH_LONG).show()
  236. }
  237. }
  238. val copyrightOverlay = CopyrightOverlay(context)
  239. binding.map.overlays?.add(copyrightOverlay)
  240. binding.map.setMultiTouchControls(true)
  241. binding.map.isTilesScaledToDpi = true
  242. locationOverlay = MyLocationNewOverlay(GpsMyLocationProvider(context), binding.map)
  243. locationOverlay.enableMyLocation()
  244. locationOverlay.setPersonHotspot(PERSON_HOT_SPOT_X, PERSON_HOT_SPOT_Y)
  245. locationOverlay.setPersonIcon(
  246. DisplayUtils.getBitmap(
  247. ResourcesCompat.getDrawable(resources!!, R.drawable.current_location_circle, null)
  248. )
  249. )
  250. binding.map.overlays?.add(locationOverlay)
  251. val mapController = binding.map.controller
  252. if (receivedChosenGeocodingResult) {
  253. mapController?.setZoom(ZOOM_LEVEL_RECEIVED_RESULT)
  254. } else {
  255. mapController?.setZoom(ZOOM_LEVEL_DEFAULT)
  256. }
  257. val zoomToCurrentPositionOnFirstFix = !receivedChosenGeocodingResult
  258. locationOverlay.runOnFirstFix {
  259. if (locationOverlay.myLocation != null) {
  260. myLocation = locationOverlay.myLocation
  261. if (zoomToCurrentPositionOnFirstFix) {
  262. activity!!.runOnUiThread {
  263. mapController?.setZoom(ZOOM_LEVEL_DEFAULT)
  264. mapController?.setCenter(myLocation)
  265. }
  266. }
  267. } else {
  268. // locationOverlay.myLocation was null. might be an osmdroid bug?
  269. // However that seems to be okay because runOnFirstFix is called twice somehow and the second time
  270. // locationOverlay.myLocation is not null.
  271. }
  272. }
  273. if (receivedChosenGeocodingResult && geocodedLat != COORDINATE_ZERO && geocodedLon != COORDINATE_ZERO) {
  274. mapController?.setCenter(GeoPoint(geocodedLat, geocodedLon))
  275. }
  276. binding.centerMapButton.setOnClickListener {
  277. if (myLocation.latitude == COORDINATE_ZERO && myLocation.longitude == COORDINATE_ZERO) {
  278. Toast.makeText(context, context?.getString(R.string.nc_location_unknown), Toast.LENGTH_LONG).show()
  279. } else {
  280. mapController?.animateTo(myLocation)
  281. moveToCurrentLocationWasClicked = true
  282. }
  283. }
  284. binding.map.addMapListener(
  285. DelayedMapListener(
  286. object : MapListener {
  287. @Suppress("Detekt.TooGenericExceptionCaught")
  288. override fun onScroll(paramScrollEvent: ScrollEvent): Boolean {
  289. try {
  290. when {
  291. moveToCurrentLocationWasClicked -> {
  292. setLocationDescription(isGpsLocation = true, isGeocodedResult = false)
  293. moveToCurrentLocationWasClicked = false
  294. }
  295. receivedChosenGeocodingResult -> {
  296. binding.shareLocation.isClickable = true
  297. setLocationDescription(isGpsLocation = false, isGeocodedResult = true)
  298. receivedChosenGeocodingResult = false
  299. }
  300. else -> {
  301. binding.shareLocation.isClickable = true
  302. setLocationDescription(isGpsLocation = false, isGeocodedResult = false)
  303. }
  304. }
  305. } catch (e: NullPointerException) {
  306. Log.d(TAG, "UI already closed")
  307. }
  308. readyToShareLocation = true
  309. return true
  310. }
  311. override fun onZoom(event: ZoomEvent): Boolean {
  312. return false
  313. }
  314. })
  315. )
  316. }
  317. private fun setLocationDescription(isGpsLocation: Boolean, isGeocodedResult: Boolean) {
  318. when {
  319. isGpsLocation -> {
  320. binding.shareLocationDescription.text = context!!.getText(R.string.nc_share_current_location)
  321. binding.placeName.visibility = View.GONE
  322. binding.placeName.text = ""
  323. }
  324. isGeocodedResult -> {
  325. binding.shareLocationDescription.text = context!!.getText(R.string.nc_share_this_location)
  326. binding.placeName.visibility = View.VISIBLE
  327. binding.placeName.text = geocodedName
  328. }
  329. else -> {
  330. binding.shareLocationDescription.text = context!!.getText(R.string.nc_share_this_location)
  331. binding.placeName.visibility = View.GONE
  332. binding.placeName.text = ""
  333. }
  334. }
  335. }
  336. private fun shareLocation(selectedLat: Double?, selectedLon: Double?, locationName: String?) {
  337. if (selectedLat != null || selectedLon != null) {
  338. val name = locationName
  339. if (name.isNullOrEmpty()) {
  340. initGeocoder()
  341. searchPlaceNameForCoordinates(selectedLat!!, selectedLon!!)
  342. } else {
  343. executeShareLocation(selectedLat, selectedLon, locationName)
  344. }
  345. }
  346. }
  347. private fun executeShareLocation(selectedLat: Double?, selectedLon: Double?, locationName: String?) {
  348. val objectId = "geo:$selectedLat,$selectedLon"
  349. var locationNameToShare = locationName
  350. if (locationNameToShare.isNullOrBlank()) {
  351. locationNameToShare = resources?.getString(R.string.nc_shared_location)
  352. }
  353. val metaData: String =
  354. "{\"type\":\"geo-location\",\"id\":\"geo:$selectedLat,$selectedLon\",\"latitude\":\"$selectedLat\"," +
  355. "\"longitude\":\"$selectedLon\",\"name\":\"$locationNameToShare\"}"
  356. val apiVersion = ApiUtils.getChatApiVersion(userUtils.currentUser, intArrayOf(1))
  357. ncApi.sendLocation(
  358. ApiUtils.getCredentials(userUtils.currentUser?.username, userUtils.currentUser?.token),
  359. ApiUtils.getUrlToSendLocation(apiVersion, userUtils.currentUser?.baseUrl, roomToken),
  360. "geo-location",
  361. objectId,
  362. metaData
  363. )
  364. .subscribeOn(Schedulers.io())
  365. .observeOn(AndroidSchedulers.mainThread())
  366. .subscribe(object : Observer<GenericOverall> {
  367. override fun onSubscribe(d: Disposable) {
  368. // unused atm
  369. }
  370. override fun onNext(t: GenericOverall) {
  371. router.popCurrentController()
  372. }
  373. override fun onError(e: Throwable) {
  374. Log.e(TAG, "error when trying to share location", e)
  375. Toast.makeText(context, R.string.nc_common_error_sorry, Toast.LENGTH_LONG).show()
  376. router.popCurrentController()
  377. }
  378. override fun onComplete() {
  379. // unused atm
  380. }
  381. })
  382. }
  383. private fun isLocationPermissionsGranted(): Boolean {
  384. fun isCoarseLocationGranted(): Boolean {
  385. return PermissionChecker.checkSelfPermission(
  386. context!!,
  387. Manifest.permission.ACCESS_COARSE_LOCATION
  388. ) == PermissionChecker.PERMISSION_GRANTED
  389. }
  390. fun isFineLocationGranted(): Boolean {
  391. return PermissionChecker.checkSelfPermission(
  392. context!!,
  393. Manifest.permission.ACCESS_FINE_LOCATION
  394. ) == PermissionChecker.PERMISSION_GRANTED
  395. }
  396. return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
  397. isCoarseLocationGranted() && isFineLocationGranted()
  398. } else {
  399. true
  400. }
  401. }
  402. private fun requestLocationPermissions() {
  403. requestPermissions(
  404. arrayOf(
  405. Manifest.permission.ACCESS_FINE_LOCATION,
  406. Manifest.permission.ACCESS_COARSE_LOCATION
  407. ),
  408. REQUEST_PERMISSIONS_REQUEST_CODE
  409. )
  410. }
  411. override fun onRequestPermissionsResult(
  412. requestCode: Int,
  413. permissions: Array<out String>,
  414. grantResults: IntArray
  415. ) {
  416. fun areAllGranted(grantResults: IntArray): Boolean {
  417. if (grantResults.isEmpty()) return false
  418. grantResults.forEach {
  419. if (it == PackageManager.PERMISSION_DENIED) return false
  420. }
  421. return true
  422. }
  423. if (requestCode == REQUEST_PERMISSIONS_REQUEST_CODE && areAllGranted(grantResults)) {
  424. initMap()
  425. } else {
  426. Toast.makeText(context, context!!.getString(R.string.nc_location_permission_required), Toast.LENGTH_LONG)
  427. .show()
  428. }
  429. }
  430. override fun receiveChosenGeocodingResult(lat: Double, lon: Double, name: String) {
  431. receivedChosenGeocodingResult = true
  432. geocodedLat = lat
  433. geocodedLon = lon
  434. geocodedName = name
  435. }
  436. private fun initGeocoder() {
  437. val baseUrl = context!!.getString(R.string.osm_geocoder_url)
  438. val email = context!!.getString(R.string.osm_geocoder_contact)
  439. nominatimClient = TalkJsonNominatimClient(baseUrl, okHttpClient, email)
  440. }
  441. private fun searchPlaceNameForCoordinates(lat: Double, lon: Double): Boolean {
  442. CoroutineScope(Dispatchers.IO).launch {
  443. executeGeocodingRequest(lat, lon)
  444. }
  445. return true
  446. }
  447. @Suppress("Detekt.TooGenericExceptionCaught")
  448. private suspend fun executeGeocodingRequest(lat: Double, lon: Double) {
  449. var address: Address? = null
  450. try {
  451. address = nominatimClient!!.getAddress(lon, lat)
  452. } catch (e: Exception) {
  453. Log.e(TAG, "Failed to get geocoded addresses", e)
  454. Toast.makeText(context, R.string.nc_common_error_sorry, Toast.LENGTH_LONG).show()
  455. }
  456. updateResultOnMainThread(lat, lon, address?.displayName)
  457. }
  458. private suspend fun updateResultOnMainThread(lat: Double, lon: Double, addressName: String?) {
  459. withContext(Dispatchers.Main) {
  460. executeShareLocation(lat, lon, addressName)
  461. }
  462. }
  463. override fun onLocationChanged(location: Location?) {
  464. myLocation = GeoPoint(location)
  465. }
  466. override fun onStatusChanged(provider: String?, status: Int, extras: Bundle?) {
  467. // empty
  468. }
  469. override fun onProviderEnabled(provider: String?) {
  470. // empty
  471. }
  472. override fun onProviderDisabled(provider: String?) {
  473. // empty
  474. }
  475. companion object {
  476. private const val TAG = "LocPicker"
  477. private const val REQUEST_PERMISSIONS_REQUEST_CODE = 1
  478. private const val PERSON_HOT_SPOT_X: Float = 20.0F
  479. private const val PERSON_HOT_SPOT_Y: Float = 20.0F
  480. private const val ZOOM_LEVEL_RECEIVED_RESULT: Double = 14.0
  481. private const val ZOOM_LEVEL_DEFAULT: Double = 14.0
  482. private const val COORDINATE_ZERO: Double = 0.0
  483. private const val MIN_LOCATION_UPDATE_TIME: Long = 30 * 1000L
  484. private const val MIN_LOCATION_UPDATE_DISTANCE: Float = 0f
  485. }
  486. }