Merge pull request #5595 from vector-im/feature/ons/live_location_service

Live Location Sharing - Foreground Service
This commit is contained in:
Onuray Sahin 2022-03-28 12:50:35 +03:00 committed by GitHub
commit 08476a91e4
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 210 additions and 25 deletions

1
changelog.d/5595.feature Normal file
View file

@ -0,0 +1 @@
Live Location Sharing - Foreground Service and Notification

View file

@ -84,8 +84,8 @@
android:resizeableActivity="true"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.Vector.Light"
android:taskAffinity="${applicationId}.${appTaskAffinitySuffix}"
android:theme="@style/Theme.Vector.Light"
tools:replace="android:allowBackup">
<!-- No limit for screen ratio: avoid black strips -->
@ -369,6 +369,11 @@
</intent-filter>
</service>
<service
android:name=".features.location.LocationSharingService"
android:exported="false"
android:foregroundServiceType="location" />
<!-- Receivers -->
<receiver

View file

@ -23,5 +23,5 @@ sealed class LocationSharingAction : VectorViewModelAction {
data class PinnedLocationSharing(val locationData: LocationData?) : LocationSharingAction()
data class LocationTargetChange(val locationData: LocationData) : LocationSharingAction()
object ZoomToUserLocation : LocationSharingAction()
object StartLiveLocationSharing : LocationSharingAction()
data class StartLiveLocationSharing(val duration: Long) : LocationSharingAction()
}

View file

