diff --git a/.claude/skills/release-app/SKILL.md b/.claude/skills/release-app/SKILL.md index d91378a7..13c72568 100644 --- a/.claude/skills/release-app/SKILL.md +++ b/.claude/skills/release-app/SKILL.md @@ -1,34 +1,52 @@ --- name: release-app -description: Use to cut a release of ApkAnalyzer — pick the MAJOR.MINOR.PATCH version, draft release notes, get them approved, and create the annotated tag that drives the release pipeline. Triggered by phrases like "release the app", "cut a release", "ship 3.6.0", "tag a release", "make a new version", "release notes for the next version", "publish to the Play Store", "promote the release to production". +description: Use to cut a release of ApkAnalyzer's phone app, Android TV app, or both — ask which, pick the MAJOR.MINOR.PATCH version, draft release notes, get them approved, and create the annotated tag that drives each release pipeline. Triggered by phrases like "release the app", "cut a release", "ship 3.6.0", "release the TV app", "tag a release", "make a new version", "release notes for the next version", "publish to the Play Store", "promote the release to production". --- # Skill: Cut a Release +The phone app and the TV app release independently, each from its own tag: + +| App | Tag | Workflow | Play track | Promote with | +|---|---|---|---|---| +| Phone | `3.6.0` | [`release.yml`](../../../.github/workflows/release.yml) | `beta` | `Promote release to production` | +| TV | `3.6.0.tv` | [`release-tv.yml`](../../../.github/workflows/release-tv.yml) | `tv:Beta TV` (closed testing) | `Promote TV release to production` | + The tag is the entire release input. It supplies the version name, the version code, and the -release notes, so **the tag must be annotated and its annotation must never be empty** — -[`release.yml`](../../../.github/workflows/release.yml) fails its first job on a lightweight tag, -and a tag that already reached GitHub is painful to replace. +release notes, so **the tag must be annotated and its annotation must never be empty** — both +workflows fail their first job on a lightweight tag, and a tag that already reached GitHub is +painful to replace. Nothing in the repository records the version. `gradle.properties` stays at `version.name=dev`; there is no version-bump commit to make. -## Step 1 — Pick the version +## Step 1 — Ask which app is being released + +**Always ask, even when the request seems to name one.** Offer exactly three choices: phone, TV, +or both. Every later step runs once per chosen app; when releasing both, keep the two tracks of +work visibly separate — two versions, two sets of notes, two tags. -The tag is exactly `MAJOR.MINOR.PATCH` — three numbers, no `v` prefix, no suffix. Anything else -does not match the workflow trigger and silently releases nothing. +## Step 2 — Pick the version + +The phone tag is exactly `MAJOR.MINOR.PATCH`; the TV tag is the same shape with a `.tv` suffix. +No `v` prefix, nothing else. A tag of any other shape matches neither workflow trigger and silently +releases nothing. ```bash git fetch --tags -git tag --sort=-v:refname | head -5 +git tag --list --sort=-v:refname | grep -E '^[0-9]+\.[0-9]+\.[0-9]+$' | head -5 # phone +git tag --list --sort=-v:refname | grep -E '^[0-9]+\.[0-9]+\.[0-9]+\.tv$' | head -5 # TV ``` -Propose the next version from the change set and let the user correct it: patch for fixes only, -minor for new user-visible behaviour, major for a break in how the app is used. `versionCode` is -derived as `MAJOR * 10000 + MINOR * 100 + PATCH`, so the new tag must sort above the last one under -that formula — Play rejects a lower code. +Propose the next version for each chosen app from its own previous tag and let the user correct +it: patch for fixes only, minor for new user-visible behaviour, major for a break in how the app is +used. Each app's version must sort above that app's previous tag — Play rejects a lower version +code. The TV version code carries a fixed offset +([`version-code.sh`](../../../.github/scripts/version-code.sh)), so the two sequences never collide +and need not match. When releasing both, propose the same number for both if it sorts above both +previous tags; it reads better on the GitHub releases page. -## Step 2 — Confirm what is being released +## Step 3 — Confirm what is being released ```bash git status --short @@ -36,15 +54,23 @@ git log --oneline -5 origin/develop ``` Release from `develop` at a commit that is pushed, clean, and green in CI. If CI for that commit -failed or is still running, say so and stop — the release workflow re-runs the same gates and will +failed or is still running, say so and stop — the release workflows re-run the same gates and will fail the same way after the tag is public. -## Step 3 — Draft the release notes +## Step 4 — Draft the release notes + +Draft each chosen app's notes from the changes since that app's previous tag. ```bash -git log --oneline ..origin/develop +git log --oneline ..origin/develop +git log --oneline ..origin/develop -- tv 'feature/*/tv' 'feature/*/api' core ':!core/design-system/phone' ``` +The TV path filter is a starting point, not the answer: a commit in a shared `core` module may +change nothing a TV user can see. Keep only what is observable on TV. If there is no previous `.tv` +tag, this is the first TV release — write notes that introduce what the TV app does instead of a +change list. + Turn the log into notes for users of the app, not for contributors: * Follow the User-Facing Copy rules in [`AGENTS.md`](../../../AGENTS.md) — active voice, present @@ -52,58 +78,62 @@ Turn the log into notes for users of the app, not for contributors: * Group into short bullets: new features first, then improvements, then fixes. * Drop anything a user cannot observe — refactors, dependency bumps, CI and agent-context changes. * Never surface class names, module names, PR numbers, or Android API names. -* Keep it short. The same text becomes the GitHub release body and is read on a phone. +* Keep it short. The same text becomes the GitHub release body and is read on a phone or a TV. -## Step 4 — Get approval, always +## Step 5 — Get approval, always -**Never create the tag from a draft the user has not approved.** Print the proposed version and -the full notes verbatim as plain text in the response itself — not only inside a question tool's -option text, where the user may not see it — then ask for approval or edits. Repeat until the user -approves. If the user supplies their own notes, use them as given rather than rewriting them. +**Never create a tag from a draft the user has not approved.** Print each proposed tag and its full +notes verbatim as plain text in the response itself — not only inside a question tool's option +text, where the user may not see it — then ask for approval or edits. When releasing both, label +the phone and TV drafts clearly. Repeat until the user approves. If the user supplies their own +notes, use them as given rather than rewriting them. -Approving the notes also approves creating and pushing the tag from them — steps 5 and 6 follow +Approving the notes also approves creating and pushing the tags from them — steps 6 and 7 follow without a separate confirmation. -## Step 5 — Create the annotated tag +## Step 6 — Create the annotated tags -Write the approved notes to a scratch file outside the repository and tag from it, so multi-line -notes survive intact: +Write each app's approved notes to its own scratch file outside the repository and tag from it, so +multi-line notes survive intact: ```bash -git tag -a 3.6.0 -F /tmp/release-notes.txt +git tag -a 3.6.0 -F /tmp/release-notes-phone.txt +git tag -a 3.6.0.tv -F /tmp/release-notes-tv.txt ``` -Use `-a` (or `-s`). Never `git tag 3.6.0` on its own, and never an empty or placeholder `-m`. +Use `-a` (or `-s`). Never a bare `git tag `, and never an empty or placeholder `-m`. -Verify before pushing — both checks must pass: +Verify every tag before pushing — both checks must pass: ```bash git cat-file -t 3.6.0 # must print: tag git for-each-ref --format='%(contents)' refs/tags/3.6.0 # must print the approved notes ``` -If either check disagrees, delete the tag locally (`git tag -d 3.6.0`) and redo this step. +If either check disagrees, delete the tag locally (`git tag -d `) and redo this step. -## Step 6 — Push the tag +## Step 7 — Push the tags -Pushing starts the release. The approval gate is Step 4 (notes) and Step 5 (tag verification) — -once both are done, proceed to push: +Pushing starts the release. The approval gate is Step 5 (notes) and Step 6 (tag verification) — +once both are done, push each tag on its own, so a failure is easy to attribute: ```bash git push origin 3.6.0 +git push origin 3.6.0.tv ``` If a bad tag was already pushed, do not force-move it. Delete it on the remote, delete the GitHub release it created, then push a corrected tag — or release the fix as the next patch version. -## Step 7 — Watch the run and promote +## Step 8 — Watch the runs and promote -The release run publishes to the Play **beta** track, never production. Follow it with the -`analyze-ci-failure` skill if a job goes red. +Each release run publishes to its beta track, never production. Follow it with the +`analyze-ci-failure` skill if a job goes red. The first TV release also goes through Play's +Android TV quality review, which can take longer or come back with requested changes. -Production is a separate, deliberate step: the `Promote release to production` workflow, run -manually with the version and a rollout percentage. It promotes whatever currently sits on beta, so -check the beta track first if the release run finished a while ago. +Production is a separate, deliberate step per app: run the matching promote workflow from the +table above manually, with the version and a rollout percentage. It promotes whatever currently +sits on that app's beta track, so check the track first if the release run finished a while ago. Full pipeline reference, including secrets and the promotion rules: [`ci-and-release.md`](../../../docs/engineering/ci-and-release.md). diff --git a/.claude/skills/store-listing-carousel/SKILL.md b/.claude/skills/store-listing-carousel/SKILL.md index 596c7492..8727f76a 100644 --- a/.claude/skills/store-listing-carousel/SKILL.md +++ b/.claude/skills/store-listing-carousel/SKILL.md @@ -1,16 +1,27 @@ --- name: store-listing-carousel -description: Maintain the Apk Analyzer Play Store screenshot carousel templates (docs/store/graphics/carousel_template.html and carousel_template_dark.html) and export their frames as upload-ready PNGs (docs/store/graphics/phone-screenshots/day and night). Covers two independent workflows — editing (swap in new screenshots after a UI change, refresh light/dark, add/remove/reorder a frame) and exporting (render the current frames to individual PNGs for Play Store upload) — either can run without the other. Triggered by phrases like "update the carousel", "refresh the store screenshots", "new Play Store screenshots", "carousel template", "export store screenshots", "generate store images", "render the carousel to PNGs" — even if the user doesn't name this skill explicitly. +description: Maintain the Apk Analyzer Play Store screenshot carousel templates — phone (docs/store/graphics/carousel_template.html and carousel_template_dark.html) and Android TV (carousel_template_tv.html) — and export their frames as upload-ready PNGs (docs/store/graphics/phone-screenshots/day and night, docs/store/graphics/tv-screenshots). Covers two independent workflows — editing (swap in new screenshots after a UI change, refresh light/dark, add/remove/reorder a frame) and exporting (render the current frames to individual PNGs for Play Store upload) — either can run without the other. Triggered by phrases like "update the carousel", "refresh the store screenshots", "new Play Store screenshots", "TV store screenshots", "carousel template", "export store screenshots", "generate store images", "render the carousel to PNGs" — even if the user doesn't name this skill explicitly. --- # Store Listing Carousel -Maintains the two Apk Analyzer Play Store screenshot carousels — -`docs/store/graphics/carousel_template.html` (light) and `carousel_template_dark.html` (dark) — each -a single self-contained HTML file with 8 phone-frame "slides." Every frame shows a real app -screenshot behind a styled device frame, with a themed background, a headline, and a shared -"skyline" motif (rounded-arch + antenna dots, derived from the app icon) that ties all frames into -one visual system. +Maintains the Apk Analyzer Play Store screenshot carousels. Each is a single self-contained HTML +file with 8 "slides." Every frame shows a real app screenshot inside a styled device frame, with a +themed background, a headline, and a shared "skyline" motif (rounded-arch + antenna dots, derived +from the app icon) that ties all frames into one visual system. + +| Template | Device | Frame (CSS) | Export size | Export folder | +|---|---|---|---|---| +| `carousel_template.html` | phone, light | `360×640` portrait | `1080×1920` | `phone-screenshots/day/` | +| `carousel_template_dark.html` | phone, dark | `360×640` portrait | `1080×1920` | `phone-screenshots/night/` | +| `carousel_template_tv.html` | Android TV | `800×450` landscape | `1920×1080` | `tv-screenshots/` | + +All paths are under `docs/store/graphics/`. The phone and TV carousels share the theme palette and +skyline motif but not the layout. The phone frames put the headline above a portrait phone. The TV +frames put the headline on the left and a TV bezel (`.tv` / `.tv-screen`) on the right. The TV +carousel has a single template, because TV screenshots always use the dark theme. TV-specific +differences are collected in [Android TV carousel](#android-tv-carousel). Everything else in this +skill applies to all three templates. This skill covers two independent workflows that share the same templates but don't depend on each other — run either one without the other: @@ -23,8 +34,8 @@ other — run either one without the other: ready for Play Store upload. It's read-only against the templates — it never edits them, and doesn't require having just run the edit workflow first. -Both files render at CSS scale (`.frame` is `360×640`) — that's a review resolution, not the upload -resolution produced by the export workflow. +The templates render at CSS scale, which is for review only. The export workflow produces the +upload resolution. ## Before touching anything @@ -41,13 +52,14 @@ whenever you add, remove, or reorder a frame — a previous edit left them out o 6,4,5`), which silently broke the invariant this skill relies on; that's now fixed, so don't reintroduce the drift. -Do not restructure the shared CSS system (`.frame`, `.device`, `.screen`, `.skyline`, `.headtext`, -theme classes) unless asked. The job is almost always: swap screenshots in, keep everything else. +Do not restructure the shared CSS system (`.frame`, `.device`/`.screen` on phone, `.tv`/`.tv-screen` +on TV, `.skyline`, `.headtext`, theme classes) unless asked. The job is almost always: swap screenshots in, keep everything else. ## The 8-frame map -Current locked structure for both templates. Preserve this order and theme unless the user asks to -change it — Play Store allows up to 8 screenshots, and this set is already at that cap. +This is the locked structure for both phone templates. The TV template has its own map in +[Android TV carousel](#android-tv-carousel). Preserve this order and these themes unless the user +asks to change them. Play Store allows up to 8 screenshots, and this set is already at that cap. | # | Feature | Theme class | Headline | Eyebrow | `alt` text | |---|---------|-------------|----------|---------|------------| @@ -254,14 +266,76 @@ each side between 320 px and 3,840 px. The frames are natively 9:16 (`360×640` falls out automatically as long as you scale both dimensions together — never crop to force the ratio. -This environment has no headless HTML-to-PNG renderer (no Puppeteer/Playwright, no ImageMagick — -`convert` on `PATH` resolves to the Windows disk-conversion utility, not ImageMagick) and no -Python/PIL. Rendering goes through the `claude-in-chrome` browser tools plus PowerShell's built-in -`System.Drawing` for the pixel work — load the chrome tools once via +There are two ways to render. Use [headless Chrome](#preferred-headless-chrome) first. It needs no +browser extension, has no screen-size limit, and produces each frame in a single capture. Fall back +to the [`claude-in-chrome` slice-and-stitch path](#fallback-claude-in-chrome-slice-and-stitch) only +if headless Chrome isn't installed. + +### Preferred: headless Chrome + +Chrome is at `C:\Program Files\Google\Chrome\Application\chrome.exe`, and Edge's `msedge.exe` takes +the same flags. Headless mode loads `file://` URLs directly and renders at any window size, so the +viewport-clamp and `file://` problems described in the fallback don't apply here. + +For each frame, write a scratch copy of the template with one extra `" +$chrome = "C:\Program Files\Google\Chrome\Application\chrome.exe" +for ($n = 1; $n -le $slugs.Count; $n++) { + $page = "$sp\frame$n.html" + [IO.File]::WriteAllText($page, $html.Replace('', $style.Replace('N', "$n")), (New-Object System.Text.UTF8Encoding($false))) + $dest = Join-Path (Resolve-Path $out) "$($slugs[$n-1]).png" + & $chrome --headless=new --disable-gpu --hide-scrollbars --force-device-scale-factor=1 ` + --window-size=$w,$h --virtual-time-budget=5000 "--user-data-dir=$sp\profile" ` + "--screenshot=$dest" "file:///$($page.Replace('\','/'))" 2>$null | Out-Null +} +Remove-Item -Recurse -Force $sp +``` + +- `--virtual-time-budget=5000` gives the Google Fonts stylesheet time to load. If a headline + renders in a fallback system font, raise the budget. +- A separate `--user-data-dir` keeps headless Chrome away from the user's running browser profile. +- `.frame:nth-of-type(N)` counts the `.frame` divs, which are the only element children of `.strip`. + The `` comments don't affect the count. + +The TV run was verified end to end with this exact recipe on 2026-10-23. The phone values (zoom 3, +`1080×1920`) follow the same arithmetic but haven't been run through this path yet. Check the first +phone export before trusting the rest. + +QA is the same as [step 8 of the fallback](#8-qa-before-calling-it-done), except there's no stitch +seam to check. Also confirm that a corner pixel matches the frame's theme background and not +`#292929`. + +### Fallback: `claude-in-chrome` slice-and-stitch + +This path drives the user's real Chrome through the `claude-in-chrome` browser tools and uses +PowerShell's built-in `System.Drawing` for the pixel work. It needs the extension to be connected. +Load the chrome tools once via `ToolSearch("select:mcp__claude-in-chrome__tabs_context_mcp,mcp__claude-in-chrome__navigate,mcp__claude-in-chrome__computer,mcp__claude-in-chrome__javascript_tool,mcp__claude-in-chrome__browser_batch,mcp__claude-in-chrome__tabs_create_mcp,mcp__claude-in-chrome__tabs_close_mcp")` before starting. -### Why this isn't a single screenshot per frame +#### Why this isn't a single screenshot per frame Two constraints, discovered by testing this pipeline directly rather than assumed: @@ -275,7 +349,7 @@ Two constraints, discovered by testing this pipeline directly rather than assume viewport can ever show at once*, on any normal monitor. The fix is to capture each frame in two vertical slices and stitch them — not to hunt for a bigger window. -### 1. Serve the templates directory over local HTTP +#### 1. Serve the templates directory over local HTTP ```bash node -e " @@ -303,7 +377,7 @@ curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:8743/carousel_template Kill the `node` process (matched by the port in its command line) once every export is done — don't leave a stray server running past this session. -### 2. Open a tab and measure the real viewport +#### 2. Open a tab and measure the real viewport ``` tabs_context_mcp { createIfEmpty: true } @@ -314,7 +388,7 @@ javascript_tool -> window.innerWidth, window.innerHeight (after the page has set Don't hardcode a prior session's viewport height — screen resolution and OS chrome differ by machine. This session measured `1920×957`; recompute per session. -### 3. Isolate one frame at a fixed, padding-free origin +#### 3. Isolate one frame at a fixed, padding-free origin For each frame index `i` (0–7, in the [8-frame map](#the-8-frame-map) order), navigate fresh and run: @@ -330,7 +404,7 @@ With every sibling hidden, the flex `.strip` collapses to just the one visible f `body` padding is zeroed — always lands at exactly `(0, 0)` regardless of which frame it is. Verify this once with `getBoundingClientRect()`; after that it's safe to assume without re-querying. -### 4. Picking the zoom factor +#### 4. Picking the zoom factor The two-slice budget is `2 × innerHeight` (each slice can be at most `innerHeight` tall). At `innerHeight = 957`, that's `1914`. Pick a zoom so `640 × zoom` sits comfortably under that budget — @@ -338,7 +412,7 @@ The two-slice budget is `2 × innerHeight` (each slice can be at most `innerHeig last verified against. Recompute against the actual measured `innerHeight` rather than reusing `2.9` blindly: `zoom ≤ (2 × innerHeight × 0.97) / 640`. -### 5. Capture slice 1 (top) +#### 5. Capture slice 1 (top) ``` computer.zoom { tabId, action: "zoom", region: [0, 0, frameWidth, innerHeight], save_to_disk: true } @@ -349,7 +423,7 @@ frequently times out (`CDP sendCommand "Page.captureScreenshot" timed out after succeeds immediately on retry — this happened repeatedly during testing and is not a sign anything is actually wrong. Only escalate to the user if a *retry* also fails. -### 6. Scroll and capture slice 2 (bottom) +#### 6. Scroll and capture slice 2 (bottom) ```javascript window.scrollTo(0, innerHeight); // e.g. scrollTo(0, 957) @@ -360,7 +434,7 @@ Then capture region `[0, 0, frameWidth, frameHeight - innerHeight]` (e.g. `[0, 0 scrolling by exactly the first slice's height aligns the frame's remaining content to viewport `y=0` with no gap and no overlap — no need to eyeball or fudge an offset. -### 7. Stitch the two slices +#### 7. Stitch the two slices ```powershell Add-Type -AssemblyName System.Drawing @@ -387,7 +461,7 @@ statement first, as shown above. template, `.../night/.png` for the dark one, using the slug from the [8-frame map](#the-8-frame-map). -### 8. QA before calling it done +#### 8. QA before calling it done - [ ] All 8 day files and all 8 night files exist, named per the 8-frame map's slugs - [ ] No visible seam at the slice boundary (open one and check where the stitch line falls — @@ -398,6 +472,67 @@ template, `.../night/.png` for the dark one, using the slug from the Tell the user which frames were exported and to which folders — this workflow doesn't touch the templates, so there's nothing to report there. +## Android TV carousel + +`carousel_template_tv.html` is one dark-stage template with 8 landscape frames. Each frame is +`800×450` (16:9), with the headline block on the left and a TV bezel on the right. + +### TV frame map + +| # | Feature | Theme class | Headline | Eyebrow | `alt` text | Export slug | +|---|---------|-------------|----------|---------|------------|-------------| +| 1 | Installed apps | `theme-teal` | "Every app on / your TV, in / one list." | installed apps | `Installed apps list with search, sort and filters` | `01-installed-apps` | +| 2 | App detail | `theme-cream` | "See exactly / what's inside." | full inspection | `App detail overview with permissions, components and certificate cards` | `02-app-detail` | +| 3 | Permissions | `theme-tealLight` | "Every permission, / plainly explained." | permission audit | `Permissions list with detail panel for the microphone permission` | `03-permissions` | +| 4 | Certificates | `theme-clay` | "Verify who / really signed it." | signing & trust | `Signing certificate details with algorithm strength explained` | `04-certificates` | +| 5 | Components | `theme-browse` | "Every component, / and who can / reach it." | components | `Components list with reachability details and launch action` | `05-components` | +| 6 | Browse by attribute | `theme-teal` | "Flip the question / around." | browse by attribute | `Browse screen grouping apps by signer, target version and install source` | `06-browse-attribute` | +| 7 | Permission browser | `theme-cream` | "Find apps by / permission." | permission browser | `Permissions index with the apps that request a selected permission` | `07-permission-browser` | +| 8 | Drill down | `theme-tealLight` | "See every app / that holds it." | drill down | `App grid for the Post Notifications permission` | `08-drill-down` | + +### Editing the TV template — what differs from Workflow A + +- **One pass, not two.** There is no light or dark variant. +- **No status-bar crop.** A TV capture (`adb exec-out screencap -p`, `1920×1080`) has no status bar + or notch, so skip steps 2–4 of Workflow A. The `.tv-screen` box is exactly 16:9, which means a + full-frame capture fills it with no gap and no seam. +- **Embed as JPEG, downscaled to `1600×900`.** The TV images are `data:image/jpeg`, not PNG, which + keeps the template at about 1.5 MB. A screenshot shows at about 450 CSS px wide and about + 1,070 px in the 2.4× export, so 1600 px wide is more than enough: + + ```powershell + Add-Type -AssemblyName System.Drawing + $src = [System.Drawing.Image]::FromFile($rawPath) + $bmp = New-Object System.Drawing.Bitmap(1600, 900) + $g = [System.Drawing.Graphics]::FromImage($bmp) + $g.InterpolationMode = [System.Drawing.Drawing2D.InterpolationMode]::HighQualityBicubic + $g.DrawImage($src, 0, 0, 1600, 900) + $codec = [System.Drawing.Imaging.ImageCodecInfo]::GetImageEncoders() | Where-Object MimeType -eq 'image/jpeg' + $params = New-Object System.Drawing.Imaging.EncoderParameters(1) + $params.Param[0] = New-Object System.Drawing.Imaging.EncoderParameter([System.Drawing.Imaging.Encoder]::Quality, [long]88) + $bmp.Save($jpegPath, $codec, $params) + $g.Dispose(); $bmp.Dispose(); $src.Dispose() + ``` + +- **Step 5's regex must match JPEG.** Use + `'(?s)(([^)'` and key + `$altToFile` on the `alt` text from the TV frame map. +- **Headlines can run to 3 lines.** The left column (`.headtext`) is 270 CSS px wide. Check that a + new headline doesn't collide with the bezel or the skyline. +- **Raw captures don't belong in `tv-screenshots/`.** That folder is the export output. Keep raw + `adb` captures in the session scratchpad. Once they're embedded in the template, they're no + longer needed. + +### Exporting the TV template + +Use [headless Chrome](#preferred-headless-chrome) at zoom `2.4` and window `1920×1080`. That gives +an `800×450` frame at 2.4×, which is exactly 1920×1080. Write the output to `tv-screenshots/` using +the slugs above. Play Store TV screenshots must be landscape 16:9, and this size meets that. + +If you fall back to the `claude-in-chrome` path, a 1080-px-tall frame is still taller than a typical +`~957`-px viewport, so it needs two slices as well. Use `zoom ≤ (2 × innerHeight × 0.97) / 450`, and +don't count on hitting exactly 1920×1080. + ## Adding, removing, or reordering a frame - **Adding**: only if under the 8-frame cap. Pick a theme color not already adjacent to its @@ -421,6 +556,11 @@ templates, so there's nothing to report there. for swapping content into a locked design, not iterating the design itself. - Editing only one of the two templates when a change (theme color, headline, frame order) should apply to both light and dark. +- (Export) Capturing a frame with its `border-radius` and `box-shadow` still on. The four corners + then show the page's dark stage background. Square them off at export time and leave the + template alone. +- (TV) Reusing the phone regex (`data:image/png`) on the TV template. It matches nothing, and the + replace "succeeds" without changing a single frame. - (Export) Navigating to the templates via `file://` — the `claude-in-chrome` extension blocks it. Serve the directory over local HTTP first. - (Export) Treating a single `computer.zoom` timeout as a real failure instead of retrying once — diff --git a/.github/actions/setup-release-signing/action.yml b/.github/actions/setup-release-signing/action.yml new file mode 100644 index 00000000..cb8560c3 --- /dev/null +++ b/.github/actions/setup-release-signing/action.yml @@ -0,0 +1,51 @@ +name: Set up release signing +description: Decodes the release keystore and writes the signing properties Gradle reads, refusing to continue if any signing secret is missing + +inputs: + sign-key: + required: true + sign-key-alias: + required: true + sign-key-store-password: + required: true + sign-key-password: + required: true + +runs: + using: composite + steps: + - name: Verify signing secrets are present + shell: bash + env: + SIGN_KEY: ${{ inputs.sign-key }} + SIGN_KEY_ALIAS: ${{ inputs.sign-key-alias }} + SIGN_KEY_STORE_PASSWORD: ${{ inputs.sign-key-store-password }} + SIGN_KEY_PASSWORD: ${{ inputs.sign-key-password }} + run: | + for name in SIGN_KEY SIGN_KEY_ALIAS SIGN_KEY_STORE_PASSWORD SIGN_KEY_PASSWORD; do + if [ -z "${!name}" ]; then + echo "::error::Secret $name is empty. Without it the release signingConfig falls back to the debug key, so refusing to build rather than silently shipping a debug-signed release." + exit 1 + fi + done + + - name: Decode signing keystore + shell: bash + env: + SIGN_KEY: ${{ inputs.sign-key }} + run: echo "$SIGN_KEY" | base64 -d > "$RUNNER_TEMP/release.keystore" + + - name: Write signing properties + shell: bash + env: + SIGN_KEY_ALIAS: ${{ inputs.sign-key-alias }} + SIGN_KEY_STORE_PASSWORD: ${{ inputs.sign-key-store-password }} + SIGN_KEY_PASSWORD: ${{ inputs.sign-key-password }} + run: | + mkdir -p "$HOME/.gradle" + { + echo "signing.storeFile=$RUNNER_TEMP/release.keystore" + echo "signing.storePassword=$SIGN_KEY_STORE_PASSWORD" + echo "signing.keyAlias=$SIGN_KEY_ALIAS" + echo "signing.keyPassword=$SIGN_KEY_PASSWORD" + } >> "$HOME/.gradle/gradle.properties" diff --git a/.github/scripts/version-code.sh b/.github/scripts/version-code.sh new file mode 100755 index 00000000..1e427ed7 --- /dev/null +++ b/.github/scripts/version-code.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +set -euo pipefail + +VERSION="${1:-}" +FORM_FACTOR="${2:-phone}" + +if ! [[ "$VERSION" =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then + echo "::error::Version '$VERSION' is not MAJOR.MINOR.PATCH" >&2 + exit 1 +fi + +MAJOR=$((10#${BASH_REMATCH[1]})) +MINOR=$((10#${BASH_REMATCH[2]})) +PATCH=$((10#${BASH_REMATCH[3]})) + +if [ "$MAJOR" -gt 999 ] || [ "$MINOR" -gt 99 ] || [ "$PATCH" -gt 99 ]; then + echo "::error::Version '$VERSION' exceeds 999.99.99, so its version code would overlap another field or form factor" >&2 + exit 1 +fi + +case "$FORM_FACTOR" in + phone) OFFSET=0 ;; + tv) OFFSET=10000000 ;; + *) + echo "::error::Form factor '$FORM_FACTOR' is not phone or tv" >&2 + exit 1 + ;; +esac + +echo $((OFFSET + MAJOR * 10000 + MINOR * 100 + PATCH)) diff --git a/.github/workflows/promote-release.yml b/.github/workflows/promote-release.yml index 987249e0..396d6673 100644 --- a/.github/workflows/promote-release.yml +++ b/.github/workflows/promote-release.yml @@ -10,6 +10,18 @@ on: description: 'Percentage of production users to receive the update (1-100)' required: false default: '100' + workflow_call: + inputs: + version: + type: string + required: true + rollout-percentage: + type: string + required: false + default: '100' + form-factor: + type: string + required: true run-name: Promote ${{ inputs.version }} to production (${{ inputs.rollout-percentage }}%) @@ -19,6 +31,11 @@ permissions: jobs: promote: runs-on: ubuntu-latest + env: + FORM_FACTOR: ${{ inputs.form-factor || 'phone' }} + MODULE: ${{ inputs.form-factor == 'tv' && 'tv' || 'app' }} + TESTING_TRACK: ${{ inputs.form-factor == 'tv' && 'tv:Beta TV' || 'beta' }} + PRODUCTION_TRACK: ${{ inputs.form-factor == 'tv' && 'tv:production' || 'production' }} steps: - uses: actions/checkout@v7 @@ -40,11 +57,11 @@ jobs: ACCESS_TOKEN: ${{ steps.auth.outputs.access_token }} VERSION: ${{ inputs.version }} run: | - IFS='.' read -r MAJOR MINOR PATCH <<< "$VERSION" - TARGET_VERSION_CODE=$((MAJOR * 10000 + MINOR * 100 + PATCH)) + TARGET_VERSION_CODE=$(bash .github/scripts/version-code.sh "${VERSION%.tv}" "$FORM_FACTOR") echo "version-code=$TARGET_VERSION_CODE" >> "$GITHUB_OUTPUT" BASE="https://androidpublisher.googleapis.com/androidpublisher/v3/applications/sk.styk.martin.apkanalyzer" + PRODUCTION_TRACK_PATH=$(jq -rn --arg track "$PRODUCTION_TRACK" '$track | @uri') TRACK_BODY_FILE=$(mktemp) cleanup() { @@ -63,7 +80,7 @@ jobs: exit 1 fi - TRACK_STATUS=$(curl -sS -o "$TRACK_BODY_FILE" -w "%{http_code}" -H "Authorization: Bearer $ACCESS_TOKEN" "$BASE/edits/$EDIT_ID/tracks/production") + TRACK_STATUS=$(curl -sS -o "$TRACK_BODY_FILE" -w "%{http_code}" -H "Authorization: Bearer $ACCESS_TOKEN" "$BASE/edits/$EDIT_ID/tracks/$PRODUCTION_TRACK_PATH") if [ "$TRACK_STATUS" -eq 404 ]; then PROD_VERSION_CODE="" @@ -73,7 +90,7 @@ jobs: PROD_VERSION_CODE=$(echo "$PROD_RELEASE" | jq -r '.versionCodes[0] // empty') PROD_RELEASE_STATUS=$(echo "$PROD_RELEASE" | jq -r '.status // empty') else - echo "::error::Failed to read production track. HTTP status: $TRACK_STATUS" + echo "::error::Failed to read $PRODUCTION_TRACK track. HTTP status: $TRACK_STATUS" echo "::error::Response: $(cat "$TRACK_BODY_FILE")" exit 1 fi @@ -86,10 +103,10 @@ jobs: exit 1 fi echo "Production's in-progress release (version code $PROD_VERSION_CODE) matches the target; adjusting its rollout percentage." - echo "from-track=production" >> "$GITHUB_OUTPUT" + echo "from-track=$PRODUCTION_TRACK" >> "$GITHUB_OUTPUT" else - echo "Promoting version code $TARGET_VERSION_CODE fresh from beta." - echo "from-track=beta" >> "$GITHUB_OUTPUT" + echo "Promoting version code $TARGET_VERSION_CODE fresh from $TESTING_TRACK." + echo "from-track=$TESTING_TRACK" >> "$GITHUB_OUTPUT" fi - name: Promote Play Store release to production @@ -104,15 +121,15 @@ jobs: exit 1 fi - if [ "$FROM_TRACK" = "production" ]; then - TRACK_ARGS=(--update production) + if [ "$FROM_TRACK" = "$PRODUCTION_TRACK" ]; then + TRACK_ARGS=(--update "$PRODUCTION_TRACK") else - TRACK_ARGS=(--from-track "$FROM_TRACK" --promote-track production) + TRACK_ARGS=(--from-track "$FROM_TRACK" --promote-track "$PRODUCTION_TRACK") fi if [ "$ROLLOUT_PERCENTAGE" -eq 100 ]; then - ./gradlew :app:promoteArtifact "${TRACK_ARGS[@]}" --version-code "$VERSION_CODE" --release-status completed + ./gradlew ":$MODULE:promoteArtifact" "${TRACK_ARGS[@]}" --version-code "$VERSION_CODE" --release-status completed else USER_FRACTION=$(awk "BEGIN { printf \"%.2f\", $ROLLOUT_PERCENTAGE / 100 }") - ./gradlew :app:promoteArtifact "${TRACK_ARGS[@]}" --version-code "$VERSION_CODE" --release-status inProgress --user-fraction "$USER_FRACTION" + ./gradlew ":$MODULE:promoteArtifact" "${TRACK_ARGS[@]}" --version-code "$VERSION_CODE" --release-status inProgress --user-fraction "$USER_FRACTION" fi diff --git a/.github/workflows/promote-tv-release.yml b/.github/workflows/promote-tv-release.yml new file mode 100644 index 00000000..8aece972 --- /dev/null +++ b/.github/workflows/promote-tv-release.yml @@ -0,0 +1,26 @@ +name: Promote TV release to production + +on: + workflow_dispatch: + inputs: + version: + description: 'TV version being promoted (e.g. 3.5.0 or 3.5.0.tv)' + required: true + rollout-percentage: + description: 'Percentage of TV production users to receive the update (1-100)' + required: false + default: '100' + +run-name: Promote TV ${{ inputs.version }} to production (${{ inputs.rollout-percentage }}%) + +permissions: + contents: read + +jobs: + promote: + uses: ./.github/workflows/promote-release.yml + with: + version: ${{ inputs.version }} + rollout-percentage: ${{ inputs.rollout-percentage }} + form-factor: tv + secrets: inherit diff --git a/.github/workflows/release-tv.yml b/.github/workflows/release-tv.yml new file mode 100644 index 00000000..1c2b13af --- /dev/null +++ b/.github/workflows/release-tv.yml @@ -0,0 +1,179 @@ +name: Release TV + +on: + push: + tags: + - '[0-9]+.[0-9]+.[0-9]+.tv' + +run-name: Release TV ${{ github.ref_name }} + +permissions: + contents: read + +jobs: + check-tag: + runs-on: ubuntu-latest + outputs: + notes: ${{ steps.notes.outputs.body }} + version: ${{ steps.version.outputs.name }} + + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + + - name: Read tag annotation as release notes + id: notes + run: | + BODY=$(git for-each-ref --format='%(contents)' "refs/tags/$GITHUB_REF_NAME") + if [ -z "$BODY" ]; then + echo "::error::Tag $GITHUB_REF_NAME has no annotation message, so it can't provide release notes. Push an annotated tag instead: git tag -a $GITHUB_REF_NAME -m \"release notes\" && git push origin $GITHUB_REF_NAME" + exit 1 + fi + { + echo "body<> "$GITHUB_OUTPUT" + + - name: Strip the TV suffix from the tag + id: version + run: echo "name=${GITHUB_REF_NAME%.tv}" >> "$GITHUB_OUTPUT" + + verify: + needs: [ check-tag ] + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v7 + + - name: Set up Gradle build environment + uses: ./.github/actions/setup-gradle-build + + - name: Fetch Firebase google-services.json + uses: ./.github/actions/fetch-google-services + with: + firebase-token: ${{ secrets.FIREBASE_TOKEN }} + firebase-app-id: ${{ secrets.FIREBASE_APP_ID }} + firebase-project-id: ${{ secrets.FIREBASE_PROJECT_ID }} + + - name: Verify release + run: | + ./gradlew spotlessCheck detektDebug :build-logic:convention:detektMain lintRelease --continue + + build-release: + needs: [ check-tag ] + runs-on: ubuntu-latest + outputs: + apk_name: apk-analyzer-tv-${{ needs.check-tag.outputs.version }}.apk + aab_name: apk-analyzer-tv-${{ needs.check-tag.outputs.version }}.aab + + steps: + - uses: actions/checkout@v7 + + - name: Set up Gradle build environment + uses: ./.github/actions/setup-gradle-build + + - name: Fetch Firebase google-services.json + uses: ./.github/actions/fetch-google-services + with: + firebase-token: ${{ secrets.FIREBASE_TOKEN }} + firebase-app-id: ${{ secrets.FIREBASE_APP_ID }} + firebase-project-id: ${{ secrets.FIREBASE_PROJECT_ID }} + + - name: Compute versionCode from tag + id: version + env: + VERSION: ${{ needs.check-tag.outputs.version }} + run: | + CODE=$(bash .github/scripts/version-code.sh "$VERSION" tv) + echo "code=$CODE" >> "$GITHUB_OUTPUT" + + - name: Set up release signing + uses: ./.github/actions/setup-release-signing + with: + sign-key: ${{ secrets.SIGN_KEY }} + sign-key-alias: ${{ secrets.SIGN_KEY_ALIAS }} + sign-key-store-password: ${{ secrets.SIGN_KEY_STORE_PASSWORD }} + sign-key-password: ${{ secrets.SIGN_KEY_PASSWORD }} + + - name: Build and sign release AAB and APK + env: + VERSION: ${{ needs.check-tag.outputs.version }} + run: | + ./gradlew :tv:bundleRelease :tv:assembleRelease \ + -Pversion.code=${{ steps.version.outputs.code }} \ + -Pversion.name="$VERSION" + + - name: Rename release artifacts + env: + VERSION: ${{ needs.check-tag.outputs.version }} + run: | + mv tv/build/outputs/bundle/release/*.aab "tv/build/outputs/bundle/release/apk-analyzer-tv-$VERSION.aab" + mv tv/build/outputs/apk/release/*.apk "tv/build/outputs/apk/release/apk-analyzer-tv-$VERSION.apk" + + - name: Upload AAB artifact + uses: actions/upload-artifact@v7 + with: + name: apk-analyzer-tv-aab + path: tv/build/outputs/bundle/release/apk-analyzer-tv-${{ needs.check-tag.outputs.version }}.aab + compression-level: 0 + if-no-files-found: error + + - name: Upload APK artifact + uses: actions/upload-artifact@v7 + with: + name: apk-analyzer-tv-apk + path: tv/build/outputs/apk/release/apk-analyzer-tv-${{ needs.check-tag.outputs.version }}.apk + compression-level: 0 + if-no-files-found: error + + - name: Upload mapping file artifact + uses: actions/upload-artifact@v7 + with: + name: apk-analyzer-tv-mapping + path: tv/build/outputs/mapping/release/mapping.txt + if-no-files-found: error + + publish-github-release: + needs: [ check-tag, verify, build-release ] + runs-on: ubuntu-latest + permissions: + contents: write + + steps: + - name: Download APK artifact + uses: actions/download-artifact@v8 + with: + name: apk-analyzer-tv-apk + path: artifacts + + - name: Create GitHub release + uses: softprops/action-gh-release@v2 + with: + name: TV ${{ needs.check-tag.outputs.version }} + tag_name: ${{ github.ref_name }} + body: ${{ needs.check-tag.outputs.notes }} + files: artifacts/${{ needs['build-release'].outputs.apk_name }} + make_latest: false + + publish-play-store: + needs: [ verify, build-release ] + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v7 + + - name: Set up Gradle build environment + uses: ./.github/actions/setup-gradle-build + + - name: Download AAB artifact + uses: actions/download-artifact@v8 + with: + name: apk-analyzer-tv-aab + path: artifacts + + - name: Publish to Play Store Beta TV track + env: + ANDROID_PUBLISHER_CREDENTIALS: ${{ secrets.GOOGLE_SERVICE_ACCOUNT }} + run: ./gradlew :tv:publishBundle --artifact-dir artifacts --track "tv:Beta TV" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9e9a8fc9..0bcc6394 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -79,45 +79,20 @@ jobs: - name: Compute versionCode from tag id: version run: | - IFS='.' read -r MAJOR MINOR PATCH <<< "$GITHUB_REF_NAME" - echo "code=$((MAJOR * 10000 + MINOR * 100 + PATCH))" >> "$GITHUB_OUTPUT" + CODE=$(bash .github/scripts/version-code.sh "$GITHUB_REF_NAME") + echo "code=$CODE" >> "$GITHUB_OUTPUT" - - name: Verify signing secrets are present - env: - SIGN_KEY: ${{ secrets.SIGN_KEY }} - SIGN_KEY_ALIAS: ${{ secrets.SIGN_KEY_ALIAS }} - SIGN_KEY_STORE_PASSWORD: ${{ secrets.SIGN_KEY_STORE_PASSWORD }} - SIGN_KEY_PASSWORD: ${{ secrets.SIGN_KEY_PASSWORD }} - run: | - for name in SIGN_KEY SIGN_KEY_ALIAS SIGN_KEY_STORE_PASSWORD SIGN_KEY_PASSWORD; do - if [ -z "${!name}" ]; then - echo "::error::Secret $name is empty. Without it the release signingConfig falls back to the debug key, so refusing to build rather than silently shipping a debug-signed release." - exit 1 - fi - done - - - name: Decode signing keystore - env: - SIGN_KEY: ${{ secrets.SIGN_KEY }} - run: echo "$SIGN_KEY" | base64 -d > "$RUNNER_TEMP/release.keystore" - - - name: Write signing properties - env: - SIGN_KEY_ALIAS: ${{ secrets.SIGN_KEY_ALIAS }} - SIGN_KEY_STORE_PASSWORD: ${{ secrets.SIGN_KEY_STORE_PASSWORD }} - SIGN_KEY_PASSWORD: ${{ secrets.SIGN_KEY_PASSWORD }} - run: | - mkdir -p "$HOME/.gradle" - { - echo "signing.storeFile=$RUNNER_TEMP/release.keystore" - echo "signing.storePassword=$SIGN_KEY_STORE_PASSWORD" - echo "signing.keyAlias=$SIGN_KEY_ALIAS" - echo "signing.keyPassword=$SIGN_KEY_PASSWORD" - } >> "$HOME/.gradle/gradle.properties" + - name: Set up release signing + uses: ./.github/actions/setup-release-signing + with: + sign-key: ${{ secrets.SIGN_KEY }} + sign-key-alias: ${{ secrets.SIGN_KEY_ALIAS }} + sign-key-store-password: ${{ secrets.SIGN_KEY_STORE_PASSWORD }} + sign-key-password: ${{ secrets.SIGN_KEY_PASSWORD }} - name: Build and sign release AAB and APK run: | - ./gradlew bundleRelease assembleRelease \ + ./gradlew :app:bundleRelease :app:assembleRelease \ -Pversion.code=${{ steps.version.outputs.code }} \ -Pversion.name="$GITHUB_REF_NAME" diff --git a/core/design-system/AGENTS.md b/core/design-system/AGENTS.md index 2393cea7..1a892fbe 100644 --- a/core/design-system/AGENTS.md +++ b/core/design-system/AGENTS.md @@ -92,9 +92,9 @@ exception is `InactiveSearchBar`, which lives in `SearchBarInactive.kt`. * Every interactive component must be reachable and operable with a D-pad alone; touch is not required on TV. -* Focused cards get a thin accent border and a slight lift, no glow (`FocusStyle.kt`); list rows rely - on their filled focus state alone. Keep focus quiet: reuse these values for a new focusable card - rather than adding a stronger effect. +* Focused cards get a thin accent border and a slight lift, no glow (`FocusStyle.kt`); list rows get + the same border over a `surfaceVariant` fill rather than an inverted white block. Keep focus quiet: + reuse these values for a new focusable card or row rather than adding a stronger effect. * Previews use `@Preview(device = Devices.TV_1080p)` and wrap content in `ApkAnalyzerTvTheme`. ## Shared Component Rules diff --git a/core/design-system/tv/src/main/kotlin/sk/styk/martin/apkanalyzer/core/designsystem/tv/components/AppCard.kt b/core/design-system/tv/src/main/kotlin/sk/styk/martin/apkanalyzer/core/designsystem/tv/components/AppCard.kt index 6e422f16..e55d1912 100644 --- a/core/design-system/tv/src/main/kotlin/sk/styk/martin/apkanalyzer/core/designsystem/tv/components/AppCard.kt +++ b/core/design-system/tv/src/main/kotlin/sk/styk/martin/apkanalyzer/core/designsystem/tv/components/AppCard.kt @@ -53,7 +53,7 @@ fun AppCard( ) TvMaterialCard( onClick = onClick, - modifier = modifier.minAspectRatio(TILE_ASPECT_RATIO), + modifier = modifier, shape = CardDefaults.shape(shape = CardShape), colors = CardDefaults.colors( containerColor = AppTheme.colors.surface, @@ -72,6 +72,7 @@ fun AppCard( Column( modifier = Modifier .fillMaxWidth() + .minAspectRatio(TILE_ASPECT_RATIO) .background(Brush.linearGradient(listOf(accent.copy(alpha = TINT_START_ALPHA), accent.copy(alpha = TINT_END_ALPHA)))) .padding(12.dp), verticalArrangement = Arrangement.SpaceBetween, diff --git a/core/design-system/tv/src/main/kotlin/sk/styk/martin/apkanalyzer/core/designsystem/tv/components/InfoListItem.kt b/core/design-system/tv/src/main/kotlin/sk/styk/martin/apkanalyzer/core/designsystem/tv/components/InfoListItem.kt index 8624cb1d..ad781a63 100644 --- a/core/design-system/tv/src/main/kotlin/sk/styk/martin/apkanalyzer/core/designsystem/tv/components/InfoListItem.kt +++ b/core/design-system/tv/src/main/kotlin/sk/styk/martin/apkanalyzer/core/designsystem/tv/components/InfoListItem.kt @@ -1,5 +1,6 @@ package sk.styk.martin.apkanalyzer.core.designsystem.tv.components +import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width @@ -12,6 +13,7 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Devices import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.tv.material3.Border import androidx.tv.material3.Icon import androidx.tv.material3.ListItemDefaults import androidx.tv.material3.LocalContentColor @@ -39,8 +41,11 @@ fun InfoListItem( colors = ListItemDefaults.colors( containerColor = Color.Transparent, contentColor = AppTheme.colors.onSurface, - focusedContainerColor = AppTheme.colors.onSurface, - focusedContentColor = AppTheme.colors.surface, + focusedContainerColor = AppTheme.colors.surfaceVariant, + focusedContentColor = AppTheme.colors.onSurface, + ), + border = ListItemDefaults.border( + focusedBorder = Border(border = BorderStroke(width = FocusBorderWidth, color = AppTheme.colors.primary), shape = ItemShape), ), scale = ListItemDefaults.scale(focusedScale = 1.02f), headlineContent = { diff --git a/core/design-system/tv/src/main/kotlin/sk/styk/martin/apkanalyzer/core/designsystem/tv/components/ScrollablePanel.kt b/core/design-system/tv/src/main/kotlin/sk/styk/martin/apkanalyzer/core/designsystem/tv/components/ScrollablePanel.kt index 67c128da..342da1a9 100644 --- a/core/design-system/tv/src/main/kotlin/sk/styk/martin/apkanalyzer/core/designsystem/tv/components/ScrollablePanel.kt +++ b/core/design-system/tv/src/main/kotlin/sk/styk/martin/apkanalyzer/core/designsystem/tv/components/ScrollablePanel.kt @@ -49,6 +49,7 @@ fun ScrollablePanel( resetKey: Any? = null, contentPadding: PaddingValues = PaddingValues(28.dp), spacing: Dp = 16.dp, + onClick: (() -> Unit)? = null, content: @Composable ColumnScope.() -> Unit, ) { val scrollState = rememberScrollState() @@ -72,6 +73,10 @@ fun ScrollablePanel( .fillMaxWidth() .onFocusChanged { isFocused = it.isFocused } .onPreviewKeyEvent { event -> + if (onClick != null && event.key in ClickKeys) { + if (event.type == KeyEventType.KeyUp) onClick() + return@onPreviewKeyEvent true + } if (event.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false val step = when (event.key) { Key.DirectionDown -> if (scrollState.canScrollForward) SCROLL_STEP else return@onPreviewKeyEvent false @@ -81,7 +86,7 @@ fun ScrollablePanel( coroutineScope.launch { scrollState.animateScrollBy(step) } true } - .focusable(enabled = scrollState.maxValue > 0) + .focusable(enabled = onClick != null || scrollState.maxValue > 0) .verticalScroll(scrollState) .padding(contentPadding), verticalArrangement = Arrangement.spacedBy(spacing), @@ -103,6 +108,7 @@ fun ScrollablePanel( private val PanelShape = RoundedCornerShape(16.dp) private const val SCROLL_STEP = 240f +private val ClickKeys = setOf(Key.DirectionCenter, Key.Enter, Key.NumPadEnter) @Preview(device = Devices.TV_1080p, showBackground = true, backgroundColor = 0xFF000000) @Composable diff --git a/core/design-system/tv/src/main/kotlin/sk/styk/martin/apkanalyzer/core/designsystem/tv/components/SelectableListItem.kt b/core/design-system/tv/src/main/kotlin/sk/styk/martin/apkanalyzer/core/designsystem/tv/components/SelectableListItem.kt index b422e065..2143c63b 100644 --- a/core/design-system/tv/src/main/kotlin/sk/styk/martin/apkanalyzer/core/designsystem/tv/components/SelectableListItem.kt +++ b/core/design-system/tv/src/main/kotlin/sk/styk/martin/apkanalyzer/core/designsystem/tv/components/SelectableListItem.kt @@ -1,5 +1,6 @@ package sk.styk.martin.apkanalyzer.core.designsystem.tv.components +import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.size import androidx.compose.runtime.Composable @@ -8,6 +9,7 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.tooling.preview.Devices import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.tv.material3.Border import androidx.tv.material3.Icon import androidx.tv.material3.ListItemDefaults import sk.styk.martin.apkanalyzer.core.designsystem.foundation.icons.ApkAnalyzerIcons @@ -38,8 +40,11 @@ fun SelectableListItem( contentColor = AppTheme.colors.onSurface, selectedContainerColor = AppTheme.colors.primaryContainer, selectedContentColor = AppTheme.colors.primary, - focusedContainerColor = AppTheme.colors.onSurface, - focusedContentColor = AppTheme.colors.surface, + focusedContainerColor = AppTheme.colors.surfaceVariant, + focusedContentColor = AppTheme.colors.onSurface, + ), + border = ListItemDefaults.border( + focusedBorder = Border(border = BorderStroke(width = FocusBorderWidth, color = AppTheme.colors.primary)), ), headlineContent = { Text(text = label, style = AppTheme.typography.titleMedium, maxLines = 1) diff --git a/docs/engineering/ci-and-release.md b/docs/engineering/ci-and-release.md index 0284e379..0c6b4377 100644 --- a/docs/engineering/ci-and-release.md +++ b/docs/engineering/ci-and-release.md @@ -46,19 +46,38 @@ supplies the version name, the version code, and the release notes. |---|---| | `check-tag` | Fails with a remediation message if the tag annotation is empty — a lightweight tag can't provide release notes. The annotation body becomes the GitHub release body | | `verify` | Repeats the CI gates against the release variant (`lintRelease` rather than `lintDebug`) | -| `build-release` | Derives `versionCode` from the tag, decodes the signing keystore, runs `bundleRelease assembleRelease`, and uploads the AAB, APK, and ProGuard `mapping.txt` as separate artifacts | +| `build-release` | Derives `versionCode` from the tag, decodes the signing keystore, runs `:app:bundleRelease :app:assembleRelease`, and uploads the AAB, APK, and ProGuard `mapping.txt` as separate artifacts | | `publish-github-release` | Attaches the APK to a GitHub release named after the tag | | `publish-play-store` | Runs `:app:publishBundle --track beta` — new releases always land on **beta**, never production | | `distribute-app-distribution` | Sends the release APK to `internal-testers`, tagged with the release notes | Non-obvious details: -* `versionCode` is `MAJOR * 10000 + MINOR * 100 + PATCH`; the tag itself becomes `version.name`. +* `versionCode` is `MAJOR * 10000 + MINOR * 100 + PATCH`, plus `10000000` for the TV app because + Play requires unique version codes across all tracks of the same application ID. It is computed + only by [`version-code.sh`](../../.github/scripts/version-code.sh); the tag itself becomes + `version.name`. * `build-release` fails outright if any of the four signing secrets is missing, rather than falling back to the debug key — a debug-signed release that uploads successfully is worse than a failed build. * The three publishing jobs run in parallel once `build-release` finishes. +## TV release + +The TV app ships on its own schedule. A `MAJOR.MINOR.PATCH` tag releases only the phone app; an +annotated `MAJOR.MINOR.PATCH.tv` tag starts [`release-tv.yml`](../../.github/workflows/release-tv.yml), +which releases only the TV app. The jobs mirror the phone release, except: + +* `version.name` is the tag without `.tv`, and the version code uses the TV offset. +* The GitHub release is named `TV ` and is never marked as latest, so the latest release + stays the phone APK. +* The bundle goes to `tv:Beta TV` — the closed testing track named "Beta TV" on Play's dedicated + Android TV form factor; a custom track's API name is `tv:` plus its exact name — and there is no + Firebase App Distribution step. + +Promote with `promote-tv-release.yml`, which takes the same inputs as the phone promotion and runs +`promote-release.yml` from `tv:Beta TV` to `tv:production`. + ## Promotion to production `promote-release.yml` is the only path to the production track, and it is always a deliberate manual diff --git a/docs/store/graphics/carousel_template_tv.html b/docs/store/graphics/carousel_template_tv.html new file mode 100644 index 00000000..bc105438 --- /dev/null +++ b/docs/store/graphics/carousel_template_tv.html @@ -0,0 +1,243 @@ + +Store Carousel TV + + + + + +
+ + +
+
+
+
+
installed apps
+
Every app on
your TV, in
one list.
+
+
+
+ Installed apps list with search, sort and filters +
+
+
+ +
+
+ +
+
full inspection
+
See exactly
what's inside.
+
+
+
+ App detail overview with permissions, components and certificate cards +
+
+
+ +
+
+ +
+
permission audit
+
Every permission,
plainly explained.
+
+
+
+ Permissions list with detail panel for the microphone permission +
+
+
+ +
+
+ +
+
signing & trust
+
Verify who
really signed it.
+
+
+
+ Signing certificate details with algorithm strength explained +
+
+
+ +
+
+ +
+
components
+
Every component,
and who can
reach it.
+
+
+
+ Components list with reachability details and launch action +
+
+
+ +
+
+ +
+
browse by attribute
+
Flip the question
around.
+
+
+
+ Browse screen grouping apps by signer, target version and install source +
+
+
+ +
+
+ +
+
permission browser
+
Find apps by
permission.
+
+
+
+ Permissions index with the apps that request a selected permission +
+
+
+ +
+
+ +
+
drill down
+
See every app
that holds it.
+
+
+
+ App grid for the Post Notifications permission +
+
+
+
+ + \ No newline at end of file diff --git a/docs/store/graphics/tv-banner.png b/docs/store/graphics/tv-banner.png new file mode 100644 index 00000000..6f6e1bee Binary files /dev/null and b/docs/store/graphics/tv-banner.png differ diff --git a/docs/store/graphics/tv-screenshots/01-installed-apps.png b/docs/store/graphics/tv-screenshots/01-installed-apps.png new file mode 100644 index 00000000..c0f6d3bd Binary files /dev/null and b/docs/store/graphics/tv-screenshots/01-installed-apps.png differ diff --git a/docs/store/graphics/tv-screenshots/02-app-detail.png b/docs/store/graphics/tv-screenshots/02-app-detail.png new file mode 100644 index 00000000..63b412c2 Binary files /dev/null and b/docs/store/graphics/tv-screenshots/02-app-detail.png differ diff --git a/docs/store/graphics/tv-screenshots/03-permissions.png b/docs/store/graphics/tv-screenshots/03-permissions.png new file mode 100644 index 00000000..40aef674 Binary files /dev/null and b/docs/store/graphics/tv-screenshots/03-permissions.png differ diff --git a/docs/store/graphics/tv-screenshots/04-certificates.png b/docs/store/graphics/tv-screenshots/04-certificates.png new file mode 100644 index 00000000..6b9697dc Binary files /dev/null and b/docs/store/graphics/tv-screenshots/04-certificates.png differ diff --git a/docs/store/graphics/tv-screenshots/05-components.png b/docs/store/graphics/tv-screenshots/05-components.png new file mode 100644 index 00000000..91242b32 Binary files /dev/null and b/docs/store/graphics/tv-screenshots/05-components.png differ diff --git a/docs/store/graphics/tv-screenshots/06-browse-attribute.png b/docs/store/graphics/tv-screenshots/06-browse-attribute.png new file mode 100644 index 00000000..d47ce566 Binary files /dev/null and b/docs/store/graphics/tv-screenshots/06-browse-attribute.png differ diff --git a/docs/store/graphics/tv-screenshots/07-permission-browser.png b/docs/store/graphics/tv-screenshots/07-permission-browser.png new file mode 100644 index 00000000..8d7b4f02 Binary files /dev/null and b/docs/store/graphics/tv-screenshots/07-permission-browser.png differ diff --git a/docs/store/graphics/tv-screenshots/08-drill-down.png b/docs/store/graphics/tv-screenshots/08-drill-down.png new file mode 100644 index 00000000..55284036 Binary files /dev/null and b/docs/store/graphics/tv-screenshots/08-drill-down.png differ diff --git a/docs/store/graphics/tv_banner_template.html b/docs/store/graphics/tv_banner_template.html new file mode 100644 index 00000000..1bc10e50 --- /dev/null +++ b/docs/store/graphics/tv_banner_template.html @@ -0,0 +1,68 @@ + + + + +TV Banner + + + + + + + diff --git a/feature/apps/AGENTS.md b/feature/apps/AGENTS.md index c1d2220a..ae96d654 100644 --- a/feature/apps/AGENTS.md +++ b/feature/apps/AGENTS.md @@ -39,7 +39,8 @@ ViewModel and never depends on `phone`. Unlike the phone, TV shows recently view because typing with a remote is slow and the row costs only one line above the grid. Like the phone, it hides the row while filters are active, so everything on screen matches the filter. -* On entry, focus goes to the last focused card, or the first installed app on first launch, never +* On entry, focus goes to the last focused card, or on first launch the first recently viewed app + (the first installed app when there are none), so nothing scrolls before the user moves; never the side menu. Card keys carry their section (`recent:` / `installed:`) because one app can appear in both. * Selecting an app opens `AppDetailNavKey`, the same key the phone uses. diff --git a/feature/apps/tv/src/main/kotlin/sk/styk/martin/apkanalyzer/feature/apps/tv/list/TvAppsScreen.kt b/feature/apps/tv/src/main/kotlin/sk/styk/martin/apkanalyzer/feature/apps/tv/list/TvAppsScreen.kt index 8cf2af4c..30493dae 100644 --- a/feature/apps/tv/src/main/kotlin/sk/styk/martin/apkanalyzer/feature/apps/tv/list/TvAppsScreen.kt +++ b/feature/apps/tv/src/main/kotlin/sk/styk/martin/apkanalyzer/feature/apps/tv/list/TvAppsScreen.kt @@ -174,7 +174,9 @@ private fun TvAppsContent(state: TvAppsState, onAction: (TvAppsAction) -> Unit) state.recents.forEach { add(recentCardKey(it.packageName)) } apps.apps.forEach { add(installedCardKey(it.packageName)) } }, - defaultKey = apps.apps.firstOrNull()?.let { installedCardKey(it.packageName) } ?: SEARCH_KEY, + defaultKey = state.recents.firstOrNull()?.let { recentCardKey(it.packageName) } + ?: apps.apps.firstOrNull()?.let { installedCardKey(it.packageName) } + ?: SEARCH_KEY, ) Column(modifier = Modifier.fillMaxSize()) { diff --git a/feature/browse/AGENTS.md b/feature/browse/AGENTS.md index 9ec2c7a0..438bdde8 100644 --- a/feature/browse/AGENTS.md +++ b/feature/browse/AGENTS.md @@ -13,19 +13,25 @@ package `sk.styk.martin.apkanalyzer.feature.browse.phone`; the TV one uses Both `phone` and `tv` share the same layout: -* The root package owns the hub (dimension cards) and the dimension icons. +* The root package owns the hub and the dimension icons. The phone hub shows one card per dimension; + the TV hub shows one row per dimension with its top options as tiles (certificates grouped by + publisher), ranked user apps first for the icons, and a "See all" tile. * `options/` owns searchable bucket lists and dimension-specific row presentation. * `apps/` owns the searchable app list for one bucket and its bucket detail. * `navigation/` owns internal option and bucket destinations. -Phone-only: `BrowseAnalytics`, which TV does not track. On TV the bucket detail sits in a side panel -next to the app grid instead of behind an info button, and certificate buckets show the fingerprint -there because the title is the algorithm name. +Phone-only: `BrowseAnalytics`, which TV does not track. On TV the options screen is a list beside a +preview pane that follows focus (the option's apps and bucket detail), so users browse by moving focus +and click only to open the full app grid. Search sits behind a header button rather than an +always-visible field. The bucket detail also sits in a side panel next to the app grid instead of +behind an info button, and certificate buckets show the fingerprint there because the title is the +publisher. ## Data and Navigation Semantics -The navigation depth is hub -> options -> apps -> app detail. Internal keys carry the dimension and -raw bucket identity, not a package list. +The navigation depth is hub -> options -> apps -> app detail; on TV a hub tile skips straight to apps, +while its "See all" tile opens options. Internal keys carry the dimension and raw bucket identity, +not a package list. The apps screen recombines the live index with installed apps. Uninstalling an app while viewing a bucket must update the list, and navigation state must remain small. diff --git a/feature/browse/tv/src/main/kotlin/sk/styk/martin/apkanalyzer/feature/browse/tv/TvBrowseAction.kt b/feature/browse/tv/src/main/kotlin/sk/styk/martin/apkanalyzer/feature/browse/tv/TvBrowseAction.kt index 20f29924..3119c439 100644 --- a/feature/browse/tv/src/main/kotlin/sk/styk/martin/apkanalyzer/feature/browse/tv/TvBrowseAction.kt +++ b/feature/browse/tv/src/main/kotlin/sk/styk/martin/apkanalyzer/feature/browse/tv/TvBrowseAction.kt @@ -1,7 +1,14 @@ package sk.styk.martin.apkanalyzer.feature.browse.tv import sk.styk.martin.apkanalyzer.core.appindex.browse.BrowseDimension +import sk.styk.martin.apkanalyzer.core.appindex.browse.BrowseOption +import sk.styk.martin.apkanalyzer.core.appindex.browse.BrowseSubAttribute internal sealed interface TvBrowseAction { data class SelectDimension(val dimension: BrowseDimension) : TvBrowseAction + data class SelectOption( + val dimension: BrowseDimension, + val option: BrowseOption, + val subAttribute: BrowseSubAttribute?, + ) : TvBrowseAction } diff --git a/feature/browse/tv/src/main/kotlin/sk/styk/martin/apkanalyzer/feature/browse/tv/TvBrowseEvent.kt b/feature/browse/tv/src/main/kotlin/sk/styk/martin/apkanalyzer/feature/browse/tv/TvBrowseEvent.kt index b5418ee3..015850b2 100644 --- a/feature/browse/tv/src/main/kotlin/sk/styk/martin/apkanalyzer/feature/browse/tv/TvBrowseEvent.kt +++ b/feature/browse/tv/src/main/kotlin/sk/styk/martin/apkanalyzer/feature/browse/tv/TvBrowseEvent.kt @@ -1,7 +1,14 @@ package sk.styk.martin.apkanalyzer.feature.browse.tv import sk.styk.martin.apkanalyzer.core.appindex.browse.BrowseDimension +import sk.styk.martin.apkanalyzer.core.appindex.browse.BrowseSubAttribute internal sealed interface TvBrowseEvent { data class NavigateToDimension(val dimension: BrowseDimension) : TvBrowseEvent + data class NavigateToApps( + val dimension: BrowseDimension, + val bucketKey: String, + val bucketLabel: String, + val subAttribute: BrowseSubAttribute?, + ) : TvBrowseEvent } diff --git a/feature/browse/tv/src/main/kotlin/sk/styk/martin/apkanalyzer/feature/browse/tv/TvBrowseScreen.kt b/feature/browse/tv/src/main/kotlin/sk/styk/martin/apkanalyzer/feature/browse/tv/TvBrowseScreen.kt index c96ea638..d7d8450b 100644 --- a/feature/browse/tv/src/main/kotlin/sk/styk/martin/apkanalyzer/feature/browse/tv/TvBrowseScreen.kt +++ b/feature/browse/tv/src/main/kotlin/sk/styk/martin/apkanalyzer/feature/browse/tv/TvBrowseScreen.kt @@ -3,33 +3,40 @@ package sk.styk.martin.apkanalyzer.feature.browse.tv import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.grid.GridCells -import androidx.compose.foundation.lazy.grid.GridItemSpan -import androidx.compose.foundation.lazy.grid.LazyVerticalGrid -import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalResources +import androidx.compose.ui.focus.focusRestorer +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Devices import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import sk.styk.martin.apkanalyzer.core.appindex.browse.BrowseDimension -import sk.styk.martin.apkanalyzer.core.appindex.browse.BrowseDimensionSummary -import sk.styk.martin.apkanalyzer.core.appindex.browse.subtitleRes +import sk.styk.martin.apkanalyzer.core.appindex.browse.BrowseOption +import sk.styk.martin.apkanalyzer.core.appindex.browse.BrowseSubAttribute import sk.styk.martin.apkanalyzer.core.appindex.browse.titleRes +import sk.styk.martin.apkanalyzer.core.common.model.AppReference +import sk.styk.martin.apkanalyzer.core.common.model.PackageName +import sk.styk.martin.apkanalyzer.core.designsystem.foundation.icons.ApkAnalyzerIcons +import sk.styk.martin.apkanalyzer.core.designsystem.foundation.icons.AppIcon import sk.styk.martin.apkanalyzer.core.designsystem.foundation.theme.AppTheme import sk.styk.martin.apkanalyzer.core.designsystem.tv.components.InfoCard +import sk.styk.martin.apkanalyzer.core.designsystem.tv.components.SectionHeader import sk.styk.martin.apkanalyzer.core.designsystem.tv.components.Text +import sk.styk.martin.apkanalyzer.core.designsystem.tv.focus.RestorableFocus import sk.styk.martin.apkanalyzer.core.designsystem.tv.focus.rememberRestorableFocus import sk.styk.martin.apkanalyzer.core.designsystem.tv.focus.restorableFocus import sk.styk.martin.apkanalyzer.core.designsystem.tv.theme.ApkAnalyzerTvTheme @@ -38,13 +45,18 @@ import sk.styk.martin.apkanalyzer.core.appindex.R as AppIndexR import sk.styk.martin.apkanalyzer.feature.browse.api.R as BrowseApiR @Composable -internal fun TvBrowseScreen(onOpenDimension: (BrowseDimension) -> Unit, viewModel: TvBrowseViewModel = hiltViewModel()) { +internal fun TvBrowseScreen( + onOpenDimension: (BrowseDimension) -> Unit, + onOpenOption: (dimension: BrowseDimension, bucketKey: String, bucketLabel: String, subAttribute: BrowseSubAttribute?) -> Unit, + viewModel: TvBrowseViewModel = hiltViewModel(), +) { val state by viewModel.state.collectAsStateWithLifecycle() LaunchedEffect(viewModel) { viewModel.events.collect { event -> when (event) { is TvBrowseEvent.NavigateToDimension -> onOpenDimension(event.dimension) + is TvBrowseEvent.NavigateToApps -> onOpenOption(event.dimension, event.bucketKey, event.bucketLabel, event.subAttribute) } } } @@ -63,19 +75,24 @@ private fun TvBrowseContent(state: TvBrowseState, onAction: (TvBrowseAction) -> @Composable private fun LoadedContent(state: TvBrowseState.Loaded, onAction: (TvBrowseAction) -> Unit) { val focus = rememberRestorableFocus( - availableKeys = state.dimensions.mapTo(mutableSetOf()) { it.dimension.name }, - defaultKey = state.dimensions.firstOrNull()?.dimension?.name.orEmpty(), + availableKeys = buildSet { + state.rows.forEach { row -> + row.options.forEach { add(optionKey(row.dimension, it.option)) } + add(seeAllKey(row.dimension)) + } + }, + defaultKey = state.rows.firstOrNull()?.let { row -> + row.options.firstOrNull()?.let { optionKey(row.dimension, it.option) } ?: seeAllKey(row.dimension) + }.orEmpty(), ) - LazyVerticalGrid( - columns = GridCells.Fixed(COLUMNS), + LazyColumn( modifier = Modifier.fillMaxSize(), - contentPadding = PaddingValues(horizontal = 48.dp, vertical = 32.dp), - horizontalArrangement = Arrangement.spacedBy(24.dp), - verticalArrangement = Arrangement.spacedBy(24.dp), + contentPadding = PaddingValues(top = 27.dp, bottom = 27.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), ) { - item(key = HEADER_KEY, span = { GridItemSpan(maxLineSpan) }) { - Column(modifier = Modifier.padding(bottom = 8.dp)) { + item(key = HEADER_KEY) { + Column(modifier = Modifier.padding(horizontal = HorizontalPadding)) { Text(text = stringResource(BrowseApiR.string.browse), style = AppTheme.typography.headlineSmall) Text( text = pluralStringResource(AppIndexR.plurals.browse_subtitle, state.totalApps, state.totalApps), @@ -84,71 +101,137 @@ private fun LoadedContent(state: TvBrowseState.Loaded, onAction: (TvBrowseAction ) } } - items(state.dimensions, key = { it.dimension.name }) { summary -> - DimensionCard( - summary = summary, - onClick = { onAction(TvBrowseAction.SelectDimension(summary.dimension)) }, - modifier = Modifier.restorableFocus(focus, summary.dimension.name).height(CardHeight), + items(state.rows, key = { it.dimension.name }) { row -> + DimensionRow(row = row, focus = focus, onAction = onAction) + } + } +} + +@Composable +private fun DimensionRow( + row: TvBrowseRow, + focus: RestorableFocus, + onAction: (TvBrowseAction) -> Unit, +) { + Column { + Row( + modifier = Modifier.padding(start = HorizontalPadding, end = HorizontalPadding, top = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + SectionHeader(text = stringResource(row.dimension.titleRes)) + Text( + text = pluralStringResource(AppIndexR.plurals.browse_dimension_option_count, row.optionCount, row.optionCount), + style = AppTheme.typography.bodyMedium, + color = AppTheme.colors.onSurfaceVariant, ) } + LazyRow( + modifier = Modifier.focusRestorer(), + contentPadding = PaddingValues(horizontal = HorizontalPadding, vertical = FocusRoom), + horizontalArrangement = Arrangement.spacedBy(16.dp), + ) { + items(row.options, key = { it.option.key }) { rowOption -> + OptionTile( + rowOption = rowOption, + icon = row.dimension.bucketIcon(rowOption.option.key), + onClick = { onAction(TvBrowseAction.SelectOption(row.dimension, rowOption.option, row.subAttribute)) }, + modifier = Modifier.restorableFocus(focus, optionKey(row.dimension, rowOption.option)), + ) + } + item(key = SEE_ALL_KEY) { + InfoCard( + onClick = { onAction(TvBrowseAction.SelectDimension(row.dimension)) }, + title = stringResource(R.string.tv_browse_see_all), + icon = ApkAnalyzerIcons.ChevronRight, + stackedHeader = true, + modifier = Modifier + .restorableFocus(focus, seeAllKey(row.dimension)) + .size(width = SeeAllWidth, height = TileHeight), + ) { + Text( + text = pluralStringResource(AppIndexR.plurals.browse_dimension_option_count, row.optionCount, row.optionCount), + style = AppTheme.typography.bodyMedium, + color = AppTheme.colors.onSurfaceVariant, + ) + } + } + } } } @Composable -private fun DimensionCard( - summary: BrowseDimensionSummary, +private fun OptionTile( + rowOption: TvBrowseRowOption, + icon: ImageVector, onClick: () -> Unit, modifier: Modifier = Modifier, ) { InfoCard( onClick = onClick, - title = stringResource(summary.dimension.titleRes), - icon = summary.dimension.icon, + title = rowOption.option.label, + icon = icon, stackedHeader = true, - modifier = modifier, + modifier = modifier.size(width = TileWidth, height = TileHeight), ) { - Text( - text = stringResource(summary.dimension.subtitleRes), - style = AppTheme.typography.bodyMedium, - color = AppTheme.colors.onSurfaceVariant, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - ) - if (summary.topLabels.isNotEmpty()) { - val resources = LocalResources.current + Row( + modifier = Modifier.padding(top = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { + rowOption.packages.forEach { packageName -> + AppIcon(source = AppReference.InstalledPackage(packageName), size = 26.dp) + } + } Text( - text = summary.topLabels.reduce { joined, label -> resources.getString(R.string.tv_browse_label_list, joined, label) }, - style = AppTheme.typography.bodyMedium, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.padding(top = 4.dp), + text = pluralStringResource(AppIndexR.plurals.browse_apps_count, rowOption.option.count, rowOption.option.count), + style = AppTheme.typography.labelLarge, + color = AppTheme.colors.primary, ) } - Text( - text = pluralStringResource(AppIndexR.plurals.browse_dimension_option_count, summary.optionCount, summary.optionCount), - style = AppTheme.typography.labelLarge, - color = AppTheme.colors.primary, - modifier = Modifier.padding(top = 4.dp), - ) } } -private const val COLUMNS = 3 +private fun optionKey(dimension: BrowseDimension, option: BrowseOption) = "${dimension.name}:option:${option.key}" + +private fun seeAllKey(dimension: BrowseDimension) = "${dimension.name}:all" + private const val HEADER_KEY = "header" -private val CardHeight = 228.dp +private const val SEE_ALL_KEY = "see-all" +private val HorizontalPadding = 48.dp +private val FocusRoom = 12.dp +private val TileWidth = 240.dp +private val SeeAllWidth = 160.dp +private val TileHeight = 116.dp @Preview(device = Devices.TV_1080p, showBackground = true, backgroundColor = 0xFF000000) @Composable private fun TvBrowseContentPreview() { + val packages = listOf(PackageName("com.google.android.youtube.tv"), PackageName("com.netflix.ninja"), PackageName("com.spotify.tv.android")) ApkAnalyzerTvTheme { TvBrowseContent( state = TvBrowseState.Loaded( totalApps = 142, - dimensions = listOf( - BrowseDimensionSummary(BrowseDimension.Permission, 186, listOf("Full network access", "Run at startup", "Camera")), - BrowseDimensionSummary(BrowseDimension.SigningCertificate, 24, listOf("Google LLC", "Android", "Netflix")), - BrowseDimensionSummary(BrowseDimension.TargetSdk, 9, listOf("Android 15", "Android 14", "Android 13")), - BrowseDimensionSummary(BrowseDimension.InstallSource, 3, listOf("Google Play", "System", "Sideloaded")), + rows = listOf( + TvBrowseRow( + dimension = BrowseDimension.Permission, + subAttribute = null, + optionCount = 186, + options = listOf( + TvBrowseRowOption( + BrowseOption.Labeled("android.permission.INTERNET", "Full network access", "android.permission.INTERNET", 176), + packages, + ), + TvBrowseRowOption(BrowseOption.Labeled("android.permission.CAMERA", "Camera", "android.permission.CAMERA", 24), packages), + ), + ), + TvBrowseRow( + dimension = BrowseDimension.SigningCertificate, + subAttribute = BrowseSubAttribute.CertificateOrganization, + optionCount = 24, + options = listOf(TvBrowseRowOption(BrowseOption.Labeled("Google LLC", "Google LLC", null, 38), packages)), + ), ), ), onAction = {}, diff --git a/feature/browse/tv/src/main/kotlin/sk/styk/martin/apkanalyzer/feature/browse/tv/TvBrowseState.kt b/feature/browse/tv/src/main/kotlin/sk/styk/martin/apkanalyzer/feature/browse/tv/TvBrowseState.kt index 53a01f24..6fc390cb 100644 --- a/feature/browse/tv/src/main/kotlin/sk/styk/martin/apkanalyzer/feature/browse/tv/TvBrowseState.kt +++ b/feature/browse/tv/src/main/kotlin/sk/styk/martin/apkanalyzer/feature/browse/tv/TvBrowseState.kt @@ -1,7 +1,10 @@ package sk.styk.martin.apkanalyzer.feature.browse.tv import androidx.compose.runtime.Immutable -import sk.styk.martin.apkanalyzer.core.appindex.browse.BrowseDimensionSummary +import sk.styk.martin.apkanalyzer.core.appindex.browse.BrowseDimension +import sk.styk.martin.apkanalyzer.core.appindex.browse.BrowseOption +import sk.styk.martin.apkanalyzer.core.appindex.browse.BrowseSubAttribute +import sk.styk.martin.apkanalyzer.core.common.model.PackageName @Immutable internal sealed interface TvBrowseState { @@ -9,5 +12,16 @@ internal sealed interface TvBrowseState { data object Loading : TvBrowseState @Immutable - data class Loaded(val totalApps: Int, val dimensions: List) : TvBrowseState + data class Loaded(val totalApps: Int, val rows: List) : TvBrowseState } + +@Immutable +internal data class TvBrowseRow( + val dimension: BrowseDimension, + val subAttribute: BrowseSubAttribute?, + val optionCount: Int, + val options: List, +) + +@Immutable +internal data class TvBrowseRowOption(val option: BrowseOption, val packages: List) diff --git a/feature/browse/tv/src/main/kotlin/sk/styk/martin/apkanalyzer/feature/browse/tv/TvBrowseViewModel.kt b/feature/browse/tv/src/main/kotlin/sk/styk/martin/apkanalyzer/feature/browse/tv/TvBrowseViewModel.kt index 77cf8fda..8363a0d4 100644 --- a/feature/browse/tv/src/main/kotlin/sk/styk/martin/apkanalyzer/feature/browse/tv/TvBrowseViewModel.kt +++ b/feature/browse/tv/src/main/kotlin/sk/styk/martin/apkanalyzer/feature/browse/tv/TvBrowseViewModel.kt @@ -11,11 +11,17 @@ import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.stateIn import sk.styk.martin.apkanalyzer.core.appindex.AppIndexRepository +import sk.styk.martin.apkanalyzer.core.appindex.browse.BrowseDimension import sk.styk.martin.apkanalyzer.core.appindex.browse.BrowseDimensionLabeler -import sk.styk.martin.apkanalyzer.core.appindex.browse.dimensionSummaries +import sk.styk.martin.apkanalyzer.core.appindex.browse.BrowseSubAttribute +import sk.styk.martin.apkanalyzer.core.appindex.browse.browseOptions +import sk.styk.martin.apkanalyzer.core.appindex.browse.bucketsFor +import sk.styk.martin.apkanalyzer.core.appindex.model.AppAttributeIndex import sk.styk.martin.apkanalyzer.core.appindex.model.AppIndexStatus import sk.styk.martin.apkanalyzer.core.apps.InstalledAppsRepository +import sk.styk.martin.apkanalyzer.core.apps.model.InstalledApp import sk.styk.martin.apkanalyzer.core.common.coroutines.DispatcherProvider +import sk.styk.martin.apkanalyzer.core.common.model.PackageName import javax.inject.Inject @HiltViewModel @@ -32,11 +38,11 @@ internal class TvBrowseViewModel @Inject constructor( is AppIndexStatus.Data -> TvBrowseState.Loaded( totalApps = apps.size, - dimensions = status.index.dimensionSummaries(labeler, TOP_LABELS_COUNT), + rows = BrowseDimension.entries.map { status.index.row(it, apps.iconRanking()) }, ) } } - .flowOn(dispatcherProvider.io()) + .flowOn(dispatcherProvider.default()) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), TvBrowseState.Loading) private val eventChannel = Channel(Channel.BUFFERED) @@ -45,8 +51,46 @@ internal class TvBrowseViewModel @Inject constructor( fun onAction(action: TvBrowseAction) { when (action) { is TvBrowseAction.SelectDimension -> eventChannel.trySend(TvBrowseEvent.NavigateToDimension(action.dimension)) + + is TvBrowseAction.SelectOption -> eventChannel.trySend( + TvBrowseEvent.NavigateToApps( + dimension = action.dimension, + bucketKey = action.option.key, + bucketLabel = action.option.label, + subAttribute = action.subAttribute, + ), + ) } } + + private fun AppAttributeIndex.row(dimension: BrowseDimension, iconRanking: Map): TvBrowseRow { + val subAttribute = if (dimension == BrowseDimension.SigningCertificate) BrowseSubAttribute.CertificateOrganization else null + val buckets = bucketsFor(dimension, subAttribute) + return TvBrowseRow( + dimension = dimension, + subAttribute = subAttribute, + optionCount = bucketsFor(dimension).size, + options = browseOptions(dimension, subAttribute, labeler) + .take(ROW_OPTION_LIMIT) + .map { + TvBrowseRowOption( + option = it, + packages = buckets[it.key].orEmpty().sortedBy { packageName -> + iconRanking[packageName] ?: Int.MAX_VALUE + }.take(ROW_ICON_LIMIT), + ) + }, + ) + } } -private const val TOP_LABELS_COUNT = 4 +private const val ROW_OPTION_LIMIT = 4 +private const val ROW_ICON_LIMIT = 4 + +private fun List.iconRanking(): Map = sortedWith( + compareBy { it.isSystemApp } + .thenBy { it.applicationName == it.packageName.value } + .thenByDescending { it.lastUpdateTime }, +) + .withIndex() + .associate { (rank, app) -> app.packageName to rank } diff --git a/feature/browse/tv/src/main/kotlin/sk/styk/martin/apkanalyzer/feature/browse/tv/apps/TvBrowseAppsScreen.kt b/feature/browse/tv/src/main/kotlin/sk/styk/martin/apkanalyzer/feature/browse/tv/apps/TvBrowseAppsScreen.kt index 6c18fb41..bfdacfcc 100644 --- a/feature/browse/tv/src/main/kotlin/sk/styk/martin/apkanalyzer/feature/browse/tv/apps/TvBrowseAppsScreen.kt +++ b/feature/browse/tv/src/main/kotlin/sk/styk/martin/apkanalyzer/feature/browse/tv/apps/TvBrowseAppsScreen.kt @@ -2,9 +2,9 @@ package sk.styk.martin.apkanalyzer.feature.browse.tv.apps import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding @@ -16,7 +16,6 @@ import androidx.compose.foundation.lazy.grid.items import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource @@ -30,23 +29,13 @@ import sk.styk.martin.apkanalyzer.core.appindex.browse.BrowseDimension import sk.styk.martin.apkanalyzer.core.appindex.browse.BrowseSubAttribute import sk.styk.martin.apkanalyzer.core.appindex.browse.cardLabelRes import sk.styk.martin.apkanalyzer.core.appindex.browse.isCertificateHash -import sk.styk.martin.apkanalyzer.core.apppermissions.detail.explanationRes -import sk.styk.martin.apkanalyzer.core.apppermissions.detail.labelRes import sk.styk.martin.apkanalyzer.core.apps.permissions.ProtectionLevel -import sk.styk.martin.apkanalyzer.core.apps.signing.CertificateValidity -import sk.styk.martin.apkanalyzer.core.apps.signing.displayLine -import sk.styk.martin.apkanalyzer.core.apps.signing.explanationRes -import sk.styk.martin.apkanalyzer.core.apps.signing.isWeak -import sk.styk.martin.apkanalyzer.core.apps.signing.labelRes import sk.styk.martin.apkanalyzer.core.common.model.PackageName -import sk.styk.martin.apkanalyzer.core.designsystem.foundation.icons.ApkAnalyzerIcons import sk.styk.martin.apkanalyzer.core.designsystem.foundation.theme.AppTheme import sk.styk.martin.apkanalyzer.core.designsystem.tv.components.AppCard import sk.styk.martin.apkanalyzer.core.designsystem.tv.components.AppCardWidth import sk.styk.martin.apkanalyzer.core.designsystem.tv.components.CodeBox import sk.styk.martin.apkanalyzer.core.designsystem.tv.components.ScrollablePanel -import sk.styk.martin.apkanalyzer.core.designsystem.tv.components.SearchField -import sk.styk.martin.apkanalyzer.core.designsystem.tv.components.Tag import sk.styk.martin.apkanalyzer.core.designsystem.tv.components.Text import sk.styk.martin.apkanalyzer.core.designsystem.tv.focus.rememberRestorableFocus import sk.styk.martin.apkanalyzer.core.designsystem.tv.focus.restorableFocus @@ -55,10 +44,9 @@ import sk.styk.martin.apkanalyzer.feature.browse.tv.R import sk.styk.martin.apkanalyzer.feature.browse.tv.bucketIcon import sk.styk.martin.apkanalyzer.feature.browse.tv.components.BrowseHeader import sk.styk.martin.apkanalyzer.feature.browse.tv.components.BrowseLoading -import java.text.DateFormat -import java.util.Date +import sk.styk.martin.apkanalyzer.feature.browse.tv.components.BrowseSearch +import sk.styk.martin.apkanalyzer.feature.browse.tv.components.BucketDetailInfo import sk.styk.martin.apkanalyzer.core.appindex.R as AppIndexR -import sk.styk.martin.apkanalyzer.core.apps.R as AppsR @Composable internal fun TvBrowseAppsScreen( @@ -142,52 +130,51 @@ private fun LoadedContent( ?: stringResource(AppIndexR.string.browse_signer_unknown) }, subtitle = pluralStringResource(AppIndexR.plurals.browse_apps_count, state.totalApps, state.totalApps), - ) - Row(modifier = Modifier.padding(top = 20.dp, bottom = 27.dp), horizontalArrangement = Arrangement.spacedBy(32.dp)) { + ) { + BrowseSearch( + query = state.query, + onQueryChange = { onAction(TvBrowseAppsAction.ChangeQuery(it)) }, + placeholder = pluralStringResource(AppIndexR.plurals.browse_apps_filter_hint, state.totalApps, state.totalApps), + modifier = Modifier.restorableFocus(focus, SEARCH_KEY), + ) + } + Row(modifier = Modifier.padding(top = 8.dp, bottom = 15.dp), horizontalArrangement = Arrangement.spacedBy(20.dp)) { if (state.bucketDetail != null || isHashBucket) { BucketInfo( detail = state.bucketDetail, fingerprint = bucketLabel.takeIf { isHashBucket }, fingerprintLabel = subAttribute?.let { stringResource(it.cardLabelRes) }, - modifier = Modifier.width(InfoWidth), + modifier = Modifier.padding(vertical = FocusRoom).width(InfoWidth), ) } - Column(modifier = Modifier.weight(1f)) { - SearchField( - query = state.query, - onQueryChange = { onAction(TvBrowseAppsAction.ChangeQuery(it)) }, - placeholder = pluralStringResource(AppIndexR.plurals.browse_apps_filter_hint, state.totalApps, state.totalApps), - modifier = Modifier.restorableFocus(focus, SEARCH_KEY).fillMaxWidth(), - ) - LazyVerticalGrid( - columns = GridCells.Adaptive(minSize = AppCardWidth), - modifier = Modifier.fillMaxSize(), - contentPadding = PaddingValues(top = 20.dp, bottom = 27.dp), - horizontalArrangement = Arrangement.spacedBy(20.dp), - verticalArrangement = Arrangement.spacedBy(20.dp), - ) { - if (state.apps.isEmpty()) { - item(key = EMPTY_KEY, span = { GridItemSpan(maxLineSpan) }) { - Text( - text = if (state.query.isBlank()) { - stringResource(R.string.tv_browse_no_apps) - } else { - stringResource(AppIndexR.string.browse_apps_empty_query, state.query) - }, - style = AppTheme.typography.titleMedium, - color = AppTheme.colors.onSurfaceVariant, - ) - } - } - items(state.apps, key = { it.packageName.value }) { app -> - AppCard( - packageName = app.packageName, - applicationName = app.applicationName, - onClick = { onAction(TvBrowseAppsAction.SelectApp(app.packageName)) }, - modifier = Modifier.restorableFocus(focus, app.packageName.value), + LazyVerticalGrid( + columns = GridCells.Adaptive(minSize = AppCardWidth), + modifier = Modifier.weight(1f).fillMaxHeight(), + contentPadding = PaddingValues(all = FocusRoom), + horizontalArrangement = Arrangement.spacedBy(20.dp), + verticalArrangement = Arrangement.spacedBy(20.dp), + ) { + if (state.apps.isEmpty()) { + item(key = EMPTY_KEY, span = { GridItemSpan(maxLineSpan) }) { + Text( + text = if (state.query.isBlank()) { + stringResource(R.string.tv_browse_no_apps) + } else { + stringResource(AppIndexR.string.browse_apps_empty_query, state.query) + }, + style = AppTheme.typography.titleMedium, + color = AppTheme.colors.onSurfaceVariant, ) } } + items(state.apps, key = { it.packageName.value }) { app -> + AppCard( + packageName = app.packageName, + applicationName = app.applicationName, + onClick = { onAction(TvBrowseAppsAction.SelectApp(app.packageName)) }, + modifier = Modifier.restorableFocus(focus, app.packageName.value), + ) + } } } } @@ -208,83 +195,14 @@ private fun BucketInfo( style = AppTheme.typography.titleMedium, ) fingerprint?.let { CodeBox(text = it, label = fingerprintLabel, modifier = Modifier.fillMaxWidth()) } - when (detail) { - is BrowseBucketDetail.Permission -> PermissionInfo(detail) - is BrowseBucketDetail.Certificate -> CertificateInfo(detail) - null -> Unit - } - } -} - -@Composable -private fun PermissionInfo(detail: BrowseBucketDetail.Permission) { - Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { - Tag( - label = stringResource(detail.protectionLevel.labelRes), - icon = if (detail.protectionLevel == ProtectionLevel.Dangerous) ApkAnalyzerIcons.Warning else ApkAnalyzerIcons.Permissions, - ) - InfoText(text = stringResource(detail.protectionLevel.explanationRes)) - detail.description?.let { InfoText(text = it) } - } -} - -@Composable -private fun CertificateInfo(detail: BrowseBucketDetail.Certificate) { - val dateFormat = remember { DateFormat.getDateInstance(DateFormat.MEDIUM) } - Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { - FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { - Tag( - label = stringResource(detail.validity.labelRes), - icon = if (detail.validity == CertificateValidity.Valid) ApkAnalyzerIcons.Calendar else ApkAnalyzerIcons.Warning, - ) - Tag( - label = stringResource(detail.algorithmAssessment.labelRes), - icon = if (detail.algorithmAssessment.isWeak) ApkAnalyzerIcons.Warning else ApkAnalyzerIcons.VerifiedShield, - ) - if (detail.isSelfSigned) { - Tag(label = stringResource(AppIndexR.string.browse_certificate_self_signed)) - } - } - detail.subject.organization?.let { InfoField(label = stringResource(AppsR.string.certificates_organization), value = it) } - detail.subject.name?.takeIf { it != detail.subject.organization }?.let { InfoField(label = stringResource(AppsR.string.certificates_name), value = it) } - detail.subject.country?.let { InfoField(label = stringResource(AppsR.string.certificates_country), value = it) } - if (!detail.isSelfSigned) { - detail.issuer.displayLine?.let { InfoField(label = stringResource(AppsR.string.certificates_issuer), value = it) } - } - InfoField( - label = stringResource(AppsR.string.certificates_validity), - value = stringResource( - AppsR.string.certificates_date_range, - dateFormat.format(Date.from(detail.validFrom)), - dateFormat.format(Date.from(detail.validUntil)), - ), - ) - if (detail.validity != CertificateValidity.Valid) { - InfoText(text = stringResource(detail.validity.explanationRes)) - } - InfoText(text = stringResource(detail.algorithmAssessment.explanationRes, detail.algorithm)) - if (detail.isSelfSigned) { - InfoText(text = stringResource(AppIndexR.string.browse_certificate_self_signed_explanation)) - } - } -} - -@Composable -private fun InfoField(label: String, value: String) { - Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { - Text(text = label, style = AppTheme.typography.labelMedium, color = AppTheme.colors.onSurfaceVariant) - Text(text = value, style = AppTheme.typography.bodyLarge) + detail?.let { BucketDetailInfo(detail = it) } } } -@Composable -private fun InfoText(text: String) { - Text(text = text, style = AppTheme.typography.bodyMedium, color = AppTheme.colors.onSurfaceVariant) -} - private const val SEARCH_KEY = "search" private const val EMPTY_KEY = "empty" private val InfoWidth = 300.dp +private val FocusRoom = 12.dp @Preview(device = Devices.TV_1080p, showBackground = true, backgroundColor = 0xFF000000) @Composable diff --git a/feature/browse/tv/src/main/kotlin/sk/styk/martin/apkanalyzer/feature/browse/tv/components/BrowseHeader.kt b/feature/browse/tv/src/main/kotlin/sk/styk/martin/apkanalyzer/feature/browse/tv/components/BrowseHeader.kt index 0b0df3da..56d12134 100644 --- a/feature/browse/tv/src/main/kotlin/sk/styk/martin/apkanalyzer/feature/browse/tv/components/BrowseHeader.kt +++ b/feature/browse/tv/src/main/kotlin/sk/styk/martin/apkanalyzer/feature/browse/tv/components/BrowseHeader.kt @@ -3,6 +3,8 @@ package sk.styk.martin.apkanalyzer.feature.browse.tv.components import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -23,14 +25,15 @@ internal fun BrowseHeader( title: String, subtitle: String, modifier: Modifier = Modifier, + actions: @Composable RowScope.() -> Unit = {}, ) { Row( - modifier = modifier, + modifier = modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(20.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp), ) { IconBadge(icon = icon, size = 56.dp) - Column { + Column(modifier = Modifier.weight(1f)) { Text(text = title, style = AppTheme.typography.headlineSmall, maxLines = 1, overflow = TextOverflow.Ellipsis) Text( text = subtitle, @@ -40,6 +43,7 @@ internal fun BrowseHeader( overflow = TextOverflow.Ellipsis, ) } + actions() } } diff --git a/feature/browse/tv/src/main/kotlin/sk/styk/martin/apkanalyzer/feature/browse/tv/components/BrowseSearch.kt b/feature/browse/tv/src/main/kotlin/sk/styk/martin/apkanalyzer/feature/browse/tv/components/BrowseSearch.kt new file mode 100644 index 00000000..9a6060be --- /dev/null +++ b/feature/browse/tv/src/main/kotlin/sk/styk/martin/apkanalyzer/feature/browse/tv/components/BrowseSearch.kt @@ -0,0 +1,97 @@ +package sk.styk.martin.apkanalyzer.feature.browse.tv.components + +import androidx.compose.foundation.layout.width +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Devices +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import sk.styk.martin.apkanalyzer.core.designsystem.foundation.icons.ApkAnalyzerIcons +import sk.styk.martin.apkanalyzer.core.designsystem.tv.components.Button +import sk.styk.martin.apkanalyzer.core.designsystem.tv.components.SearchField +import sk.styk.martin.apkanalyzer.core.designsystem.tv.theme.ApkAnalyzerTvTheme +import sk.styk.martin.apkanalyzer.feature.browse.tv.R + +@Composable +internal fun BrowseSearch( + query: String, + onQueryChange: (String) -> Unit, + placeholder: String, + modifier: Modifier = Modifier, +) { + var opened by remember { mutableStateOf(false) } + + if (opened || query.isNotEmpty()) { + OpenedSearch( + query = query, + onQueryChange = onQueryChange, + placeholder = placeholder, + focusOnOpen = opened, + onClose = { opened = false }, + modifier = modifier, + ) + } else { + Button( + onClick = { opened = true }, + label = stringResource(R.string.tv_browse_search), + leadingIcon = ApkAnalyzerIcons.Search, + modifier = modifier, + ) + } +} + +@Composable +private fun OpenedSearch( + query: String, + onQueryChange: (String) -> Unit, + placeholder: String, + focusOnOpen: Boolean, + onClose: () -> Unit, + modifier: Modifier = Modifier, +) { + val focusRequester = remember { FocusRequester() } + val keyboardController = LocalSoftwareKeyboardController.current + var wasFocused by remember { mutableStateOf(false) } + + LaunchedEffect(Unit) { + if (focusOnOpen) { + focusRequester.requestFocus() + keyboardController?.show() + } + } + + SearchField( + query = query, + onQueryChange = onQueryChange, + placeholder = placeholder, + modifier = modifier + .width(SearchWidth) + .focusRequester(focusRequester) + .onFocusChanged { state -> + when { + state.isFocused -> wasFocused = true + wasFocused && query.isEmpty() -> onClose() + } + }, + ) +} + +private val SearchWidth = 360.dp + +@Preview(device = Devices.TV_1080p, showBackground = true, backgroundColor = 0xFF000000) +@Composable +private fun BrowseSearchPreview() { + ApkAnalyzerTvTheme { + BrowseSearch(query = "camera", onQueryChange = {}, placeholder = "Filter 186 options") + } +} diff --git a/feature/browse/tv/src/main/kotlin/sk/styk/martin/apkanalyzer/feature/browse/tv/components/BucketDetailInfo.kt b/feature/browse/tv/src/main/kotlin/sk/styk/martin/apkanalyzer/feature/browse/tv/components/BucketDetailInfo.kt new file mode 100644 index 00000000..8434d936 --- /dev/null +++ b/feature/browse/tv/src/main/kotlin/sk/styk/martin/apkanalyzer/feature/browse/tv/components/BucketDetailInfo.kt @@ -0,0 +1,119 @@ +package sk.styk.martin.apkanalyzer.feature.browse.tv.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.width +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Devices +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import sk.styk.martin.apkanalyzer.core.appindex.browse.BrowseBucketDetail +import sk.styk.martin.apkanalyzer.core.apppermissions.detail.explanationRes +import sk.styk.martin.apkanalyzer.core.apppermissions.detail.labelRes +import sk.styk.martin.apkanalyzer.core.apps.permissions.ProtectionLevel +import sk.styk.martin.apkanalyzer.core.apps.signing.CertificateValidity +import sk.styk.martin.apkanalyzer.core.apps.signing.displayLine +import sk.styk.martin.apkanalyzer.core.apps.signing.explanationRes +import sk.styk.martin.apkanalyzer.core.apps.signing.isWeak +import sk.styk.martin.apkanalyzer.core.apps.signing.labelRes +import sk.styk.martin.apkanalyzer.core.designsystem.foundation.icons.ApkAnalyzerIcons +import sk.styk.martin.apkanalyzer.core.designsystem.foundation.theme.AppTheme +import sk.styk.martin.apkanalyzer.core.designsystem.tv.components.Tag +import sk.styk.martin.apkanalyzer.core.designsystem.tv.components.Text +import sk.styk.martin.apkanalyzer.core.designsystem.tv.theme.ApkAnalyzerTvTheme +import java.text.DateFormat +import java.util.Date +import sk.styk.martin.apkanalyzer.core.appindex.R as AppIndexR +import sk.styk.martin.apkanalyzer.core.apps.R as AppsR + +@Composable +internal fun BucketDetailInfo(detail: BrowseBucketDetail, modifier: Modifier = Modifier) { + when (detail) { + is BrowseBucketDetail.Permission -> PermissionInfo(detail, modifier) + is BrowseBucketDetail.Certificate -> CertificateInfo(detail, modifier) + } +} + +@Composable +private fun PermissionInfo(detail: BrowseBucketDetail.Permission, modifier: Modifier = Modifier) { + Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(12.dp)) { + Tag( + label = stringResource(detail.protectionLevel.labelRes), + icon = if (detail.protectionLevel == ProtectionLevel.Dangerous) ApkAnalyzerIcons.Warning else ApkAnalyzerIcons.Permissions, + ) + InfoText(text = stringResource(detail.protectionLevel.explanationRes)) + detail.description?.let { InfoText(text = it) } + } +} + +@Composable +private fun CertificateInfo(detail: BrowseBucketDetail.Certificate, modifier: Modifier = Modifier) { + val dateFormat = remember { DateFormat.getDateInstance(DateFormat.MEDIUM) } + Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(12.dp)) { + FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Tag( + label = stringResource(detail.validity.labelRes), + icon = if (detail.validity == CertificateValidity.Valid) ApkAnalyzerIcons.Calendar else ApkAnalyzerIcons.Warning, + ) + Tag( + label = stringResource(detail.algorithmAssessment.labelRes), + icon = if (detail.algorithmAssessment.isWeak) ApkAnalyzerIcons.Warning else ApkAnalyzerIcons.VerifiedShield, + ) + if (detail.isSelfSigned) { + Tag(label = stringResource(AppIndexR.string.browse_certificate_self_signed)) + } + } + detail.subject.organization?.let { InfoField(label = stringResource(AppsR.string.certificates_organization), value = it) } + detail.subject.name?.takeIf { it != detail.subject.organization }?.let { InfoField(label = stringResource(AppsR.string.certificates_name), value = it) } + detail.subject.country?.let { InfoField(label = stringResource(AppsR.string.certificates_country), value = it) } + if (!detail.isSelfSigned) { + detail.issuer.displayLine?.let { InfoField(label = stringResource(AppsR.string.certificates_issuer), value = it) } + } + InfoField( + label = stringResource(AppsR.string.certificates_validity), + value = stringResource( + AppsR.string.certificates_date_range, + dateFormat.format(Date.from(detail.validFrom)), + dateFormat.format(Date.from(detail.validUntil)), + ), + ) + if (detail.validity != CertificateValidity.Valid) { + InfoText(text = stringResource(detail.validity.explanationRes)) + } + InfoText(text = stringResource(detail.algorithmAssessment.explanationRes, detail.algorithm)) + if (detail.isSelfSigned) { + InfoText(text = stringResource(AppIndexR.string.browse_certificate_self_signed_explanation)) + } + } +} + +@Composable +private fun InfoField(label: String, value: String) { + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text(text = label, style = AppTheme.typography.labelMedium, color = AppTheme.colors.onSurfaceVariant) + Text(text = value, style = AppTheme.typography.bodyLarge) + } +} + +@Composable +private fun InfoText(text: String) { + Text(text = text, style = AppTheme.typography.bodyMedium, color = AppTheme.colors.onSurfaceVariant) +} + +@Preview(device = Devices.TV_1080p, showBackground = true, backgroundColor = 0xFF000000) +@Composable +private fun BucketDetailInfoPreview() { + ApkAnalyzerTvTheme { + BucketDetailInfo( + detail = BrowseBucketDetail.Permission( + protectionLevel = ProtectionLevel.Dangerous, + description = "Allows the app to take pictures and videos with any camera.", + ), + modifier = Modifier.width(400.dp), + ) + } +} diff --git a/feature/browse/tv/src/main/kotlin/sk/styk/martin/apkanalyzer/feature/browse/tv/navigation/TvBrowseEntryProvider.kt b/feature/browse/tv/src/main/kotlin/sk/styk/martin/apkanalyzer/feature/browse/tv/navigation/TvBrowseEntryProvider.kt index 0d2c5959..ac134a7f 100644 --- a/feature/browse/tv/src/main/kotlin/sk/styk/martin/apkanalyzer/feature/browse/tv/navigation/TvBrowseEntryProvider.kt +++ b/feature/browse/tv/src/main/kotlin/sk/styk/martin/apkanalyzer/feature/browse/tv/navigation/TvBrowseEntryProvider.kt @@ -13,7 +13,12 @@ import sk.styk.martin.apkanalyzer.feature.browse.tv.options.TvBrowseOptionsScree fun EntryProviderScope.tvBrowseEntries(navigator: Navigator) { entry { - TvBrowseScreen(onOpenDimension = { dimension -> navigator.navigate(TvBrowseOptionsNavKey(dimension)) }) + TvBrowseScreen( + onOpenDimension = { dimension -> navigator.navigate(TvBrowseOptionsNavKey(dimension)) }, + onOpenOption = { dimension, bucketKey, bucketLabel, subAttribute -> + navigator.navigate(TvBrowseAppsNavKey(dimension, bucketKey, bucketLabel, subAttribute)) + }, + ) } entry(metadata = slideFromEndEntryMetadata()) { key -> diff --git a/feature/browse/tv/src/main/kotlin/sk/styk/martin/apkanalyzer/feature/browse/tv/options/TvBrowseOptionsAction.kt b/feature/browse/tv/src/main/kotlin/sk/styk/martin/apkanalyzer/feature/browse/tv/options/TvBrowseOptionsAction.kt index fb5560cd..c9086017 100644 --- a/feature/browse/tv/src/main/kotlin/sk/styk/martin/apkanalyzer/feature/browse/tv/options/TvBrowseOptionsAction.kt +++ b/feature/browse/tv/src/main/kotlin/sk/styk/martin/apkanalyzer/feature/browse/tv/options/TvBrowseOptionsAction.kt @@ -6,5 +6,6 @@ import sk.styk.martin.apkanalyzer.core.appindex.browse.BrowseSubAttribute internal sealed interface TvBrowseOptionsAction { data class ChangeQuery(val query: String) : TvBrowseOptionsAction data class SelectSubAttribute(val subAttribute: BrowseSubAttribute) : TvBrowseOptionsAction + data class FocusOption(val option: BrowseOption) : TvBrowseOptionsAction data class SelectOption(val option: BrowseOption) : TvBrowseOptionsAction } diff --git a/feature/browse/tv/src/main/kotlin/sk/styk/martin/apkanalyzer/feature/browse/tv/options/TvBrowseOptionsScreen.kt b/feature/browse/tv/src/main/kotlin/sk/styk/martin/apkanalyzer/feature/browse/tv/options/TvBrowseOptionsScreen.kt index 47a6bb66..7efb534d 100644 --- a/feature/browse/tv/src/main/kotlin/sk/styk/martin/apkanalyzer/feature/browse/tv/options/TvBrowseOptionsScreen.kt +++ b/feature/browse/tv/src/main/kotlin/sk/styk/martin/apkanalyzer/feature/browse/tv/options/TvBrowseOptionsScreen.kt @@ -1,51 +1,71 @@ package sk.styk.martin.apkanalyzer.feature.browse.tv.options +import androidx.compose.animation.Crossfade +import androidx.compose.animation.core.tween import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyRow -import androidx.compose.foundation.lazy.grid.GridCells -import androidx.compose.foundation.lazy.grid.GridItemSpan -import androidx.compose.foundation.lazy.grid.LazyVerticalGrid -import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.lazy.items import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusProperties +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.focusRestorer +import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Devices import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle +import sk.styk.martin.apkanalyzer.core.appindex.browse.BrowseBucketDetail import sk.styk.martin.apkanalyzer.core.appindex.browse.BrowseDimension import sk.styk.martin.apkanalyzer.core.appindex.browse.BrowseOption import sk.styk.martin.apkanalyzer.core.appindex.browse.BrowseSubAttribute import sk.styk.martin.apkanalyzer.core.appindex.browse.cardLabelRes import sk.styk.martin.apkanalyzer.core.appindex.browse.isSearchable import sk.styk.martin.apkanalyzer.core.appindex.browse.titleRes +import sk.styk.martin.apkanalyzer.core.apps.permissions.ProtectionLevel +import sk.styk.martin.apkanalyzer.core.common.model.AppReference +import sk.styk.martin.apkanalyzer.core.common.model.PackageName +import sk.styk.martin.apkanalyzer.core.designsystem.foundation.icons.ApkAnalyzerIcons +import sk.styk.martin.apkanalyzer.core.designsystem.foundation.icons.AppIcon import sk.styk.martin.apkanalyzer.core.designsystem.foundation.theme.AppTheme import sk.styk.martin.apkanalyzer.core.designsystem.tv.components.CodeBox import sk.styk.martin.apkanalyzer.core.designsystem.tv.components.FilterChip -import sk.styk.martin.apkanalyzer.core.designsystem.tv.components.InfoCard -import sk.styk.martin.apkanalyzer.core.designsystem.tv.components.SearchField +import sk.styk.martin.apkanalyzer.core.designsystem.tv.components.IconBadge +import sk.styk.martin.apkanalyzer.core.designsystem.tv.components.InfoListItem +import sk.styk.martin.apkanalyzer.core.designsystem.tv.components.ScrollablePanel import sk.styk.martin.apkanalyzer.core.designsystem.tv.components.Text import sk.styk.martin.apkanalyzer.core.designsystem.tv.focus.RestorableFocus import sk.styk.martin.apkanalyzer.core.designsystem.tv.focus.rememberRestorableFocus import sk.styk.martin.apkanalyzer.core.designsystem.tv.focus.restorableFocus -import sk.styk.martin.apkanalyzer.core.designsystem.tv.modifier.bleedHorizontally import sk.styk.martin.apkanalyzer.core.designsystem.tv.theme.ApkAnalyzerTvTheme +import sk.styk.martin.apkanalyzer.feature.browse.tv.R +import sk.styk.martin.apkanalyzer.feature.browse.tv.apps.TvBrowseAppItem import sk.styk.martin.apkanalyzer.feature.browse.tv.bucketIcon import sk.styk.martin.apkanalyzer.feature.browse.tv.components.BrowseHeader import sk.styk.martin.apkanalyzer.feature.browse.tv.components.BrowseLoading +import sk.styk.martin.apkanalyzer.feature.browse.tv.components.BrowseSearch +import sk.styk.martin.apkanalyzer.feature.browse.tv.components.BucketDetailInfo import sk.styk.martin.apkanalyzer.feature.browse.tv.icon import sk.styk.martin.apkanalyzer.core.appindex.R as AppIndexR @@ -86,6 +106,7 @@ private fun LoadedContent( state: TvBrowseOptionsState.Loaded, onAction: (TvBrowseOptionsAction) -> Unit, ) { + val listFocusRequester = remember { FocusRequester() } val focus = rememberRestorableFocus( availableKeys = buildSet { if (dimension.isSearchable) add(SEARCH_KEY) @@ -94,125 +115,205 @@ private fun LoadedContent( }, defaultKey = state.options.firstOrNull()?.let(::optionKey) ?: if (dimension.isSearchable) SEARCH_KEY else "", ) - val showsHashes = state.options.any { it is BrowseOption.CertificateHash } Column(modifier = Modifier.fillMaxSize().padding(start = 48.dp, end = 48.dp, top = 27.dp)) { BrowseHeader( icon = dimension.icon, title = stringResource(dimension.titleRes), subtitle = pluralStringResource(AppIndexR.plurals.browse_dimension_option_count, state.totalOptions, state.totalOptions), - ) - Controls(dimension = dimension, state = state, focus = focus, onAction = onAction) - LazyVerticalGrid( - columns = GridCells.Fixed(if (showsHashes) HASH_COLUMNS else LABEL_COLUMNS), - modifier = Modifier.fillMaxSize(), - contentPadding = PaddingValues(top = 16.dp, bottom = 27.dp), - horizontalArrangement = Arrangement.spacedBy(20.dp), - verticalArrangement = Arrangement.spacedBy(20.dp), ) { - if (state.options.isEmpty()) { - item(key = EMPTY_KEY, span = { GridItemSpan(maxLineSpan) }) { - Text( - text = stringResource(AppIndexR.string.browse_options_empty_query, state.query), - style = AppTheme.typography.titleMedium, - color = AppTheme.colors.onSurfaceVariant, - ) - } + if (dimension.isSearchable) { + BrowseSearch( + query = state.query, + onQueryChange = { onAction(TvBrowseOptionsAction.ChangeQuery(it)) }, + placeholder = pluralStringResource(AppIndexR.plurals.browse_options_filter_hint, state.totalOptions, state.totalOptions), + modifier = Modifier + .restorableFocus(focus, SEARCH_KEY) + .focusProperties { down = listFocusRequester }, + ) } - items(state.options, key = ::optionKey) { option -> - OptionCard( - option = option, - icon = dimension.bucketIcon(option.key), - onClick = { onAction(TvBrowseOptionsAction.SelectOption(option)) }, - modifier = Modifier.restorableFocus(focus, optionKey(option)), + } + Row( + modifier = Modifier.padding(top = 16.dp, bottom = 27.dp), + horizontalArrangement = Arrangement.spacedBy(32.dp), + ) { + Column(modifier = Modifier.weight(LIST_WEIGHT)) { + if (state.subAttributes.isNotEmpty()) { + SubAttributeChips(state = state, focus = focus, onAction = onAction) + } + OptionList( + dimension = dimension, + state = state, + focus = focus, + onAction = onAction, + modifier = Modifier.focusRequester(listFocusRequester), ) } + Crossfade( + targetState = state.preview, + animationSpec = tween(durationMillis = CROSSFADE_DURATION), + modifier = Modifier.weight(PREVIEW_WEIGHT).fillMaxHeight(), + label = "optionPreview", + ) { preview -> + if (preview != null) { + OptionPreview( + preview = preview, + icon = dimension.bucketIcon(preview.option.key), + onClick = { onAction(TvBrowseOptionsAction.SelectOption(preview.option)) }, + modifier = Modifier.fillMaxSize(), + ) + } + } } } } @Composable -private fun Controls( - dimension: BrowseDimension, +private fun SubAttributeChips( state: TvBrowseOptionsState.Loaded, focus: RestorableFocus, onAction: (TvBrowseOptionsAction) -> Unit, ) { - if (!dimension.isSearchable && state.subAttributes.isEmpty()) return - Column(modifier = Modifier.padding(top = 20.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { - if (dimension.isSearchable) { - SearchField( - query = state.query, - onQueryChange = { onAction(TvBrowseOptionsAction.ChangeQuery(it)) }, - placeholder = pluralStringResource(AppIndexR.plurals.browse_options_filter_hint, state.totalOptions, state.totalOptions), - modifier = Modifier.restorableFocus(focus, SEARCH_KEY).fillMaxWidth(), + LazyRow( + horizontalArrangement = Arrangement.spacedBy(12.dp), + contentPadding = PaddingValues(vertical = 4.dp), + modifier = Modifier.padding(bottom = 8.dp), + ) { + items(state.subAttributes, key = ::subAttributeKey) { subAttribute -> + FilterChip( + selected = subAttribute == state.subAttribute, + onClick = { onAction(TvBrowseOptionsAction.SelectSubAttribute(subAttribute)) }, + label = stringResource(subAttribute.labelRes), + modifier = Modifier.restorableFocus(focus, subAttributeKey(subAttribute)), ) } - if (state.subAttributes.isNotEmpty()) { - LazyRow( - modifier = Modifier.bleedHorizontally(ChipBleed), - horizontalArrangement = Arrangement.spacedBy(12.dp), - contentPadding = PaddingValues(horizontal = ChipBleed, vertical = 4.dp), - ) { - items(state.subAttributes, key = ::subAttributeKey) { subAttribute -> - FilterChip( - selected = subAttribute == state.subAttribute, - onClick = { onAction(TvBrowseOptionsAction.SelectSubAttribute(subAttribute)) }, - label = stringResource(subAttribute.labelRes), - modifier = Modifier.restorableFocus(focus, subAttributeKey(subAttribute)), - ) - } + } +} + +@Composable +private fun OptionList( + dimension: BrowseDimension, + state: TvBrowseOptionsState.Loaded, + focus: RestorableFocus, + onAction: (TvBrowseOptionsAction) -> Unit, + modifier: Modifier = Modifier, +) { + LazyColumn( + modifier = modifier.focusRestorer(), + contentPadding = PaddingValues(vertical = 4.dp), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + if (state.options.isEmpty()) { + item(key = EMPTY_KEY) { + Text( + text = stringResource(AppIndexR.string.browse_options_empty_query, state.query), + style = AppTheme.typography.titleMedium, + color = AppTheme.colors.onSurfaceVariant, + modifier = Modifier.padding(16.dp), + ) } } + items(state.options, key = ::optionKey) { option -> + InfoListItem( + label = option.title(), + value = option.summary(), + onClick = { onAction(TvBrowseOptionsAction.SelectOption(option)) }, + leadingIcon = dimension.bucketIcon(option.key), + trailingIcon = ApkAnalyzerIcons.ChevronRight, + modifier = Modifier + .restorableFocus(focus, optionKey(option)) + .onFocusChanged { if (it.isFocused) onAction(TvBrowseOptionsAction.FocusOption(option)) }, + ) + } } } @Composable -private fun OptionCard( - option: BrowseOption, +private fun OptionPreview( + preview: TvBrowseOptionPreview, icon: ImageVector, onClick: () -> Unit, modifier: Modifier = Modifier, ) { - when (option) { - is BrowseOption.Labeled -> InfoCard( - onClick = onClick, - title = option.label, - icon = icon, - modifier = modifier.height(LabeledCardHeight), - ) { - option.rawIdentifier?.let { rawIdentifier -> + ScrollablePanel(modifier = modifier, resetKey = preview.option.key, spacing = 20.dp, onClick = onClick) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(14.dp)) { + IconBadge(icon = icon, size = 40.dp) + Column { Text( - text = rawIdentifier, + text = preview.option.title(), + style = AppTheme.typography.titleLarge, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = pluralStringResource(AppIndexR.plurals.browse_apps_count, preview.totalApps, preview.totalApps), style = AppTheme.typography.bodyMedium, - color = AppTheme.colors.onSurfaceVariant, - maxLines = 1, - overflow = TextOverflow.StartEllipsis, + color = AppTheme.colors.primary, ) } - AppCount(count = option.count) } + when (val option = preview.option) { + is BrowseOption.CertificateHash -> CodeBox( + text = option.label, + label = stringResource(option.algorithm.cardLabelRes), + compact = true, + modifier = Modifier.fillMaxWidth(), + ) - is BrowseOption.CertificateHash -> InfoCard( - onClick = onClick, - title = option.publisher ?: stringResource(AppIndexR.string.browse_signer_unknown), - icon = icon, - modifier = modifier.height(HashCardHeight), - ) { - CodeBox(text = option.label, label = stringResource(option.algorithm.cardLabelRes), maxLines = 3) - AppCount(count = option.count) + is BrowseOption.Labeled -> option.rawIdentifier?.takeIf { it != option.label }?.let { + Text(text = it, style = AppTheme.typography.bodyMedium, color = AppTheme.colors.onSurfaceVariant) + } } + AppWall(apps = preview.apps, hiddenCount = preview.totalApps - preview.apps.size) + preview.detail?.let { BucketDetailInfo(detail = it) } } } @Composable -private fun AppCount(count: Int) { - Text( - text = pluralStringResource(AppIndexR.plurals.browse_apps_count, count, count), - style = AppTheme.typography.labelLarge, - color = AppTheme.colors.primary, - modifier = Modifier.padding(top = 4.dp), - ) +private fun AppWall(apps: List, hiddenCount: Int) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + FlowRow(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + apps.forEach { app -> AppTile(app = app) } + } + if (hiddenCount > 0) { + Text( + text = pluralStringResource(R.plurals.tv_browse_more_apps, hiddenCount, hiddenCount), + style = AppTheme.typography.labelLarge, + color = AppTheme.colors.onSurfaceVariant, + ) + } + } +} + +@Composable +private fun AppTile(app: TvBrowseAppItem) { + val reference = remember(app.packageName) { AppReference.InstalledPackage(app.packageName) } + Column(modifier = Modifier.width(AppTileWidth), horizontalAlignment = Alignment.CenterHorizontally) { + AppIcon(source = reference, size = 44.dp) + Text( + text = app.applicationName, + style = AppTheme.typography.labelMedium, + textAlign = TextAlign.Center, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(top = 6.dp), + ) + } +} + +@Composable +private fun BrowseOption.title(): String = when (this) { + is BrowseOption.CertificateHash -> publisher ?: stringResource(AppIndexR.string.browse_signer_unknown) + is BrowseOption.Labeled -> label +} + +@Composable +private fun BrowseOption.summary(): String { + val apps = pluralStringResource(AppIndexR.plurals.browse_apps_count, count, count) + return when (this) { + is BrowseOption.CertificateHash -> stringResource(R.string.tv_browse_label_list, apps, label.take(FINGERPRINT_PREFIX_LENGTH)) + is BrowseOption.Labeled -> apps + } } private fun optionKey(option: BrowseOption) = "option:${option.key}" @@ -221,15 +322,22 @@ private fun subAttributeKey(subAttribute: BrowseSubAttribute) = "attribute:${sub private const val SEARCH_KEY = "search" private const val EMPTY_KEY = "empty" -private const val LABEL_COLUMNS = 3 -private const val HASH_COLUMNS = 2 -private val ChipBleed = 16.dp -private val LabeledCardHeight = 160.dp -private val HashCardHeight = 228.dp +private const val LIST_WEIGHT = 0.42f +private const val PREVIEW_WEIGHT = 0.58f +private const val CROSSFADE_DURATION = 180 +private const val FINGERPRINT_PREFIX_LENGTH = 11 +private val AppTileWidth = 76.dp + +private val previewApps = listOf( + TvBrowseAppItem(PackageName("com.google.android.youtube.tv"), "YouTube"), + TvBrowseAppItem(PackageName("com.netflix.ninja"), "Netflix"), + TvBrowseAppItem(PackageName("com.spotify.tv.android"), "Spotify"), +) @Preview(device = Devices.TV_1080p, showBackground = true, backgroundColor = 0xFF000000) @Composable private fun TvBrowseOptionsContentPreview() { + val camera = BrowseOption.Labeled("android.permission.CAMERA", "Camera", "android.permission.CAMERA", 24) ApkAnalyzerTvTheme { TvBrowseOptionsContent( dimension = BrowseDimension.Permission, @@ -240,9 +348,18 @@ private fun TvBrowseOptionsContentPreview() { totalOptions = 3, options = listOf( BrowseOption.Labeled("android.permission.INTERNET", "Full network access", "android.permission.INTERNET", 176), - BrowseOption.Labeled("android.permission.CAMERA", "Camera", "android.permission.CAMERA", 24), + camera, BrowseOption.Labeled("android.permission.ACCESS_FINE_LOCATION", "Precise location", "android.permission.ACCESS_FINE_LOCATION", 18), ), + preview = TvBrowseOptionPreview( + option = camera, + detail = BrowseBucketDetail.Permission( + protectionLevel = ProtectionLevel.Dangerous, + description = "Allows the app to take pictures and videos with any camera.", + ), + apps = previewApps, + totalApps = 24, + ), ), onAction = {}, ) @@ -252,6 +369,13 @@ private fun TvBrowseOptionsContentPreview() { @Preview(device = Devices.TV_1080p, showBackground = true, backgroundColor = 0xFF000000) @Composable private fun TvBrowseOptionsCertificatePreview() { + val certificate = BrowseOption.CertificateHash( + key = "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2", + label = "A1:B2:C3:D4:E5:F6:A7:B8:C9:D0:E1:F2:A3:B4:C5:D6:E7:F8:A9:B0:C1:D2:E3:F4:A5:B6:C7:D8:E9:F0:A1:B2", + algorithm = BrowseSubAttribute.CertificateSha256, + count = 12, + publisher = "Google LLC", + ) ApkAnalyzerTvTheme { TvBrowseOptionsContent( dimension = BrowseDimension.SigningCertificate, @@ -260,15 +384,8 @@ private fun TvBrowseOptionsCertificatePreview() { subAttribute = BrowseSubAttribute.CertificateSha256, subAttributes = BrowseSubAttribute.entries, totalOptions = 1, - options = listOf( - BrowseOption.CertificateHash( - key = "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2", - label = "A1:B2:C3:D4:E5:F6:A7:B8:C9:D0:E1:F2:A3:B4:C5:D6:E7:F8:A9:B0:C1:D2:E3:F4:A5:B6:C7:D8:E9:F0:A1:B2", - algorithm = BrowseSubAttribute.CertificateSha256, - count = 12, - publisher = "Google LLC", - ), - ), + options = listOf(certificate), + preview = TvBrowseOptionPreview(option = certificate, detail = null, apps = previewApps, totalApps = 12), ), onAction = {}, ) diff --git a/feature/browse/tv/src/main/kotlin/sk/styk/martin/apkanalyzer/feature/browse/tv/options/TvBrowseOptionsState.kt b/feature/browse/tv/src/main/kotlin/sk/styk/martin/apkanalyzer/feature/browse/tv/options/TvBrowseOptionsState.kt index 8b022749..cf4d6c05 100644 --- a/feature/browse/tv/src/main/kotlin/sk/styk/martin/apkanalyzer/feature/browse/tv/options/TvBrowseOptionsState.kt +++ b/feature/browse/tv/src/main/kotlin/sk/styk/martin/apkanalyzer/feature/browse/tv/options/TvBrowseOptionsState.kt @@ -1,8 +1,10 @@ package sk.styk.martin.apkanalyzer.feature.browse.tv.options import androidx.compose.runtime.Immutable +import sk.styk.martin.apkanalyzer.core.appindex.browse.BrowseBucketDetail import sk.styk.martin.apkanalyzer.core.appindex.browse.BrowseOption import sk.styk.martin.apkanalyzer.core.appindex.browse.BrowseSubAttribute +import sk.styk.martin.apkanalyzer.feature.browse.tv.apps.TvBrowseAppItem @Immutable internal sealed interface TvBrowseOptionsState { @@ -16,5 +18,14 @@ internal sealed interface TvBrowseOptionsState { val subAttributes: List, val totalOptions: Int, val options: List, + val preview: TvBrowseOptionPreview?, ) : TvBrowseOptionsState } + +@Immutable +internal data class TvBrowseOptionPreview( + val option: BrowseOption, + val detail: BrowseBucketDetail?, + val apps: List, + val totalApps: Int, +) diff --git a/feature/browse/tv/src/main/kotlin/sk/styk/martin/apkanalyzer/feature/browse/tv/options/TvBrowseOptionsViewModel.kt b/feature/browse/tv/src/main/kotlin/sk/styk/martin/apkanalyzer/feature/browse/tv/options/TvBrowseOptionsViewModel.kt index bd9ad1f9..b3882809 100644 --- a/feature/browse/tv/src/main/kotlin/sk/styk/martin/apkanalyzer/feature/browse/tv/options/TvBrowseOptionsViewModel.kt +++ b/feature/browse/tv/src/main/kotlin/sk/styk/martin/apkanalyzer/feature/browse/tv/options/TvBrowseOptionsViewModel.kt @@ -12,27 +12,34 @@ import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flowOn -import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import sk.styk.martin.apkanalyzer.core.appindex.AppIndexRepository +import sk.styk.martin.apkanalyzer.core.appindex.browse.BrowseBucketDetailResolver import sk.styk.martin.apkanalyzer.core.appindex.browse.BrowseDimension import sk.styk.martin.apkanalyzer.core.appindex.browse.BrowseDimensionLabeler import sk.styk.martin.apkanalyzer.core.appindex.browse.BrowseOption import sk.styk.martin.apkanalyzer.core.appindex.browse.BrowseSubAttribute import sk.styk.martin.apkanalyzer.core.appindex.browse.browseOptions +import sk.styk.martin.apkanalyzer.core.appindex.browse.bucketsFor import sk.styk.martin.apkanalyzer.core.appindex.browse.matches import sk.styk.martin.apkanalyzer.core.appindex.browse.subAttributes import sk.styk.martin.apkanalyzer.core.appindex.browse.subAttributesOrDefault +import sk.styk.martin.apkanalyzer.core.appindex.model.AppAttributeIndex import sk.styk.martin.apkanalyzer.core.appindex.model.AppIndexStatus +import sk.styk.martin.apkanalyzer.core.apps.InstalledAppsRepository import sk.styk.martin.apkanalyzer.core.common.coroutines.DispatcherProvider +import sk.styk.martin.apkanalyzer.core.common.model.PackageName +import sk.styk.martin.apkanalyzer.feature.browse.tv.apps.TvBrowseAppItem @HiltViewModel(assistedFactory = TvBrowseOptionsViewModel.Factory::class) internal class TvBrowseOptionsViewModel @AssistedInject constructor( @Assisted private val dimension: BrowseDimension, appIndexRepository: AppIndexRepository, + installedAppsRepository: InstalledAppsRepository, private val labeler: BrowseDimensionLabeler, + private val bucketDetailResolver: BrowseBucketDetailResolver, dispatcherProvider: DispatcherProvider, ) : ViewModel() { @@ -43,24 +50,26 @@ internal class TvBrowseOptionsViewModel @AssistedInject constructor( private val narrowing = MutableStateFlow(Narrowing()) - private val source = appIndexRepository.index() - .map { status -> - when (status) { - AppIndexStatus.Loading -> OptionsSource.Loading + private val source = combine(appIndexRepository.index(), installedAppsRepository.apps()) { status, apps -> + when (status) { + AppIndexStatus.Loading -> OptionsSource.Loading - is AppIndexStatus.Data -> OptionsSource.Ready( - dimension.subAttributesOrDefault().associateWith { status.index.browseOptions(dimension, it, labeler) }, - ) - } + is AppIndexStatus.Data -> OptionsSource.Ready( + index = status.index, + optionsBySubAttribute = dimension.subAttributesOrDefault().associateWith { status.index.browseOptions(dimension, it, labeler) }, + appNames = apps.associate { it.packageName to it.applicationName }, + ) } + } .flowOn(dispatcherProvider.io()) val state: StateFlow = combine(source, narrowing) { source, narrowing -> when (source) { OptionsSource.Loading -> TvBrowseOptionsState.Loading - is OptionsSource.Ready -> source.narrowedBy(narrowing, dimension.subAttributes()) + is OptionsSource.Ready -> narrowedState(source, narrowing) } } + .flowOn(dispatcherProvider.default()) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), TvBrowseOptionsState.Loading) private val eventChannel = Channel(Channel.BUFFERED) @@ -72,6 +81,8 @@ internal class TvBrowseOptionsViewModel @AssistedInject constructor( is TvBrowseOptionsAction.SelectSubAttribute -> narrowing.update { it.copy(subAttribute = action.subAttribute) } + is TvBrowseOptionsAction.FocusOption -> narrowing.update { it.copy(focusedKey = action.option.key) } + is TvBrowseOptionsAction.SelectOption -> eventChannel.trySend( TvBrowseOptionsEvent.NavigateToApps( bucketKey = action.option.key, @@ -81,24 +92,53 @@ internal class TvBrowseOptionsViewModel @AssistedInject constructor( ) } } + + private fun narrowedState(source: OptionsSource.Ready, narrowing: Narrowing): TvBrowseOptionsState.Loaded { + val subAttribute = narrowing.subAttribute?.takeIf { it in source.optionsBySubAttribute } ?: source.optionsBySubAttribute.keys.firstOrNull() + val allOptions = source.optionsBySubAttribute[subAttribute].orEmpty() + val options = allOptions.filter { it.matches(narrowing.query) } + val previewed = options.firstOrNull { it.key == narrowing.focusedKey } ?: options.firstOrNull() + return TvBrowseOptionsState.Loaded( + query = narrowing.query, + subAttribute = subAttribute, + subAttributes = dimension.subAttributes(), + totalOptions = allOptions.size, + options = options, + preview = previewed?.let { preview(source, it, subAttribute) }, + ) + } + + private fun preview( + source: OptionsSource.Ready, + option: BrowseOption, + subAttribute: BrowseSubAttribute?, + ): TvBrowseOptionPreview { + val apps = source.index.bucketsFor(dimension, subAttribute)[option.key].orEmpty() + .mapNotNull { packageName -> source.appNames[packageName]?.let { TvBrowseAppItem(packageName = packageName, applicationName = it) } } + .sortedBy { it.applicationName.lowercase() } + return TvBrowseOptionPreview( + option = option, + detail = bucketDetailResolver.resolve(source.index, dimension, option.key, subAttribute), + apps = apps.take(PREVIEW_APP_LIMIT), + totalApps = apps.size, + ) + } } -private data class Narrowing(val query: String = "", val subAttribute: BrowseSubAttribute? = null) +private const val PREVIEW_APP_LIMIT = 12 + +private data class Narrowing( + val query: String = "", + val subAttribute: BrowseSubAttribute? = null, + val focusedKey: String? = null, +) private sealed interface OptionsSource { data object Loading : OptionsSource - data class Ready(val optionsBySubAttribute: Map>) : OptionsSource -} - -private fun OptionsSource.Ready.narrowedBy(narrowing: Narrowing, subAttributes: List): TvBrowseOptionsState.Loaded { - val subAttribute = narrowing.subAttribute?.takeIf { it in optionsBySubAttribute } ?: optionsBySubAttribute.keys.firstOrNull() - val allOptions = optionsBySubAttribute[subAttribute].orEmpty() - return TvBrowseOptionsState.Loaded( - query = narrowing.query, - subAttribute = subAttribute, - subAttributes = subAttributes, - totalOptions = allOptions.size, - options = allOptions.filter { it.matches(narrowing.query) }, - ) + data class Ready( + val index: AppAttributeIndex, + val optionsBySubAttribute: Map>, + val appNames: Map, + ) : OptionsSource } diff --git a/feature/browse/tv/src/main/res/values/strings.xml b/feature/browse/tv/src/main/res/values/strings.xml index 255f2361..cf7eaa5b 100644 --- a/feature/browse/tv/src/main/res/values/strings.xml +++ b/feature/browse/tv/src/main/res/values/strings.xml @@ -5,4 +5,10 @@ About this certificate No apps on this device match anymore. %1$s · %2$s + Search + See all + + +%1$d more app + +%1$d more apps + diff --git a/tv/AGENTS.md b/tv/AGENTS.md index eee3e9e2..be35faae 100644 --- a/tv/AGENTS.md +++ b/tv/AGENTS.md @@ -33,8 +33,9 @@ function in its `feature/*/tv/navigation/` package and wires it into the `entryP Android hides most installed apps from the list; without the second, the TV usage access screen never lists the app. * The `LEANBACK_LAUNCHER` Activity and `android:banner` are mandatory for the TV home screen. - `res/drawable/tv_banner.xml` is a placeholder; Play's TV review expects a 320x180 banner that - shows the app name. + `res/drawable-xhdpi/tv_banner.png` (640x360, the 320x180dp banner) is exported from + `docs/store/graphics/tv_banner_template.html`, the same source as the Play listing banner; change + the template and re-export both rather than editing the PNG. `google-services.json` is a copy of the phone app's file; CI's `fetch-google-services` action writes both. `debug.keystore` matches `app`'s so debug builds share one signature. diff --git a/tv/src/main/res/drawable-xhdpi/tv_banner.png b/tv/src/main/res/drawable-xhdpi/tv_banner.png new file mode 100644 index 00000000..b0ea0139 Binary files /dev/null and b/tv/src/main/res/drawable-xhdpi/tv_banner.png differ diff --git a/tv/src/main/res/drawable/tv_banner.xml b/tv/src/main/res/drawable/tv_banner.xml deleted file mode 100644 index a5f9fbc6..00000000 --- a/tv/src/main/res/drawable/tv_banner.xml +++ /dev/null @@ -1,16 +0,0 @@ - - - - - - - - - -