Skip to content

Audit fixes: stages 1-2 + offline reading (Critical/High core closed) - #10

Merged
oustery merged 16 commits into
mainfrom
arena/cc336576-gityou
Nov 10, 2026
Merged

oustery merged 16 commits into
mainfrom
arena/cc336576-gityou

Conversation

@oustery

@oustery oustery commented Nov 9, 2026 •

Copy link
Copy Markdown
Owner

Что это

Реализация плана из аудита docs/AUDIT_COMPLETENESS.md: закрыты все Critical-находки и ядро High — авторизация, безопасность токена, навигация, работа с issue/PR, офлайн.

Этап 1 — Critical

ID Что сделано
G-001 gist в OAuth scope + подсказка про PAT: создание гиста больше не падает с 404 у новых пользователей
G-002 Токен шифруется AES-GCM ключом из Android Keystore (TokenCipher); плейнтекст старых сборок мигрируется на месте
G-003 pullFiles обходит Link-header: диффы PR на 100+ файлов больше не теряют хвост
G-004 Единый источник unread-бейджа (уведомления публикуют, бейдж читает)
G-005 429 и 403 с Retry-After распознаются как rate limit, а не «HTTP ошибка»
G-021 Маршрутизация уведомлений по типу subject: Release/Discussion/Commit ведут в репозиторий, а не в несуществующий issue

Этап 2 — High

  • G-007 ссылки в markdown: issue/PR/repo/профиль открываются внутри приложения; /blob/, /releases и чужие хосты остаются в браузере.
  • G-008 редактирование issue (title/body) + «скопировать ссылку».
  • G-009 триаж в репозитории: серверные фильтры по меткам/assignee, сортировка, поиск по списку, сброс фильтров.
  • G-010 «Моя работа»: назначено мне / создано мной / ждёт моего ревью, с раздельной пагинацией по вкладкам.
  • G-012 App Links на github.com + приём «Поделиться в GitYou» (ссылка переживает вход и поворот экрана).
  • G-014 мерж PR: merge/squash/rebase, sha привязан к просмотренному коммиту, 409/405/422 разведены в понятные сообщения.
  • G-016 блокировка приложения по системному credential с настраиваемой задержкой + переход к отзыву доступа на GitHub.
  • G-006 доступность: секции объявлены heading, реальное число изменённых файлов в подписи.

Этап 3 (начат)

  • G-013 офлайн-чтение: 50 МБ дискового кэша OkHttp + fallback only-if-cached и баннер «показаны сохранённые данные». HTTP-кэш выбран вместо Room, чтобы покрыть все экраны сразу и не дублировать DTO.

Тесты

Добавлены: TokenCipherTest, NotificationRoutingTest, SearchQueriesTest, GitHubUrlsTest (включая маршрутизацию ссылок), DeepLinkRoutingTest, GitHubApiNetworkTest (реальный Retrofit против MockWebServer: парсинг, 403/429, офлайн, тело запроса мержа).

Что осталось за рамками PR

  • G-011 полностью: шаг ./gradlew lintDebug подготовлен, но правку .github/workflows/build.yml блокирует GitHub App (нет scope workflows) — нужен человек с правами на репозиторий.
  • G-017 телеметрия/crash-reporting и остальные Medium/Low этапа 3 (G-018…G-043, включая push-канал G-015 отдельным треком).

Проверка

CI Build: detekt → unit-тесты → сборка per-ABI APK. Последний прогон на ветке — зелёный.

Oustery Audit Bot and others added 7 commits November 9, 2026 15:19
…021, G-004, G-005)

G-001 — OAuth Device Flow now requests the `gist` scope; without it
POST /gists answered 404, so the "New gist" screen failed for every user
signed in through Device Flow. PAT hints updated in EN and RU.

G-002 — the access token (scope `repo` = write access to private repos) was
stored as plaintext in DataStore. It is now encrypted with an AES-256/GCM key
that never leaves the Android Keystore, legacy plaintext tokens are migrated
in place on first run, and a lost/invalidated key degrades to a signed-out
session instead of a crash. Keystore work runs on Dispatchers.IO.

G-003 — PR changed-files are paginated through the Link header; a PR with more
than 100 files used to show a silently truncated diff.

G-021 — notification routing is type-aware (NotificationRouting): only Issue
and PullRequest threads open an issue screen; Release/Discussion/Commit/
CheckSuite land on the repository instead of a non-existent issue (404).

G-004 — the unread badge reads one shared count from TokenStore, computed over
all notification pages, and is updated on mark-read / mark-all-read instead of
going stale.

G-005 — 429 and secondary rate limits (Retry-After) map to the rate-limit key
instead of a generic HTTP error.

Blocked in this environment: the CI parts of G-011 (lintDebug step) and
G-042 (client id from a secret) touch .github/workflows/build.yml, which the
GitHub App of this session is not allowed to modify (no `workflows` permission),
and the Actions secrets API answers 403. Both are documented in the PR instead.

