Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Live Location Sharing - Foreground Service #5595

Merged
merged 14 commits into from
Mar 28, 2022
1 change: 1 addition & 0 deletions changelog.d/5595.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Live Location Sharing - Foreground Service
7 changes: 6 additions & 1 deletion vector/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -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 -->
Expand Down Expand Up @@ -369,6 +369,11 @@
</intent-filter>
</service>

<service
android:name=".features.location.LocationSharingService"
android:exported="false"
android:foregroundServiceType="location" />

<!-- Receivers -->

<receiver
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
}
}
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -221,7 +234,9 @@ class LocationSharingFragment @Inject constructor(
}

private fun startLiveLocationSharing() {
viewModel.handle(LocationSharingAction.StartLiveLocationSharing)
// TODO. Get duration from user
val duration = 30 * 1000L
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

might be worth a constant?

not for this PR - thinking out of loud about todos I'm wondering if they are helpful or add noise, would a User can set live location duration ticket that references the class provide the same information 🤔

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I already started implementing time-limiting UI. I will create another quick PR today.

viewModel.handle(LocationSharingAction.StartLiveLocationSharing(duration))
}

private fun updateMap(state: LocationSharingViewState) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
/*
* 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>()

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().schedule(object : TimerTask() {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we might also want to keep a reference to the timer and cancel it if it's still running when the service is destroyed

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice catch, done.

override fun run() {
stopSharingLocation(roomId)
}
}, durationMillis)
}

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)
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"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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()
Expand Down Expand Up @@ -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)
}
}

Expand Down Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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() {
Copy link
Contributor

@ouchadam ouchadam Mar 25, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

now that we're calling start from multiple places, do we need to guard the...

locationManager.requestLocationUpdates(
  provider,
  MIN_TIME_TO_UPDATE_LOCATION_MILLIS,
  MIN_DISTANCE_TO_UPDATE_LOCATION_METERS,
  this
)

to avoid registering duplicate updates?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is protected internally and doesn't create multiple requests.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

good to know, thanks!

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
}
Expand Down Expand Up @@ -79,7 +80,7 @@ class LocationTracker @Inject constructor(
)
}
?: run {
callback?.onLocationProviderIsNotAvailable()
callbacks.forEach { it.onLocationProviderIsNotAvailable() }
Timber.v("## LocationTracker. There is no location provider available")
}
}
Expand All @@ -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) {
Expand All @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
2 changes: 2 additions & 0 deletions vector/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -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>

Expand Down