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

Android Foreground Service #186

Merged
merged 16 commits into from
Aug 27, 2024
Merged
Show file tree
Hide file tree
Changes from 13 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
package com.stadiamaps.ferrostar.composeui.notification

import android.annotation.SuppressLint
import android.app.Notification
import android.content.Context
import android.os.Build
import android.widget.RemoteViews
import com.stadiamaps.ferrostar.composeui.R
import com.stadiamaps.ferrostar.composeui.formatting.DateTimeFormatter
import com.stadiamaps.ferrostar.composeui.formatting.DistanceFormatter
import com.stadiamaps.ferrostar.composeui.formatting.DurationFormatter
import com.stadiamaps.ferrostar.composeui.formatting.EstimatedArrivalDateTimeFormatter
import com.stadiamaps.ferrostar.composeui.formatting.LocalizedDistanceFormatter
import com.stadiamaps.ferrostar.composeui.formatting.LocalizedDurationFormatter
import com.stadiamaps.ferrostar.composeui.views.maneuver.maneuverIcon
import com.stadiamaps.ferrostar.core.extensions.estimatedArrivalTime
import com.stadiamaps.ferrostar.core.service.ForegroundNotificationBuilder
import uniffi.ferrostar.TripProgress
import uniffi.ferrostar.TripState
import uniffi.ferrostar.VisualInstruction

class DefaultForegroundNotificationBuilder(
context: Context,
private var estimatedArrivalFormatter: DateTimeFormatter = EstimatedArrivalDateTimeFormatter(),
private var distanceFormatter: DistanceFormatter = LocalizedDistanceFormatter(),
private var durationFormatter: DurationFormatter = LocalizedDurationFormatter(),
) : ForegroundNotificationBuilder(context) {

override fun build(tripState: TripState?): Notification {
if (channelId == null) {
throw IllegalStateException("channelId must be set before building the notification.")
}

// Generate the notification builder. Note that channelId is set on newer versions of Android.
// The channel is used to associate the notification in settings.
val builder: Notification.Builder =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
Notification.Builder(context, channelId)
} else {
Notification.Builder(context)
}

if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
// Colorize the notification w/ the background color.
builder.setColorized(true).setColor(context.getColor(R.color.background_color))
}

// Set the notification's icon to a simple user location icon (Material's navigation icon).
builder.setSmallIcon(R.drawable.notification_icon)

// Build the notification's content based on the trip state.
// When navigating, show the visual instruction, icon and formatted progress items.
// When complete, show the arrival title and description.
// Otherwise, show the preparing title (this typically only happens on launch).
when (tripState) {
is TripState.Navigating -> {
val tripProgress = tripState.progress
tripState.visualInstruction?.let {
val contentView = getLayoutWith(tripProgress, it)
val expandedView = getLayoutWith(tripProgress, it, expanded = true)
expandedView.setOnClickPendingIntent(R.id.stop_navigation_button, stopPendingIntent)

builder.setCustomContentView(contentView)
builder.setCustomBigContentView(expandedView)
}
}
is TripState.Complete -> {
builder.setContentTitle(context.getString(R.string.arrived_title))
builder.setContentText(context.getString(R.string.arrived_description))
}
else -> {
builder.setContentTitle(context.getString(R.string.preparing))
}
}

return builder
.setOngoing(true)
.setContentIntent(openPendingIntent)
.setVisibility(Notification.VISIBILITY_PUBLIC)
.build()
}

