Skip to content
This repository was archived by the owner on Aug 23, 2023. It is now read-only.

Commit c81d2b9

Browse files
committed
Implement bound service with foreground notification
Bug: 210514977 Change-Id: I9b739992f82739489f0835f4c6b07db4f1f436c4
1 parent f1c2907 commit c81d2b9

File tree

8 files changed

+311
-9
lines changed

8 files changed

+311
-9
lines changed

ForegroundLocationUpdates/app/build.gradle

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ dependencies {
7171
implementation 'androidx.core:core-ktx:1.7.0'
7272
implementation "androidx.datastore:datastore-preferences:1.0.0"
7373
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.4.0'
74+
implementation 'androidx.lifecycle:lifecycle-service:2.4.0'
7475

7576
implementation 'com.google.android.material:material:1.4.0'
7677

ForegroundLocationUpdates/app/src/main/AndroidManifest.xml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
-->
2727
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
2828
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
29+
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
2930

3031
<application
3132
android:name=".ForegroundLocationApp"
@@ -46,6 +47,8 @@
4647
<category android:name="android.intent.category.LAUNCHER" />
4748
</intent-filter>
4849
</activity>
50+
51+
<service android:name=".ForegroundLocationService" android:exported="false" />
4952
</application>
5053

5154
</manifest>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,252 @@
1+
/*
2+
* Copyright (C) 2021 Google Inc.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.google.android.gms.location.sample.foregroundlocation
18+
19+
import android.Manifest.permission
20+
import android.app.Notification
21+
import android.app.NotificationChannel
22+
import android.app.NotificationManager
23+
import android.app.PendingIntent
24+
import android.content.ComponentName
25+
import android.content.Intent
26+
import android.content.ServiceConnection
27+
import android.location.Location
28+
import android.os.Binder
29+
import android.os.Build.VERSION
30+
import android.os.Build.VERSION_CODES
31+
import android.os.IBinder
32+
import androidx.core.app.NotificationCompat
33+
import androidx.lifecycle.LifecycleService
34+
import androidx.lifecycle.lifecycleScope
35+
import com.google.android.gms.location.sample.foregroundlocation.ForegroundLocationService.LocalBinder
36+
import com.google.android.gms.location.sample.foregroundlocation.data.LocationPreferences
37+
import com.google.android.gms.location.sample.foregroundlocation.data.LocationRepository
38+
import com.google.android.gms.location.sample.foregroundlocation.ui.hasPermission
39+
import dagger.hilt.android.AndroidEntryPoint
40+
import kotlinx.coroutines.delay
41+
import kotlinx.coroutines.flow.collect
42+
import kotlinx.coroutines.flow.first
43+
import kotlinx.coroutines.launch
44+
import javax.inject.Inject
45+
46+
/**
47+
* Service which manages turning location updates on and off. UI clients should bind to this service
48+
* to access this functionality.
49+
*
50+
* This service can be started the usual way (i.e. startService), but it will also start itself when
51+
* the first client binds to it. Thereafter it will manage its own lifetime as follows:
52+
* - While there are any bound clients, the service remains started in the background. If it was
53+
* in the foreground, it will exit the foreground, cancelling any ongoing notification.
54+
* - When there are no bound clients and location updates are on, the service moves to the
55+
* foreground and shows an ongoing notification with the latest location.
56+
* - When there are no bound clients and location updates are off, the service stops itself.
57+
*/
58+
@AndroidEntryPoint
59+
class ForegroundLocationService : LifecycleService() {
60+
61+
@Inject
62+
lateinit var locationRepository: LocationRepository
63+
64+
@Inject
65+
lateinit var locationPreferences: LocationPreferences
66+
67+
private val localBinder = LocalBinder()
68+
private var bindCount = 0
69+
70+
private var started = false
71+
private var isForeground = false
72+
73+
private fun isBound() = bindCount > 0
74+
75+
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
76+
super.onStartCommand(intent, flags, startId)
77+
78+
// Startup tasks only happen once.
79+
if (!started) {
80+
started = true
81+
// Check if we should turn on location updates.
82+
lifecycleScope.launch {
83+
if (locationPreferences.isLocationTurnedOn.first()) {
84+
// If the service is restarted for any reason, we may have lost permission to
85+
// access location since last time. In that case we won't turn updates on here,
86+
// and the service will stop when we manage its lifetime below. Then the user
87+
// will have to open the app to turn updates on again.
88+
if (hasPermission(permission.ACCESS_FINE_LOCATION) ||
89+
hasPermission(permission.ACCESS_COARSE_LOCATION)
90+
) {
91+
locationRepository.startLocationUpdates()
92+
}
93+
}
94+
}
95+
// Update any foreground notification when we receive location updates.
96+
lifecycleScope.launch {
97+
locationRepository.lastLocation.collect(::showNotification)
98+
}
99+
}
100+
101+
// Decide whether to remain in the background, promote to the foreground, or stop.
102+
manageLifetime()
103+
104+
// In case we are stopped by the system, have the system restart this service so we can
105+
// manage our lifetime appropriately.
106+
return START_STICKY
107+
}
108+
109+
override fun onBind(intent: Intent): IBinder {
110+
super.onBind(intent)
111+
handleBind()
112+
return localBinder
113+
}
114+
115+
override fun onRebind(intent: Intent?) {
116+
handleBind()
117+
}
118+
119+
private fun handleBind() {
120+
bindCount++
121+
// Start ourself. This will let us manage our lifetime separately from bound clients.
122+
startService(Intent(this, this::class.java))
123+
}
124+
125+
override fun onUnbind(intent: Intent?): Boolean {
126+
bindCount--
127+
lifecycleScope.launch {
128+
// UI client can unbind because it went through a configuration change, in which case it
129+
// will be recreated and bind again shortly. Wait a few seconds, and if still not bound,
130+
// manage our lifetime accordingly.
131+
delay(UNBIND_DELAY_MILLIS)
132+
manageLifetime()
133+
}
134+
// Allow clients to rebind, in which case onRebind will be called.
135+
return true
136+
}
137+
138+
private fun manageLifetime() {
139+
when {
140+
// We should not be in the foreground while UI clients are bound.
141+
isBound() -> exitForeground()
142+
143+
// Location updates were started.
144+
locationRepository.isReceivingLocationUpdates.value -> enterForeground()
145+
146+
// Nothing to do, so we can stop.
147+
else -> stopSelf()
148+
}
149+
}
150+
151+
private fun exitForeground() {
152+
if (isForeground) {
153+
isForeground = false
154+
stopForeground(true)
155+
}
156+
}
157+
158+
private fun enterForeground() {
159+
if (!isForeground) {
160+
isForeground = true
161+
162+
// Show notification with the latest location.
163+
showNotification(locationRepository.lastLocation.value)
164+
}
165+
}
166+
167+
private fun showNotification(location: Location?) {
168+
if (!isForeground) {
169+
return
170+
}
171+
172+
createNotificationChannel()
173+
startForeground(NOTIFICATION_ID, buildNotification(location))
174+
}
175+
176+
private fun createNotificationChannel() {
177+
if (VERSION.SDK_INT >= VERSION_CODES.O) {
178+
val notificationChannel = NotificationChannel(
179+
NOTIFICATION_CHANNEL_ID,
180+
getString(R.string.notification_channel_name),
181+
NotificationManager.IMPORTANCE_DEFAULT
182+
)
183+
val manager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
184+
manager.createNotificationChannel(notificationChannel)
185+
}
186+
}
187+
188+
private fun buildNotification(location: Location?) : Notification {
189+
// Tapping the notification opens the app.
190+
val pendingIntent = PendingIntent.getActivity(
191+
this,
192+
0,
193+
packageManager.getLaunchIntentForPackage(this.packageName),
194+
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
195+
)
196+
val contentText = if (location != null) {
197+
getString(R.string.location_lat_lng, location.latitude, location.longitude)
198+
} else {
199+
getString(R.string.waiting_for_location)
200+
}
201+
202+
return NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID)
203+
.setContentTitle(getString(R.string.notification_title))
204+
.setContentText(contentText)
205+
.setContentIntent(pendingIntent)
206+
.setSmallIcon(R.drawable.ic_location)
207+
.setOngoing(true)
208+
.setCategory(NotificationCompat.CATEGORY_SERVICE)
209+
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
210+
.setVisibility(NotificationCompat.VISIBILITY_PRIVATE)
211+
.build()
212+
}
213+
214+
// Methods for clients.
215+
216+
fun startLocationUpdates() {
217+
locationRepository.startLocationUpdates()
218+
}
219+
220+
fun stopLocationUpdates() {
221+
locationRepository.stopLocationUpdates()
222+
}
223+
224+
/** Binder which provides clients access to the service. */
225+
internal inner class LocalBinder : Binder() {
226+
fun getService(): ForegroundLocationService = this@ForegroundLocationService
227+
}
228+
229+
private companion object {
230+
const val UNBIND_DELAY_MILLIS = 2000.toLong() // 2 seconds
231+
const val NOTIFICATION_ID = 1
232+
const val NOTIFICATION_CHANNEL_ID = "LocationUpdates"
233+
}
234+
}
235+
236+
/**
237+
* ServiceConnection that provides access to a [ForegroundLocationService].
238+
*/
239+
class ForegroundLocationServiceConnection @Inject constructor() : ServiceConnection {
240+
241+
var service: ForegroundLocationService? = null
242+
private set
243+
244+
override fun onServiceConnected(name: ComponentName, binder: IBinder) {
245+
service = (binder as LocalBinder).getService()
246+
}
247+
248+
override fun onServiceDisconnected(name: ComponentName) {
249+
// Note: this should never be called since the service is in the same process.
250+
service = null
251+
}
252+
}

