LocationPickerController.kt 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488
  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.location.Location
  26. import android.location.LocationListener
  27. import android.location.LocationManager
  28. import android.os.Build
  29. import android.os.Bundle
  30. import android.text.InputType
  31. import android.util.Log
  32. import android.view.Menu
  33. import android.view.MenuInflater
  34. import android.view.MenuItem
  35. import android.view.View
  36. import android.view.inputmethod.EditorInfo
  37. import android.widget.Toast
  38. import androidx.appcompat.widget.SearchView
  39. import androidx.core.content.PermissionChecker
  40. import androidx.core.content.res.ResourcesCompat
  41. import androidx.core.view.MenuItemCompat
  42. import androidx.preference.PreferenceManager
  43. import autodagger.AutoInjector
  44. import com.bluelinelabs.conductor.RouterTransaction
  45. import com.bluelinelabs.conductor.changehandler.HorizontalChangeHandler
  46. import com.nextcloud.talk.R
  47. import com.nextcloud.talk.api.NcApi
  48. import com.nextcloud.talk.application.NextcloudTalkApplication
  49. import com.nextcloud.talk.controllers.base.NewBaseController
  50. import com.nextcloud.talk.controllers.util.viewBinding
  51. import com.nextcloud.talk.databinding.ControllerLocationBinding
  52. import com.nextcloud.talk.models.json.generic.GenericOverall
  53. import com.nextcloud.talk.utils.ApiUtils
  54. import com.nextcloud.talk.utils.DisplayUtils
  55. import com.nextcloud.talk.utils.bundle.BundleKeys
  56. import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_ROOM_TOKEN
  57. import com.nextcloud.talk.utils.database.user.UserUtils
  58. import fr.dudie.nominatim.client.JsonNominatimClient
  59. import fr.dudie.nominatim.model.Address
  60. import io.reactivex.Observer
  61. import io.reactivex.android.schedulers.AndroidSchedulers
  62. import io.reactivex.disposables.Disposable
  63. import io.reactivex.schedulers.Schedulers
  64. import kotlinx.coroutines.CoroutineScope
  65. import kotlinx.coroutines.Dispatchers
  66. import kotlinx.coroutines.launch
  67. import kotlinx.coroutines.withContext
  68. import org.apache.http.client.HttpClient
  69. import org.apache.http.conn.ClientConnectionManager
  70. import org.apache.http.conn.scheme.Scheme
  71. import org.apache.http.conn.scheme.SchemeRegistry
  72. import org.apache.http.conn.ssl.SSLSocketFactory
  73. import org.apache.http.impl.client.DefaultHttpClient
  74. import org.apache.http.impl.conn.SingleClientConnManager
  75. import org.osmdroid.config.Configuration.getInstance
  76. import org.osmdroid.events.DelayedMapListener
  77. import org.osmdroid.events.MapListener
  78. import org.osmdroid.events.ScrollEvent
  79. import org.osmdroid.events.ZoomEvent
  80. import org.osmdroid.tileprovider.tilesource.TileSourceFactory
  81. import org.osmdroid.util.GeoPoint
  82. import org.osmdroid.views.overlay.CopyrightOverlay
  83. import org.osmdroid.views.overlay.mylocation.GpsMyLocationProvider
  84. import org.osmdroid.views.overlay.mylocation.MyLocationNewOverlay
  85. import javax.inject.Inject
  86. @AutoInjector(NextcloudTalkApplication::class)
  87. class LocationPickerController(args: Bundle) :
  88. NewBaseController(
  89. R.layout.controller_location,
  90. args
  91. ),
  92. SearchView.OnQueryTextListener,
  93. LocationListener,
  94. GeocodingController.GeocodingResultListener {
  95. private val binding: ControllerLocationBinding by viewBinding(ControllerLocationBinding::bind)
  96. @Inject
  97. lateinit var ncApi: NcApi
  98. @Inject
  99. lateinit var userUtils: UserUtils
  100. var nominatimClient: JsonNominatimClient? = null
  101. var roomToken: String?
  102. var myLocation: GeoPoint = GeoPoint(0.0, 0.0)
  103. private var locationManager: LocationManager? = null
  104. private lateinit var locationOverlay: MyLocationNewOverlay
  105. var moveToCurrentLocationWasClicked: Boolean = true
  106. var readyToShareLocation: Boolean = false
  107. var searchItem: MenuItem? = null
  108. var searchView: SearchView? = null
  109. var receivedChosenGeocodingResult: Boolean = false
  110. var geocodedLat: Double = 0.0
  111. var geocodedLon: Double = 0.0
  112. var geocodedName: String = ""
  113. init {
  114. setHasOptionsMenu(true)
  115. NextcloudTalkApplication.sharedApplication!!.componentApplication.inject(this)
  116. getInstance().load(context, PreferenceManager.getDefaultSharedPreferences(context))
  117. roomToken = args.getString(KEY_ROOM_TOKEN)
  118. }
  119. override fun onAttach(view: View) {
  120. super.onAttach(view)
  121. initMap()
  122. }
  123. @Suppress("Detekt.TooGenericExceptionCaught")
  124. override fun onDetach(view: View) {
  125. super.onDetach(view)
  126. try {
  127. locationManager!!.removeUpdates(this)
  128. } catch (e: Exception) {
  129. Log.e(TAG, "error when trying to remove updates for location Manager", e)
  130. }
  131. locationOverlay.disableMyLocation()
  132. }
  133. override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
  134. super.onCreateOptionsMenu(menu, inflater)
  135. inflater.inflate(R.menu.menu_locationpicker, menu)
  136. searchItem = menu.findItem(R.id.location_action_search)
  137. initSearchView()
  138. }
  139. override fun onPrepareOptionsMenu(menu: Menu) {
  140. super.onPrepareOptionsMenu(menu)
  141. actionBar?.title = context!!.getString(R.string.nc_share_location)
  142. }
  143. override val title: String
  144. get() =
  145. resources!!.getString(R.string.nc_share_location)
  146. override fun onViewBound(view: View) {
  147. setLocationDescription(false, receivedChosenGeocodingResult)
  148. binding.shareLocation.isClickable = false
  149. binding.shareLocation.setOnClickListener {
  150. if (readyToShareLocation) {
  151. shareLocation(
  152. binding.map.mapCenter?.latitude,
  153. binding.map.mapCenter?.longitude,
  154. binding.placeName.text.toString()
  155. )
  156. } else {
  157. Log.w(TAG, "readyToShareLocation was false while user tried to share location.")
  158. }
  159. }
  160. }
  161. private fun initSearchView() {
  162. if (activity != null) {
  163. val searchManager = activity!!.getSystemService(Context.SEARCH_SERVICE) as SearchManager
  164. if (searchItem != null) {
  165. searchView = MenuItemCompat.getActionView(searchItem) as SearchView
  166. searchView?.maxWidth = Int.MAX_VALUE
  167. searchView?.inputType = InputType.TYPE_TEXT_VARIATION_FILTER
  168. var imeOptions = EditorInfo.IME_ACTION_DONE or EditorInfo.IME_FLAG_NO_FULLSCREEN
  169. if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && appPreferences!!.isKeyboardIncognito) {
  170. imeOptions = imeOptions or EditorInfo.IME_FLAG_NO_PERSONALIZED_LEARNING
  171. }
  172. searchView?.imeOptions = imeOptions
  173. searchView?.queryHint = resources!!.getString(R.string.nc_search)
  174. searchView?.setSearchableInfo(searchManager.getSearchableInfo(activity!!.componentName))
  175. searchView?.setOnQueryTextListener(this)
  176. }
  177. }
  178. }
  179. override fun onQueryTextSubmit(query: String?): Boolean {
  180. if (!query.isNullOrEmpty()) {
  181. val bundle = Bundle()
  182. bundle.putString(BundleKeys.KEY_GEOCODING_QUERY, query)
  183. bundle.putString(BundleKeys.KEY_ROOM_TOKEN, roomToken)
  184. router.pushController(
  185. RouterTransaction.with(GeocodingController(bundle, this))
  186. .pushChangeHandler(HorizontalChangeHandler())
  187. .popChangeHandler(HorizontalChangeHandler())
  188. )
  189. }
  190. return true
  191. }
  192. override fun onQueryTextChange(newText: String?): Boolean {
  193. return true
  194. }
  195. private fun initMap() {
  196. if (!isFineLocationPermissionGranted()) {
  197. requestFineLocationPermission()
  198. }
  199. binding.map.setTileSource(TileSourceFactory.MAPNIK)
  200. binding.map.onResume()
  201. locationManager = activity!!.getSystemService(Context.LOCATION_SERVICE) as LocationManager
  202. try {
  203. locationManager!!.requestLocationUpdates(LocationManager.NETWORK_PROVIDER, 0L, 0f, this)
  204. } catch (ex: SecurityException) {
  205. Log.w(TAG, "Error requesting location updates", ex)
  206. }
  207. val copyrightOverlay = CopyrightOverlay(context)
  208. binding.map.overlays?.add(copyrightOverlay)
  209. binding.map.setMultiTouchControls(true)
  210. binding.map.isTilesScaledToDpi = true
  211. locationOverlay = MyLocationNewOverlay(GpsMyLocationProvider(context), binding.map)
  212. locationOverlay.enableMyLocation()
  213. locationOverlay.setPersonHotspot(PERSON_HOT_SPOT_X, PERSON_HOT_SPOT_Y)
  214. locationOverlay.setPersonIcon(
  215. DisplayUtils.getBitmap(
  216. ResourcesCompat.getDrawable(resources!!, R.drawable.current_location_circle, null)
  217. )
  218. )
  219. binding.map.overlays?.add(locationOverlay)
  220. val mapController = binding.map.controller
  221. if (receivedChosenGeocodingResult) {
  222. mapController?.setZoom(ZOOM_LEVEL_RECEIVED_RESULT)
  223. } else {
  224. mapController?.setZoom(ZOOM_LEVEL_DEFAULT)
  225. }
  226. val zoomToCurrentPositionOnFirstFix = !receivedChosenGeocodingResult
  227. locationOverlay.runOnFirstFix {
  228. myLocation = locationOverlay.myLocation
  229. if (zoomToCurrentPositionOnFirstFix) {
  230. activity!!.runOnUiThread {
  231. mapController?.setZoom(ZOOM_LEVEL_DEFAULT)
  232. mapController?.setCenter(myLocation)
  233. }
  234. }
  235. }
  236. if (receivedChosenGeocodingResult && geocodedLat != GEOCODE_ZERO && geocodedLon != GEOCODE_ZERO) {
  237. mapController?.setCenter(GeoPoint(geocodedLat, geocodedLon))
  238. }
  239. binding.centerMapButton.setOnClickListener {
  240. mapController?.animateTo(myLocation)
  241. moveToCurrentLocationWasClicked = true
  242. }
  243. binding.map.addMapListener(
  244. DelayedMapListener(
  245. object : MapListener {
  246. override fun onScroll(paramScrollEvent: ScrollEvent): Boolean {
  247. when {
  248. moveToCurrentLocationWasClicked -> {
  249. setLocationDescription(isGpsLocation = true, isGeocodedResult = false)
  250. moveToCurrentLocationWasClicked = false
  251. }
  252. receivedChosenGeocodingResult -> {
  253. binding.shareLocation.isClickable = true
  254. setLocationDescription(isGpsLocation = false, isGeocodedResult = true)
  255. receivedChosenGeocodingResult = false
  256. }
  257. else -> {
  258. binding.shareLocation.isClickable = true
  259. setLocationDescription(isGpsLocation = false, isGeocodedResult = false)
  260. }
  261. }
  262. readyToShareLocation = true
  263. return true
  264. }
  265. override fun onZoom(event: ZoomEvent): Boolean {
  266. return false
  267. }
  268. })
  269. )
  270. }
  271. private fun setLocationDescription(isGpsLocation: Boolean, isGeocodedResult: Boolean) {
  272. when {
  273. isGpsLocation -> {
  274. binding.shareLocationDescription.text = context!!.getText(R.string.nc_share_current_location)
  275. binding.placeName.visibility = View.GONE
  276. binding.placeName.text = ""
  277. }
  278. isGeocodedResult -> {
  279. binding.shareLocationDescription.text = context!!.getText(R.string.nc_share_this_location)
  280. binding.placeName.visibility = View.VISIBLE
  281. binding.placeName.text = geocodedName
  282. }
  283. else -> {
  284. binding.shareLocationDescription.text = context!!.getText(R.string.nc_share_this_location)
  285. binding.placeName.visibility = View.GONE
  286. binding.placeName.text = ""
  287. }
  288. }
  289. }
  290. private fun shareLocation(selectedLat: Double?, selectedLon: Double?, locationName: String?) {
  291. if (selectedLat != null || selectedLon != null) {
  292. val name = locationName
  293. if (name.isNullOrEmpty()) {
  294. initGeocoder()
  295. searchPlaceNameForCoordinates(selectedLat!!, selectedLon!!)
  296. } else {
  297. executeShareLocation(selectedLat, selectedLon, locationName)
  298. }
  299. }
  300. }
  301. private fun executeShareLocation(selectedLat: Double?, selectedLon: Double?, locationName: String?) {
  302. val objectId = "geo:$selectedLat,$selectedLon"
  303. val metaData: String =
  304. "{\"type\":\"geo-location\",\"id\":\"geo:$selectedLat,$selectedLon\",\"latitude\":\"$selectedLat\"," +
  305. "\"longitude\":\"$selectedLon\",\"name\":\"$locationName\"}"
  306. ncApi.sendLocation(
  307. ApiUtils.getCredentials(userUtils.currentUser?.username, userUtils.currentUser?.token),
  308. ApiUtils.getUrlToSendLocation(userUtils.currentUser?.baseUrl, roomToken),
  309. "geo-location",
  310. objectId,
  311. metaData
  312. )
  313. .subscribeOn(Schedulers.io())
  314. .observeOn(AndroidSchedulers.mainThread())
  315. .subscribe(object : Observer<GenericOverall> {
  316. override fun onSubscribe(d: Disposable) {
  317. // unused atm
  318. }
  319. override fun onNext(t: GenericOverall) {
  320. router.popCurrentController()
  321. }
  322. override fun onError(e: Throwable) {
  323. Log.e(TAG, "error when trying to share location", e)
  324. Toast.makeText(context, R.string.nc_common_error_sorry, Toast.LENGTH_LONG).show()
  325. router.popCurrentController()
  326. }
  327. override fun onComplete() {
  328. // unused atm
  329. }
  330. })
  331. }
  332. private fun isFineLocationPermissionGranted(): Boolean {
  333. if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
  334. if (PermissionChecker.checkSelfPermission(
  335. context!!,
  336. Manifest.permission.ACCESS_FINE_LOCATION
  337. ) == PermissionChecker.PERMISSION_GRANTED
  338. ) {
  339. Log.d(TAG, "Permission is granted")
  340. return true
  341. } else {
  342. Log.d(TAG, "Permission is revoked")
  343. return false
  344. }
  345. } else {
  346. Log.d(TAG, "Permission is granted")
  347. return true
  348. }
  349. }
  350. private fun requestFineLocationPermission() {
  351. requestPermissions(
  352. arrayOf(
  353. Manifest.permission.ACCESS_FINE_LOCATION
  354. ),
  355. REQUEST_PERMISSIONS_REQUEST_CODE
  356. )
  357. }
  358. override fun onRequestPermissionsResult(
  359. requestCode: Int,
  360. permissions: Array<out String>,
  361. grantResults: IntArray
  362. ) {
  363. if (requestCode == REQUEST_PERMISSIONS_REQUEST_CODE &&
  364. grantResults.size > 0 &&
  365. grantResults[0] == PackageManager.PERMISSION_GRANTED
  366. ) {
  367. initMap()
  368. } else {
  369. Toast.makeText(context, context!!.getString(R.string.nc_location_permission_required), Toast.LENGTH_LONG)
  370. .show()
  371. }
  372. }
  373. override fun receiveChosenGeocodingResult(lat: Double, lon: Double, name: String) {
  374. receivedChosenGeocodingResult = true
  375. geocodedLat = lat
  376. geocodedLon = lon
  377. geocodedName = name
  378. }
  379. private fun initGeocoder() {
  380. val registry = SchemeRegistry()
  381. registry.register(Scheme("https", SSLSocketFactory.getSocketFactory(), HTTPS_PORT))
  382. val connexionManager: ClientConnectionManager = SingleClientConnManager(null, registry)
  383. val httpClient: HttpClient = DefaultHttpClient(connexionManager, null)
  384. val baseUrl = context!!.getString(R.string.osm_geocoder_url)
  385. val email = context!!.getString(R.string.osm_geocoder_contact)
  386. nominatimClient = JsonNominatimClient(baseUrl, httpClient, email)
  387. }
  388. private fun searchPlaceNameForCoordinates(lat: Double, lon: Double): Boolean {
  389. CoroutineScope(Dispatchers.IO).launch {
  390. executeGeocodingRequest(lat, lon)
  391. }
  392. return true
  393. }
  394. @Suppress("Detekt.TooGenericExceptionCaught")
  395. private suspend fun executeGeocodingRequest(lat: Double, lon: Double) {
  396. var address: Address? = null
  397. try {
  398. address = nominatimClient!!.getAddress(lon, lat)
  399. } catch (e: Exception) {
  400. Log.e(TAG, "Failed to get geocoded addresses", e)
  401. Toast.makeText(context, R.string.nc_common_error_sorry, Toast.LENGTH_LONG).show()
  402. }
  403. updateResultOnMainThread(lat, lon, address?.displayName)
  404. }
  405. private suspend fun updateResultOnMainThread(lat: Double, lon: Double, addressName: String?) {
  406. withContext(Dispatchers.Main) {
  407. executeShareLocation(lat, lon, addressName)
  408. }
  409. }
  410. override fun onLocationChanged(location: Location?) {
  411. myLocation = GeoPoint(location)
  412. }
  413. override fun onStatusChanged(provider: String?, status: Int, extras: Bundle?) {
  414. // empty
  415. }
  416. override fun onProviderEnabled(provider: String?) {
  417. // empty
  418. }
  419. override fun onProviderDisabled(provider: String?) {
  420. // empty
  421. }
  422. companion object {
  423. private const val TAG = "LocPicker"
  424. private const val REQUEST_PERMISSIONS_REQUEST_CODE = 1
  425. private const val PERSON_HOT_SPOT_X: Float = 20.0F
  426. private const val PERSON_HOT_SPOT_Y: Float = 20.0F
  427. private const val ZOOM_LEVEL_RECEIVED_RESULT: Double = 14.0
  428. private const val ZOOM_LEVEL_DEFAULT: Double = 14.0
  429. private const val GEOCODE_ZERO : Double = 0.0
  430. private const val HTTPS_PORT: Int = 443
  431. }
  432. }