Skip to content

Commit dd0c7be

Browse files
committed
reduce app size
1 parent ef8903b commit dd0c7be

23 files changed

Lines changed: 203 additions & 79 deletions

File tree

‎app/build.gradle.kts‎

Lines changed: 0 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -43,8 +43,6 @@ android {
4343
versionCode = appVersionCode
4444
versionName = appVersionName
4545

46-
// Add package name as string resource for release
47-
resValue("string", "app_package_name", "dev.tricked.solidverdant")
4846
}
4947

5048
buildTypes {
@@ -53,8 +51,6 @@ android {
5351
isMinifyEnabled = false
5452
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
5553

56-
// Override package name for debug builds
57-
resValue("string", "app_package_name", "dev.tricked.solidverdant.dev")
5854
}
5955

6056
getByName("release") {
@@ -63,8 +59,6 @@ android {
6359
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
6460
signingConfig = signingConfigs.getByName("release")
6561

66-
// Override package name for release builds
67-
resValue("string", "app_package_name", "dev.tricked.solidverdant")
6862
}
6963

7064
// Release-signed, separately-installable test build for quick device testing of a
@@ -80,7 +74,6 @@ android {
8074
isShrinkResources = false
8175
signingConfig = signingConfigs.getByName("release")
8276

83-
resValue("string", "app_package_name", "dev.tricked.solidverdant.test")
8477
}
8578

8679
// Release-representative build for performance measurement (perf/run_perf.sh): full R8 +
@@ -94,7 +87,6 @@ android {
9487
isShrinkResources = true
9588
signingConfig = signingConfigs.getByName("debug")
9689

97-
resValue("string", "app_package_name", "dev.tricked.solidverdant.bench")
9890
}
9991

10092
// Instrumentable release-type build for frame-timing measurement. Release Compose (no
@@ -110,7 +102,6 @@ android {
110102
isShrinkResources = false
111103
signingConfig = signingConfigs.getByName("debug")
112104

113-
resValue("string", "app_package_name", "dev.tricked.solidverdant.perftest")
114105
}
115106
}
116107

@@ -121,7 +112,6 @@ android {
121112
buildFeatures {
122113
compose = true
123114
buildConfig = true
124-
resValues = true
125115
}
126116

127117
// Robolectric needs the merged Android resources/assets/manifest on the JVM classpath so it
@@ -267,11 +257,8 @@ dependencies {
267257
implementation(libs.androidx.compose.material.iconsExtended)
268258
implementation(libs.androidx.compose.ui.tooling.preview)
269259
implementation(libs.androidx.navigation.compose)
270-
implementation(libs.androidx.compose.material3.windowSizeClass)
271260
implementation(libs.androidx.lifecycle.runtimeCompose)
272261
implementation(libs.androidx.lifecycle.viewModelCompose)
273-
implementation(libs.accompanist.appcompat.theme)
274-
implementation(libs.accompanist.swiperefresh)
275262

276263
debugImplementation(composeBom)
277264
debugImplementation(libs.androidx.compose.ui.tooling.core)

‎app/src/androidTest/java/dev/tricked/solidverdant/e2e/E2eRule.kt‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ import dev.tricked.solidverdant.data.local.db.AppDatabase
2929
import dev.tricked.solidverdant.data.local.db.OutboxEntity
3030
import dev.tricked.solidverdant.data.local.db.OutboxOpType
3131
import dev.tricked.solidverdant.data.local.db.TemplateEntity
32+
import dev.tricked.solidverdant.data.local.db.toModel
33+
import dev.tricked.solidverdant.data.model.Tag
3234
import dev.tricked.solidverdant.data.model.TimeEntry
3335
import dev.tricked.solidverdant.data.remote.RemoteDataSource
3436
import dev.tricked.solidverdant.e2e.di.TestClock
@@ -223,6 +225,14 @@ class E2eRule(private val test: Any) : TestRule {
223225
infos.forEach { info -> testDriver?.setAllConstraintsMet(info.id) }
224226
}
225227

228+
fun pendingOutboxCount(): Int = runBlocking { entryPoint.database().outboxDao().peekPending().size }
229+
230+
fun localEntry(handle: E2eFixtureHandle): TimeEntry? = runBlocking {
231+
val id = handle.serverId ?: return@runBlocking null
232+
val dao = entryPoint.database().timeEntryDao()
233+
dao.getById(id)?.let { entity -> entity.toModel(dao.tagIdsFor(entity.id).map(::Tag)) }
234+
}
235+
226236
/** Whether the optimistic local STOP has committed before the worker is allowed to run. */
227237
fun hasPendingStop(entryId: String): Boolean = runBlocking {
228238
entryPoint.database().outboxDao().peekAll().any {

‎app/src/androidTest/java/dev/tricked/solidverdant/e2e/flows/MultiDayEntryE2eTest.kt‎

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,103 @@ class MultiDayEntryE2eTest {
133133
assertEquals(24 * 60 * 60, persisted.duration)
134134
}
135135

136+
@BackendPortable
137+
@Test
138+
fun shorteningOneAndAHalfDayEntryToOneDayByDurationSyncsWithSolidtime() {
139+
val zone = e2e.session.zone
140+
val startDate = YearMonth.now(zone).atDay(1)
141+
val start = startDate.atTime(10, 0).atZone(zone).toInstant()
142+
val entry = TimeEntry(
143+
id = ENTRY_ID,
144+
description = "One-and-a-half-day deployment",
145+
userId = e2e.session.userId,
146+
start = start.toString(),
147+
end = start.plusSeconds(36 * 60 * 60L).toString(),
148+
duration = 36 * 60 * 60,
149+
organizationId = e2e.session.organizationId,
150+
)
151+
val handle = e2e.prepare(E2eFixture.Completed(entry))
152+
e2e.launchApp()
153+
154+
TrackRobot(e2e.composeRule)
155+
.waitForHistory()
156+
.assertEntryVisible(entry.description!!)
157+
.tapFirstEntryEdit()
158+
.replaceSheetDuration((24 * 60).toString())
159+
.saveSheet()
160+
161+
val snapshot = e2e.awaitServer(WAIT_MS, driveSync = true) { current ->
162+
current.entry(handle)?.let { persisted ->
163+
persisted.start == start.toString() &&
164+
persisted.end == start.plusSeconds(24 * 60 * 60L).toString() &&
165+
persisted.duration == 24 * 60 * 60
166+
} == true
167+
}
168+
val persisted = requireNotNull(snapshot.entry(handle))
169+
assertEquals(start.toString(), persisted.start)
170+
assertEquals(start.plusSeconds(24 * 60 * 60L).toString(), persisted.end)
171+
assertEquals(24 * 60 * 60, persisted.duration)
172+
}
173+
174+
@Test
175+
fun delayedRefreshCannotRestoreTheOldDurationAfterEditSyncs() {
176+
val mock = e2e.requireMockBackend()
177+
val zone = e2e.session.zone
178+
val startDate = YearMonth.now(zone).atDay(1)
179+
val start = startDate.atTime(10, 0).atZone(zone).toInstant()
180+
val oldEnd = start.plusSeconds(36 * 60 * 60L)
181+
val newEnd = start.plusSeconds(24 * 60 * 60L)
182+
val entry = TimeEntry(
183+
id = ENTRY_ID,
184+
description = "Refresh race deployment",
185+
userId = e2e.session.userId,
186+
start = start.toString(),
187+
end = oldEnd.toString(),
188+
duration = 36 * 60 * 60,
189+
organizationId = e2e.session.organizationId,
190+
)
191+
val handle = e2e.prepare(E2eFixture.Completed(entry))
192+
e2e.launchApp()
193+
val robot = TrackRobot(e2e.composeRule)
194+
.waitForHistory()
195+
.assertEntryVisible(entry.description!!)
196+
197+
val historyCallsBeforeRefresh = mock.callsMatching("GET", "/time-entries").size
198+
val delayedResponseGate = mock.delayNextTimeEntriesResponseUntilReleased()
199+
try {
200+
robot.tapRefresh()
201+
e2e.composeRule.waitUntil(WAIT_MS) {
202+
mock.callsMatching("GET", "/time-entries").size > historyCallsBeforeRefresh
203+
}
204+
205+
robot.tapFirstEntryEdit()
206+
.replaceSheetDuration((24 * 60).toString())
207+
.saveSheet()
208+
209+
val snapshot = e2e.awaitServer(WAIT_MS, driveSync = true) { current ->
210+
current.entry(handle)?.let { persisted ->
211+
persisted.end == newEnd.toString() && persisted.duration == 24 * 60 * 60
212+
} == true
213+
}
214+
assertEquals(newEnd.toString(), snapshot.entry(handle)?.end)
215+
e2e.composeRule.waitUntil(WAIT_MS) { e2e.pendingOutboxCount() == 0 }
216+
217+
// Release the deliberately stale response only after the server write has completed.
218+
delayedResponseGate.countDown()
219+
e2e.composeRule.waitUntil(WAIT_MS) {
220+
e2e.localEntry(handle)?.end == newEnd.toString()
221+
}
222+
val newEndTime = newEnd.atZone(zone).format(DateTimeFormatter.ofPattern("HH:mm"))
223+
e2e.composeRule.onAllNodes(
224+
hasTestTag(TestTags.trackEntryTimeRange(requireNotNull(handle.serverId))) and
225+
hasText(newEndTime, substring = true),
226+
useUnmergedTree = true,
227+
).onFirst().assertIsDisplayed()
228+
} finally {
229+
delayedResponseGate.countDown()
230+
}
231+
}
232+
136233
private fun assertCalendarCellTotal(day: LocalDate, total: String) {
137234
// The clickable day cell merges its descendants into one semantics node on real devices.
138235
// Match that stable merged node directly instead of walking an unmerged ancestor chain,

‎app/src/androidTest/java/dev/tricked/solidverdant/e2e/mock/MockSolidtimeServer.kt‎

Lines changed: 30 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ import okhttp3.mockwebserver.MockWebServer
3636
import okhttp3.mockwebserver.RecordedRequest
3737
import java.time.Instant
3838
import java.util.Collections
39+
import java.util.concurrent.CountDownLatch
3940
import java.util.concurrent.atomic.AtomicInteger
4041

4142
/**
@@ -78,6 +79,9 @@ class MockSolidtimeServer {
7879
@Volatile
7980
var activeEntry: TimeEntry? = null
8081

82+
private var nextTimeEntriesResponseGate: CountDownLatch? = null
83+
private val responseGate = ThreadLocal<CountDownLatch?>()
84+
8185
/** Every request the app made, in order. Read from tests for assertions. */
8286
val recordedCalls: MutableList<RecordedCall> = Collections.synchronizedList(mutableListOf())
8387

@@ -189,17 +193,32 @@ class MockSolidtimeServer {
189193

190194
fun wasRequested(method: String, pathContains: String): Boolean = callsMatching(method, pathContains).isNotEmpty()
191195

196+
/** Hold one history response until the test has completed a concurrent local edit and sync. */
197+
fun delayNextTimeEntriesResponseUntilReleased(): CountDownLatch = CountDownLatch(1).also { gate ->
198+
synchronized(lock) { nextTimeEntriesResponseGate = gate }
199+
}
200+
192201
// ---- Dispatcher ---------------------------------------------------------------------------
193202

194203
private val dispatcher = object : Dispatcher() {
195-
override fun dispatch(request: RecordedRequest): MockResponse = synchronized(lock) {
196-
val method = request.method ?: "GET"
197-
val fullPath = request.path ?: "/"
198-
val path = fullPath.substringBefore("?")
199-
val body = runCatching { request.body.readUtf8() }.getOrDefault("")
200-
recordedCalls += RecordedCall(method, fullPath, body)
201-
202-
route(method, path, fullPath, body)
204+
override fun dispatch(request: RecordedRequest): MockResponse {
205+
val response = synchronized(lock) {
206+
val method = request.method ?: "GET"
207+
val fullPath = request.path ?: "/"
208+
val path = fullPath.substringBefore("?")
209+
val body = runCatching { request.body.readUtf8() }.getOrDefault("")
210+
recordedCalls += RecordedCall(method, fullPath, body)
211+
212+
route(method, path, fullPath, body)
213+
}
214+
responseGate.get()?.let { gate ->
215+
try {
216+
gate.await()
217+
} finally {
218+
responseGate.remove()
219+
}
220+
}
221+
return response
203222
}
204223
}
205224

@@ -268,7 +287,7 @@ class MockSolidtimeServer {
268287
val offset = params["offset"]?.toIntOrNull() ?: 0
269288
val sorted = timeEntries.sortedByDescending { it.start }
270289
val page = sorted.drop(offset).take(limit)
271-
return ok(
290+
val response = ok(
272291
json.encodeToString(
273292
TimeEntriesResponse(
274293
data = page,
@@ -281,6 +300,8 @@ class MockSolidtimeServer {
281300
),
282301
),
283302
)
303+
responseGate.set(nextTimeEntriesResponseGate.also { nextTimeEntriesResponseGate = null })
304+
return response
284305
}
285306

286307
private fun handleCreateOrStart(orgId: String, body: String): MockResponse {

‎app/src/main/java/dev/tricked/solidverdant/MainActivity.kt‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ import androidx.compose.runtime.mutableStateOf
2525
import androidx.compose.runtime.setValue
2626
import androidx.compose.ui.Modifier
2727
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
28-
import androidx.hilt.navigation.compose.hiltViewModel
28+
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
2929
import androidx.lifecycle.lifecycleScope
3030
import androidx.navigation.compose.rememberNavController
3131
import dagger.hilt.android.AndroidEntryPoint
@@ -89,7 +89,7 @@ open class MainActivity : ComponentActivity() {
8989
val initialTheme by startupTheme.collectAsState()
9090
val appTheme by trackingViewModel.appTheme.collectAsState(initial = initialTheme)
9191
val syncStatus by syncStatusReporter.status.collectAsState()
92-
val resolvedTheme = appTheme ?: initialTheme
92+
val resolvedTheme = appTheme
9393
SolidVerdantTheme(themeMode = resolvedTheme) {
9494
Surface(
9595
modifier = Modifier.fillMaxSize(),

‎app/src/main/java/dev/tricked/solidverdant/data/local/db/TimeEntryDao.kt‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,7 @@ interface TimeEntryDao {
107107
tagIdsByEntry: Map<String, List<String>>,
108108
baseSnapshotsByEntry: Map<String, String> = emptyMap(),
109109
serverJsonByEntry: Map<String, String> = emptyMap(),
110+
pullStartedAtMs: Long? = null,
110111
) {
111112
entries.forEach { entity ->
112113
val local = getById(entity.id)
@@ -120,6 +121,17 @@ interface TimeEntryDao {
120121
}
121122
return@forEach
122123
}
124+
if (
125+
local != null &&
126+
local.syncState == SyncState.SYNCED &&
127+
!local.pendingDelete &&
128+
pullStartedAtMs != null &&
129+
local.updatedAt >= pullStartedAtMs
130+
) {
131+
// This response was already in flight when the local row was last written.
132+
// Keep the newer Room value; a later pull will observe the server copy.
133+
return@forEach
134+
}
123135
if (local != null && (local.syncState == SyncState.PENDING || local.pendingDelete)) {
124136
val base = baseSnapshotsByEntry[entity.id]
125137
if (base == null) return@forEach

‎app/src/main/java/dev/tricked/solidverdant/data/remote/TokenAuthenticator.kt‎

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -71,12 +71,9 @@ private class HttpTokenRefresher(private val json: Json, private val client: OkH
7171

7272
return try {
7373
client.newCall(request).execute().use { response ->
74-
val body = response.body?.string()
74+
val body = response.body.string()
7575
when {
76-
response.isSuccessful ->
77-
body?.let { json.decodeFromString<TokenResponse>(it) }
78-
?.let(RefreshResult::Success)
79-
?: RefreshResult.Transient
76+
response.isSuccessful -> RefreshResult.Success(json.decodeFromString(body))
8077
// 401, or 400 invalid_grant/invalid_client, means the refresh token is no longer
8178
// usable: force logout. Other codes (e.g. 429, 5xx) are transient.
8279
response.code == HTTP_UNAUTHORIZED || isDefinitiveRejection(response.code, body) -> {

0 commit comments

Comments
 (0)