Sample audits · Open-source projects and documentation, eight more

android/nowinandroid

Google's reference app for modern Android development.

Auditedandroid/nowinandroid at commit a49ed253d75e61a2b6ab80a8da677b57437b08eb
Date11 October 2026
How it rancloud session, full audit, Standard review
Verdict after reviewPass with notes (rule: Fail if a High finding remains after review, otherwise Pass with notes)
0
High after review
3
Medium after review
9
Low after review
15
Info after review
0
excluded on review
How to read this page

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; 3 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 (3)

22. Medium A preferences migration is never registered, so upgrading users lose follows and bookmarks
First rating: High · Reviewed rating: Medium · Review: rated too high
From the report
Evidence
migrations = listOf(
    IntToStringIdsMigration,
),
Why it matters

ListToMapMigration exists and has unit tests, but nothing in the code registers it (searched the whole repository; it is used only in its own file and its tests). The app reads only the map fields. Users whose stored data still uses the older list fields keep their followed topics, authors and bookmarks there, and that data silently disappears from the app. This is loss of user data on upgrade.

Suggested fix, not tested

Register it after the existing migration: listOf(IntToStringIdsMigration, ListToMapMigration). Add a test that runs the real DataStore factory with a legacy file.

Review: The defect is real. DataStoreModule.kt:53-55 lists only IntToStringIdsMigration, and grep ListToMapMigration finds the object only in its own file and its test. IntToStringIdsMigration writes into deprecatedFollowedTopicIds, and ListToMapMigration is the only code that moves those into the map fields. The app reads only followedTopicIds.keys and bookmarkedNewsResourceIds.keys (NiaPreferencesDataSource.kt:26-30), so legacy list data is dropped. It is not HIGH because it only hits installs whose stored file predates the map format, and it loses follows and bookmarks (re-doable by the user), not content or credentials. It would be HIGH only if the project has such installs in the field.

Upstream: No matching report among the project's newest 1,000 issues and pull requests (11 October 2026).

23. Medium The search index gains duplicate rows on every successful sync
First rating: Medium · Reviewed rating: Medium · Review: confirmed
From the report
Evidence
newsResourceFtsDao.insertAll(
    newsResourceDao.getNewsResources(useFilterTopicIds = false, useFilterNewsIds = false)
        .first().map(PopulatedNewsResource::asFtsEntity)
Why it matters

The full-text entities have no stable row key, so "replace on conflict" never fires. Each sync appends a full copy of every article and topic, and nothing ever deletes the old rows. Storage grows without bound, and counts include the duplicates.

Suggested fix, not tested

Give the full-text rows a stable rowid, or clear and rebuild both tables in one transaction. Skip the rebuild when nothing changed.

Review: NewsResourceFtsEntity and TopicFtsEntity declare no key, and the Room schema shows "primaryKey": {"columnNames": []} for newsResourcesFts. SyncWorker.kt:81 calls populateFtsData() after every successful sync, and insertAll with REPLACE therefore appends a full copy each time, with no delete anywhere. User-visible search results are masked by .toSet() in searchContents, so the effect is unbounded storage and FTS index growth, plus an inflated getSearchContentsCount, which only feeds the SEARCH_MIN_FTS_ENTITY_COUNT threshold. Borderline MEDIUM/LOW because the demo dataset is small (311 items).

Upstream: Already reported upstream: #1267 (issue, open, bug), #1268 (PR, open).

24. Medium A failed push-topic subscription aborts the whole data sync
First rating: Medium · Reviewed rating: Medium · Review: confirmed
From the report
Evidence
syncSubscriber.subscribe()

// First sync the repositories in parallel
Why it matters

The subscription runs before any data is fetched and is not guarded. On a device without Play Services, or after a transient messaging error, the worker fails and no content syncs.

Suggested fix, not tested

Wrap the call in a try/catch that rethrows cancellation and logs everything else, or run it after the data sync.

Review: SyncWorker.kt:70 calls syncSubscriber.subscribe() unguarded before any data work. The prod FirebaseSyncSubscriber does firebaseMessaging.subscribeToTopic(SYNC_TOPIC).await(), which throws when the task fails (for example no Play Services), and the exception ends doWork before the repositories sync. Only the prod flavor has this. The demo flavor binds StubSyncSubscriber, which only logs. The README also says the prod backend is not publicly available.

Upstream: No matching report among the project's newest 1,000 issues and pull requests (11 October 2026).

Low after review (9)

1. Low Opening an article link can crash the app and accepts any URI scheme
First rating: Medium · Reviewed rating: Low · Review: rated too high
From the report
Evidence
customTabsIntent.launchUrl(context, uri)
Why it matters

The URI comes straight from server data (Uri.parse(userNewsResource.url) at NewsFeed.kt:74, NewsResourceCardList.kt:46 and ForYouScreen.kt:479). The ForYouScreen.kt call runs automatically from a deep link, with no tap. If the device or profile has no browser, ActivityNotFoundException is not caught, so the app crashes on the main reading path. A non-web scheme in the data is handed to whatever app claims it.

Suggested fix, not tested

Before launching, accept only http and https (and ideally check the host). Resolve the intent or catch ActivityNotFoundException, and fall back to a snackbar.

Review: NewsFeed.kt:102 does call launchUrl with no ActivityNotFoundException handling, so a device with no browser would crash. Almost all Android devices have a browser. The deep link carries only a news-resource id (ForYouViewModel.kt:57-72, URL read from the local DB), so the deep link cannot inject a URL. The "any scheme" half needs a compromised backend. Real but low.

4. Low Full-text search passes raw user input into MATCH
First rating: Low · Reviewed rating: Low · Review: confirmed
From the report
Evidence
@Query("SELECT topicId FROM topicsFts WHERE topicsFts MATCH :query")
Why it matters

The caller builds the query as "*$searchQuery*". Quotes or FTS operators typed by the user produce a malformed expression, which shows up only as a generic load-failed state. NewsResourceFtsDao.kt:34 has the same pattern.

Suggested fix, not tested

Escape or quote user input in one helper before it reaches MATCH.

Review: LOW.

10. Low A missing backend URL silently falls back to a cleartext placeholder
First rating: Medium · Reviewed rating: Low · Review: rated too high
From the report
Evidence
}.orElse("http://example.com")
Why it matters