@ -16,11 +16,13 @@
package im.vector.app.features.location
import android.content.Intent
import android.graphics.drawable.Drawable
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.content.ContextCompat
import androidx.core.view.isGone
import androidx.lifecycle.lifecycleScope
import com.airbnb.mvrx.fragmentViewModel
@ -82,9 +84,10 @@ class LocationSharingFragment @Inject constructor(
viewModel.observeViewEvents {
when (it) {
LocationSharingViewEvents.Close -> locationSharingNavigator.quit()
LocationSharingViewEvents.LocationNotAvailableError -> handleLocationNotAvailableError()
is LocationSharingViewEvents.ZoomToUserLocation -> handleZoomToUserLocationEvent(it)
LocationSharingViewEvents.Close -> locationSharingNavigator.quit()
LocationSharingViewEvents.LocationNotAvailableError -> handleLocationNotAvailableError()
is LocationSharingViewEvents.ZoomToUserLocation -> handleZoomToUserLocationEvent(it)
is LocationSharingViewEvents.StartLiveLocationService -> handleStartLiveLocationService(it)
}
}
}
@ -176,6 +179,16 @@ class LocationSharingFragment @Inject constructor(
views.mapView.zoomToLocation(event.userLocation.latitude, event.userLocation.longitude)
}
private fun handleStartLiveLocationService(event: LocationSharingViewEvents.StartLiveLocationService) {
val args = LocationSharingService.RoomArgs(event.sessionId, event.roomId, event.duration)
Intent(requireContext(), LocationSharingService::class.java)
.putExtra(LocationSharingService.EXTRA_ROOM_ARGS, args)
.also {
ContextCompat.startForegroundService(requireContext(), it)
}
}
private fun initOptionsPicker() {
// set no option at start
views.shareLocationOptionsPicker.render()
@ -221,7 +234,9 @@ class LocationSharingFragment @Inject constructor(
}
private fun startLiveLocationSharing() {
viewModel.handle(LocationSharingAction.StartLiveLocationSharing)
// TODO. Get duration from user
val duration = 30 * 1000L
viewModel.handle(LocationSharingAction.StartLiveLocationSharing(duration))
}
private fun updateMap(state: LocationSharingViewState) {

View file

@ -0,0 +1,130 @@
/*
* Copyright (c) 2022 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.app.features.location
import android.content.Intent
import android.os.IBinder
import android.os.Parcelable
import dagger.hilt.android.AndroidEntryPoint
import im.vector.app.core.services.VectorService
import im.vector.app.features.notifications.NotificationUtils
import kotlinx.parcelize.Parcelize
import timber.log.Timber
import java.util.Timer
import java.util.TimerTask
import javax.inject.Inject
@AndroidEntryPoint
class LocationSharingService : VectorService(), LocationTracker.Callback {
@Parcelize
data class RoomArgs(
val sessionId: String,
val roomId: String,
val durationMillis: Long
) : Parcelable
@Inject lateinit var notificationUtils: NotificationUtils
@Inject lateinit var locationTracker: LocationTracker
private var roomArgsList = mutableListOf<RoomArgs>()
private var timers = mutableListOf<Timer>()
override fun onCreate() {
super.onCreate()
Timber.i("### LocationSharingService.onCreate")
// Start tracking location
locationTracker.addCallback(this)
locationTracker.start()
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
val roomArgs = intent?.getParcelableExtra(EXTRA_ROOM_ARGS) as? RoomArgs
Timber.i("### LocationSharingService.onStartCommand. sessionId - roomId ${roomArgs?.sessionId} - ${roomArgs?.roomId}")
if (roomArgs != null) {
roomArgsList.add(roomArgs)
// Show a sticky notification
val notification = notificationUtils.buildLiveLocationSharingNotification()
startForeground(roomArgs.roomId.hashCode(), notification)
// Schedule a timer to stop sharing
scheduleTimer(roomArgs.roomId, roomArgs.durationMillis)
}
return START_STICKY
}
private fun scheduleTimer(roomId: String, durationMillis: Long) {
Timer()
.apply {
schedule(object : TimerTask() {
override fun run() {
stopSharingLocation(roomId)
timers.remove(this@apply)
}
}, durationMillis)
}
.also {
timers.add(it)
}
}
private fun stopSharingLocation(roomId: String) {
Timber.i("### LocationSharingService.stopSharingLocation for $roomId")
synchronized(roomArgsList) {
roomArgsList.removeAll { it.roomId == roomId }
if (roomArgsList.isEmpty()) {
Timber.i("### LocationSharingService. Destroying self, time is up for all rooms")
destroyMe()
}
}
}
override fun onLocationUpdate(locationData: LocationData) {
Timber.i("### LocationSharingService.onLocationUpdate. Uncertainty: ${locationData.uncertainty}")
}
override fun onLocationProviderIsNotAvailable() {
stopForeground(true)
stopSelf()
}
private fun destroyMe() {
locationTracker.removeCallback(this)
timers.forEach { it.cancel() }
timers.clear()
stopSelf()
}
override fun onDestroy() {
super.onDestroy()
Timber.i("### LocationSharingService.onDestroy")
destroyMe()
}
override fun onBind(intent: Intent?): IBinder? {
return null
}
companion object {
const val EXTRA_ROOM_ARGS = "EXTRA_ROOM_ARGS"
}
}

View file

@ -22,4 +22,5 @@ sealed class LocationSharingViewEvents : VectorViewEvents {
object Close : LocationSharingViewEvents()
object LocationNotAvailableError : LocationSharingViewEvents()
data class ZoomToUserLocation(val userLocation: LocationData) : LocationSharingViewEvents()
data class StartLiveLocationService(val sessionId: String, val roomId: String, val duration: Long) : LocationSharingViewEvents()
}

View file

@ -37,7 +37,6 @@ import kotlinx.coroutines.launch
import org.matrix.android.sdk.api.extensions.orFalse
import org.matrix.android.sdk.api.session.Session
import org.matrix.android.sdk.api.util.toMatrixItem
import timber.log.Timber
/**
* Sampling period to compare target location and user location.
@ -64,7 +63,8 @@ class LocationSharingViewModel @AssistedInject constructor(
companion object : MavericksViewModelFactory<LocationSharingViewModel, LocationSharingViewState> by hiltMavericksViewModelFactory()
init {
locationTracker.start(this)
locationTracker.addCallback(this)
locationTracker.start()
setUserItem()
updatePin()
compareTargetAndUserLocation()
@ -111,16 +111,16 @@ class LocationSharingViewModel @AssistedInject constructor(
override fun onCleared() {
super.onCleared()
locationTracker.stop()
locationTracker.removeCallback(this)
}
override fun handle(action: LocationSharingAction) {
when (action) {
LocationSharingAction.CurrentUserLocationSharing -> handleCurrentUserLocationSharingAction()
is LocationSharingAction.PinnedLocationSharing -> handlePinnedLocationSharingAction(action)
is LocationSharingAction.LocationTargetChange -> handleLocationTargetChangeAction(action)
LocationSharingAction.ZoomToUserLocation -> handleZoomToUserLocationAction()
LocationSharingAction.StartLiveLocationSharing -> handleStartLiveLocationSharingAction()
LocationSharingAction.CurrentUserLocationSharing -> handleCurrentUserLocationSharingAction()
is LocationSharingAction.PinnedLocationSharing -> handlePinnedLocationSharingAction(action)
is LocationSharingAction.LocationTargetChange -> handleLocationTargetChangeAction(action)
LocationSharingAction.ZoomToUserLocation -> handleZoomToUserLocationAction()
is LocationSharingAction.StartLiveLocationSharing -> handleStartLiveLocationSharingAction(action.duration)
}
}
@ -158,9 +158,12 @@ class LocationSharingViewModel @AssistedInject constructor(
}
}
private fun handleStartLiveLocationSharingAction() {
// TODO start sharing live location and update view state
Timber.d("live location sharing started")
private fun handleStartLiveLocationSharingAction(duration: Long) {
_viewEvents.post(LocationSharingViewEvents.StartLiveLocationService(
sessionId = session.sessionId,
roomId = room.roomId,
duration = duration
))
}
override fun onLocationUpdate(locationData: LocationData) {

View file

@ -26,7 +26,9 @@ import androidx.core.location.LocationListenerCompat
import im.vector.app.BuildConfig
import timber.log.Timber
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class LocationTracker @Inject constructor(
context: Context
) : LocationListenerCompat {
@ -38,18 +40,17 @@ class LocationTracker @Inject constructor(
fun onLocationProviderIsNotAvailable()
}
private var callback: Callback? = null
private var callbacks = mutableListOf<Callback>()
private var hasGpsProviderLiveLocation = false
@RequiresPermission(anyOf = [Manifest.permission.ACCESS_COARSE_LOCATION, Manifest.permission.ACCESS_FINE_LOCATION])
fun start(callback: Callback?) {
fun start() {
Timber.d("## LocationTracker. start()")
hasGpsProviderLiveLocation = false
this.callback = callback
if (locationManager == null) {
callback?.onLocationProviderIsNotAvailable()
callbacks.forEach { it.onLocationProviderIsNotAvailable() }
Timber.v("## LocationTracker. LocationManager is not available")
return
}
@ -79,7 +80,7 @@ class LocationTracker @Inject constructor(
)
}
?: run {
callback?.onLocationProviderIsNotAvailable()
callbacks.forEach { it.onLocationProviderIsNotAvailable() }
Timber.v("## LocationTracker. There is no location provider available")
}
}
@ -88,7 +89,20 @@ class LocationTracker @Inject constructor(
fun stop() {
Timber.d("## LocationTracker. stop()")
locationManager?.removeUpdates(this)
callback = null
callbacks.clear()
}
fun addCallback(callback: Callback) {
if (!callbacks.contains(callback)) {
callbacks.add(callback)
}
}
fun removeCallback(callback: Callback) {
callbacks.remove(callback)
if (callbacks.size == 0) {
stop()
}
}
override fun onLocationChanged(location: Location) {
@ -113,12 +127,12 @@ class LocationTracker @Inject constructor(
}
}
}
callback?.onLocationUpdate(location.toLocationData())
callbacks.forEach { it.onLocationUpdate(location.toLocationData()) }
}
override fun onProviderDisabled(provider: String) {
Timber.d("## LocationTracker. onProviderDisabled: $provider")
callback?.onLocationProviderIsNotAvailable()
callbacks.forEach { it.onLocationProviderIsNotAvailable() }
}
private fun Location.toLocationData(): LocationData {

View file

@ -521,6 +521,20 @@ class NotificationUtils @Inject constructor(private val context: Context,
return builder.build()
}
/**
* Creates a notification that indicates the application is retrieving location even if it is in background or killed.
*/
fun buildLiveLocationSharingNotification(): Notification {
return NotificationCompat.Builder(context, SILENT_NOTIFICATION_CHANNEL_ID)
.setContentTitle(stringProvider.getString(R.string.live_location_sharing_notification_title))
.setContentText(stringProvider.getString(R.string.live_location_sharing_notification_description))
.setSmallIcon(R.drawable.ic_attachment_location_live_white)
.setColor(ThemeUtils.getColor(context, android.R.attr.colorPrimary))
.setCategory(NotificationCompat.CATEGORY_LOCATION_SHARING)
.setContentIntent(buildOpenHomePendingIntentForSummary())
.build()
}
fun buildDownloadFileNotification(uri: Uri, fileName: String, mimeType: String): Notification {
return NotificationCompat.Builder(context, SILENT_NOTIFICATION_CHANNEL_ID)
.setGroup(stringProvider.getString(R.string.app_name))

View file

@ -2950,6 +2950,8 @@
<string name="location_timeline_failed_to_load_map">Failed to load map</string>
<string name="location_share_live_enabled">Live location enabled</string>
<string name="location_share_live_stop">Stop</string>
<string name="live_location_sharing_notification_title">${app_name} Live Location</string>
<string name="live_location_sharing_notification_description">Location sharing is in progress</string>
<string name="message_bubbles">Show Message bubbles</string>