Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
17 changes: 8 additions & 9 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -98,41 +98,40 @@ dependencies {
debugImplementation(libs.androidx.ui.test.manifest)
debugImplementation(libs.androidx.ui.tooling)

// hilt
// Dependency injection
implementation(libs.hilt.android)
ksp(libs.hilt.compiler)
implementation(libs.androidx.hilt.lifecycle.viewmodel.compose)

// retrofit
// Networking
implementation(libs.retrofit)
implementation(libs.converter.kotlinx.serialization)

// okhttp
implementation(libs.logging.interceptor)

// compose
// Compose
implementation(libs.androidx.lifecycle.viewmodel.navigation3)
implementation(libs.androidx.lifecycle.runtime.compose)

// navigation
// Navigation
implementation(libs.navigation3.runtime)
implementation(libs.navigation3.ui)
implementation(libs.kotlinx.serialization.json)

// maps
// Maps
implementation(libs.maps.compose)
implementation(libs.play.services.maps)
implementation(libs.play.services.location)
implementation(libs.google.oss.licenses)

// datastore (similar to SharedPreferences)
// DataStore
implementation(libs.androidx.datastore.preferences)

// firebase
// Firebase
implementation(platform(libs.firebase.bom))
implementation(libs.firebase.messaging)

// home screen widget
// Home-screen widget
implementation(libs.glance.appwidget)
implementation(libs.glance.material3)
implementation(libs.androidx.work.runtime.ktx)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,6 @@ class EtaComponentsTest {
fun stopsWithNoApproachingVehicleShowTheEmptyEtaMessage() {
setListContent(routes = mapOf("NORTH" to testRoute()), vehicles = emptyList())

// Both of NORTH's stops (union, academy) lack a live eta here.
composeRule.onAllNodesWithText("No live ETAs").assertCountEquals(2)
}

Expand Down Expand Up @@ -161,7 +160,6 @@ class EtaComponentsTest {
}
}

// The sheet's appear animation runs as a coroutine, so give it a chance to settle.
composeRule.waitForIdle()

composeRule.onNodeWithText("Student Union").assertIsDisplayed()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,7 @@ import org.junit.Test
import org.junit.runner.RunWith
import java.time.Instant

/**
* Constructs MapsViewModel directly with fakes (bypassing hiltViewModel()) so the bottom
* navigation can be exercised without a Hilt test runner.
* */
/** Uses a directly constructed ViewModel so navigation tests do not need a Hilt runner. */
@RunWith(AndroidJUnit4::class)
class MapsScreenNavigationTest {
@get:Rule
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,8 +57,7 @@ class ScheduleContentTest {
setContent()

composeRule.onNodeWithText("Wed").performClick()
// The auto-expand LaunchedEffect races the click's own recomposition, so explicitly force
// a row open rather than assuming one is already expanded when this assertion runs.
// Explicitly open a row because auto-expand and click recomposition can race.
composeRule.onNodeWithText("7:00 AM").performClick()
composeRule.waitForIdle()

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,7 @@ class AboutScreenTest {
repeat(10) {
composeRule.onNodeWithText("Version").performClick()
}
// The 10th tap's unlock write goes through viewModelScope.launch, so it isn't guaranteed
// to have landed in the fake preferences the instant performClick() returns.
// Wait for the asynchronous preference write from the final tap.
composeRule.waitForIdle()

assertTrue(preferences.devOptions.value)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,7 @@ import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith

/**
* Constructs DevMenuViewModel directly with a fake, bypassing hiltViewModel(), the same way
* MapsScreenNavigationTest does - DevMenuContent itself is private, so the real screen is
* exercised instead of trying to reach into it.
* */
/** Uses a directly constructed ViewModel to test the private-backed screen without Hilt. */
@RunWith(AndroidJUnit4::class)
class DevMenuScreenTest {
@get:Rule
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,7 @@ import edu.rpi.shuttletracker.data.repository.ShuttleRepository
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow

/**
* Mirrors app/src/test's fake of the same name. Duplicated here because androidTest and test are
* separate source sets with no shared fixtures module in this project.
* */
/** Duplicated because `test` and `androidTest` do not share fakes. */
class FakeShuttleRepository : ShuttleRepository {
val vehicleLocations = MutableSharedFlow<NetworkResult<Map<String, VehicleLocation>>>(replay = 1)
val vehicleEtas = MutableSharedFlow<NetworkResult<Map<String, VehicleStopEta>>>(replay = 1)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,7 @@ import edu.rpi.shuttletracker.data.local.preferences.UserPreferences
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow

/**
* Mirrors app/src/test's fake of the same name. Duplicated here because androidTest and test are
* separate source sets with no shared fixtures module in this project.
* */
/** Duplicated because `test` and `androidTest` do not share fakes. */
class FakeUserPreferences : UserPreferences {
val mapType = MutableStateFlow(MapType.NORMAL)
val privacyPolicyAccepted = MutableStateFlow(false)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,7 @@ import edu.rpi.shuttletracker.data.models.Route
import edu.rpi.shuttletracker.data.models.Schedule
import edu.rpi.shuttletracker.data.models.Stop

/**
* Mirrors app/src/test's fixtures of the same name. Duplicated here because androidTest and test
* are separate source sets with no shared fixtures module in this project.
* */
/** Duplicated because `test` and `androidTest` do not share fixtures. */
fun testRoute() =
Route(
color = "#D32F2F",
Expand Down
17 changes: 3 additions & 14 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -40,11 +40,7 @@
android:name="com.google.android.geo.API_KEY"
android:value="${MAPS_API_KEY}" />

<!--
Play Services' Maps renderer module still references the legacy Apache HTTP client, which
was removed from the platform classpath in API 28+. Without this, some emulator images
crash with NoClassDefFoundError: org.apache.http.ProtocolVersion when the map first loads.
-->
<!-- Required by the Maps renderer on devices without the legacy HTTP client. -->
<uses-library
android:name="org.apache.http.legacy"
android:required="false" />
Expand All @@ -61,10 +57,7 @@
android:resource="@xml/eta_widget_info" />
</receiver>

<!--
taskAffinity="" keeps this out of MainActivity's task - otherwise finishing it while the
app is already open in the background resurfaces the app instead of the home screen.
-->
<!-- Keep widget configuration outside MainActivity's task. -->
<activity
android:name=".widget.EtaWidgetConfigureActivity"
android:exported="true"
Expand All @@ -84,11 +77,7 @@
</intent-filter>
</service>

<!--
Used by Firebase to render notification+data messages it displays automatically while the
app is backgrounded or not running, since FirebaseService.onMessageReceived never runs in
that case.
-->
<!-- Defaults for notifications Firebase renders while the app is in the background. -->
<meta-data
android:name="com.google.firebase.messaging.default_notification_icon"
android:resource="@drawable/ic_stat_default" />
Expand Down
14 changes: 2 additions & 12 deletions app/src/main/java/edu/rpi/shuttletracker/app/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -26,12 +26,7 @@ import edu.rpi.shuttletracker.data.local.preferences.UserPreferences
import kotlinx.coroutines.flow.map
import javax.inject.Inject

/**
* The app's one and only [android.app.Activity]. Every screen you see is Compose content set here
* via [setContent] and hosted by [AppNavigation] - there is no second Activity to navigate to.
* Also decides whether to show setup or the map first, based on [UserPreferences], and turns a
* tapped push notification into either "just open the app" or an external URL.
* */
/** Hosts the Compose UI, selects the initial screen, and handles notification taps. */
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
@Inject
Expand Down Expand Up @@ -86,12 +81,7 @@ class MainActivity : ComponentActivity() {
handleNotificationTap(intent)
}

/**
* The map is already the app's home screen, so a tapped push notification needs no
* navigation of its own - the only special case is an optional safe `url` to open instead.
* Covers both the foreground PendingIntent we build in [FirebaseService] and the intent FCM
* builds automatically for background/terminated taps, since both use the same extra name.
* */
/** Opens a notification's optional safe URL; otherwise the app stays on its home screen. */
private fun handleNotificationTap(intent: Intent?) {
val url = intent?.getStringExtra(FirebaseService.EXTRA_URL) ?: return
intent.removeExtra(FirebaseService.EXTRA_URL)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,7 @@ import android.app.Application
import dagger.hilt.android.HiltAndroidApp
import edu.rpi.shuttletracker.background.notification.Notifications

/**
* The app's [Application] class. Marking it `@HiltAndroidApp` is what turns on Hilt dependency
* injection for the whole app - every `@AndroidEntryPoint`/`@HiltViewModel` elsewhere depends on
* this. Also does the one-time setup that has to happen before any screen shows, like creating
* notification channels.
* */
/** Enables Hilt and performs process-wide startup work. */
@HiltAndroidApp
class ShuttleTrackerApplication : Application() {
override fun onCreate() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,7 @@ import edu.rpi.shuttletracker.data.repository.DefaultShuttleRepository
import edu.rpi.shuttletracker.data.repository.ShuttleRepository
import javax.inject.Singleton

/**
* Tells Hilt which implementation to hand out for each data-layer interface. `@Binds` just says
* "when something asks for [ShuttleRemoteDataSource] or [ShuttleRepository], give it this class" -
* this is what lets features and tests depend on the interface only.
* */
/** Binds data interfaces to their production implementations. */
@Module
@InstallIn(SingletonComponent::class)
abstract class DataModule {
Expand Down
15 changes: 3 additions & 12 deletions app/src/main/java/edu/rpi/shuttletracker/app/di/NetworkModule.kt
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,7 @@ import retrofit2.Retrofit
import retrofit2.converter.kotlinx.serialization.asConverterFactory
import javax.inject.Singleton

/**
* Builds the networking stack the app talks to the backend with: an [OkHttpClient] (with a small
* disk cache so recent responses are usable offline), a [Retrofit] instance configured for JSON,
* and the generated [ShuttleApi] implementation. Everything here is a `@Singleton` - one instance
* for the whole app.
* */
/** Provides the shared HTTP cache, client, JSON converter, and Shuttle API. */
@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {
Expand All @@ -38,12 +33,9 @@ object NetworkModule {
var request = chain.request()

if (request.url.pathSegments.lastOrNull() in LivePolledPaths) {
// Vehicle locations/etas/velocities are meaningless once stale - never write them to
// the disk cache and never serve them from it, so going offline fails outright
// instead of silently replaying old vehicle positions as if they were live.
// Live vehicle data must never be replayed from cache.
request = request.newBuilder().header("Cache-Control", "no-store").build()
} else if (!context.hasNetwork()) {
// 2 week cache for offline
request =
request
.newBuilder()
Expand All @@ -60,7 +52,6 @@ object NetworkModule {
@ApplicationContext context: Context,
cacheInterceptor: Interceptor,
): OkHttpClient {
// 5 mb of cache
val cacheSize = (5 * 1024 * 1024).toLong()
val myCache = Cache(context.cacheDir, cacheSize)

Expand Down Expand Up @@ -96,5 +87,5 @@ object NetworkModule {
fun provideShuttleApi(retrofit: Retrofit): ShuttleApi = retrofit.create(ShuttleApi::class.java)
}

/** Endpoints that poll live vehicle state - these must never be cached or replayed while stale. */
/** Live endpoints that must bypass the HTTP cache. */
private val LivePolledPaths = setOf("locations", "etas", "velocities")
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,7 @@ import javax.inject.Singleton

private const val USER_PREFERENCES = "user_preferences"

/**
* Provides the single Jetpack DataStore file the app's settings are saved in, and binds
* [UserPreferences] to its real [DataStoreUserPreferences] implementation. Migrates from the old
* SharedPreferences file automatically the first time this runs.
* */
/** Provides app preferences and migrates the legacy SharedPreferences file. */
@Module
@InstallIn(SingletonComponent::class)
abstract class PreferencesModule {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,12 +29,7 @@ private data object AboutRoute : NavKey
@Serializable
private data object DeveloperOptionsRoute : NavKey

/**
* Owns the app's complete navigation state and maps route keys to feature screens.
*
* Feature screens receive callbacks instead of a navigation object, keeping them easy to preview,
* test, and reuse.
*/
/** Maps route keys to screens and passes navigation callbacks into each feature. */
@Composable
fun AppNavigation(setupCompleted: Boolean) {
val startRoute: NavKey = if (setupCompleted) MapsRoute else SetupRoute
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,7 @@ import androidx.core.app.NotificationManagerCompat
import androidx.core.app.NotificationManagerCompat.IMPORTANCE_DEFAULT
import edu.rpi.shuttletracker.R

/**
* Creates the notification channel(s) the app posts to, and cleans up channels from
* removed/never-shipped features so they don't linger in the user's system settings. Called once
* from [edu.rpi.shuttletracker.app.ShuttleTrackerApplication] on startup. Based on the
* notification generator for Tachiyomi.
* */
/** Creates current notification channels and removes obsolete ones at startup. */
object Notifications {
private const val GROUP_PUSH = "group_push"
const val CHANNEL_PUSH = "push_channel"
Expand All @@ -34,11 +29,9 @@ object Notifications {
fun createChannels(context: Context) {
val notificationManager = NotificationManagerCompat.from(context)

// deletes channels/groups from removed or never-shipped features so they don't linger
deprecatedChannels.forEach(notificationManager::deleteNotificationChannel)
deprecatedGroups.forEach(notificationManager::deleteNotificationChannelGroup)

// creates notification groups
notificationManager.createNotificationChannelGroupsCompat(
listOf(
buildNotificationChannelGroup(
Expand All @@ -48,7 +41,6 @@ object Notifications {
),
)

// create notification channels
notificationManager.createNotificationChannelsCompat(
listOf(
buildNotificationChannel(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,27 +13,16 @@ import edu.rpi.shuttletracker.R
import edu.rpi.shuttletracker.app.MainActivity
import edu.rpi.shuttletracker.background.notification.Notifications

/**
* Firebase Cloud Messaging is a manually-operated push channel: staff send notifications directly
* from the Firebase Console, independent of the shuttle API and its announcement banners. This
* service only has to render what Firebase hands it and route taps back into the app.
* */
/** Renders staff-sent Firebase messages independently of API announcement banners. */
@AndroidEntryPoint
class FirebaseService : FirebaseMessagingService() {
/**
* Debug-only: prints the registration token so it can be pasted into the Firebase Console's
* "Send test message" field, which targets a single device rather than the whole app.
* */
/** Logs the registration token in debug builds for Firebase test messages. */
@Suppress("OVERRIDE_DEPRECATION")
override fun onNewToken(token: String) {
if (BuildConfig.DEBUG) Log.d("FCM_TOKEN", token)
}

/**
* Only fires when the app is in the foreground; Firebase Console notification+data messages
* are otherwise displayed automatically (using the manifest's default icon/color/channel)
* when the app is backgrounded or not running, and never reach this callback.
* */
/** Handles foreground messages; Firebase renders background messages from manifest defaults. */
override fun onMessageReceived(message: RemoteMessage) {
super.onMessageReceived(message)

Expand Down Expand Up @@ -89,17 +78,10 @@ class FirebaseService : FirebaseMessagingService() {
}

companion object {
/**
* Matches the raw FCM data key so the same extra name works whether we built the
* PendingIntent ourselves (foreground) or Firebase copied its data payload onto the
* launcher intent for us (background/terminated).
* */
/** Shared URL key for intents created here or by Firebase. */
const val EXTRA_URL = "url"

/**
* A constant ID would silently replace every previous push; the message ID is stable per
* notification but unique across them, falling back to the clock only if Firebase omits it.
* */
/** Uses a stable unique ID so new pushes do not replace older ones. */
private fun notificationIdFor(message: RemoteMessage): Int =
message.messageId?.hashCode() ?: System.currentTimeMillis().toInt()
}
Expand Down
Loading