A build without local.properties (for example on CI) compiles successfully but points the prod network layer at a placeholder http host. Cleartext traffic is blocked on modern Android, and sync errors are only logged (finding 11), so the misconfiguration is invisible.

Suggested fix, not tested

Fail prod and release builds when BACKEND_URL is absent, require https://, and keep a placeholder only for explicitly local builds.

Review: core/network/build.gradle.kts:57 falls back to http://example.com. README:39 and 77-78 state that prod needs a backend that is not public, and the release workflow builds demoRelease, so the placeholder is deliberate for a sample. Cleartext is blocked on modern Android, and no usesCleartextTraffic appears in any manifest, so it fails closed. Only the prod flavor reads it.

11. Low Sync failures are logged at info level only, with no staleness signal
First rating: Low · Reviewed rating: Low · Review: confirmed
From the report
Evidence
Log.i("suspendRunCatching", "Failed to evaluate a suspendRunCatchingBlock. Returning failure Result", exception)
Why it matters

A broken backend looks healthy. Users see stale content with no explanation, and permanent errors are retried just like transient ones.

Suggested fix, not tested

Log at warning or error level and report the failure. Record the last successful sync time and show stale data in the UI. Tell permanent errors apart from transient ones.

Review: Log.i at SyncUtilities.kt:60-63; sync failures return Result.retry(). A logging-level and observability issue.

12. Low The network model is stricter than the stored model: a missing header image fails the whole sync
First rating: Medium · Reviewed rating: Low · Review: rated too high
From the report
Evidence
val headerImageUrl: String,
Why it matters

The database entity, the domain model and the card all treat the header image as optional. A single item with no image causes a deserialization error, the news fetch fails, and the worker retries forever on the same payload.

Suggested fix, not tested

Declare val headerImageUrl: String? = null. Give other non-critical fields defaults too, and consider decoding item by item so one bad record is skipped.

Review: NetworkNewsResource.kt:34 is val headerImageUrl: String while the entity and domain model use String?. The mismatch is real, but all 311 items in core/network/src/main/assets/news.json carry the field, and test data uses "" for "no image", which suggests the backend sends an empty string. Failure needs a backend contract change.

16. Low Preference writes handle disk errors inconsistently, and the unguarded ones can crash Settings
First rating: Medium · Reviewed rating: Low · Review: rated too high
From the report
Evidence
suspend fun setThemeBrand(themeBrand: ThemeBrand) {
    userPreferences.updateData {
Why it matters

Some write methods catch IOException (lines 62–74 and 123–137). setThemeBrand, setDynamicColorPreference, setDarkThemeConfig, setNewsResourcesViewed and setShouldHideOnboarding do not. SettingsViewModel.kt:57–71 calls them in viewModelScope.launch with no exception handler, so a disk-write failure while changing a setting crashes the app.

Suggested fix, not tested

Wrap every updateData call in one shared helper that handles IOException the same way.

Review: Verified: setThemeBrand, setDynamicColorPreference, setDarkThemeConfig, setNewsResourcesViewed and setShouldHideOnboarding lack the IOException catch (lines 93-165, 189), and SettingsViewModel.kt:~57-71 launches them with no handler, so an IOException would crash. An IOException on a small DataStore file is rare, so LOW.

25. Low A failed sync-cursor write is swallowed, so the sync reports success
First rating: Medium · Reviewed rating: Low · Review: rated too high
From the report
Evidence
} catch (ioException: IOException) {
    Log.e("NiaPreferences", "Failed to update user preferences", ioException)
}
Why it matters

The sync helper treats the version update as done and returns success. On the next run everything is fetched again, and the first-sync path marks every article as viewed again.

Suggested fix, not tested

Let the exception propagate so the sync reports failure and is retried, or have the update return a success flag that the caller checks.

Review: Confirmed that updateChangeListVersion (NiaPreferencesDataSource.kt:184) swallows IOException, so changeListSync returns true. The consequence is self-healing: the next sync re-fetches from the old cursor and upserts idempotently. The "viewed again" effect only occurs when the cursor was never written (first sync) and is harmless. An IOException on the small DataStore file is rare.

26. Low Undo after removing bookmarks only remembers the last item
First rating: Medium · Reviewed rating: Low · Review: rated too high
From the report
Evidence
shouldDisplayUndoBookmark = true
lastRemovedBookmarkId = newsResourceId
userDataRepository.setNewsResourceBookmarked(newsResourceId, false)
Why it matters

If the user removes A and then B while the snackbar for A is still showing, the flag is already true, so no new snackbar appears. Undo then restores only B, and A's removal can no longer be undone.

Suggested fix, not tested

Keep the removed ids in a list (or restart the snackbar for each removal), and make undo restore the item the snackbar refers to.

Review: Confirmed (BookmarksViewModel.kt:57-62, LaunchedEffect(shouldDisplayUndoBookmark) in BookmarksScreen.kt:119). Removing A then B while A's snackbar shows leaves the flag true, and undo restores B only. It is a minor UX gap with no data loss.

27. Low Release builds are signed with the debug key
First rating: Medium · Reviewed rating: Low · Review: rated too high
From the report
Evidence
// TODO: Abstract the signing configuration to a separate file to avoid hardcoding this.
signingConfig = signingConfigs.named("debug").get()
Why it matters

The release workflow publishes an APK built this way. Anyone with the widely known debug key can produce an "update" that installs over it. The comment says this is a deliberate convenience, but it should never apply to published artifacts.

Suggested fix, not tested

Sign releases with a dedicated keystore read from CI secrets. Allow the debug-key fallback only behind an explicit local-only flag, and make CI fail when a release is debug-signed.

Review: app/build.gradle.kts:52-55 has a comment saying it is deliberate so anyone who clones can run release. The report's claim that the debug key is "widely known" is wrong: the Android debug keystore is generated per machine (on CI, fresh per run), so there is no shared key for an attacker to use. The release workflow creates a draft release (Release.yml:71, draft: true) of the demo flavor only. Real hygiene issue for a template, not an exploitable hole.

Info after review (15)

2. Info Jetifier state is not pinned
First rating: Low · Reviewed rating: Info · Review: rated too high
From the report
Evidence
android.useAndroidX=true
Why it matters

No android.enableJetifier entry exists in any properties, Gradle or TOML file. The intended "off" state relies on the build tool's default, which can change or be toggled by accident and slow builds.

Suggested fix, not tested

Add android.enableJetifier=false next to android.useAndroidX=true.

Review: AGP defaults Jetifier to off, and no enableJetifier appears anywhere; pinning is a nicety.

3. Info Deep-link entry has no guard against duplicate delivery
First rating: Low · Reviewed rating: Info · Review: rated too high
From the report
Evidence
<activity android:name=".MainActivity" android:configChanges="uiMode" android:exported="true">
Why it matters

The activity declares a browsable https filter but sets no launch mode and does not handle repeated intents idempotently. A notification tap and a link tap can stack instances or process the same link twice.

Suggested fix, not tested

Use singleTop (or singleTask) and handle onNewIntent idempotently by remembering the last handled link. Add android:autoVerify="true" for App Links.

Review: ForYouViewModel.onDeepLinkOpened (line 130-133) clears the stored link id after it is handled, which mitigates double processing.

5. Info Credential review: only a placeholder config was found
First rating: Info · Reviewed rating: Info · Review: confirmed
From the report
Evidence
"current_key": "APla…"
Why it matters

This is a placeholder API key (the project id is a dummy). No real credentials or private keys were found anywhere in the code.

Suggested fix, not tested

No action needed. For production builds, inject the real file from CI secrets and keep it out of the repository.

Review: Placeholder keys (APlaceholderAPIKeyWith-ThirtyNineCharsX), correct as stated.

6. Info Two gesture handlers write the scrollbar thumb position
First rating: Low · Reviewed rating: Info · Review: could not be settled
From the report
Evidence
LaunchedEffect(Unit) { snapshotFlow { draggedOffset }.collect { ... onThumbMoved(currentTravel) ...
Why it matters

The press-to-scroll loop (around line 371) and the drag handler both call onThumbMoved. If the user presses the track and then drags during the press-scroll window, both write the position and the thumb can jitter.

Suggested fix, not tested

Keep one gesture state (idle, pressing or dragging) and cancel the press loop when a drag starts. Use collectLatest for the press loop.

Review: Press loop and drag handler both call onThumbMoved (Scrollbar.kt:371-418), and detectTapGestures and drag detectors run on the same node, so overlap is conceivable. Cosmetic only and needs a device, so CANNOT-VERIFY by reading.

7. Info Compose Foundation is pinned separately from the BOM, at an alpha version
First rating: Low · Reviewed rating: Info · Review: rated too high
From the report
Evidence
androidxComposeFoundation = "1.8.0-alpha07"
Why it matters

The BOM resolves a newer version anyway, so the pin misstates which API surface the code builds against. Both come from the alpha channel.

Suggested fix, not tested

Let the BOM manage Foundation (drop the explicit version). Use the stable BOM for release builds, or document why alpha is needed.

Review: Cited line 79 is the library alias. The version is at line 11. The BOM is compose-bom-alpha, so the alpha channel is deliberate.

8. Info The "onboarding hidden requires followed topics" rule is enforced on only some write paths
First rating: Low · Reviewed rating: Info · Review: rated too high
From the report
Evidence
suspend fun setShouldHideOnboarding(shouldHideOnboarding: Boolean) {
    userPreferences.updateData {
        it.copy { this.shouldHideOnboarding = shouldHideOnboarding }
Why it matters

Two other writers re-check this rule (lines 68 and 85), but this one does not. The stored pair can disagree with what the For You screen and the news sync expect.

Suggested fix, not tested

Send every writer of these two fields through one function that enforces the rule, and document the rule next to the stored fields.

Review: The rule is enforced in the UI. isDismissable gates the dismiss button (ForYouScreen.kt:301), so the unguarded writer is not reachable in the wrong state.

9. Info Placeholder topic rows are marked only by empty strings
First rating: Low · Reviewed rating: Info · Review: rated too high
From the report
Evidence
TopicEntity(
    id = topicId,
    name = "",
Why it matters

News sync inserts shell topic rows to satisfy the foreign key. No reader filters them, so if the topic sync never fills a row in, a blank topic that can be followed appears in the topics list.

Suggested fix, not tested

Add an explicit placeholder flag (or filter blank names in readers), and check after a full sync that no placeholder rows remain.

Review: The shells are replaced by topic sync, which fetches all topics. A leftover shell needs a backend invariant break.

13. Info Topic sync has no size cap on server-driven input
First rating: Low · Reviewed rating: Info · Review: rated too high
From the report
Evidence
val networkTopics = network.getTopics(ids = changedIds)
Why it matters

News sync fetches in chunks, but topic sync sends every changed id in one request. The HTTP client also sets no explicit timeouts and no response-size limit.

Suggested fix, not tested

Fetch topics in chunks the same way news is fetched, cap the size of accepted change lists and responses, and set OkHttp timeouts.

Review: Topics are not chunked (confirmed), but the topic set is small. "No explicit timeouts" is imprecise because OkHttp has 10-second defaults.

14. Info Product tuning values are compiled into the client
First rating: Info · Reviewed rating: Info · Review: confirmed
From the report
Evidence
private const val MAX_NUM_NOTIFICATIONS = 5
Why it matters

The notification cap, the sync batch size and the minimum search length can only change with an app release.

Suggested fix, not tested

If these should be tunable, serve them from versioned remote config. Otherwise, document them as deliberate client constants.

Review: Observations, not defects.

15. Info User-facing text has no override path
First rating: Info · Reviewed rating: Info · Review: confirmed
From the report
Evidence
.setContentTitle(getString(R.string.sync_work_notification_title))
Why it matters

A fix to the wording of a notification or other copy requires an app update.

Suggested fix, not tested

Only if you need it: resolve strings through a helper that checks remote overrides first and falls back to the bundled resources.

Review: Observations, not defects.

17. Info The flow sharing timeout is copied as a literal into about 14 call sites
First rating: Low · Reviewed rating: Info · Review: rated too high
From the report
Evidence
started = SharingStarted.WhileSubscribed(5_000),
Why it matters

The same policy is written as 5_000 in about 14 places across 8 files, and as 5.seconds.inWholeMilliseconds in SettingsViewModel. Changing it means editing every copy, and one is easy to miss.

Suggested fix, not tested

Define one shared constant or stateIn helper in a core module and use it everywhere.

Review: 14 copies of 5_000 counted; a style point.

18. Info UI-state hierarchies repeat the same loading, success and error shape
First rating: Info · Reviewed rating: Info · Review: confirmed
From the report
Evidence
sealed interface TopicUiState { data class Success(...); data object Error; data object Loading }
Why it matters

About eight screens repeat this skeleton. It is likely intentional, but nothing records that decision.

Suggested fix, not tested

Document the per-screen choice, or introduce a shared generic type for the simple cases.

Review: Observations, not defects.

19. Info The release APK is published without a checksum
First rating: Low · Reviewed rating: Info · Review: rated too high
From the report
Evidence
- name: Upload app
  uses: actions/upload-release-asset@v1
Why it matters

People who download the APK cannot check that it is intact.

Suggested fix, not tested

Publish a SHA-256 hash and the file size next to the APK. The same applies to the copies made by build_android_release.sh.

Review: Release.yml creates a draft release (draft: true), so a human publishes it; the report's "still produces a public release" is wrong. The job also has if: github.repository == 'android/nowinandroid'.

20. Info The release workflow publishes without running verification
First rating: Low · Reviewed rating: Info · Review: rated too high
From the report
Evidence
- name: Build release variant including baseline profile generation
  run: ./gradlew :app:assembleDemoRelease ...
Why it matters

A tag on a commit that CI never validated still produces a public release.

Suggested fix, not tested

Run the tests and a smoke check before the release is created, or make the release job depend on a green build of the same commit.

Review: Release.yml creates a draft release (draft: true), so a human publishes it; the report's "still produces a public release" is wrong. The job also has if: github.repository == 'android/nowinandroid'.

21. Info The release script copies artifacts without checking the build result or the destination
First rating: Low · Reviewed rating: Info · Review: rated too high
From the report
Evidence
cp $APP_OUT/apk/prod/release/app-prod-release.apk $DIST_DIR/app-prod-release.apk
Why it matters

After a failed build, the script still copies the files, and it silently overwrites earlier artifacts. The variables are unquoted.

Suggested fix, not tested

Check that DIST_DIR is set, and copy only when the build succeeded. Refuse to overwrite existing files, or ask first. Quote the variables.

Review: The file header says "IGNORE this file, it's only used in the internal Google release process", and exit $BUILD_RESULT preserves the build status.

Want this for your code?

Upload a ZIP or link a public GitHub repo, pick the areas and the depth, and get findings by severity with fixes.