-
Notifications
You must be signed in to change notification settings - Fork 2
Background Location Tracking
Plz Stop의 Mission은 사용자가 막차 시간보다 먼저 정류장에 도착하는지 시합을 겨룰 수 있는 기능이다.
이때 사용자의 이동경로를 지도에 그려주기 위해서는 Background에서의 Location Tracking이 필요하다.
이 기능을 구현하기 위해 겪었던 과정은 아래와 같다.
백그라운드 작업은 즉시, 지연, 정시로 나뉜다.
- 즉시 : 사용자가 애플리케이션과 상호작용하는 동안 작업을 완료해야한다
- 정시 : 작업을 정확한 시간에 실행해야 한다.
- 지연 : 작업을 정확한 시간에 실행할 필요가 없다.
사용자의 이동경로 그리기는 location을 바로바로 받아와 화면에 그려줘야하기 때문에 즉시 실행해야 하는 작업이다.
즉시 실행해야 하는 작업에는 WorkManager와 Foreground Service를 이용할 수 있다.
장시간 실행되고 사용자에게 진행 중임을 알려야하는 작업을 수행하려면 Foreground Service를 사용해야한다.
처음에 WorkManager를 이용해 Notification을 띄워주었는데, 미션이 시작하고 난 직후 바로 뜨지않고 약간의 딜레이가 존재해 Foreground Service로 마이그레이션하였다.
Service는 백그라운드에서 오래 실행되는 작업을 수행할 수 있는 애플리케이션 구성요소이다.
- Foreground : 사용자에게 보이는 작업. 앱과 상호작용 할 수 있음. ex) 음악 앱의 재생창
- Background : 사용자에게 직접 보이지 않는 작업. 앱이 어떤 작업을 하고 있지만 사용자가 직관적으로 확인 할 수 없음. ex ) 앱을 꺼도 지속되는 다운로드
- Bound : 해당 서비스를 바인드한 액티비티들이 전부 종료되기 전까지 서비스가 유지됨. 액티비티들이 모두 종료되면 작업이 끝나지 않아도 종료됨.
Android O부터 Background Service 실행이 제한되었다.
Application이 Background로 이동하게 되면, 몇 분 정도의 허용시간만 주고 System은 Background Service를 종료시킨다.
Background일 때의 사용자의 이동경로를 지속해서 그려주기 위해서 Foreground Service를 만들어 Location을 받아오도록 구현했다.
Foreground Service를 구현하기 위해 Notification을 만들어 Foreground로 만들어주었다.
class MissionService : LifecycleService() {
private val notificationManager by lazy {
this.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
}
...
override fun onCreate() {
super.onCreate()
setForeground()
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
getTimer(intent)
getStatus(intent)
return super.onStartCommand(intent, flags, startId)
}
private fun setForeground() {
createNotification()
getPersonLocation()
}
private fun createNotification() {
val id = applicationContext.getString(R.string.mission_notification_channel_id)
val title = applicationContext.getString(R.string.mission_notification_title)
createChannel(id)
val pendingIntent = PendingIntent.getActivity(
applicationContext,
0,
Intent(applicationContext, AlarmActivity::class.java).apply {
putExtra("MISSION_CODE", MISSION_CODE)
flags = Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
},
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
val notification = NotificationCompat.Builder(this, id)
.setContentTitle(title)
.setTicker(title)
.setContentText(NOTIFICATION_CONTENT)
.setSmallIcon(R.mipmap.ic_bus)
.setOngoing(true)
.setContentIntent(pendingIntent)
.setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE)
.build()
startForeground(NOTIFICATION_ID, notification)
}
private fun createChannel(id: String) {
if (isMoreThanOreo()) {
if (notificationManager.getNotificationChannel(id) == null) {
val name = this.getString(R.string.mission_notification_channel_name)
NotificationChannel(id, name, NotificationManager.IMPORTANCE_DEFAULT).apply {
notificationManager.createNotificationChannel(this)
}
}
}
}
...
}
미션 기능을 이용하기 위해서는 Background의 위치정보를 얻어야한다.
Background 위치 정보를 얻기위해서는 Android 10 이상에서는 **ACCESS_BACKGROUND_LOCATION**
권한이 필요하다. 이전 버전에서는 Foreground 위치 정보 액세스 권한을 수신하면 자동으로 Background 위치 정보 액세스 권한도 수신한다.
Plz Stop의 경우 미션 화면 이외에 홈 화면에서 지도를 사용하고 있기 때문에 앱을 실행시키면 먼저 Foreground 위치를 요청한다. 이후에 미션 화면에 진입하게 되면 그 때 Background 위치를 요청하고 있다.
이렇게 위치 정보 액세스 권한의 점진적 요청을 실행하면 사용자가 앱의 어떤 기능에 백그라운드 위치 정보 액세스 권한이 필요한지 더 잘 파악할 수 있으므로 사용자에게 더 많은 컨트롤과 투명성을 제공할 수 있다.
또한, Andorid 11 이상에서는 Foreground와 Background 위치 정보 엑세스 권한을 동시에 요청하면 시스템이 요청을 무시하고 앱에 어떤 권한도 부여하지 않게 된다.
Android 10에서는 Background 위치를 요청하면 시스템 권한 대화상자에서 항상 허용이라는 옵션이 포함된다.
하지만 Android 11 이상에서는 시스템 대화상자에 항상 허용 옵션이 포함되지 않는다. 설정 페이지에서 Background 위치를 사용 설정해야한다.
따라서 Dialog를 띄워서 설정 페이지로 이동해 사용자가 항상 허용 권한을 선택하게 하였다.
FusedLocationProvider는 구글의 라이브러리이며 기기의 배터리 전력 사용을 최적화해준다.
LocationCallback
을 이용해 이동 위치를 받아오고 이를 ArrayList에 저장해 사용자의 이동경로를 그릴 때 사용하였다.
Fragment가 죽어 다시 생성되면 이전 경로들을 Service에서 받아와 그려주게 된다.
// MissionService.kt
private fun getPersonLocation() {
if (ActivityCompat.checkSelfPermission(
applicationContext,
Manifest.permission.ACCESS_FINE_LOCATION
) != PackageManager.PERMISSION_GRANTED && ActivityCompat.checkSelfPermission(
applicationContext,
Manifest.permission.ACCESS_COARSE_LOCATION
) != PackageManager.PERMISSION_GRANTED
) {
return
}
fusedLocationClient.lastLocation
.addOnSuccessListener { location ->
if (location != null) {
userLocation.add(Location(location.latitude, location.longitude))
}
}
.addOnFailureListener {
it.printStackTrace()
}
locationCallback = object : LocationCallback() {
override fun onLocationResult(locationResult: LocationResult) {
for (location in locationResult.locations) {
if (location != null) {
userLocation.add(Location(location.latitude, location.longitude))
}
}
}
}
fusedLocationClient.requestLocationUpdates(locationRequest, locationCallback, Looper.getMainLooper())
}
// MissionFragment.kt
private fun drawPersonLine() {
lateinit var beforeLocation: Location
viewLifecycleOwner.lifecycleScope.launch {
missionViewModel.userLocations.collectIndexed { index, userLocation ->
if (index == 1) {
initMarker(userLocation) // <- 이 함수에서 이전 경로를 그려주게 된다.
beforeLocation = userLocation.last()
} else if (index > 1) {
drawNowLocationLine(
TMapPoint(userLocation.last().latitude, userLocation.last().longitude),
TMapPoint(beforeLocation.latitude, beforeLocation.longitude)
)
personCurrentLocation = userLocation.last()
if (tMap.isTracking) {
tMap.tMapView.setCenterPoint(userLocation.last().latitude, userLocation.last().longitude)
}
beforeLocation = userLocation.last()
arriveDestination(userLocation.last().latitude, userLocation.last().longitude)
}
}
}
}
private fun initMarker(nowLocation: ArrayList<Location>) {
with(tMap) {
addMarker(
Marker.PERSON_MARKER,
Marker.PERSON_MARKER_IMG,
TMapPoint(nowLocation.last().latitude, nowLocation.last().longitude)
)
personCurrentLocation = nowLocation.last()
latitudes.add(nowLocation.last().latitude)
longitudes.add(nowLocation.last().longitude)
setRouteDetailFocus()
arriveDestination(nowLocation.last().latitude, nowLocation.last().longitude)
// 이전 경로를 그려주는 함수
drawWalkLines(
nowLocation.map { TMapPoint(it.latitude, it.longitude) } as ArrayList<TMapPoint>,
Marker.PERSON_LINE + PERSON_LINE_NUM.toString(),
Marker.PERSON_LINE_COLOR
)
PERSON_LINE_NUM += 1
}
}
위에서 FusedLocationProvider로 얻은 Location을 Broadcast를 통해 Fragment에 전달하였다.
// MissionService.kt
private fun sendUserInfo() {
val statusIntent = Intent().apply {
action = MISSION_USER_INFO
putExtra(MISSION_LAST_TIME, lastTime)
putParcelableArrayListExtra(MISSION_LOCATIONS, userLocation)
}
sendBroadcast(statusIntent)
sendMissionStatus()
}
private fun sendMissionStatus() {
val statusIntent = Intent().apply {
action = MISSION_STATUS
putExtra(MISSION_STATUS, true)
}
sendBroadcast(statusIntent)
}
// MissionFragment.kt
private fun setBroadcastReceiver() {
val intentFilter = IntentFilter().apply {
addAction(MissionService.MISSION_USER_INFO)
}
val receiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
missionViewModel.lastTime.value = intent?.getStringExtra(MISSION_LAST_TIME)
missionViewModel.userLocations.value =
intent?.getParcelableArrayListExtra<Location>(MISSION_LOCATIONS) as ArrayList<Location>
}
}
requireActivity().registerReceiver(receiver, intentFilter)
}