diff --git a/changelog.d/5595.feature b/changelog.d/5595.feature new file mode 100644 index 0000000000..8fd4d4b144 --- /dev/null +++ b/changelog.d/5595.feature @@ -0,0 +1 @@ +Live Location Sharing - Foreground Service and Notification \ No newline at end of file diff --git a/vector/src/main/AndroidManifest.xml b/vector/src/main/AndroidManifest.xml index 1d99fba91a..d52e95b382 100644 --- a/vector/src/main/AndroidManifest.xml +++ b/vector/src/main/AndroidManifest.xml @@ -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 diff --git a/vector/src/main/java/im/vector/app/features/location/LocationSharingAction.kt b/vector/src/main/java/im/vector/app/features/location/LocationSharingAction.kt index d7d686ee60..4025fbefa8 100644 --- a/vector/src/main/java/im/vector/app/features/location/LocationSharingAction.kt +++ b/vector/src/main/java/im/vector/app/features/location/LocationSharingAction.kt @@ -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() } diff --git a/vector/src/main/java/im/vector/app/features/location/LocationSharingFragment.kt b/vector/src/main/java/im/vector/app/features/location/LocationSharingFragment.kt index d61d53ae51..b779b50c8b 100644 --- a/vector/src/main/java/im/vector/app/features/location/LocationSharingFragment.kt +++ b/vector/src/main/java/im/vector/app/features/location/LocationSharingFragment.kt @@ -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) { diff --git a/vector/src/main/java/im/vector/app/features/location/LocationSharingService.kt b/vector/src/main/java/im/vector/app/features/location/LocationSharingService.kt new file mode 100644 index 0000000000..a2a68e4188 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/location/LocationSharingService.kt @@ -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" + } +} diff --git a/vector/src/main/java/im/vector/app/features/location/LocationSharingViewEvents.kt b/vector/src/main/java/im/vector/app/features/location/LocationSharingViewEvents.kt index 8d31db1119..b25a4988b0 100644 --- a/vector/src/main/java/im/vector/app/features/location/LocationSharingViewEvents.kt +++ b/vector/src/main/java/im/vector/app/features/location/LocationSharingViewEvents.kt @@ -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() } diff --git a/vector/src/main/java/im/vector/app/features/location/LocationSharingViewModel.kt b/vector/src/main/java/im/vector/app/features/location/LocationSharingViewModel.kt index 1d68247f2c..dfa936dcaa 100644 --- a/vector/src/main/java/im/vector/app/features/location/LocationSharingViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/location/LocationSharingViewModel.kt @@ -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) { diff --git a/vector/src/main/java/im/vector/app/features/location/LocationTracker.kt b/vector/src/main/java/im/vector/app/features/location/LocationTracker.kt index 162fbc5959..b7006370a6 100644 --- a/vector/src/main/java/im/vector/app/features/location/LocationTracker.kt +++ b/vector/src/main/java/im/vector/app/features/location/LocationTracker.kt @@ -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 { diff --git a/vector/src/main/java/im/vector/app/features/notifications/NotificationUtils.kt b/vector/src/main/java/im/vector/app/features/notifications/NotificationUtils.kt index d39926f620..161b58d53d 100755 --- a/vector/src/main/java/im/vector/app/features/notifications/NotificationUtils.kt +++ b/vector/src/main/java/im/vector/app/features/notifications/NotificationUtils.kt @@ -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)) diff --git a/vector/src/main/res/values/strings.xml b/vector/src/main/res/values/strings.xml index 551e5961ec..a276e07b1e 100644 --- a/vector/src/main/res/values/strings.xml +++ b/vector/src/main/res/values/strings.xml @@ -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>