Skip to content

Commit 84fda7f

Browse files
committed
Faster startup, resilient search, and real subway bullets
- Parse the 496-station catalog once off the main thread (StationCatalog) so the first frame renders immediately instead of a long black splash. - Lazy HttpClient; all heavy work on Dispatchers.Default. - CrashReporter: capture uncaught exceptions to a file and surface the trace on the home screen (no logcat needed on-device). - RouteBadge: authentic monochrome subway bullets — filled circle (local) / diamond (express), route glyph reversed out. Used on home boards, search results, and station header.
1 parent d5e3052 commit 84fda7f

8 files changed

Lines changed: 294 additions & 70 deletions

File tree

examples/LightNYCSubway/src/main/kotlin/com/thelightphone/subway/ArrivalsRepository.kt

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,14 @@ import kotlinx.coroutines.awaitAll
1010
import kotlinx.coroutines.coroutineScope
1111

1212
/**
13-
* Fetches and decodes MTA realtime feeds, returning the upcoming arrivals for a
14-
* single station. Holds one [HttpClient]; remember to [close].
13+
* Fetches and decodes MTA realtime feeds for a single station. The [HttpClient]
14+
* is created lazily on first use (off the main thread) so it never slows startup.
1515
*/
1616
class ArrivalsRepository {
1717

18-
private val client = HttpClient(OkHttp)
18+
private var client: HttpClient? = null
19+
20+
private fun client(): HttpClient = client ?: HttpClient(OkHttp).also { client = it }
1921

2022
/**
2123
* Query every feed that serves [station], decode it, and keep only the trains
@@ -42,11 +44,14 @@ class ArrivalsRepository {
4244

4345
private suspend fun fetch(slug: String): List<GtfsRealtime.Raw> {
4446
// Pass the already-encoded URL string so the %2F survives to the server.
45-
val bytes: ByteArray = client.get(MtaFeeds.url(slug)) {
47+
val bytes: ByteArray = client().get(MtaFeeds.url(slug)) {
4648
header("Accept", "application/x-protobuf")
4749
}.body()
4850
return GtfsRealtime.parse(bytes)
4951
}
5052

51-
fun close() = client.close()
53+
fun close() {
54+
client?.close()
55+
client = null
56+
}
5257
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
package com.thelightphone.subway
2+
3+
import android.util.Log
4+
import java.io.File
5+
6+
/**
7+
* Captures uncaught exceptions to a file so the last crash can be shown on the
8+
* home screen (the Light Phone has no easy logcat access). Also keeps the app
9+
* from silently dying without a trace.
10+
*/
11+
object CrashReporter {
12+
private const val FILE = "last_crash.txt"
13+
private var dir: File? = null
14+
private var installed = false
15+
16+
fun install(filesDir: File) {
17+
if (installed) return
18+
installed = true
19+
dir = filesDir
20+
val previous = Thread.getDefaultUncaughtExceptionHandler()
21+
Thread.setDefaultUncaughtExceptionHandler { thread, error ->
22+
runCatching {
23+
File(filesDir, FILE).writeText(
24+
"Subway Times crash\n\n" + Log.getStackTraceString(error)
25+
)
26+
}
27+
previous?.uncaughtException(thread, error)
28+
}
29+
}
30+
31+
fun lastCrash(): String? =
32+
dir?.let { d -> File(d, FILE).takeIf { it.exists() }?.readText()?.takeIf { it.isNotBlank() } }
33+
34+
fun clear() {
35+
dir?.let { runCatching { File(it, FILE).delete() } }
36+
}
37+
}

examples/LightNYCSubway/src/main/kotlin/com/thelightphone/subway/HomeScreen.kt

Lines changed: 61 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,12 @@ import androidx.compose.foundation.layout.padding
88
import androidx.compose.runtime.Composable
99
import androidx.compose.runtime.collectAsState
1010
import androidx.compose.runtime.getValue
11+
import androidx.compose.runtime.mutableStateOf
12+
import androidx.compose.runtime.remember
13+
import androidx.compose.runtime.setValue
1114
import androidx.compose.ui.Modifier
15+
import androidx.datastore.core.DataStore
16+
import androidx.datastore.preferences.core.Preferences
1217
import androidx.lifecycle.viewModelScope
1318
import com.thelightphone.sdk.InitialScreen
1419
import com.thelightphone.sdk.LightScreen
@@ -46,7 +51,8 @@ data class HomeUiState(
4651
)
4752

4853
class HomeViewModel(
49-
private val store: StationStore,
54+
private val dataStore: DataStore<Preferences>,
55+
private val readAsset: (String) -> ByteArray,
5056
) : LightViewModel<Unit>() {
5157

5258
private val repo = ArrivalsRepository()
@@ -59,20 +65,23 @@ class HomeViewModel(
5965
}
6066

6167
fun refresh() {
62-
viewModelScope.launch(Dispatchers.IO) {
63-
val starred = store.starredStations()
68+
// All heavy work (catalog parse, network) happens off the main thread so
69+
// the first frame renders immediately.
70+
viewModelScope.launch(Dispatchers.Default) {
71+
val stations = StationCatalog.load(readAsset)
72+
val store = StationStore(dataStore, stations)
73+
val starred = runCatching { store.starredStations() }.getOrDefault(emptyList())
6474
if (starred.isEmpty()) {
6575
_state.value = HomeUiState(loading = false, boards = emptyList())
6676
return@launch
6777
}
6878
_state.value = _state.value.copy(loading = true)
6979
val now = System.currentTimeMillis() / 1000
7080
val boards = starred.map { station ->
71-
runCatching { repo.arrivalsFor(station, now) }
72-
.fold(
73-
onSuccess = { StationBoard(station, it.take(5)) },
74-
onFailure = { StationBoard(station, failed = true) },
75-
)
81+
runCatching { repo.arrivalsFor(station, now) }.fold(
82+
onSuccess = { StationBoard(station, it.take(5)) },
83+
onFailure = { StationBoard(station, failed = true) },
84+
)
7685
}
7786
_state.value = HomeUiState(loading = false, boards = boards, nowSeconds = now)
7887
}
@@ -91,23 +100,33 @@ class HomeScreen(sealedActivity: SealedLightActivity) :
91100
override val viewModelClass: Class<HomeViewModel> get() = HomeViewModel::class.java
92101

93102
override fun createViewModel(): HomeViewModel {
94-
val catalog = String(lightContext.readAsset("stations.json"), Charsets.UTF_8)
95-
return HomeViewModel(StationStore(lightContext.dataStore, catalog))
103+
CrashReporter.install(lightContext.filesDir)
104+
return HomeViewModel(lightContext.dataStore, lightContext::readAsset)
96105
}
97106

98107
@Composable
99108
override fun Content() {
100109
val state by viewModel.state.collectAsState()
101110
val themeColors by LightThemeController.colors.collectAsState()
111+
val lastCrash = remember { CrashReporter.lastCrash() }
112+
var showCrash by remember { mutableStateOf(lastCrash != null) }
102113

103114
LightTheme(colors = themeColors) {
115+
if (showCrash && lastCrash != null) {
116+
CrashReportView(lastCrash) {
117+
CrashReporter.clear()
118+
showCrash = false
119+
}
120+
return@LightTheme
121+
}
122+
104123
Column(
105124
modifier = Modifier
106125
.fillMaxSize()
107126
.background(LightThemeTokens.colors.background),
108127
) {
109128
LightTopBar(
110-
center = LightTopBarCenter.Text("Subway"),
129+
center = LightTopBarCenter.Text("Subway Times"),
111130
rightButton = LightBarButton.LightIcon(
112131
icon = LightIcons.SEARCH,
113132
onClick = { navigateTo(::SearchScreen) },
@@ -172,11 +191,9 @@ private fun StationBoardView(
172191
.padding(bottom = 1f.gridUnitsAsDp()),
173192
) {
174193
LightText(text = board.station.name, variant = LightTextVariant.Heading)
175-
LightText(
176-
text = board.station.routes.joinToString(" "),
177-
variant = LightTextVariant.Detail,
178-
lighten = true,
179-
modifier = Modifier.padding(bottom = 0.25f.gridUnitsAsDp()),
194+
RouteBadgeRow(
195+
routes = board.station.routes,
196+
modifier = Modifier.padding(top = 0.25f.gridUnitsAsDp(), bottom = 0.5f.gridUnitsAsDp()),
180197
)
181198
when {
182199
board.failed ->
@@ -187,3 +204,31 @@ private fun StationBoardView(
187204
}
188205
}
189206
}
207+
208+
/** Shows the last captured crash so it can be read/screenshotted off-device. */
209+
@Composable
210+
private fun CrashReportView(trace: String, onDismiss: () -> Unit) {
211+
Column(
212+
modifier = Modifier
213+
.fillMaxSize()
214+
.background(LightThemeTokens.colors.background),
215+
) {
216+
LightTopBar(
217+
center = LightTopBarCenter.Text("Last error"),
218+
modifier = Modifier.padding(bottom = 0.25f.gridUnitsAsDp()),
219+
)
220+
LightScrollView(
221+
modifier = Modifier
222+
.weight(1f)
223+
.fillMaxWidth()
224+
.padding(horizontal = 1f.gridUnitsAsDp()),
225+
) {
226+
LightText(text = trace, variant = LightTextVariant.Detail)
227+
}
228+
LightBottomBar(
229+
items = listOf(
230+
LightBarButton.Text(text = "Dismiss", onClick = onDismiss),
231+
),
232+
)
233+
}
234+
}

examples/LightNYCSubway/src/main/kotlin/com/thelightphone/subway/SearchScreen.kt

Lines changed: 39 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@ import androidx.compose.runtime.Composable
99
import androidx.compose.runtime.collectAsState
1010
import androidx.compose.runtime.getValue
1111
import androidx.compose.ui.Modifier
12+
import androidx.datastore.core.DataStore
13+
import androidx.datastore.preferences.core.Preferences
14+
import androidx.lifecycle.viewModelScope
1215
import com.thelightphone.sdk.LightScreen
1316
import com.thelightphone.sdk.LightViewModel
1417
import com.thelightphone.sdk.SealedLightActivity
@@ -25,18 +28,45 @@ import com.thelightphone.sdk.ui.LightTopBar
2528
import com.thelightphone.sdk.ui.LightTopBarCenter
2629
import com.thelightphone.sdk.ui.gridUnitsAsDp
2730
import com.thelightphone.sdk.ui.lightClickable
31+
import kotlinx.coroutines.Dispatchers
2832
import kotlinx.coroutines.flow.MutableStateFlow
2933
import kotlinx.coroutines.flow.StateFlow
34+
import kotlinx.coroutines.launch
35+
36+
class SearchViewModel(
37+
private val dataStore: DataStore<Preferences>,
38+
private val readAsset: (String) -> ByteArray,
39+
) : LightViewModel<Unit>() {
40+
41+
@Volatile
42+
private var store: StationStore? = null
3043

31-
class SearchViewModel(val store: StationStore) : LightViewModel<Unit>() {
3244
private val _query = MutableStateFlow("")
3345
val query: StateFlow<String> = _query
3446
private val _results = MutableStateFlow<List<Station>>(emptyList())
3547
val results: StateFlow<List<Station>> = _results
3648

49+
init {
50+
// Warm the catalog off the main thread so the first keystroke is instant.
51+
viewModelScope.launch(Dispatchers.Default) {
52+
store = StationStore(dataStore, StationCatalog.load(readAsset))
53+
if (_query.value.isNotBlank()) {
54+
_results.value = store?.search(_query.value).orEmpty()
55+
}
56+
}
57+
}
58+
3759
fun setQuery(q: String) {
3860
_query.value = q
39-
_results.value = store.search(q)
61+
val ready = store
62+
if (ready != null) {
63+
_results.value = ready.search(q)
64+
} else {
65+
viewModelScope.launch(Dispatchers.Default) {
66+
val s = StationStore(dataStore, StationCatalog.load(readAsset)).also { store = it }
67+
_results.value = s.search(q)
68+
}
69+
}
4070
}
4171
}
4272

@@ -45,10 +75,8 @@ class SearchScreen(sealedActivity: SealedLightActivity) :
4575

4676
override val viewModelClass: Class<SearchViewModel> get() = SearchViewModel::class.java
4777

48-
override fun createViewModel(): SearchViewModel {
49-
val catalog = String(lightContext.readAsset("stations.json"), Charsets.UTF_8)
50-
return SearchViewModel(StationStore(lightContext.dataStore, catalog))
51-
}
78+
override fun createViewModel(): SearchViewModel =
79+
SearchViewModel(lightContext.dataStore, lightContext::readAsset)
5280

5381
private fun openKeyboard() {
5482
navigateTo<String?>(
@@ -115,9 +143,12 @@ class SearchScreen(sealedActivity: SealedLightActivity) :
115143
.padding(bottom = 0.75f.gridUnitsAsDp()),
116144
) {
117145
LightText(text = station.name, variant = LightTextVariant.Copy)
146+
RouteBadgeRow(
147+
routes = station.routes,
148+
modifier = Modifier.padding(top = 0.25f.gridUnitsAsDp()),
149+
)
118150
LightText(
119-
text = station.routes.joinToString(" ") +
120-
" · " + station.boroLabel,
151+
text = station.boroLabel,
121152
variant = LightTextVariant.Detail,
122153
lighten = true,
123154
)
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
package com.thelightphone.subway
2+
3+
import kotlinx.serialization.json.Json
4+
5+
/**
6+
* Parses assets/stations.json exactly once and caches it process-wide.
7+
*
8+
* Parsing ~500 stations is CPU work that must NOT run on the main thread during
9+
* composition (that was the long black screen at launch). Call [load] from a
10+
* background dispatcher; every screen then shares the cached list instantly.
11+
*/
12+
object StationCatalog {
13+
14+
@Volatile
15+
private var cache: List<Station>? = null
16+
17+
private val json = Json { ignoreUnknownKeys = true }
18+
19+
fun isLoaded(): Boolean = cache != null
20+
21+
fun cachedOrEmpty(): List<Station> = cache ?: emptyList()
22+
23+
/** Idempotent, thread-safe. Returns the parsed catalog (empty on failure). */
24+
fun load(readAsset: (String) -> ByteArray): List<Station> {
25+
cache?.let { return it }
26+
return synchronized(this) {
27+
cache ?: run {
28+
val parsed = runCatching {
29+
val text = String(readAsset("stations.json"), Charsets.UTF_8)
30+
json.decodeFromString<StationsFile>(text).stations
31+
}.getOrElse { emptyList() }
32+
cache = parsed
33+
parsed
34+
}
35+
}
36+
}
37+
}

0 commit comments

Comments
 (0)