@SuppressLint("DiscouragedApi")
private fun getLayoutWith(
tripProgress: TripProgress,
visualInstruction: VisualInstruction,
expanded: Boolean = false
): RemoteViews {
val remoteViews =
if (expanded) {
RemoteViews(context.packageName, R.layout.expanded_navigation_notification)
} else {
RemoteViews(context.packageName, R.layout.navigation_notification)
}

val instructionImage = visualInstruction.primaryContent.maneuverIcon
remoteViews.setImageViewResource(
R.id.instruction_image,
context.resources.getIdentifier(instructionImage, "drawable", context.packageName))

// Set the text
remoteViews.setTextViewText(
R.id.estimated_arrival_time,
estimatedArrivalFormatter.format(tripProgress.estimatedArrivalTime()))
remoteViews.setTextViewText(
R.id.duration_remaining, durationFormatter.format(tripProgress.durationRemaining))
remoteViews.setTextViewText(
R.id.distance_remaining, distanceFormatter.format(tripProgress.distanceRemaining))
remoteViews.setTextViewText(R.id.instruction, visualInstruction.primaryContent.text)

return remoteViews
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import uniffi.ferrostar.ManeuverModifier
import uniffi.ferrostar.ManeuverType
import uniffi.ferrostar.VisualInstructionContent

private val VisualInstructionContent.maneuverIcon: String
val VisualInstructionContent.maneuverIcon: String
get() {
val descriptor =
listOfNotNull(
Expand Down
5 changes: 5 additions & 0 deletions android/composeui/src/main/res/drawable/notification_icon.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="26dp" android:viewportHeight="960" android:viewportWidth="960" android:width="26dp">

<path android:fillColor="#FFFFFF" android:pathData="M193.33,840 L160,806.67 480,80l320,726.67L766.67,840 480,712 193.33,840Z"/>

</vector>
5 changes: 5 additions & 0 deletions android/composeui/src/main/res/drawable/rounded_button.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<corners android:radius="26dp"/>
</shape>
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/navigation_notification"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:background="@color/background_color">

<include layout="@layout/navigation_notification" />

<Button
android:id="@+id/stop_navigation_button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/stop_navigation"
android:textAllCaps="false"
android:textFontWeight="700"
android:layout_marginTop="8dp"
android:layout_marginLeft="40dp"
android:textColor="@color/foreground_color"
android:backgroundTint="@color/background_color_darkened"
android:shadowColor="@android:color/transparent"
android:background="@drawable/rounded_button"
android:paddingLeft="16dp"
android:paddingRight="16dp"
/>

</LinearLayout>
83 changes: 83 additions & 0 deletions android/composeui/src/main/res/layout/navigation_notification.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/navigation_notification"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:background="@color/background_color">

<ImageView
android:layout_width="48dp"
android:layout_height="48dp"
android:id="@+id/instruction_image"
android:contentDescription="@string/instruction_image"
android:src="@drawable/direction_depart_straight"
android:tintMode="src_atop"
android:tint="@color/foreground_color"
android:layout_marginRight="8dp"
tools:ignore="UseAppTint" />

<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">

<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:paddingBottom="8dp">

<TextView
android:id="@+id/estimated_arrival_time"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="10:01 PM"
android:paddingEnd="8dp"
android:textColor="@color/foreground_color"
/>

<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/dot"
android:paddingEnd="8dp"
android:textColor="@color/foreground_color" />

<TextView
android:id="@+id/duration_remaining"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="32 min"
android:paddingEnd="8dp"
android:textColor="@color/foreground_color" />

<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/dot"
android:paddingEnd="8dp"
android:textColor="@color/foreground_color" />

<TextView
android:id="@+id/distance_remaining"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="25.2 mi"
android:textColor="@color/foreground_color" />

</LinearLayout>

<TextView
android:id="@+id/instruction"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textStyle="bold"
android:text="Turn left onto SE 50th Ave."
android:textColor="@color/foreground_color" />

</LinearLayout>

</LinearLayout>
6 changes: 6 additions & 0 deletions android/composeui/src/main/res/values/colors.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="foreground_color">#FFFFFF</color>
<color name="background_color">#00695C</color>
<color name="background_color_darkened">#006457</color>
</resources>
10 changes: 10 additions & 0 deletions android/composeui/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="stop_navigation">Stop Navigating</string>
<string name="maneuver_instruction_image">Maneuver instruction image</string>
<string name="dot">•</string>
<string name="instruction_image">Instruction image</string>
<string name="preparing">Preparing...</string>
<string name="arrived_title">Arrived</string>
<string name="arrived_description">You have arrived at your destination.</string>
</resources>
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.stadiamaps.ferrostar.core

import com.stadiamaps.ferrostar.core.service.ForegroundServiceManager
import java.time.Instant
import kotlinx.coroutines.test.runTest
import okhttp3.OkHttpClient
Expand Down Expand Up @@ -51,6 +52,26 @@ class MockRouteResponseParser(private val routes: List<Route>) : RouteResponsePa
override fun parseResponse(response: ByteArray): List<Route> = routes
}

class MockForegroundNotificationManager : ForegroundServiceManager {
var startCalled = false

override fun startService(stopNavigation: () -> Unit) {
startCalled = true
}

var stopCalled = false

override fun stopService() {
stopCalled = true
}

var onCurrentStateUpdated: ((NavigationState) -> Unit)? = null

override fun onNavigationStateUpdated(state: NavigationState) {
onCurrentStateUpdated?.invoke(state)
}
}

class FerrostarCoreTest {
private val errorBody =
"""
Expand Down Expand Up @@ -109,6 +130,7 @@ class FerrostarCoreTest {
responseParser = MockRouteResponseParser(routes = listOf())),
httpClient = OkHttpClient.Builder().addInterceptor(interceptor).build(),
locationProvider = SimulatedLocationProvider(),
foregroundServiceManager = MockForegroundNotificationManager(),
navigationControllerConfig =
NavigationControllerConfig(StepAdvanceMode.Manual, RouteDeviationTracking.None))

Expand Down Expand Up @@ -155,6 +177,7 @@ class FerrostarCoreTest {
responseParser = MockRouteResponseParser(routes = listOf(mockRoute))),
httpClient = OkHttpClient.Builder().addInterceptor(interceptor).build(),
locationProvider = SimulatedLocationProvider(),
foregroundServiceManager = MockForegroundNotificationManager(),
navigationControllerConfig =
NavigationControllerConfig(StepAdvanceMode.Manual, RouteDeviationTracking.None))
val routes =
Expand Down Expand Up @@ -204,6 +227,7 @@ class FerrostarCoreTest {
customRouteProvider = routeProvider,
httpClient = OkHttpClient.Builder().addInterceptor(interceptor).build(),
locationProvider = SimulatedLocationProvider(),
foregroundServiceManager = MockForegroundNotificationManager(),
navigationControllerConfig =
NavigationControllerConfig(StepAdvanceMode.Manual, RouteDeviationTracking.None))
val routes =
Expand Down Expand Up @@ -238,6 +262,8 @@ class FerrostarCoreTest {
rule(post, url eq valhallaEndpointUrl) { respond(200, "".toResponseBody()) }
}

val foregroundServiceManager = MockForegroundNotificationManager()

class DeviationHandler : RouteDeviationHandler {
var called = false

Expand Down Expand Up @@ -271,6 +297,7 @@ class FerrostarCoreTest {
responseParser = MockRouteResponseParser(routes = listOf(mockRoute))),
httpClient = OkHttpClient.Builder().addInterceptor(interceptor).build(),
locationProvider = locationProvider,
foregroundServiceManager = foregroundServiceManager,
navigationControllerConfig =
NavigationControllerConfig(StepAdvanceMode.Manual, RouteDeviationTracking.None))

Expand Down Expand Up @@ -323,6 +350,7 @@ class FerrostarCoreTest {
}
})))

assert(foregroundServiceManager.startCalled)
assert(deviationHandler.called)

// TODO: Figure out how to test this properly with Kotlin coroutines + JUnit in the way.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,7 @@ class ValhallaCoreTest {
profile = "auto",
httpClient = OkHttpClient.Builder().addInterceptor(interceptor).build(),
locationProvider = SimulatedLocationProvider(),
foregroundServiceManager = MockForegroundNotificationManager(),
navigationControllerConfig =
NavigationControllerConfig(StepAdvanceMode.Manual, RouteDeviationTracking.None))

Expand Down Expand Up @@ -296,6 +297,7 @@ class ValhallaCoreTest {
profile = "auto",
httpClient = OkHttpClient.Builder().addInterceptor(interceptor).build(),
locationProvider = SimulatedLocationProvider(),
foregroundServiceManager = MockForegroundNotificationManager(),
navigationControllerConfig =
NavigationControllerConfig(StepAdvanceMode.Manual, RouteDeviationTracking.None),
costingOptions = mapOf("auto" to mapOf("useTolls" to 0)))
Expand Down
12 changes: 12 additions & 0 deletions android/core/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,16 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />

<!-- Foreground service permission -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" />

<application>
<service
android:name="com.stadiamaps.ferrostar.core.service.FerrostarForegroundService"
android:foregroundServiceType="location"
/>
</application>
</manifest>
Loading
Loading