| Audited | android/architecture-samples at commit ee66e1526b84c026615df032c705842b7d2a521f |
|---|---|
| Date | 11 October 2026 |
| How it ran | API run on the Nacodex server, full audit, Standard review |
| Verdict after review | Pass with notes (rule: Fail if a High finding remains after review, otherwise Pass with notes) |
Each finding keeps the number it has in the audit report. The rating shown first is the one after review; the first automated rating is listed with it. 10 findings were first rated Medium or High; 2 of them are Medium or High after review. Text marked "From the report" is quoted from the audit report; fixes are suggestions and were not tested. Code locations link to the file and line at the audited commit.
Medium after review (2)
app/src/main/java/com/example/android/architecture/blueprints/todoapp/data/DefaultTaskRepository.kt:157 (line corrected on review; the report cites :159)From the report
val remoteTasks = networkDataSource.loadTasks()
localDataSource.deleteAll()
localDataSource.upsertAll(remoteTasks.toLocal())
Every local write pushes the whole table to the network in a background job that returns immediately. refresh() then replaces the local table with whatever the network holds. clearCompletedTasks() calls refresh() straight after the local delete (TasksViewModel.kt:108-110), and a pull-to-refresh right after an edit has the same race. If the read reaches the network before the push, cleared tasks come back, and new or edited tasks revert or disappear.
Do not replace local rows that have unsynced changes. Track a pending-sync flag or version per task and merge by row, or wait for any outstanding push to finish before pulling. Do not refresh immediately after a local mutation.
Review: Mechanism verified. clearCompletedTasks() deletes locally then calls saveTasksToNetwork() (DefaultTaskRepository.kt:125-128), which is scope.launch { localDataSource.getAll(); ...; networkDataSource.saveTasks } (173-180) and returns immediately. The ViewModel then calls refresh() (TasksViewModel.kt:110 -> 143). Both then queue on the network source's accessMutex (TaskNetworkDataSource.kt:41,46; mutex is FIFO; 2000 ms simulated latency, line 52). The refresh will therefore normally take the lock first, read the old list that still holds the completed tasks, and after 2 s deleteAll() + upsertAll(old) brings the cleared tasks back locally (the network is then corrected by the push, which snapshotted earlier). The same applies to pull-to-refresh within about 2 s of any edit. Not executed; the ordering is inferred from the code, but the report's own hedge ("If the read reaches the network before the push") is accurate and the likely order favours the failure. Upstream: open PR #1067 "Fix clear completed tasks being undone by immediate refresh" (2026-05-23) states the same: "An immediate full refresh can reload stale network data before saveTasksToNetwork() finishes (the fake network has a 2s delay), which can restore completed tasks the user just cleared". It removes the call at TasksViewModel:110 only; the general pull-to-refresh race is not covered.
Upstream: Already reported upstream: #1067.
From the report
Scaffold(
modifier = modifier.fillMaxSize(),
The activity calls enableEdgeToEdge() (TodoActivity.kt:33), so the manifest's adjustResize no longer shrinks the window. Nothing in app/src/main applies IME insets (searched for imePadding and WindowInsets: none found). With the keyboard open, the lower part of the 350dp description field and the save button sit under the keyboard. Users have to dismiss the keyboard to reach the save button.
Add Modifier.imePadding() to the form column, or include IME insets in the Scaffold's content insets.
Review: Verified: no imePadding/WindowInsets in app/src/main (grep returned only unrelated ime substrings), targetSdk is 35 (libs.versions.toml: targetSdk = "35"), Compose BOM 2024.12.01, and the FAB sits in the Scaffold at the bottom. With edge-to-edge the window is not resized, so the keyboard overlaps the FAB. Not run, but upstream reports the exact symptom: issue #1025 "Using edgeToEdge breaks windowSoftInputMode" ("buttons hidden behind the keyboard", with screenshots), PR #1028 "Resolve layout issues caused by edgeToEdge and keyboard overlap", PR #1056 "show save button on New task screen when keyboard is displayed" (all open). Fair: MEDIUM (primary form unusable until keyboard is dismissed), for a sample app. ---------------------------------------------------------------------
Low after review (15)
app/proguard-rules.pro:4From the report
# Some methods are only called from tests, so make sure the shrinker keeps them.
-keep class com.example.android.architecture.blueprints.** { *; }
The release build type enables minification with this file. The blanket keep rule, added only so tests can reach some methods, turns off shrinking and obfuscation for the whole app in production. -ignorewarnings (line 19) hides real missing-class problems, and the Espresso keep rules ship in the release APK. The result is a larger, easier-to-reverse-engineer release, and build warnings that could flag broken code are silenced.
Move the test-only keep rules to proguardTest-rules.pro, or to a debug-only rules file. Keep only what release actually needs (Room and Hilt entry points) and remove -ignorewarnings.
Review: Factually right. app/build.gradle.kts:55-58: release has isMinifyEnabled = true, proguardFiles(..., "proguard-rules.pro"). proguardTest-rules.pro exists (build.gradle.kts:57), so the author's intent is visible. Impact is small: it is a sample not published to a store; library code and resources are still shrunk; obfuscation is not a security control; no secret is in the app. It is the same rule file the project has carried since 2017. Upstream: issue #306 "[all] Proguard test rules" (open since 2017-03-22) says exactly this ("the proguard-rules.pro file contains a rules which keeps all the app classes... for release builds this seems unneeded"). Build file, not shipped library code.
Upstream: Already reported upstream: #306.
app/build.gradle.kts:117From the report
implementation(libs.androidx.test.espresso.idling.resources)
The Espresso idling-resource library is on the production classpath. The helper util/SimpleCountingIdlingResource.kt lives in the main source set although no production code uses it.
Move the helper to a debug or test source set (or :shared-test). Change the dependency to debugImplementation or androidTestImplementation.
Review: util/SimpleCountingIdlingResource.kt imports androidx.test.espresso.IdlingResource (:19) and nothing else in the repo references the class (searched all *.kt/*.kts). Dead helper plus a tiny test-support dependency in implementation. Hygiene only.
From the report
// TODO: Show error message?
StatisticsUiState(isEmpty = true, isLoading = false)
When loading fails, the screen shows the empty state with no explanation. The user cannot tell a failure from having no tasks.
Add a userMessage to StatisticsUiState, as the other screens have, and show the existing "error while loading tasks" string in a snackbar.
Review: StatisticsUiState (lines 37-42) has no message field. Branch is only reachable when the Room flow throws, so rare.
From the report
} catch (e: Exception) {
// In a real app you'd handle the exception e.g. by exposing a `networkStatus` flow
If the backup to the network fails, the user is never told their data is not backed up, and a later refresh can replace the unsaved local data (see finding 14). Catching Exception also swallows coroutine cancellation.
Rethrow CancellationException. Expose a sync-status or error state that the UI can show, and retry, for example with WorkManager.
Review: True, and the comment plus KDoc (165-172) say so. Catching Exception in scope.launch also swallows CancellationException, but the scope is the application scope so cancellation is not exercised. Related upstream: #858 (open) "refreshTasks() might throw an exception" is about unhandled exceptions in refresh, not this catch.
Upstream: No exact upstream report; related items: #858.
From the report
focusedBorderColor = Color.Transparent,
unfocusedBorderColor = Color.Transparent,
cursorColor = MaterialTheme.colorScheme.onSecondary
The app theme does not override onSecondary, so it stays white in the light colour scheme, and the caret is drawn white on a near-white surface. Both fields also have transparent borders in every state. Users get no sign of where to tap or where they are typing.
Use a contrasting cursor colour such as colorScheme.primary. Give the fields a visible focused border or a filled container.
Review: TodoTheme.kt:11-15 overrides only primary, secondary, tertiary; onSecondary therefore keeps the Material 3 lightColorScheme default (white) and the page background is the near-white default. So the caret colour is white on near-white: the cursor claim is right (static reasoning from M3 defaults; I did not run the app). The transparent borders are a deliberate look (placeholders show where to type; README says the UI is intentionally simple). Typed text and placeholders remain visible, so this is cosmetic. Fair: LOW.
Upstream: No matching upstream report found (11 October 2026).
app/src/main/res/values/colors.xml:18From the report
<color name="colorPrimary">#FFFFF0</color>
Several colour and drawable tokens, and the drawables built on them, are no longer referenced by any code. attrs.xml still declares an unused styleable. colorPrimary here also contradicts the Compose primary colour. This makes it unclear which colour source is authoritative.
Delete the unused resources and keep one source of colour tokens.
Review: colorPrimary and colorTextPrimary have zero references; drawer_item_color, list_completed_touch_feedback, touch_feedback, trash_icon, ic_add, ic_done, ic_edit, ic_menu, ic_assignment_turned_in_24dp are referenced nowhere in app/src or shared-test; the ScrollChildSwipeRefreshLayout styleable is referenced only by its own declaration (attrs.xml). The claim that colorPrimary "contradicts the Compose primary" is moot since it is unused. (PR #1078 also removes the leftover styleable.)
app/src/main/java/com/example/android/architecture/blueprints/todoapp/util/TopAppBars.kt:166From the report
Icon(Icons.Filled.ArrowBack, stringResource(id = R.string.menu_back))
The app declares RTL support, and the add/edit bar uses the auto-mirrored icon. The detail bar uses the deprecated non-mirrored one, so its arrow points the wrong way in RTL languages.
Use Icons.AutoMirrored.Filled.ArrowBack in both places.
app/src/main/java/com/example/android/architecture/blueprints/todoapp/tasks/TasksScreen.kt:163From the report
items(tasks) { task ->
After a refresh, a filter change or a completion toggle, item identity and scroll position follow the list index rather than the task, so the view can jump or animate the wrong row.
Use items(tasks, key = { it.id }).
Review: No key =. Minor scroll/animation identity issue.
app/src/main/java/com/example/android/architecture/blueprints/todoapp/util/ComposeUtils.kt:49From the report
SwipeRefresh(
The shared loading container uses the deprecated Accompanist SwipeRefresh, while the add/edit screen uses Material 3 PullToRefreshBox. In the add/edit screen, isRefreshing is never set to true, onRefresh does nothing and the content is empty, so the loading state is a blank screen. An Accompanist theme dependency is also carried that no code uses.
Move to Material 3 PullToRefreshBox throughout. Show a progress indicator while the edit form loads, and drop the unused Accompanist dependencies.
Review: All as described; accompanist.appcompat.theme is in build.gradle.kts:145 with no import anywhere. The blank state lasts only for one local DB read. PR #1078 ("Use the material3 pull-to-refresh instead of accompanist", open) describes the same migration.
From the report
private fun saveTasksToNetwork() {
scope.launch {
val localTasks = localDataSource.getAll()
Each mutation launches an independent job that reads a snapshot and then waits for the network. With quick successive edits, the jobs can reach the network in a different order from the one in which they took their snapshots. The last write then carries stale data, and the next refresh brings the stale data back into the app.
Keep a single sync job. If one is in flight, mark it dirty and run it once more when it finishes, or use a conflated channel or WorkManager unique work, so the final push always carries the latest snapshot.
Review: Each push is an independent job (snapshot, then saveTasks). Reordering requires job B to take its snapshot after job A yet reach the FIFO accessMutex (TaskNetworkDataSource.kt:46) first: a window of well under a millisecond, while user edits arrive at human speed and each push then holds the lock for 2 s. A later snapshot cannot overtake while an earlier push holds the lock. With a real concurrent HTTP backend the risk would be larger, but this repo ships only the in-memory simulation, and the KDoc (165-172) tells readers to use WorkManager. Fair: LOW.
Upstream: No matching upstream report found (11 October 2026).
From the report
private fun createNewTask() = viewModelScope.launch {
taskRepository.createTask(uiState.value.title, uiState.value.description)
The save button (AddEditTaskScreen.kt:74) stays active while a save runs, and each call generates a new random id. A second tap before navigation completes creates a second, identical task.
Ignore saveTask() while a save is in progress (an isSaving flag or a held job), or generate the id once per screen so repeated calls update the same row.
Review: No in-flight guard; createTask makes a new UUID.randomUUID() per call (DefaultTaskRepository.kt:54-56). The defect is real in code. Whether two taps can land is uncertain: the first createTask only does one Room insert and returns (the network push is non-blocking), then isTaskSaved triggers navigation (AddEditTaskScreen.kt:91-95); the second tap must hit inside that few-ms window or the exit transition. Cannot verify the transition-time input handling without running. Fair: LOW.
Upstream: No matching upstream report found (11 October 2026).
From the report
localDataSource.deleteAll()
localDataSource.upsertAll(remoteTasks.toLocal())
The two statements run as separate database operations. List observers can briefly receive an empty table, so the "no tasks" state flickers. If the process dies or the insert fails between the two calls, every local task is gone, including any not yet pushed.
Do both in one Room transaction (a @Transaction DAO method such as replaceAll, or withTransaction {}), or upsert the remote rows and delete only the rows missing from the remote list.
Review: Two separate DAO calls (TaskDao.kt:77-78, 99-100), no @Transaction/withTransaction anywhere. Observers of observeAll() can see an empty table between them (brief "no tasks" flash on each refresh). Data loss needs a process death or exception between two calls that run milliseconds apart, and the unsynced rows are exposed to the larger overwrite problem of finding 14 anyway. Fair: LOW (cosmetic flicker + small integrity window).
Upstream: No matching upstream report found (11 October 2026).
app/src/main/java/com/example/android/architecture/blueprints/todoapp/data/DefaultTaskRepository.kt:67 (line corrected on review; the report cites :71)From the report
) ?: throw Exception("Task (id $taskId) not found")
When the task is missing, AddEditTaskViewModel.loadTask silently shows an empty form (lines 140-143). The task can vanish this way after a refresh (finding 14). If the user fills the form and taps save, updateTask throws inside viewModelScope.launch with no handler, and the app crashes.
When the task is null, show a "task not found" message and close the screen. Catch the failure in the ViewModel, or have updateTask return a result instead of throwing.
Review: Code path verified: the exception is thrown inside viewModelScope.launch with no handler, so it propagates to the thread's uncaught-exception handler (app crash). loadTask leaves an empty form for a null task (lines 140-143). The trigger requires the task to be present when the edit screen opens, then absent at save: only via the refresh overwrite window (finding 14/25) with a very fast user, or a concurrent delete. Rare; reviewed LOW. Related open issue #858 (unhandled exception in refresh inside viewModelScope.launch) is a different path.
Upstream: No exact upstream report; related items: #858.
app/src/main/java/com/example/android/architecture/blueprints/todoapp/data/source/local/TaskDao.kt:44 (line corrected on review; the report cites :45)From the report
fun observeById(taskId: String): Flow<LocalTask>
The repository exposes Flow<Task?> and the detail ViewModel has a dedicated "task not found" branch. Because the DAO promises a non-null row, a missing or just-deleted task fails inside the stream instead of emitting null. The user sees the generic "error while loading" message and the stream stops. The in-memory test fakes emit null, so the tests hide the difference.
Declare Flow<LocalTask?>, map with it?.toExternal(), and add a test that deletes the observed task.
Review: With the real repository it.toExternal() returns a non-null Task, so handleTask(null) can never run: the "task not found" branch is dead on the real path (certain from types). When the row disappears Room (2.6.1, libs.versions.toml:53) cannot emit a valid LocalTask; the stream throws and .catch { emit(Async.Error(R.string.loading_task_error)) } (TaskDetailViewModel.kt:64) shows the generic error (exact exception type depends on Room's generated code; not run). Test hiding is confirmed: FakeTaskRepository.getTaskStream returns Flow<Task?> with firstOrNull (FakeTaskRepository.kt:69-73) and FakeTaskDao.observeById is TODO("Not implemented") (FakeTaskDao.kt:76); no test touches the real DAO stream. Visible effect: an error message instead of "not found", and a brief error state before the delete navigation in TaskDetailScreen.kt:93-97. Fair: LOW.
Upstream: No matching upstream report found (11 October 2026).
From the report
modifier = modifier.padding(paddingValues)
The padded modifier goes to the refresh container (line 85) and is also the base of commonModifier (line 77), which the inner column uses. The top-bar and system-bar insets are therefore added twice, and the statistics text sits visibly lower than on the other screens. The screen's own modifier parameter is also reused on both the Scaffold and the content.
Pass Modifier.padding(paddingValues) to the content. Apply the insets once on the outer container, and build the inner modifier from a fresh Modifier.
Review: modifier already includes padding(paddingValues) and is applied to the SwipeRefresh container (via LoadingContent, ComposeUtils.kt:49-52) and again, through commonModifier, to the inner Column: the scaffold insets are applied twice in the non-empty state (the empty state renders the Text directly and pads once). Also the screen's own modifier is applied to the Scaffold (:51) and re-used. Cosmetic (extra gap below the top bar). Fair: LOW.
Upstream: No matching upstream report found (11 October 2026).
Info after review (12)
gradle.properties:19From the report
android.enableJetifier=true
Jetifier rewrites every dependency at build time. On an AndroidX-only project it only adds build time.
Set android.enableJetifier=false and confirm the build still passes.
Review: The setting exists. No com.android.support reference appears in the sources, toml or manifest (only the word supportsRtl), but whether every transitive dependency is AndroidX cannot be established without resolving the dependency graph, so the "set it false" advice is unproven. Costs build time only.
app/src/main/java/com/example/android/architecture/blueprints/todoapp/util/ComposeUtils.kt:25From the report
val primaryDarkColor: Color = Color(0xFF263238)
The same value is declared in TodoTheme, here, and in colors.xml. The drawer header paints this constant but takes its text colour from the theme, so any theme change (such as a future dark scheme) breaks the pairing.
Use MaterialTheme.colorScheme.primary and onPrimary in the drawer header, and delete the constant.
Review: Same value as TodoTheme.kt:12 and colors.xml:19. Used at TodoDrawer.kt:119 with text colorScheme.surface (line 131); works today. Duplication, no defect.
app/src/main/java/com/example/android/architecture/blueprints/todoapp/tasks/TasksScreen.kt:232From the report
private fun TasksContentPreview() {
MaterialTheme {
Surface {
The previews show a different colour scheme from the one the app renders, so visual reviews are misleading.
Wrap every preview in TodoTheme.
Review: MaterialTheme { appears at TasksScreen.kt:233, 283, 315, 333; TodoTheme { at :302 and in TodoDrawer.kt:179 and TopAppBars.kt:194, 204, 214, 224. So "most previews" is false repo-wide: it is 4 of 5 in one file. Preview-only code.
gradle/libs.versions.toml:11From the report
androidxCompose = "1.2.0"
The entry is unused, but it suggests a Compose version far older than the APIs the code relies on. That misleads readers and tooling.
Remove it so the Compose BOM is the only version source.
Review: Only occurrence of the key in the repo (no version.ref = "androidxCompose"). Harmless.
priority field is dropped in the round tripapp/src/main/java/com/example/android/architecture/blueprints/todoapp/data/ModelMappingExt.kt:73From the report
fun LocalTask.toNetwork() = NetworkTask(
id = id,
title = title,
NetworkTask has a priority field that the local and external models do not store. Each push would write it back as null. Nothing in this codebase sets a priority today, but it would be silently erased once a real backend supplies one.
Carry priority through the local model (with a Room migration), or remove it from the network model until it is supported.
Review: NetworkTask.priority: Int? = null (NetworkTask.kt) is never set anywhere in the repo; toLocal() (62-67) does not read it either. Hypothetical, as the report admits.
From the report
localDataSource.updateCompleted(taskId = taskId, completed = true)
saveTasksToNetwork()
Seven methods repeat the same pair of calls. Every current path does call the push, but a future write method that forgets it would silently leave the network copy out of date.
Route all mutations through one private helper that performs the local write and schedules the sync.
Review: Seven methods call saveTasksToNetwork() (createTask, updateTask, completeTask, activateTask, clearCompletedTasks, deleteAllTasks, deleteTask). The report concedes "Every current path does call the push". Refactoring suggestion only.
From the report
val isEmpty
get() = title.isEmpty() || description.isEmpty()
Only the add/edit ViewModel checks this. Repository create and update calls, and data imported from the network, can write rows that break it.
Validate in createTask and updateTask, and filter or normalise imported rows.
Review: Real validation lives at AddEditTaskViewModel.kt:70. Task.isEmpty is never read anywhere (grep), so the cited line is dead code. titleForList (Task.kt:37-38) shows the model deliberately tolerates an empty title. UI-level validation is a normal design choice in a sample.
app/src/main/java/com/example/android/architecture/blueprints/todoapp/tasks/TasksViewModel.kt:140From the report
_isLoading.value = true
viewModelScope.launch {
taskRepository.refresh()
A pull-to-refresh and a menu refresh can run at the same time. The first to finish clears the loading flag while the other is still running. The same pattern appears in the detail and statistics ViewModels.
Keep a refreshJob and return early while it is active.
Review: True for Tasks and TaskDetail (TaskDetailViewModel.kt:111-117). The claim "the same pattern appears in the statistics ViewModel" is wrong: StatisticsViewModel.refresh() (63-67) has no loading flag at all. Cosmetic.
From the report
// TODO: Show error message?
Unresolved questions, such as error display on statistics and the sync-failure strategy in the repository, are not recorded anywhere a maintainer would look. The only documents in the repository are README.md and CONTRIBUTING.md (searched **/*.md).
Keep a short "open decisions" section in a design document and link the TODOs to it.
Review: Same line as finding 4 (StatisticsViewModel.kt:75). Only README.md and CONTRIBUTING.md exist (find). A process remark, not a defect; duplicates 4.
README.md:21From the report
For more information, see the [app's specification](https://github.com/googlesamples/android-architecture/wiki/To-do-app-specification).
The repository itself contains no worked example of the main flow (create, complete, filter, clear, refresh). The external link can drift from the code.
Add a short written walk-through of the main flow to the repository docs.
Review: Accurate quote; not a defect.
app/src/main/java/com/example/android/architecture/blueprints/todoapp/tasks/TasksViewModel.kt:148From the report
private fun filterTasks(tasks: List<Task>, filteringType: TasksFilterType): List<Task> {
filterTasks and getFilterUiInfo hold no state but can only be tested through the ViewModel. The statistics feature already keeps its pure logic in a separate utility file.
Move both to top-level functions next to TasksFilterType and unit-test them directly.
Review: Accurate; style preference.
From the report
private val _userMessage: MutableStateFlow<Int?> = MutableStateFlow(null)
The same message state, the show/clear functions and the LaunchedEffect snackbar block are repeated in the tasks, task-detail and add/edit ViewModel/screen pairs (TaskDetailScreen.kt:84, TasksScreen.kt:112, AddEditTaskScreen.kt:98). Fixes such as message de-duplication have to be made three times.
Extract one small message holder and one SnackbarMessageEffect composable into util/, and use them in all three places.
Review: Duplication confirmed in TasksViewModel.kt:67 and TaskDetailViewModel.kt:59 and the LaunchedEffect blocks (TaskDetailScreen.kt:84-86, TasksScreen.kt:112, AddEditTaskScreen.kt:98-103). AddEdit keeps the message inside its UiState rather than a separate flow, so it is similar not identical. Duplication in a sample.