ForegroundLocationUpdates/app/src/main/java/com/google/android/gms/location/sample/foregroundlocation/MainActivity.kt

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616

1717
package com.google.android.gms.location.sample.foregroundlocation
1818

19+
import android.content.Intent
1920
import android.os.Bundle
2021
import androidx.activity.ComponentActivity
2122
import androidx.activity.compose.setContent
@@ -70,6 +71,17 @@ class MainActivity : ComponentActivity() {
7071
}
7172
}
7273
}
74+
75+
override fun onStart() {
76+
super.onStart()
77+
val serviceIntent = Intent(this, ForegroundLocationService::class.java)
78+
bindService(serviceIntent, viewModel, BIND_AUTO_CREATE)
79+
}
80+
81+
override fun onStop() {
82+
super.onStop()
83+
unbindService(viewModel)
84+
}
7385
}
7486

7587
@Composable

ForegroundLocationUpdates/app/src/main/java/com/google/android/gms/location/sample/foregroundlocation/MainViewModel.kt

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616

1717
package com.google.android.gms.location.sample.foregroundlocation
1818

19+
import android.content.ServiceConnection
1920
import androidx.lifecycle.ViewModel
2021
import androidx.lifecycle.viewModelScope
2122
import com.google.android.gms.location.sample.foregroundlocation.PlayServicesAvailableState.Initializing
@@ -34,9 +35,10 @@ import javax.inject.Inject
3435
@HiltViewModel
3536
class MainViewModel @Inject constructor(
3637
playServicesAvailabilityChecker: PlayServicesAvailabilityChecker,
37-
private val locationRepository: LocationRepository,
38-
private val locationPreferences: LocationPreferences
39-
) : ViewModel() {
38+
locationRepository: LocationRepository,
39+
private val locationPreferences: LocationPreferences,
40+
private val serviceConnection: ForegroundLocationServiceConnection
41+
) : ViewModel(), ServiceConnection by serviceConnection {
4042

4143
val playServicesAvailableState = flow {
4244
emit(
@@ -54,23 +56,26 @@ class MainViewModel @Inject constructor(
5456
fun toggleLocationUpdates() {
5557
if (isReceivingLocationUpdates.value) {
5658
stopLocationUpdates()
57-
5859
} else {
5960
startLocationUpdates()
6061
}
6162
}
6263

6364
private fun startLocationUpdates() {
64-
locationRepository.startLocationUpdates()
65-
65+
serviceConnection.service?.startLocationUpdates()
66+
// Store that the user turned on location updates.
67+
// It's possible that the service was not connected for the above call. In that case, when
68+
// the service eventually starts, it will check the persisted value and react appropriately.
6669
viewModelScope.launch {
6770
locationPreferences.setLocationTurnedOn(true)
6871
}
6972
}
7073

7174
private fun stopLocationUpdates() {
72-
locationRepository.stopLocationUpdates()
73-
75+
serviceConnection.service?.stopLocationUpdates()
76+
// Store that the user turned off location updates.
77+
// It's possible that the service was not connected for the above call. In that case, when
78+
// the service eventually starts, it will check the persisted value and react appropriately.
7479
viewModelScope.launch {
7580
locationPreferences.setLocationTurnedOn(false)
7681
}

ForegroundLocationUpdates/app/src/main/java/com/google/android/gms/location/sample/foregroundlocation/data/LocationRepository.kt

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,9 @@ import com.google.android.gms.location.LocationResult
2626
import kotlinx.coroutines.flow.MutableStateFlow
2727
import kotlinx.coroutines.flow.asStateFlow
2828
import javax.inject.Inject
29+
import javax.inject.Singleton
2930

31+
@Singleton
3032
class LocationRepository @Inject constructor(
3133
private val fusedLocationProviderClient: FusedLocationProviderClient
3234
) {
@@ -63,7 +65,7 @@ class LocationRepository @Inject constructor(
6365
_lastLocation.value = null
6466
}
6567

66-
inner class Callback : LocationCallback() {
68+
private inner class Callback : LocationCallback() {
6769
override fun onLocationResult(result: LocationResult) {
6870
_lastLocation.value = result.lastLocation
6971
}

0 commit comments

Comments
 (0)