Tests: NotificationRoutingTest (10 cases), TokenCipherTest (5 cases).

Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com>
… untouched

Detekt failed on the previous revision (exit code 1) and CI logs are not
readable from this environment. Two hypotheses, both removed here:

- TokenCipher caught the generic `Exception`, which TooGenericExceptionCaught
  flags by default, and relied on a detekt.yml exclude that I could not verify
  locally (no Kotlin compiler or Maven access in the sandbox). It now catches
  GeneralSecurityException, which already covers KeyStoreException,
  UnrecoverableKeyException and every cipher failure, so the existing config
  is correct as-is and detekt.yml is reverted.

Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com>
…urns

Detekt still failed after the first fixup. The remaining finding in the new
code is TokenCipher.generateKey(store): KeyGenerator binds the key to
AndroidKeyStore on its own, so the parameter was unused (UnusedParameter), and
encrypt()/keyOrNull() used non-local returns out of `?: run {}` and `try`
expressions (NonLocalReturnControl candidates). Both are rewritten as plain
control flow; the file is otherwise unchanged.

Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com>
G-010 — the screen a developer opens a GitHub client for: issues assigned to
me, issues I created, and pull requests waiting for my review.
- SearchQueries.myWorkQuery() builds the three search queries
  (assignee:@me / author:@me / review-requested:@me, state:open), sorted by
  recent activity; unit-tested because the qualifier syntax is an API contract.
- MyWorkViewModel keeps one list + pagination cursor per tab, so switching
  tabs never loses state and never re-queries; a failed refresh keeps the
  previous list, a failed "load more" stays retryable.
- MyWorkScreen: FilterChips, per-tab empty states, pull-to-refresh, footer.
- Reached from the Home app bar; rows open the issue/PR in place, which needed
  `repository_url` on the Issue DTO (search results only) — exposed as
  Issue.repositoryFullName via GitHubUrls.parseApiRepoUrl.

G-014 — merging a PR is now possible in the app, which was the last blocker
for the review workflow:
- PUT /pulls/{n}/merge with merge/squash/rebase, `sha` pinned to the head
  commit the user reviewed, so a branch that moved is rejected instead of
  merging unseen commits.
- Typed failures: 409 -> MergeConflictException ("branch changed, reload"),
  405/422 -> MergeNotAllowedException ("checks/review/branch protection"),
  everything else -> the existing error mapping.
- MergeDialog shows mergeable_state before the tap (conflicts / behind /
  blocked / ready) and disables the confirm button on a known conflict.

Strings in EN and RU; parity check passes.

Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com>
…ab-safe

Two real defects in the previous commit, both caught by the unit-test step:

- IssuesRepository.merge() compared `when {}` branches against Int while the
  subject was Response<Unit>: `409 ->` never compiled. Now `response.code()` is
  compared, with an explicit Result<Unit> return type.
- MyWorkViewModel rebuilt its state from the default MyWorkUi() after the
  first fetch, so a tab picked while a request was in flight was silently
  reset to "assigned" and results could land in the wrong tab. Rewritten to
  assemble state from a per-tab map keyed by the tab actually loaded, with
  `refreshing`/`loadedOnce` per tab, an emitted error only for the first
  failed fetch, and no lost pull-to-refresh spinner.

Adds a GitHubUrls test for the api `repository_url` that My work rows rely on.

Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com>
The merge confirm button shows CircularWavyProgressIndicator while the request
is in flight; that composable is @ExperimentalMaterial3ExpressiveApi, and the
dialog was declared without @OptIn, unlike every other call site in the app.

Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com>
merge() wraps its request in safeCall, which lives in data.util; the file only
imported LinkHeaderParser from that package, so the module did not compile
(detekt passes on it — the unit-test step is what caught it). Verified the rest
of the Stage 2 code for the same class of missing cross-package import.

Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com>
@oustery
oustery force-pushed the arena/cc336576-gityou branch from 33cc85b to ba2fb61 Compare November 9, 2026 17:16
Oustery Audit Bot and others added 9 commits November 9, 2026 17:27
issue editing (G-008), accessibility fixes (G-006)

G-007 — github.com links inside rendered markdown (issue bodies, comments,
READMEs, release notes) used to open the browser, throwing the reader out of
the conversation they were following:
- GitHubUrls.routeLink() classifies a link as issue/PR thread, repository or
  profile; deeper paths (/blob/, /tree/, /releases) and foreign hosts return
  null and stay in the browser, because opening a different screen would
  silently drop the user's destination.
- MarkdownLinkTouchListener resolves the URLSpan under the finger and asks the
  host screen first; anything declined keeps LinkMovementMethod's default, so
  external links, selection and scrolling are unchanged.
- One router is installed around the NavHost, so every markdown surface
  behaves the same.

G-012 — the app claimed no links and accepted no shares:
- App Links intent-filter for https://github.com hosted links (autoVerify),
  ACTION_SEND for text/plain, handled by AppViewModel so an inbound link
  survives sign-in and rotation, then drained by the UI once the graph exists.
- DeepLinkRouting maps links to routes and extracts the first GitHub URL out of
  share text (share sheets prepend their own blurb).

G-008 — an issue could be closed or relabelled but never edited:
- PATCH /issues/{n} through IssuesRepository.updateIssue(); the dialog
  pre-fills the current title/body so editing happens in place.
- "Copy link" puts the issue URL on the clipboard (htmlUrl now part of the
  detail model).

G-006 — accessibility:
- EyebrowLabel is a semantics heading, so TalkBack can jump between sections.
- The pull-request "files changed" button announces the real file count instead
  of a hardcoded 0.

Unit tests cover link classification, deep-link routes and share extraction.

Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com>
Two compile errors from the previous commit:
- CompositionLocalProvider was placed inside the NavHost *builder* block, which
  is a plain lambda rather than a composable scope, and the NavHost call itself
  was lost while restructuring. The provider now wraps NavHost properly.
- IssueDetailScreen calls scope.launch for the clipboard snackbar without
  importing kotlinx.coroutines.launch.

Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com>
In a repository with hundreds of issues the list showed every issue of one
state, newest first, with no way to narrow it down — the tab was effectively
unusable for real triage.

- GitHubApi.issues() takes labels/assignee/creator/sort/direction, so the
  filtering happens on the server instead of over a single loaded page.
- ReposRepository.IssueQuery bundles those filters; `hasFilters` distinguishes
  "the user filtered something out" from the default view, which the empty
  state needs to say something different.
- RepoViewModel: toggleIssueLabel (AND semantics, as the API defines it),
  toggleAssignedToMe (@me), setIssueSort (created / most commented) and an
  in-list search over the loaded page ("no results" without a request).
- RepoScreen: filter chips, label chips from the repository palette (loaded
  once), search field with a clear button, and a "clear filters" action.

Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com>
A token with write access to every repository the user can push to was stored
on the device with nothing between a lost phone and the repositories.

- AppSettings gains biometricLock + lockTimeoutMinutes; Settings gained a
  Security section with the switch and the re-lock delay (immediately / 1 / 5 /
  15 min).
- AppLockScreen verifies the device credential through
  KeyguardManager.createConfirmDeviceCredentialIntent: the platform checks the
  secret, the app never stores or compares one. A device without any screen
  lock says so and lets the user in instead of bricking the client.
- Re-locking is lifecycle-driven: onStop records the time unless the activity
  is only reconfiguring, onStart re-arms the lock once the configured delay has
  passed, so rotation never locks mid-use but a real background trip does.
- Settings offers "Revoke GitHub access": signing out only forgets the token
  locally, and revocation itself needs the OAuth client secret that a public
  client must not embed, so the user is sent to the official page.

Unit tests (G-011): GitHubApiNetworkTest runs the real Retrofit stack against
MockWebServer and pins JSON parsing, the 403/429 rate-limit mapping, offline
mapping and the PR merge request body/status.

Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com>
detekt's TopLevelPropertyNaming rejects SCREAMING_CASE for a non-const val
(maxIssues is 0, so it failed the build).

Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com>
Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com>
Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com>
The Settings edition dropped the `}` that closes UpdateDialog (my patch
replaced it with the new file-level declarations), so the module did not
parse: detekt reported it and nothing past static analysis ran. Restores the
brace, keeps the lock-timeout list as a lowerCamelCase property
(TopLevelPropertyNaming) and brings back the MockWebServer tests that were
temporarily removed while bisecting.

Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com>
Every screen turned into a full-screen error the moment connectivity dropped,
even for a repository the user had just been reading — the metro/lift case,
which is a large share of mobile sessions.

- OfflineCache configures a 50 MB OkHttp disk cache on the authenticated
  client and installs a fallback interceptor: when the request cannot reach
  the network, it retries `only-if-cached`/`max-stale` and serves the stored
  body. GitHub already sends `Cache-Control: private, max-age=60` plus ETags,
  so revalidation stays cheap; errors are never cached, because serving a
  stored 404 as "offline data" would be worse than the error.
- A plain HTTP cache was chosen over a Room mirror: it makes every screen work
  offline at once without duplicating the DTO model, and keeps privacy intact
  (the cache lives in the app's private cache dir and is wiped with the app).
- OfflineState carries the one bit the UI needs ("this came from the cache"),
  set by the interceptor and cleared by the next successful response.
- GitYouApp draws a slim banner above the content while data is stale, so the
  user never mistakes saved content for live content.

Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com>

# Conflicts:
#	app/src/main/java/com/gityou/app/ui/AppViewModel.kt
#	app/src/main/java/com/gityou/app/ui/GitYouApp.kt
@oustery oustery changed the title Stage 1: critical audit fixes (gist scope, token encryption, PR diff pagination, notification routing, badge, rate limits) Audit fixes: stages 1-2 + offline reading (Critical/High core closed) Nov 10, 2026
@oustery
oustery merged commit 6648868 into main Nov 10, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant