Merge branch 'main' into feature/delete_location_note

# Conflicts:
#	app/src/main/res/values-he/strings.xml
#	app/src/main/res/values-ms/strings.xml
#	app/src/main/res/values-pl/strings.xml
#	app/src/main/res/values-ta/strings.xml
#	app/src/main/res/values-uk/strings.xml
#	app/src/main/res/values-zh-rCN/strings.xml
#	app/src/main/res/values-zh-rTW/strings.xml
This commit is contained in:
Oleksandr Pliekhov 2026-07-30 00:16:39 -04:00
commit 5cc4ba344a
259 changed files with 38233 additions and 2149 deletions

View File

@ -0,0 +1,255 @@
---
name: android-ui-visual-review
description: Analyze an Android pull request, branch, commit, or patch for user-visible changes and produce reproducible before/after screenshots from isolated builds. Use this skill whenever a user asks for PR screenshots, branch UI comparisons, visual regression evidence, Compose before/after captures, populated message-state screenshots, responsive/theme/locale comparisons, or GitHub comments containing Android UI evidence—even if they only say “show me what changed.” Also use it to determine and document that a suspected UI PR has no visual delta. Do not use it for implementing a new UI, ordinary code review without visual evidence, or physical mesh validation.
compatibility: Requires git, the Android SDK and emulator, adb, Java/Gradle, Python 3, and gh for pull-request resolution or publishing.
---
# Android UI Visual Review
Turn a PR or branch into trustworthy visual evidence. The comparison is useful
only when the before and after builds use the correct commits, Android runtime,
viewport, app state, and navigation path.
## Collect the two user choices
Before starting, resolve:
1. **Target** — a PR URL/number or a branch/commit. If it is missing, ask for it.
For a branch, also ask for the intended base when it cannot be inferred
safely; otherwise default to the repository's `main`.
2. **Publishing** — for a PR target, ask whether the final screenshots and
findings should stay local or be posted as a PR comment. Do not perform any
GitHub write unless the user explicitly chooses publishing. A prior explicit
request such as “post these to the PR” already answers this question.
Do not block on publishing preference while doing read-only analysis if the user
has not answered yet. Keep the local workflow useful on its own.
## Read the routed guidance
- Read [references/analysis-playbook.md](references/analysis-playbook.md) for
every run. It explains how to map a diff to screens, states, and a capture
matrix.
- Read [references/fixture-recipes.md](references/fixture-recipes.md) whenever
the affected screen needs messages, peers, channels, nicknames, settings,
permissions, locale, theme, onboarding state, or another non-empty fixture.
- Read [references/github-publishing.md](references/github-publishing.md) only
when the user has opted into a PR comment.
## Create an isolated review session
Never switch the user's active checkout between before and after revisions.
Run from the repository root:
```sh
.agents/skills/android-ui-visual-review/scripts/create_review_worktree.sh \
--target "<PR URL, PR number, branch, or commit>"
```
For a branch with a non-default base:
```sh
.agents/skills/android-ui-visual-review/scripts/create_review_worktree.sh \
--target "<branch>" \
--base "<base ref>"
```
The script fetches a PR head when needed, computes the **actual merge-base**,
creates a detached temporary worktree at the before SHA, and prints:
- session directory
- worktree path
- artifact directory
- before and after SHAs
- PR number/base metadata when applicable
Keep artifacts outside the worktree so checkouts cannot remove them. The script
may symlink the ignored `local.properties` into the temporary worktree; never
publish it or quote its contents.
If a review session already exists and its SHAs are verified, reuse it. Do not
create a second worktree for the same run.
## Establish the visual contract
Use the actual diff, not the PR title, to determine what should be visible.
1. Record `git diff --stat`, `--name-status`, and the focused diff between the
before and after SHAs.
2. Trace changed UI symbols to their composable/activity, state source, entry
point, and prerequisites.
3. Separate direct visual changes from indirect ones such as dynamic color,
locale recreation, launcher resources, default data, or backend state shown
by an otherwise unchanged screen.
4. Produce a local capture matrix before building. Each row should define:
screen, navigation path, fixture, logical width, theme, locale, permissions,
and what difference is expected.
5. Include a control state where the UI should remain unchanged when that helps
distinguish intentional degradation from a regression.
When the diff contains no UI/resource/state-to-UI change, say so. If the user
asked for a screenshot for every target, capture the nearest affected surface
before and after and label the expected result **no visual delta**. Do not invent
a UI claim for a behavioral fix.
## Use the newest stable Android runtime
Prefer an emulator for repeatable UI evidence. At run time:
1. Inspect installed SDK/system images and current official Android release
information. Choose the newest **stable** API; do not silently use a preview.
2. Create a dedicated AVD and isolated data directory when possible. If current
command-line tools cannot create a newly versioned image (for example an
extension image), a verified `-sysdir` override with an isolated data
directory is acceptable.
3. After boot, record guest properties rather than trusting the AVD name:
```sh
adb -s "$ANDROID_REVIEW_SERIAL" shell getprop ro.build.version.release
adb -s "$ANDROID_REVIEW_SERIAL" shell getprop ro.build.version.sdk
adb -s "$ANDROID_REVIEW_SERIAL" shell getprop ro.build.version.security_patch
adb -s "$ANDROID_REVIEW_SERIAL" shell wm size
adb -s "$ANDROID_REVIEW_SERIAL" shell wm density
```
Use an explicit emulator serial for every ADB command when any physical device
is also connected. Never put serials, device names, local paths, IP addresses,
or other machine identifiers into reports or GitHub comments.
Use a physical device only when the affected UI depends on hardware that the
emulator cannot reproduce. Ask before changing or clearing a physical device.
This skill does not replace Mesh Lab: if the diff changes mesh, transport,
crypto, service, or physical peer behavior, use the `mesh-lab` skill separately
before claiming the behavior works.
## Build and capture the before state
The worktree starts at the before SHA.
1. Build the debug APK with `./gradlew assembleDebug`.
2. Install the ABI-matching APK with `adb install -r`.
3. Complete stable prerequisites such as onboarding and permissions.
4. Apply the fixture from the capture matrix.
5. Navigate using semantic/UI-automator evidence where possible. Use coordinate
taps only after inspecting the current screen, and keep coordinates local.
6. Capture every matrix row with a descriptive name:
```text
before-<surface>-<state>-<width>-<theme>.png
```
Use `adb exec-out screencap -p` so the PNG is written directly to the artifact
directory. Inspect every screenshot immediately; a successful command is not
proof that the intended screen was visible.
## Build and capture the after state
Before switching commits:
- Preserve the artifact directory outside the worktree.
- Preserve only intentional app state.
- Record any temporary debug fixture patch.
Checkout the recorded after SHA in the detached worktree, reapply the same
debug-only fixture if needed, build, and install with `-r` when state
preservation is part of the comparison.
Replay the same navigation and capture matrix. Name files with the matching
`after-` prefix. If reinstalling cannot preserve the state, replay the fixture
from its recorded inputs rather than comparing different states.
For responsive captures, record logical width in dp. On a fixed-pixel emulator,
changing density is acceptable when the calculation is documented:
```text
density = physical_width_px × 160 / desired_width_dp
```
Restore the original density/theme/locale after the matrix is complete.
## Use deterministic artificial data
Prefer existing debug hooks. When they cannot express the visual state, add the
smallest temporary command to
`app/src/debug/java/com/bitchat/android/testhook/TestHookDriver.kt`.
The fixture must:
- stay under `src/debug`
- use synthetic names/content and deterministic IDs
- use the current mesh peer ID for self-authored messages so alignment logic is
exercised correctly
- use a fixed fixture epoch passed to both builds
- return structured success data through the existing test-hook result file
- be logically identical in before and after builds
After the final capture, remove the temporary fixture with a focused patch and
verify that the review worktree has no tracked modifications. Never commit or
publish the fixture unless the user separately asks to productize it.
## Validate and report
Create `capture-manifest.json` in the artifact directory using
[assets/capture-manifest.example.json](assets/capture-manifest.example.json) as
the shape. Use paths relative to the manifest and omit device selectors and
local absolute paths.
Validate it:
```sh
python3 \
.agents/skills/android-ui-visual-review/scripts/validate_capture_manifest.py \
"<artifact-directory>/capture-manifest.json"
```
Write a Markdown report next to the manifest with:
- target and exact before/after SHAs
- verified Android release/API/security patch and viewport
- concise code analysis
- visual findings, including intentional non-changes
- side-by-side before/after tables
- fixture disclosure
- limitations and any hardware behavior not exercised
End the local run only after:
- every manifest image exists and is a valid PNG
- each before/after pair has matching pixel dimensions
- every screenshot has been visually inspected
- the worktree has no tracked fixture changes
- the user's original checkout remains untouched
Leave the review session and artifacts available for later inspection unless
the user asks for cleanup.
## Optionally publish to the PR
Only after explicit user approval, follow
[references/github-publishing.md](references/github-publishing.md).
The PR comment should contain:
- Android capture environment
- detected visual changes
- clear before/after labels
- all requested screenshots
- limitations such as “no visual delta” or “hardware race not reproduced”
Use `gh` for all GitHub reads and writes. Verify the posted comment by reading it
back and counting the expected image embeds. Do not expose local paths or
machine identifiers, do not override Git author/committer identity, and do not
push screenshot files to a source branch unless the user separately authorizes
that repository change.
## Final handoff
Give the user:
- report and artifact links
- screenshot count
- one-line result per surface
- PR comment URL when published
- explicit statement that the original checkout was not modified
- any remaining coverage limitation

View File

@ -0,0 +1,32 @@
{
"target": "PR #000 or branch-name",
"before_sha": "40-character merge-base SHA",
"after_sha": "40-character target SHA",
"environment": {
"android_release": "stable release number",
"android_sdk": "API number",
"security_patch": "YYYY-MM-DD",
"resolution_px": "1080x1920",
"default_density_dpi": 420
},
"captures": [
{
"id": "surface-state-411dp-light",
"surface": "Human-readable surface",
"state": "Synthetic fixture and navigation state",
"expected_change": "Specific visual hypothesis",
"before": "before-surface-state-411dp-light.png",
"after": "after-surface-state-411dp-light.png"
}
],
"extras": [
{
"id": "after-expanded-menu",
"role": "after-detail",
"file": "after-expanded-menu.png"
}
],
"limitations": [
"Static screenshots do not prove hardware behavior."
]
}

View File

@ -0,0 +1,20 @@
<!-- android-ui-visual-review:TARGET:AFTER_SHA -->
## Android UI verification
Captured from clean before/after debug builds on **Android ANDROID_RELEASE
(API ANDROID_SDK)**. The before build is this target's actual merge-base.
### Visual changes detected
- FINDING_ONE
- FINDING_TWO
- LIMITATION_OR_INTENTIONAL_NON_CHANGE
### SURFACE_OR_STATE
| Before | After |
|---|---|
| ![Before: ACCESSIBLE_DESCRIPTION](BEFORE_IMAGE_URL) | ![After: ACCESSIBLE_DESCRIPTION](AFTER_IMAGE_URL) |
Fixture disclosure: FIXTURE_DESCRIPTION_OR_NONE.

View File

@ -0,0 +1,41 @@
{
"skill_name": "android-ui-visual-review",
"evals": [
{
"id": 1,
"prompt": "Review PR #813 and make before/after screenshots of its Android UI changes. Keep everything local; do not comment on GitHub.",
"expected_output": "The agent creates an isolated worktree, compares the PR head with its actual merge-base, discovers the header width thresholds, captures a wide control plus the changed compact widths on the newest stable Android emulator, and produces a validated local report without any GitHub write.",
"files": [],
"expectations": [
"Uses the actual PR merge-base rather than current main or head~1.",
"Creates a capture matrix that includes a wide control and every changed width threshold.",
"Populates joined-channel state so the conditional count is visible before testing degradation.",
"Keeps artifacts local and performs no GitHub write."
]
},
{
"id": 2,
"prompt": "Compare branch feature/message-redesign against main. The empty chat will not show the change, so give me populated light and dark screenshots and also show any fresh-install nickname change.",
"expected_output": "The agent traces message rendering and nickname generation, adds a temporary deterministic debug-only fixture with received/self short/wrapped messages, applies equivalent state to both builds, captures light/dark and fresh nickname states, removes the fixture, and validates paired PNGs.",
"files": [],
"expectations": [
"Uses synthetic deterministic messages with the real local peer ID for self classification.",
"Uses one fixed fixture epoch and identical logical data in both builds.",
"Captures both light and dark populated message states plus the fresh nickname state.",
"Removes temporary debug fixture changes before handoff."
]
},
{
"id": 3,
"prompt": "This hotspot PR looks UI-related, but inspect it rather than assuming. Capture before/after and, after you show the evidence, post the screenshots and findings to the PR.",
"expected_output": "The agent determines whether the diff actually changes UI, captures the nearest affected hotspot surface even if equality is expected, labels behavioral limitations, asks or recognizes explicit publishing authorization, uploads safe screenshots without a source branch, posts through gh, and verifies the comment.",
"files": [],
"expectations": [
"Does not invent a visual delta when the diff is backend-only.",
"Labels the screenshots as an expected no-visual-change comparison.",
"Does not claim that a static screenshot proves the hotspot ownership race.",
"Posts only after explicit authorization and verifies image count and comment URL."
]
}
]
}

View File

@ -0,0 +1,152 @@
# Diff-to-screen analysis playbook
Use this reference to turn a code diff into a minimal but complete screenshot
matrix.
## Resolve the correct comparison
For a PR, “before” is the merge-base of the fetched PR head and its configured
base branch. It is not necessarily the PR's current `baseRefOid`, the local
`main`, or the commit immediately preceding the head.
For a branch or commit, confirm the intended base. Compute the merge-base after
fetching both sides.
Record both SHAs before any checkout. Build each SHA rather than attempting to
reverse selected files on a single build.
## Triage the diff in layers
Start broad:
```sh
git diff --stat "$BEFORE_SHA" "$AFTER_SHA"
git diff --name-status "$BEFORE_SHA" "$AFTER_SHA"
git diff "$BEFORE_SHA" "$AFTER_SHA" -- app/src/main app/src/debug
```
Then classify changed files.
| Diff area | Likely visual impact |
|---|---|
| `ui/*.kt`, composables, modifiers, layouts | Direct screen/layout change |
| `ui/theme/*`, colors, shapes, typography | Cross-screen theme change |
| `res/values*`, drawables, mipmaps | Text, locale, icon, launcher, or palette |
| Manifest locale/theme/activity metadata | System or activity presentation |
| `DataManager`, preferences, defaults | Fresh-install/default-state change |
| `AppStateStore`, ViewModel/state flows | UI changes only after specific state |
| Service/transport/backend only | Usually no static UI delta; trace exposed state |
| `src/debug` or Android tests | Fixture/test mechanism, not production UI |
Do not stop at filenames. Search every changed public symbol and resource:
```sh
rg -n "<ChangedSymbol|resource_name>" app/src
```
Trace in both directions:
- Who calls or renders this code?
- Which state branch selects it?
- What user action reaches it?
- What permissions, onboarding, peers, channels, messages, theme, locale, or
width are required?
- Does the change affect an empty screen, only populated state, or both?
## Repository UI surface map
These are orientation points, not a substitute for inspecting the current
revision:
| Surface | Starting points |
|---|---|
| App launch/navigation/permissions | `MainActivity.kt`, `ui/ChatScreen.kt` |
| Top bar, nickname, peer/channel/location controls | `ui/ChatHeader.kt` |
| Message rows, bubbles, timestamps, media | `ui/MessageComponents.kt` |
| App state consumed by Compose | `services/AppStateStore.kt`, ViewModels |
| Default nickname/preferences | `ui/DataManager.kt` |
| About and Settings | `ui/AboutSheet.kt` |
| Location/geohash/channel controls | `ui/LocationChannelsSheet.kt` |
| Hotspot UI | `hotspot/HotspotActivity.kt` |
| Dynamic/fallback colors and shapes | `ui/theme/Theme.kt`, `ThemePreference.kt` |
| Debug ADB hooks | `src/debug/.../testhook/TestHookReceiver.kt`, `TestHookDriver.kt` |
Files and packages can move. Use `rg --files` and symbol search to re-establish
the current map at the target commits.
## Identify indirect UI changes
Some visual changes appear far away from the edited function:
- A nickname generator change is visible only after deleting or bypassing a
saved nickname.
- A Material You change appears only on Android 12+ and depends on the emulator
wallpaper/system palette.
- Locale selection may recreate the Activity and return to a different tab.
- A launcher background is not visible inside the running Activity.
- Message delivery status may appear only for self-authored private messages.
- A width policy may be invisible at the default device width.
- A backend ownership fix may produce no screenshot difference at all.
Write these as explicit hypotheses before capture. Each hypothesis needs either
a matrix row or a documented reason it cannot be shown statically.
## Build the capture matrix
Keep the matrix small enough to review but large enough to hit every changed
branch.
| Field | What to record |
|---|---|
| Surface | Human-readable screen/component |
| Entry path | Actions from launch to the target |
| Fixture | Messages, peer, channel, setting, or empty state |
| Platform | Android API feature needed, such as dynamic color |
| Width | Logical dp breakpoint |
| Theme | System/light/dark |
| Locale | System/default or selected locale |
| Expected before | Specific visual contract |
| Expected after | Specific visual contract |
| Control | State expected not to change, when useful |
Examples:
- Header crowding: 411 dp control, 380 dp middle breakpoint, 320 dp compact
breakpoint, with a joined channel so the count is actually present.
- Message bubbles: identical short/long received and self messages in light and
dark modes.
- Nickname prefix: fresh generated nickname in each build; explain that random
digits differ.
- Language picker: Settings before, Settings after, expanded menu, and one live
selection.
- Backend-only hotspot fix: the nearest hotspot surface before/after, explicitly
expecting equality.
## Distinguish visual proof from behavioral proof
A static screenshot can prove rendering, layout, labels, selected state, and
visible recreation. It cannot prove:
- foreign Wi-Fi group ownership
- BLE/Wi-Fi discovery or delivery
- Noise/identity correctness
- race avoidance
- background lifecycle behavior
- accessibility announcement content without an accessibility inspection
Name those limitations. Use relevant unit/instrumented tests or the repository's
Mesh Lab workflow separately.
## Compare carefully
Treat these as expected noise unless the PR changes them:
- status-bar clock
- battery/network indicator
- random nickname suffix
- dynamic palette when system wallpaper differs
- asynchronous peer counts
- animation frame
Stabilize or disclose the noise. Never describe it as a PR effect.

View File

@ -0,0 +1,209 @@
# Deterministic UI fixture recipes
Read current code before applying any recipe. These patterns intentionally use
the debug-only test-hook path and should be adapted to the APIs present at both
comparison SHAs.
## Fixture principles
1. Exercise the UI's real state classification. A self message must carry the
peer identity that production code uses to decide `isSelf`; changing only the
displayed sender nickname can produce a false layout.
2. Use the same logical records in both builds: stable IDs, sender IDs, content,
ordering, and one fixed epoch.
3. Keep content synthetic and review-safe. Do not use real messages, contacts,
peer IDs, channel memberships, device information, or locations.
4. Return structured success data and verify it before capturing.
5. Keep additions under `app/src/debug`; remove them after capture.
## Prefer the existing test hook
Inspect:
- `app/src/debug/java/com/bitchat/android/testhook/TestHookReceiver.kt`
- `app/src/debug/java/com/bitchat/android/testhook/TestHookDriver.kt`
- `app/src/debug/AndroidManifest.xml`
The receiver accepts:
```sh
adb -s "$ANDROID_REVIEW_SERIAL" shell am broadcast \
-n com.bitchat.droid/com.bitchat.android.testhook.TestHookReceiver \
-a com.bitchat.droid.TEST_HOOK \
--es cmd "<command>" \
--es id "<unique-result-id>"
```
Read the result rather than trusting broadcast delivery:
```sh
adb -s "$ANDROID_REVIEW_SERIAL" shell run-as com.bitchat.droid \
cat "cache/testhook/results/<unique-result-id>.json"
```
Existing commands such as `set_nickname`, `broadcast_msg`, and state inspection
may already be sufficient.
## Public message fixture
When existing commands cannot create both received and self messages without a
second device, add a temporary `ui_fixture` command to `TestHookDriver`.
Adapt imports and constructor fields to the checked-out revision. The core shape
is:
```kotlin
private fun uiFixture(context: Context, intent: Intent): JSONObject {
val mesh = mesh(context)
val nickname = AppStateStore.nickname.value
.ifBlank { DataManager(context).loadNickname() }
val epochMs = intent.getLongExtra("fixture_epoch_ms", 1_800_000_000_000L)
listOf(
BitchatMessage(
id = "ui-fixture-received-short",
sender = "mara",
content = "Are you seeing this?",
timestamp = Date(epochMs),
senderPeerID = "ui-fixture-peer",
),
BitchatMessage(
id = "ui-fixture-received-wrap",
sender = "mara",
content = "The mesh stays readable even when a message wraps onto a second line.",
timestamp = Date(epochMs + 60_000),
senderPeerID = "ui-fixture-peer",
),
BitchatMessage(
id = "ui-fixture-self-short",
sender = nickname,
content = "Yep — testing the message layout.",
timestamp = Date(epochMs + 120_000),
senderPeerID = mesh.myPeerID,
),
BitchatMessage(
id = "ui-fixture-self-wrap",
sender = nickname,
content = "Short and long bubbles should align consistently.",
timestamp = Date(epochMs + 180_000),
senderPeerID = mesh.myPeerID,
),
).forEach(AppStateStore::addPublicMessage)
return ok("ui_fixture")
.put("messages", 4)
.put("fixture_epoch_ms", epochMs)
}
```
Why these details matter:
- received/self exercises both alignment branches
- short/wrapped content exercises intrinsic and capped width
- fixed IDs defeat accidental duplication
- a fixed epoch makes before/after timestamps comparable
- `mesh.myPeerID` exercises the real self-classification path
If `AppStateStore` moved packages or the message constructor changed, adapt only
the debug fixture. Do not modify production state logic to accommodate it.
App state is process-local. Launch the Activity before injection, inject after
the process is ready, and capture without force-stopping it.
## Private-message and delivery-status fixture
To inspect private-message bubbles or status placement:
- set the selected private peer in `AppStateStore`
- add messages using `addPrivateMessage`
- use the current local peer ID for self messages
- populate the delivery status explicitly when the changed component reads it
- mark read state consistently
Use a synthetic conversation ID such as `ui-fixture-private-peer`. Avoid writing
to persistent conversation storage unless persistence itself is being reviewed.
If persistence cannot be bypassed safely, use a unique deterministic fixture
conversation and disclose it in the report.
## Fresh default nickname
A saved preference hides generator changes. Add a temporary command that removes
only the nickname preference, invokes the revision's real generator, and updates
the in-memory store:
```kotlin
private fun resetNicknameFixture(context: Context): JSONObject {
context.getSharedPreferences("bitchat_prefs", Context.MODE_PRIVATE)
.edit()
.remove("nickname")
.commit()
val nickname = DataManager(context).loadNickname()
AppStateStore.setNickname(nickname)
return ok("reset_nickname_fixture").put("nickname", nickname)
}
```
First inspect `DataManager` to confirm the preference file/key. Do not use this
recipe blindly after storage migrations.
Random suffixes are expected to differ. Compare the changed prefix/pattern, not
the digits. Do not seed or replace the production generator merely to make the
screenshot deterministic.
## Channel/header state
Responsive header screenshots need every conditional item present.
- Join a synthetic channel such as `#review` through the normal UI or existing
debug state API.
- Confirm the joined count is visible at the control width before changing
density.
- Keep the same nickname, channel, peer count, and location state in both builds.
- Capture a wide control and every threshold changed by the diff.
Do not use a real geohash/location. Synthetic channel state is enough for header
crowding unless the actual geohash label is the feature under review.
## Theme and dynamic color
- Force light and dark through `adb shell cmd uimode night no|yes`.
- Record the system wallpaper/palette only as anonymous environment context.
- Keep the same emulator data directory between builds so Material You input is
identical.
- Capture both themes when theme code, containers, surfaces, or contrast changes.
- Restore the original mode after capture.
## Locale
Use the new in-app picker when that is the feature. Capture:
1. Settings before
2. Settings with the new row
3. expanded picker
4. one live language selection
Locale application may recreate the Activity and reset the selected tab. This is
expected; navigate again rather than assuming the selection failed. Restore
System default after capture.
## Onboarding and permissions
Complete onboarding/permissions once in the before build, then use `adb install
-r` for after when signatures and data schemas are compatible.
If state cannot be preserved, record and replay each action. Do not clear all app
data merely to change one preference. Never run `pm clear` on a physical device
without explicit authorization.
## Remove the fixture
Use a focused patch to remove only additions made for capture. Then check:
```sh
git diff -- app/src/debug
git status --short
```
The finished worktree may contain untracked artifacts only when artifacts were
intentionally stored there. It must not contain tracked fixture changes.

View File

@ -0,0 +1,97 @@
# Publishing screenshots to a pull request
Follow this only after the user explicitly opts into a GitHub comment.
## Prepare a safe comment
The comment should contain:
- a unique hidden marker for idempotent verification
- verified Android release/API
- statement that before is the target's actual merge-base
- concise visual findings
- paired before/after image tables
- fixture disclosure when artificial state was used
- limitations and intentional non-changes
Do not include:
- local paths or usernames
- ADB serials, device names, peer IDs, addresses, or IPs
- real messages, contacts, locations, or account information
- build logs
- claims about physical behavior that screenshots do not prove
Scan the body locally for machine paths and selectors before posting.
Use [../assets/pr-comment-template.md](../assets/pr-comment-template.md) as the
starting structure, replacing every uppercase placeholder and adding one table
per comparison state.
## Upload image bytes without changing a source branch
Native `gh pr comment` accepts Markdown but not binary files. This skill bundles
an uploader that uses `gh api` to place images on a PR-scoped custom Git ref:
```sh
python3 \
.agents/skills/android-ui-visual-review/scripts/upload_pr_images.py \
--repo OWNER/REPO \
--pr NUMBER \
<ordered PNG files>
```
The helper:
- uploads only image bytes and basenames
- writes to `refs/uploads/issues/<NUMBER>`, outside `refs/heads/*`
- does not pass an author or committer override
- prints JSON containing stable GitHub blob URLs
- does not post a comment
This is a GitHub repository write even though it does not create a visible
branch. The user's approval to publish the screenshots authorizes this
PR-scoped storage. If repository policy rejects custom refs, use an authenticated
GitHub web attachment composer or ask the user for an approved image host. Do
not fall back to a source branch without separate authorization.
Use `--dry-run` first when validating new inputs:
```sh
python3 \
.agents/skills/android-ui-visual-review/scripts/upload_pr_images.py \
--dry-run \
--repo OWNER/REPO \
--pr NUMBER \
<ordered PNG files>
```
## Post through gh
Build the final Markdown body with the returned URLs. Keep before and after in
the same table row. Put extra after-state details, such as an expanded menu, in
a separate labeled table.
Post once:
```sh
gh pr comment NUMBER \
--repo OWNER/REPO \
--body-file "<validated-comment.md>"
```
Record the returned comment URL.
## Verify the side effect
Read the comment back using `gh api` or `gh pr view`. Verify:
- the unique marker is present
- image embed count equals the requested screenshot count
- no local paths or identifiers were included
- the returned URL belongs to the intended PR
If the posting command's outcome is ambiguous, query for the marker before
retrying. Do not create duplicate comments.
Report the comment URL to the user. Leave the PR-scoped image ref in place while
the comment depends on it; deleting the ref can eventually break the images.

View File

@ -0,0 +1,201 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'EOF'
Create an isolated Android UI review worktree.
Usage:
create_review_worktree.sh --target <PR-URL|PR-NUMBER|BRANCH|COMMIT> [options]
Options:
--base <REF> Base for a branch/commit target (default: main)
--repo-root <DIR> Repository root (default: current repository)
-h, --help Show this help
The script never removes an existing worktree. It prints and writes session.env
with the before/after SHAs and artifact paths.
EOF
}
target=""
base_ref=""
repo_root="."
while [[ $# -gt 0 ]]; do
case "$1" in
--target)
target="${2:-}"
shift 2
;;
--base)
base_ref="${2:-}"
shift 2
;;
--repo-root)
repo_root="${2:-}"
shift 2
;;
-h|--help)
usage
exit 0
;;
*)
echo "error: unknown argument: $1" >&2
usage >&2
exit 2
;;
esac
done
if [[ -z "$target" ]]; then
echo "error: --target is required" >&2
exit 2
fi
if [[ "$target" == -* || "$base_ref" == -* ]]; then
echo "error: target and base refs cannot begin with '-'" >&2
exit 2
fi
repo_root="$(git -C "$repo_root" rev-parse --show-toplevel)"
cd "$repo_root"
target_kind="ref"
pr_number=""
base_name=""
head_ref=""
if [[ "$target" =~ ^[0-9]+$ ]]; then
target_kind="pr"
pr_number="${BASH_REMATCH[0]}"
elif [[ "$target" =~ ^https://github\.com/([^/]+)/([^/]+)/pull/([0-9]+)/?$ ]]; then
target_kind="pr"
pr_number="${BASH_REMATCH[3]}"
url_repo="${BASH_REMATCH[1]}/${BASH_REMATCH[2]}"
current_repo="$(gh repo view --json nameWithOwner --jq .nameWithOwner)"
if [[ "${url_repo,,}" != "${current_repo,,}" ]]; then
echo "error: PR URL targets $url_repo but this checkout is $current_repo" >&2
exit 2
fi
fi
resolve_target_ref() {
local ref="$1"
local resolved=""
if git show-ref --verify --quiet "refs/heads/${ref}"; then
git rev-parse --verify "refs/heads/${ref}^{commit}"
return
fi
if [[ "$ref" == refs/* || "$ref" == *"~"* || "$ref" == *"^"* ]] &&
resolved="$(git rev-parse --verify "${ref}^{commit}" 2>/dev/null)"; then
printf '%s\n' "$resolved"
return
fi
if git check-ref-format --branch "$ref" >/dev/null 2>&1; then
if git fetch origin "$ref" >/dev/null 2>&1; then
git rev-parse --verify 'FETCH_HEAD^{commit}'
return
fi
fi
if resolved="$(git rev-parse --verify "${ref}^{commit}" 2>/dev/null)"; then
printf '%s\n' "$resolved"
return
fi
echo "error: cannot resolve ref: $ref" >&2
return 1
}
resolve_base_ref() {
local ref="$1"
local resolved=""
if git check-ref-format --branch "$ref" >/dev/null 2>&1; then
if git fetch origin "$ref" >/dev/null 2>&1; then
git rev-parse --verify 'FETCH_HEAD^{commit}'
return
fi
fi
if resolved="$(git rev-parse --verify "${ref}^{commit}" 2>/dev/null)"; then
printf '%s\n' "$resolved"
return
fi
echo "error: cannot resolve base ref: $ref" >&2
return 1
}
if [[ "$target_kind" == "pr" ]]; then
repo_slug="$(gh repo view --json nameWithOwner --jq .nameWithOwner)"
pr_data="$(gh pr view "$pr_number" --repo "$repo_slug" \
--json baseRefName,headRefOid \
--jq '"\(.baseRefName) \(.headRefOid)"')"
read -r base_name reported_head_sha <<<"$pr_data"
head_ref="refs/pr-visual-review/pull/${pr_number}/head"
git fetch origin "+pull/${pr_number}/head:${head_ref}"
git fetch origin "+refs/heads/${base_name}:refs/remotes/origin/${base_name}"
after_sha="$(git rev-parse --verify "${head_ref}^{commit}")"
if [[ "$after_sha" != "$reported_head_sha" ]]; then
latest_reported_head="$(gh pr view "$pr_number" --repo "$repo_slug" \
--json headRefOid --jq .headRefOid)"
if [[ "$after_sha" != "$latest_reported_head" ]]; then
echo "error: PR head changed while resolving; rerun for a consistent target" >&2
exit 1
fi
fi
base_sha="$(git rev-parse --verify "refs/remotes/origin/${base_name}^{commit}")"
else
after_sha="$(resolve_target_ref "$target")"
base_name="${base_ref:-main}"
base_sha="$(resolve_base_ref "$base_name")"
fi
before_sha="$(git merge-base "$base_sha" "$after_sha")"
if [[ -z "$before_sha" ]]; then
echo "error: no merge-base found between $base_name and $target" >&2
exit 1
fi
session_dir="$(mktemp -d /tmp/bitchat-ui-review.XXXXXX)"
worktree_path="${session_dir}/worktree"
artifact_dir="${session_dir}/artifacts"
mkdir -p "$artifact_dir"
git worktree add --detach "$worktree_path" "$before_sha"
if [[ -f "${repo_root}/local.properties" && ! -e "${worktree_path}/local.properties" ]]; then
ln -s "${repo_root}/local.properties" "${worktree_path}/local.properties"
fi
session_env="${session_dir}/session.env"
{
printf 'SESSION_DIR=%q\n' "$session_dir"
printf 'WORKTREE_PATH=%q\n' "$worktree_path"
printf 'ARTIFACT_DIR=%q\n' "$artifact_dir"
printf 'SOURCE_REPO_ROOT=%q\n' "$repo_root"
printf 'TARGET_KIND=%q\n' "$target_kind"
printf 'TARGET_INPUT=%q\n' "$target"
printf 'BASE_NAME=%q\n' "$base_name"
printf 'BEFORE_SHA=%q\n' "$before_sha"
printf 'AFTER_SHA=%q\n' "$after_sha"
printf 'PR_NUMBER=%q\n' "$pr_number"
} > "$session_env"
printf 'SESSION_DIR=%s\n' "$session_dir"
printf 'WORKTREE_PATH=%s\n' "$worktree_path"
printf 'ARTIFACT_DIR=%s\n' "$artifact_dir"
printf 'BEFORE_SHA=%s\n' "$before_sha"
printf 'AFTER_SHA=%s\n' "$after_sha"
printf 'TARGET_KIND=%s\n' "$target_kind"
if [[ -n "$pr_number" ]]; then
printf 'PR_NUMBER=%s\n' "$pr_number"
fi
printf 'SESSION_ENV=%s\n' "$session_env"

View File

@ -0,0 +1,217 @@
#!/usr/bin/env python3
"""Upload PR screenshots through gh api without modifying a source branch."""
from __future__ import annotations
import argparse
import base64
import json
import re
import subprocess
import sys
from pathlib import Path
from urllib.parse import quote
REPO_RE = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$")
def gh_api(
endpoint: str,
*,
method: str = "GET",
payload: dict[str, object] | None = None,
allow_not_found: bool = False,
) -> dict[str, object] | None:
command = ["gh", "api"]
if method != "GET":
command.extend(["-X", method])
command.append(endpoint)
input_text = None
if payload is not None:
command.extend(["--input", "-"])
input_text = json.dumps(payload)
result = subprocess.run(
command,
input=input_text,
text=True,
capture_output=True,
check=False,
)
if result.returncode != 0:
if allow_not_found and "HTTP 404" in result.stderr:
return None
raise RuntimeError(result.stderr.strip() or f"gh api failed for {endpoint}")
if not result.stdout.strip():
return {}
parsed = json.loads(result.stdout)
if not isinstance(parsed, dict):
raise RuntimeError(f"unexpected gh api response for {endpoint}")
return parsed
def validate_files(paths: list[Path]) -> list[tuple[Path, str, int]]:
if not paths:
raise ValueError("at least one image file is required")
files: list[tuple[Path, str, int]] = []
seen: set[str] = set()
for path in paths:
resolved = path.resolve()
if not resolved.is_file():
raise ValueError(f"file not found: {path}")
name = resolved.name
if name in seen:
raise ValueError(f"duplicate basename would collide in upload: {name}")
seen.add(name)
if resolved.suffix.lower() not in {".png", ".jpg", ".jpeg", ".webp", ".gif"}:
raise ValueError(f"unsupported image type: {name}")
files.append((resolved, name, resolved.stat().st_size))
return files
def image_markdown(files: list[dict[str, str]]) -> str:
sections: list[str] = []
for index in range(0, len(files), 2):
pair = files[index : index + 2]
labels = " | ".join(item["name"] for item in pair)
separators = " | ".join("---" for _ in pair)
images = " | ".join(
f'![{item["name"]}]({item["url"]})' for item in pair
)
sections.append(f"| {labels} |\n| {separators} |\n| {images} |")
return "\n\n".join(sections)
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--repo", required=True, help="OWNER/REPO")
parser.add_argument("--pr", required=True, type=int)
parser.add_argument("--dry-run", action="store_true")
parser.add_argument("files", nargs="+", type=Path)
args = parser.parse_args()
if not REPO_RE.fullmatch(args.repo):
raise ValueError("--repo must use OWNER/REPO")
if args.pr <= 0:
raise ValueError("--pr must be a positive integer")
files = validate_files(args.files)
if args.dry_run:
print(
json.dumps(
{
"status": "dry-run",
"repo": args.repo,
"pr": args.pr,
"ref": f"refs/uploads/issues/{args.pr}",
"files": [
{"name": name, "size": size} for _, name, size in files
],
},
indent=2,
)
)
return 0
api_prefix = f"repos/{args.repo}"
ref_path = f"uploads/issues/{args.pr}"
ref_response = gh_api(
f"{api_prefix}/git/ref/{ref_path}",
allow_not_found=True,
)
parent_sha = ""
base_tree_sha = ""
if ref_response is not None:
ref_object = ref_response.get("object")
if not isinstance(ref_object, dict) or not isinstance(ref_object.get("sha"), str):
raise RuntimeError("existing upload ref response did not contain a commit SHA")
parent_sha = str(ref_object["sha"])
parent_commit = gh_api(f"{api_prefix}/git/commits/{parent_sha}")
tree = parent_commit.get("tree") if parent_commit else None
if not isinstance(tree, dict) or not isinstance(tree.get("sha"), str):
raise RuntimeError("existing upload commit did not contain a tree SHA")
base_tree_sha = str(tree["sha"])
entries: list[dict[str, str]] = []
for path, name, _ in files:
content = base64.b64encode(path.read_bytes()).decode("ascii")
blob = gh_api(
f"{api_prefix}/git/blobs",
method="POST",
payload={"content": content, "encoding": "base64"},
)
blob_sha = blob.get("sha") if blob else None
if not isinstance(blob_sha, str):
raise RuntimeError(f"blob upload did not return a SHA for {name}")
entries.append(
{"path": name, "mode": "100644", "type": "blob", "sha": blob_sha}
)
tree_payload: dict[str, object] = {"tree": entries}
if base_tree_sha:
tree_payload["base_tree"] = base_tree_sha
tree_response = gh_api(
f"{api_prefix}/git/trees",
method="POST",
payload=tree_payload,
)
tree_sha = tree_response.get("sha") if tree_response else None
if not isinstance(tree_sha, str):
raise RuntimeError("tree creation did not return a SHA")
commit_response = gh_api(
f"{api_prefix}/git/commits",
method="POST",
payload={
"message": f"Add visual comparison screenshots for PR #{args.pr}",
"tree": tree_sha,
"parents": [parent_sha] if parent_sha else [],
},
)
commit_sha = commit_response.get("sha") if commit_response else None
if not isinstance(commit_sha, str):
raise RuntimeError("commit creation did not return a SHA")
if parent_sha:
gh_api(
f"{api_prefix}/git/refs/{ref_path}",
method="PATCH",
payload={"sha": commit_sha, "force": False},
)
else:
gh_api(
f"{api_prefix}/git/refs",
method="POST",
payload={"ref": f"refs/{ref_path}", "sha": commit_sha},
)
uploaded: list[dict[str, str]] = []
for _, name, _ in files:
url = f"https://github.com/{args.repo}/blob/{commit_sha}/{quote(name)}?raw=true"
uploaded.append({"name": name, "url": url})
print(
json.dumps(
{
"status": "ok",
"repo": args.repo,
"pr": args.pr,
"ref": f"refs/{ref_path}",
"sha": commit_sha,
"files": uploaded,
"markdown": image_markdown(uploaded),
},
indent=2,
)
)
return 0
if __name__ == "__main__":
try:
raise SystemExit(main())
except (OSError, ValueError, RuntimeError, json.JSONDecodeError) as exc:
print(f"error: {exc}", file=sys.stderr)
raise SystemExit(1)

View File

@ -0,0 +1,168 @@
#!/usr/bin/env python3
"""Validate a visual-review capture manifest and its PNG evidence."""
from __future__ import annotations
import argparse
import json
import re
import struct
import sys
from pathlib import Path
PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n"
SHA_RE = re.compile(r"^[0-9a-f]{40}$")
def fail(message: str) -> None:
raise ValueError(message)
def relative_file(root: Path, value: object, field: str) -> Path:
if not isinstance(value, str) or not value:
fail(f"{field} must be a non-empty relative path")
candidate = Path(value)
if candidate.is_absolute() or ".." in candidate.parts:
fail(f"{field} must stay relative to the artifact directory: {value!r}")
resolved = (root / candidate).resolve()
try:
resolved.relative_to(root.resolve())
except ValueError as exc:
raise ValueError(f"{field} escapes the artifact directory: {value!r}") from exc
if not resolved.is_file():
fail(f"{field} does not exist: {value}")
return resolved
def png_dimensions(path: Path) -> tuple[int, int]:
with path.open("rb") as handle:
header = handle.read(24)
if len(header) < 24 or header[:8] != PNG_SIGNATURE or header[12:16] != b"IHDR":
fail(f"not a valid PNG with an IHDR header: {path.name}")
width, height = struct.unpack(">II", header[16:24])
if width <= 0 or height <= 0:
fail(f"invalid PNG dimensions in {path.name}: {width}x{height}")
return width, height
def require_string(data: dict[str, object], key: str) -> str:
value = data.get(key)
if not isinstance(value, str) or not value.strip():
fail(f"{key} must be a non-empty string")
return value
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("manifest", type=Path)
args = parser.parse_args()
manifest_path = args.manifest.resolve()
if not manifest_path.is_file():
fail(f"manifest not found: {manifest_path}")
root = manifest_path.parent
with manifest_path.open(encoding="utf-8") as handle:
data = json.load(handle)
if not isinstance(data, dict):
fail("manifest root must be an object")
require_string(data, "target")
before_sha = require_string(data, "before_sha")
after_sha = require_string(data, "after_sha")
if not SHA_RE.fullmatch(before_sha) or not SHA_RE.fullmatch(after_sha):
fail("before_sha and after_sha must be lowercase 40-character Git SHAs")
if before_sha == after_sha:
fail("before_sha and after_sha must differ")
environment = data.get("environment")
if not isinstance(environment, dict):
fail("environment must be an object")
for key in ("android_release", "android_sdk", "security_patch", "resolution_px"):
require_string(environment, key)
density = environment.get("default_density_dpi")
if not isinstance(density, int) or density <= 0:
fail("environment.default_density_dpi must be a positive integer")
captures = data.get("captures")
if not isinstance(captures, list) or not captures:
fail("captures must contain at least one before/after pair")
used_paths: set[Path] = set()
pair_summaries: list[dict[str, object]] = []
seen_ids: set[str] = set()
for index, item in enumerate(captures):
if not isinstance(item, dict):
fail(f"captures[{index}] must be an object")
capture_id = require_string(item, "id")
if capture_id in seen_ids:
fail(f"duplicate capture id: {capture_id}")
seen_ids.add(capture_id)
require_string(item, "surface")
require_string(item, "state")
require_string(item, "expected_change")
before_path = relative_file(root, item.get("before"), f"captures[{index}].before")
after_path = relative_file(root, item.get("after"), f"captures[{index}].after")
for path in (before_path, after_path):
if path in used_paths:
fail(f"screenshot reused by multiple manifest entries: {path.name}")
used_paths.add(path)
before_size = png_dimensions(before_path)
after_size = png_dimensions(after_path)
if before_size != after_size:
fail(
f"{capture_id} dimensions differ: before={before_size[0]}x{before_size[1]}, "
f"after={after_size[0]}x{after_size[1]}"
)
pair_summaries.append(
{
"id": capture_id,
"width": before_size[0],
"height": before_size[1],
}
)
extras = data.get("extras", [])
if not isinstance(extras, list):
fail("extras must be an array")
for index, item in enumerate(extras):
if not isinstance(item, dict):
fail(f"extras[{index}] must be an object")
extra_id = require_string(item, "id")
if extra_id in seen_ids:
fail(f"duplicate capture/extra id: {extra_id}")
seen_ids.add(extra_id)
require_string(item, "role")
path = relative_file(root, item.get("file"), f"extras[{index}].file")
if path in used_paths:
fail(f"screenshot reused by multiple manifest entries: {path.name}")
used_paths.add(path)
png_dimensions(path)
limitations = data.get("limitations", [])
if not isinstance(limitations, list) or not all(
isinstance(item, str) and item.strip() for item in limitations
):
fail("limitations must be an array of non-empty strings")
result = {
"status": "ok",
"pairs": len(captures),
"extras": len(extras),
"png_files": len(used_paths),
"dimensions": pair_summaries,
}
print(json.dumps(result, indent=2))
return 0
if __name__ == "__main__":
try:
raise SystemExit(main())
except (OSError, json.JSONDecodeError, ValueError) as exc:
print(f"error: {exc}", file=sys.stderr)
raise SystemExit(1)

View File

@ -0,0 +1,224 @@
---
name: mesh-lab
description: Run, diagnose, and extend bitchat Android Mesh Lab physical-device tests. Use this skill whenever work capable of changing physical peer behavior touches mesh discovery or routing, BLE or Wi-Fi transport, Noise/crypto/identity, foreground-service or power behavior, public or private messaging, file/media transfer, protocol packets, or fragmentation; whenever a user asks for physical-device validation, ADB test hooks, hardware regression reproduction, or a new Mesh Lab scenario; and before claiming that such changes work on real devices, even if the user does not name Mesh Lab explicitly. Do not use it for docs-only, unit-test-only, or pure UI changes that cannot affect mesh or service behavior.
---
# Mesh Lab
Use the repository's debug-only ADB harness to validate mesh behavior on physical
devices. Treat it as a development integration test, not as the privacy-checked
release gate and not as proof about a release APK.
## Establish current ground truth
Run from the repository root.
Before choosing commands or changing a scenario:
1. Read the Mesh Lab section of `AGENTS.md`.
2. Read the "mesh lab" appendix in `docs/release-gate-runbook.md`.
3. Use `python3 tools/release_gate/mesh_lab.py --help` and the relevant
subcommand help.
4. Inspect the selected scenario function in `tools/release_gate/mesh_lab.py`;
its current CLI and assertions are authoritative if documentation has drifted.
5. Inspect
`app/src/debug/java/com/bitchat/android/testhook/TestHookDriver.kt` before
using an ad-hoc command, diagnosing hook behavior, or extending coverage.
Do not infer a physical pass from unit tests, compilation, old evidence, or a
successful local send call.
## Decide the physical coverage
Inspect the change or requested behavior first. Select the smallest scenario set
that exercises the affected physical contract, expanding to `all` for broad,
cross-cutting, or release-sensitive changes.
| Affected behavior | Start with |
|---|---|
| Discovery, connection management, routing, foreground-service lifecycle | `broadcast`, `dm`, `session_recovery` |
| Background, doze, or power-duty-cycle behavior | Existing setup keeps devices awake and foregrounded; add a focused workflow or use the full release gate |
| Wi-Fi Aware or transport-selection behavior | Existing setup enables BLE and does not pin Wi-Fi; add a transport-specific control/assertion or use the full release gate |
| Noise, crypto, authenticated peer state, identity persistence | `dm`, `file_private`, `session_recovery`, `identity_reset` |
| Public messaging or message delivery | `broadcast`, then `dm` if shared routing changed |
| File/media encoding, transfer, fragmentation, admission limits | `file`, `file_private`, `file_oversize` |
| Packet parsing, bridge/routing, TTL, or protocol changes | `raw`, plus a receiving scenario such as `broadcast` or `dm` |
| Broad mesh or transport refactor | `all` |
| UI-only work with no service, state, or delivery effect | Usually no Mesh Lab run; explain why |
When the selected scenarios do not exercise the new contract, add a focused
scenario instead of treating unrelated green tests as coverage.
## Protect devices and evidence
Mesh Lab setup is destructive to the app's local data. It force-stops the app,
clears package data, regenerates identity, cycles Bluetooth, grants permissions,
and changes wake/lock-screen timeout settings without restoring them.
- Use only designated disposable lab app data and deterministic test content.
- Use two authorized physical Android BLE devices on API 26 or newer. Emulators
do not exercise the required BLE mesh behavior.
- Before `setup`, `identity_reset`, or `all`, confirm that the selected devices
may have bitchat app data cleared. `identity_reset` clears device B even when
setup was skipped. If the user has not already established authorization, ask.
- Never attempt to defeat a secure lock screen. Ask the operator to unlock it.
- Keep every device unlocked, awake, foregrounded, and preferably charging.
- Treat ADB selectors as ephemeral secrets. Do not put serials, device names,
peer IDs, addresses, fingerprints, local home paths, or raw logcat in commits,
pull requests, issues, or published artifacts.
- Write raw evidence under `/tmp`, keep it local, and never commit it. Failure
evidence can include unsanitized logcat and lab identifiers.
- Use debug APKs only. The exported test-hook receiver intentionally has no
production security boundary and must never be moved into `src/main`.
- Use an authorized, controlled lab area. After setup, inspect peer state
locally and stop if an unexpected peer is present before sending broadcasts,
files, or raw packets.
- On a non-dedicated device, record the prior Bluetooth, stay-awake, screen
timeout, and lock-screen-disabled settings locally. Restore only those
recorded values after the run, or tell the operator exactly what remains
changed.
If the hardware, operator confirmation, or prerequisites are unavailable,
report the physical result as `blocked (not run)` and provide the exact handoff
command. Never soften this to "pass" or "probably works."
## Run a two-phone batch
Preflight the environment without copying device selectors into durable output:
```sh
python3 --version
adb devices
./gradlew assembleDebug
```
Set up the disposable pair:
```sh
python3 tools/release_gate/mesh_lab.py setup \
--serial-a "$MESH_SERIAL_A" \
--serial-b "$MESH_SERIAL_B" \
--apk app/build/outputs/apk/debug/app-arm64-v8a-debug.apk
```
Build and pass the current-tree debug APK during normal use. If setup
intentionally omits `--apk`, first verify on both devices that the installed
package is debuggable via `run-as` and that its package dump contains
`TestHookReceiver`; do this before any command that clears data.
Inspect `peers` on both devices after setup. Continue only when every discovered
participant belongs to the controlled lab.
Run either the selected scenario or the full suite. Run this entire block in one
shell invocation so the temporary-directory variable cannot disappear between
agent shell calls. Abort the block if the directory is empty or missing before
passing it to `--out`; otherwise an empty path can put private evidence in the
repository. Replace `dm` with
`all` only when full-suite data clearing has been authorized.
```sh
MESH_EVIDENCE_DIR="$(mktemp -d /tmp/meshlab-evidence.XXXXXX)"
if [ -z "$MESH_EVIDENCE_DIR" ] || [ ! -d "$MESH_EVIDENCE_DIR" ]; then
echo "mktemp failed; aborting so evidence cannot land in the repository" >&2
exit 1
fi
chmod 700 "$MESH_EVIDENCE_DIR"
python3 tools/release_gate/mesh_lab.py scenario dm \
--serial-a "$MESH_SERIAL_A" \
--serial-b "$MESH_SERIAL_B" \
--out "$MESH_EVIDENCE_DIR"
```
Rerun `setup` before a fresh scenario batch when prior churn, stale identities,
or zombie GATT links could contaminate the result.
Keep the private evidence only as long as the active investigation needs it.
Do not delete failure evidence that the user still needs; when it is no longer
needed, remove it or move it to an explicitly approved protected location.
## Interpret results precisely
- `dm`, `broadcast`, `file`, and `file_private` include receiver-side assertions.
- `file` and `file_private` currently validate a 1 KB deterministic fixture and
SHA-256 integrity, not sustained or boundary-sized transfer performance.
- `file_oversize` validates sender rejection and receiver absence for a 512 KB
broadcast, not the exact 256/257-fragment boundary.
- `raw` proves that the local transport bridge accepted the injected packet. It
does not prove that another device received or accepted it.
- `session_recovery` proves recoverability after process death, but may fall
back to an explicit handshake; it does not prove a fully automatic recovery.
- `identity_reset` proves new-identity recovery after `pm clear`; stale state for
the old identity is diagnostic evidence rather than a purge assertion.
- The current CLI drives exactly two phones. It does not validate a three-hop
topology, transport-specific Wi-Fi Aware behavior, permission denial, doze,
endurance, resource bounds, transfer cancellation, release builds, or
cross-client compatibility.
- For ad-hoc commands, `status: ok` can mean the command completed without
satisfying the requested state. Inspect fields such as `reached_min_peers`,
`direct`, `established`, or `cancelled`.
- `all` runs scenarios sequentially on evolving device state. In the current
runner it writes combined evidence only, and a failed sub-scenario can abort
aggregation without clean structured evidence. Run selected scenarios
individually first when durable per-scenario evidence matters, then use
`all` as broader regression coverage.
A scenario exits zero on pass and non-zero on failure. On failure, preserve the
local evidence, inspect its error first, then use state dumps and filtered logcat:
```sh
python3 tools/release_gate/mesh_lab.py cmd \
--serial "$MESH_SERIAL_A" state
adb -s "$MESH_SERIAL_A" logcat -d -t 200 -s \
TestHook MessageHandler FragmentManager BitchatFilePacket
```
Common first checks are screen/foreground state, mutual discovery, direct-peer
state, Noise session state, and stale Bluetooth connections. Rerun `setup` only
after preserving useful diagnostics.
The generic `cmd --extra` wrapper does not encode every Android extra type
correctly: Boolean `enabled` and integer `min_peers`/`ttl` are notable cases.
Use a direct `adb shell am broadcast` with `--ez` or `--ei`, after reading the
driver, when exact types matter. Use the top-level `--timeout-ms 30000` option
for command timeouts; never pass `--extra timeout_ms=...`, because it duplicates
the runner's `timeout_ms` keyword and fails before dispatch.
## Add a physical-device scenario
Prefer extending `tools/release_gate/mesh_lab.py` with existing hook commands.
Add or change an Android hook only when the public mesh API cannot express the
required action or observation.
Design the scenario around an observable remote contract:
1. Generate a unique token or deterministic fixture so stale state cannot pass.
2. Start the receiver wait before sending.
3. Assert remote sender identity, content, session state, digest, or expected
absence—not merely that the sender accepted a call.
4. Use bounded timeouts and return structured JSON evidence.
5. For negative tests, assert both the expected sender error and that the
receiver did not observe the artifact.
6. Keep hook code and manifest registration under `src/debug`.
7. Update the runbook scenario table and troubleshooting guidance.
8. Run the new scenario individually, then run relevant neighboring scenarios
or `all` to detect state contamination.
Do not add test-only branches to production mesh code merely to make a scenario
easy to drive.
## Report the outcome
End with a compact physical-test report:
- Change or contract tested
- Device topology: logical roles only, such as phone A to phone B
- Build and scenario names
- Result: `pass`, `fail`, or `blocked (not run)`
- Local evidence directory, clearly marked private and uncommitted
- On failure: the exact violated invariant and the next diagnostic
- Coverage limits and any scenario fallback that weakens the claim
Keep device selectors and raw evidence out of the report, commit, and pull
request.

View File

@ -0,0 +1,41 @@
{
"skill_name": "mesh-lab",
"evals": [
{
"id": 1,
"prompt": "I changed Noise session recovery and authenticated peer-state handling. Validate it on real Android devices before we merge. I have not told you whether any disposable phones are connected.",
"expected_output": "The agent inspects the current Mesh Lab implementation, selects the Noise and churn scenarios, checks hardware and destructive-setup authorization, and reports blocked rather than inventing a pass when devices are unavailable.",
"files": [],
"expectations": [
"Selects dm, file_private, session_recovery, and identity_reset, with any omission explicitly tied to the inspected change.",
"Recognizes that setup clears app data and requires disposable lab devices.",
"Does not run setup, identity_reset, or all without destructive authorization and reports blocked (not run) when hardware is unavailable.",
"Keeps device selectors and raw evidence out of durable artifacts."
]
},
{
"id": 2,
"prompt": "The broadcast fragment cap changed. Two unlocked disposable phones are attached over ADB. Run the relevant Mesh Lab coverage and give me a PR-safe summary.",
"expected_output": "The agent recognizes that the stock fixtures do not prove the exact fragment boundary, adds focused below/at/above-cap receiver-side coverage when the changed contract requires it, runs relevant existing file scenarios, saves private evidence in a protected temporary directory, and produces a sanitized summary.",
"files": [],
"expectations": [
"Uses the debug APK and the repository Mesh Lab runner.",
"Runs file and file_oversize, and runs file_private only if the inspected cap is shared with private transfers.",
"Adds or requires below/at/above-cap receiver-side assertions instead of treating the stock 1 KB and 512 KB fixtures as exact-boundary proof.",
"Stores unsanitized evidence in a unique mode-0700 temporary directory and does not expose ADB selectors."
]
},
{
"id": 3,
"prompt": "I added a new routed acknowledgement packet. Existing mesh_lab scenarios do not prove that the second phone receives and processes it. Add appropriate physical-device coverage.",
"expected_output": "The agent extends the host scenario and only the minimum necessary debug hook, asserting a unique remote observation with bounded timeouts and structured evidence while keeping test code out of production sources.",
"files": [],
"expectations": [
"Uses a unique receiver-side acknowledgement observation rather than treating raw_send success as end-to-end proof.",
"Starts the receiver wait before send and uses bounded timeouts with structured JSON evidence.",
"Keeps any hook and manifest changes under app/src/debug and updates scenario registration and documentation.",
"Handles unsupported device types or topologies explicitly rather than assuming parity."
]
}
]
}

6
.dockerignore Normal file
View File

@ -0,0 +1,6 @@
.git
.gradle
.reproducible-build
**/build
local.properties
tools/arti-build/.arti-source

View File

@ -3,102 +3,140 @@ name: Android CI
on:
workflow_dispatch:
push:
branches: [ "main", "develop" ]
branches: [main, develop]
pull_request:
branches: [ "main", "develop" ]
branches: [main, develop]
permissions:
contents: read
env:
SETUP_JAVA_VERSION: 21.0.11+10.0.LTS
jobs:
verify:
name: Test & Lint
runs-on: ubuntu-latest
name: Test and lint
runs-on: ubuntu-24.04
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Checkout code
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
- name: Set up JDK 17
uses: actions/setup-java@v4
with:
java-version: '17'
distribution: 'temurin'
- name: Set up pinned JDK
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5
with:
distribution: temurin
java-version: ${{ env.SETUP_JAVA_VERSION }}
- name: Setup Gradle
uses: gradle/gradle-build-action@v3
- name: Set up and validate Gradle
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
- name: Grant execute permission for gradlew
run: chmod +x gradlew
- name: Verify native library inputs
run: tools/arti-build/verify-checksums.sh
- name: Cache Gradle packages
uses: actions/cache@v3
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
restore-keys: |
${{ runner.os }}-gradle-
- name: Run unit tests
run: ./gradlew testDebugUnitTest
- name: Run unit tests
run: ./gradlew testDebugUnitTest
- name: Run lint
run: ./gradlew lintDebug
- name: Run lint
run: ./gradlew lintDebug
- name: Upload test reports
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: test-reports
path: |
**/build/test-results/
**/build/reports/tests/
- name: Upload test reports
if: always()
uses: actions/upload-artifact@v4
with:
name: test-reports
path: |
**/build/test-results/
**/build/reports/tests/
- name: Upload lint results
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: lint-results
path: "**/build/reports/lint-results-*.html"
- name: Upload lint results
uses: actions/upload-artifact@v4
if: always()
with:
name: lint-results
path: '**/build/reports/lint-results-*.html'
build-debug:
name: Build debug APK
runs-on: ubuntu-24.04
needs: verify
build:
name: Build ${{ matrix.variant }}
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
- name: Set up pinned JDK
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5
with:
distribution: temurin
java-version: ${{ env.SETUP_JAVA_VERSION }}
- name: Set up and validate Gradle
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
- name: Build debug APK
run: ./gradlew assembleDebug
- name: Upload debug APK
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: Debug-apk
path: app/build/outputs/apk/debug/*.apk
reproducible-build:
name: Reproducible release build ${{ matrix.replica }}
runs-on: ubuntu-24.04
needs: verify
strategy:
fail-fast: false
matrix:
variant: [Debug, Release]
replica: [a, b]
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Checkout code
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
- name: Set up JDK 17
uses: actions/setup-java@v4
with:
java-version: '17'
distribution: 'temurin'
- name: Set up pinned JDK
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5
with:
distribution: temurin
java-version: ${{ env.SETUP_JAVA_VERSION }}
- name: Setup Gradle
uses: gradle/gradle-build-action@v3
- name: Set up and validate Gradle
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
with:
cache-disabled: true
- name: Grant execute permission for gradlew
run: chmod +x gradlew
- name: Build canonical unsigned release
run: tools/reproducible-builds/build-in-container.sh "$RUNNER_TEMP/release-${{ matrix.replica }}"
- name: Cache Gradle packages
uses: actions/cache@v3
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
restore-keys: |
${{ runner.os }}-gradle-
- name: Upload build replica
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: reproducible-release-${{ matrix.replica }}
path: ${{ runner.temp }}/release-${{ matrix.replica }}/*
if-no-files-found: error
- name: Build ${{ matrix.variant }} APK
run: ./gradlew assemble${{ matrix.variant }}
compare-reproducible-builds:
name: Compare release bytes
runs-on: ubuntu-24.04
needs: reproducible-build
- name: Upload ${{ matrix.variant }} APK
uses: actions/upload-artifact@v4
with:
name: ${{ matrix.variant }}-apk
path: app/build/outputs/apk/**/*.apk
steps:
- name: Checkout verification script
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
- name: Download replica A
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
name: reproducible-release-a
path: ${{ runner.temp }}/release-a
- name: Download replica B
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
name: reproducible-release-b
path: ${{ runner.temp }}/release-b
- name: Compare every canonical byte
run: tools/reproducible-builds/compare-release.sh "$RUNNER_TEMP/release-a" "$RUNNER_TEMP/release-b"

View File

@ -10,11 +10,11 @@ permissions:
jobs:
update-relay-data:
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
with:
token: ${{ secrets.GITHUB_TOKEN }}
@ -37,4 +37,4 @@ jobs:
git commit -m "Automated update of relay data - $(date -u)"
git push
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@ -2,106 +2,118 @@ name: Release
on:
workflow_dispatch:
inputs:
tag:
description: Existing vX.Y.Z tag to build and attest
required: true
type: string
push:
tags:
- 'v*.*.*' # Triggers for tags like v1.0.0
- "v*.*.*"
permissions:
contents: read
env:
RELEASE_TAG: ${{ inputs.tag || github.ref_name }}
jobs:
build:
runs-on: ubuntu-latest
name: Build unsigned release ${{ matrix.replica }}
runs-on: ubuntu-24.04
strategy:
fail-fast: false
matrix:
replica: [a, b]
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up JDK
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: 17
- name: Setup Gradle
uses: gradle/gradle-build-action@v2
with:
gradle-version: wrapper
- name: Cache Gradle files
uses: actions/cache@v3
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-${{ runner.os }}-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
restore-keys: |
gradle-${{ runner.os }}-
- name: Grant execute permission for Gradlew
run: chmod +x ./gradlew
- name: Build Release APKs (with architecture splits)
run: ./gradlew assembleRelease --no-daemon --stacktrace
- name: List APK files
- name: Validate release tag
run: |
echo "APK files built:"
find app/build/outputs/apk/release -name "*.apk" -type f -exec ls -lh {} \;
if [[ ! "$RELEASE_TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+([.-][0-9A-Za-z.-]+)?$ ]]; then
echo "error: release tag must look like vX.Y.Z" >&2
exit 1
fi
- name: Rename APKs for GitHub Release
run: |
cd app/build/outputs/apk/release
[ -f "app-arm64-v8a-release-unsigned.apk" ] && mv app-arm64-v8a-release-unsigned.apk bitchat-android-arm64.apk
[ -f "app-x86_64-release-unsigned.apk" ] && mv app-x86_64-release-unsigned.apk bitchat-android-x86_64.apk
[ -f "app-universal-release-unsigned.apk" ] && mv app-universal-release-unsigned.apk bitchat-android-universal.apk
- name: DEBUG
run: |
set -x
pwd
ls -all
cd app/build/outputs/
ls -all
tree || ls -R
# Optional: Sign APKs (uncomment and configure secrets when ready)
# - name: Sign APKs
# uses: r0adkll/sign-android-release@v1
# with:
# releaseDirectory: app/build/outputs/apk/release
# signingKeyBase64: ${{ secrets.SIGNING_KEY }}
# alias: ${{ secrets.ALIAS }}
# keyStorePassword: ${{ secrets.KEY_STORE_PASSWORD }}
# keyPassword: ${{ secrets.KEY_PASSWORD }}
- name: Upload APKs as artifacts
uses: actions/upload-artifact@v4
- name: Checkout tagged source
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
with:
name: bitchat-android-release-${{ github.ref_name }}
path: app/build/outputs/apk/release/*.apk
ref: ${{ env.RELEASE_TAG }}
- name: Verify workflow provenance ref
run: |
checked_out_commit="$(git rev-parse HEAD)"
if [ "$checked_out_commit" != "$GITHUB_SHA" ]; then
echo "error: run the workflow from the $RELEASE_TAG tag ref so provenance identifies the built commit" >&2
exit 1
fi
- name: Validate the Gradle wrapper
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
with:
cache-disabled: true
- name: Build canonical unsigned APKs and AAB
run: tools/reproducible-builds/build-in-container.sh "$RUNNER_TEMP/release-${{ matrix.replica }}"
- name: Upload unsigned replica
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: unsigned-release-${{ matrix.replica }}
path: ${{ runner.temp }}/release-${{ matrix.replica }}/*
retention-days: 30
if-no-files-found: error
release:
compare:
name: Verify reproducibility
runs-on: ubuntu-24.04
needs: build
runs-on: ubuntu-latest
permissions:
attestations: write
contents: read
id-token: write
steps:
- name: Download release artifacts
uses: actions/download-artifact@v4
- name: Checkout verification script
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
with:
name: bitchat-android-release-${{ github.ref_name }}
path: release
ref: ${{ env.RELEASE_TAG }}
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
- name: Download replica A
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
files: |
release/bitchat-android-arm64.apk
release/bitchat-android-x86_64.apk
release/bitchat-android-universal.apk
name: Release ${{ github.ref_name }}
body: |
**bitchat-android-arm64.apk** - ARM64 (most phones)
**bitchat-android-x86_64.apk** - x86_64 (Chromebooks, tablets)
**bitchat-android-universal.apk** - All architectures (fallback)
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
name: unsigned-release-a
path: ${{ runner.temp }}/release-a
- name: Download replica B
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
name: unsigned-release-b
path: ${{ runner.temp }}/release-b
- name: Compare every canonical byte
run: tools/reproducible-builds/compare-release.sh "$RUNNER_TEMP/release-a" "$RUNNER_TEMP/release-b"
- name: Prepare public attestation subjects
run: |
mkdir "$RUNNER_TEMP/attestation-subjects"
cp "$RUNNER_TEMP/release-a/BUILDINFO.json" \
"$RUNNER_TEMP/attestation-subjects/BITCHAT_BUILDINFO.json"
cp "$RUNNER_TEMP/release-a/SHA256SUMS.unsigned" \
"$RUNNER_TEMP/attestation-subjects/BITCHAT_SHA256SUMS.unsigned"
cp "$RUNNER_TEMP"/release-a/bitchat-android-*-unsigned.apk \
"$RUNNER_TEMP/attestation-subjects/"
cp "$RUNNER_TEMP/release-a/bitchat-android-release-unsigned.aab" \
"$RUNNER_TEMP/attestation-subjects/"
- name: Attest verified unsigned release
uses: actions/attest-build-provenance@977bb373ede98d70efdf65b84cb5f73e068dcc2a # v3
with:
subject-path: ${{ runner.temp }}/attestation-subjects/*
- name: Upload verified unsigned release
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: verified-unsigned-release
path: ${{ runner.temp }}/release-a/*
retention-days: 30
if-no-files-found: error

2
.gitignore vendored
View File

@ -57,6 +57,7 @@ gen/
*~
*.swp
*.lock
!tools/arti-build/Cargo.lock
.goosehints
# Google services
@ -69,6 +70,7 @@ google-services.json
# Arti build artifacts (cloned repo and Rust build cache)
tools/arti-build/.arti-source/
tools/arti-build/target/
.reproducible-build/
# JVM heap dumps (a Gradle daemon OOM drops these in the repo root)
*.hprof

1
.java-version Normal file
View File

@ -0,0 +1 @@
21.0.11

View File

@ -56,6 +56,11 @@ The application follows a clean architecture pattern, heavily modularized by fea
### Testing
- **Unit Tests**: Located in `app/src/test/`. Use for business logic, protocols, and utility testing.
- **Instrumented Tests**: Located in `app/src/androidTest/`. Use for UI and permission integration testing.
- **Device Mesh Tests (ADB test hooks)**: Two-physical-device scenarios driven over ADB, **kept separate from Gradle/CI** — run them manually when changing mesh/crypto/transfer code. A debug-only broadcast receiver (`app/src/debug/java/com/bitchat/android/testhook/`, never in release builds) exposes mesh operations (scan, connect, Noise handshake, DMs, broadcast, files, raw packet injection) via `am broadcast -a com.bitchat.droid.TEST_HOOK`; the host orchestrator is `tools/release_gate/mesh_lab.py`. Full guide: `docs/release-gate-runbook.md` appendix "mesh lab".
- Prereqs: `adb` on PATH, Python 3.10+, two devices with USB debugging, **both unlocked with screen on** (locked/dozing → POWER_SAVER → flaky timing).
- Setup: `./gradlew assembleDebug && python3 tools/release_gate/mesh_lab.py setup --serial-a <s1> --serial-b <s2> --apk app/build/outputs/apk/debug/app-arm64-v8a-debug.apk`
- Run: `python3 tools/release_gate/mesh_lab.py scenario all --serial-a <s1> --serial-b <s2> --out /tmp/meshlab-evidence`
- Scenarios: `dm`, `broadcast`, `file`, `file_oversize`, `file_private`, `raw`, `session_recovery`, `identity_reset`, `all`. Ad-hoc: `... cmd --serial <s> state`.
- **Execution**:
- Unit: `./gradlew test`
- Instrumented: `./gradlew connectedAndroidTest`

View File

@ -70,6 +70,12 @@ adb install -r app/build/outputs/apk/debug/app-debug.apk
The app requests Bluetooth, location (required for BLE scanning), and notification permissions at runtime.
Release APKs and the Android App Bundle can be rebuilt byte-for-byte in the
pinned Linux container. Maintainers should follow the
[Android release guide](docs/maintainer-release-guide.md). See
[Reproducible builds](docs/reproducible-builds.md) for the build trust model
and public GitHub/Google Play verification procedures.
## Testing
```bash

View File

@ -24,6 +24,7 @@ require(
android {
namespace = "com.bitchat.android"
compileSdk = libs.versions.compileSdk.get().toInt()
buildToolsVersion = libs.versions.buildTools.get()
defaultConfig {
applicationId = "com.bitchat.droid"
@ -64,6 +65,11 @@ android {
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
vcsInfo {
// BUILDINFO.json and attestations carry the verified commit
// without depending on host-specific Git/worktree paths.
include = false
}
}
}
@ -106,6 +112,7 @@ android {
}
kotlin {
jvmToolchain(21)
compilerOptions {
jvmTarget.set(JvmTarget.JVM_11)
}

463
app/gradle.lockfile Normal file
View File

@ -0,0 +1,463 @@
# This is a Gradle generated file for dependency locking.
# Manual edits can break the build and are not advised.
# This file is expected to be part of source control.
# To regenerate this file, run: ./gradlew :app:dependencies --write-locks
androidx.activity:activity-compose:1.13.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.activity:activity-ktx:1.13.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.activity:activity:1.13.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.annotation:annotation-experimental:1.4.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.annotation:annotation-jvm:1.10.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.annotation:annotation:1.10.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.appcompat:appcompat-resources:1.7.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.appcompat:appcompat:1.7.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.arch.core:core-common:2.2.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.arch.core:core-runtime:2.2.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.autofill:autofill:1.0.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.camera.featurecombinationquery:featurecombinationquery:1.6.1=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.camera.viewfinder:viewfinder-compose:1.6.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.camera.viewfinder:viewfinder-core:1.6.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.camera:camera-camera2-pipe:1.6.1=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.camera:camera-camera2:1.6.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.camera:camera-compose:1.6.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.camera:camera-core:1.6.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.camera:camera-lifecycle:1.6.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.collection:collection-jvm:1.5.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.collection:collection-ktx:1.5.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.collection:collection:1.5.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.compose.animation:animation-android:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.compose.animation:animation-core-android:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.compose.animation:animation-core:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.compose.animation:animation:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.compose.foundation:foundation-android:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.compose.foundation:foundation-layout-android:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.compose.foundation:foundation-layout:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.compose.foundation:foundation:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.compose.material3:material3-android:1.4.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.compose.material3:material3:1.4.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.compose.material:material-icons-core-android:1.7.8=debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath
androidx.compose.material:material-icons-core-desktop:1.7.8=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugUnitTestLintChecksClasspath,releaseLintChecksClasspath
androidx.compose.material:material-icons-core:1.7.8=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.compose.material:material-icons-extended-android:1.7.8=debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath
androidx.compose.material:material-icons-extended-desktop:1.7.8=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugUnitTestLintChecksClasspath,releaseLintChecksClasspath
androidx.compose.material:material-icons-extended:1.7.8=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.compose.material:material-ripple-android:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.compose.material:material-ripple:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.compose.runtime:runtime-android:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.compose.runtime:runtime-annotation-android:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.compose.runtime:runtime-annotation:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.compose.runtime:runtime-retain-android:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.compose.runtime:runtime-retain:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.compose.runtime:runtime-saveable-android:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.compose.runtime:runtime-saveable:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.compose.runtime:runtime:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.compose.ui:ui-android:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.compose.ui:ui-geometry-android:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.compose.ui:ui-geometry:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.compose.ui:ui-graphics-android:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.compose.ui:ui-graphics:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.compose.ui:ui-test-android:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath
androidx.compose.ui:ui-test-junit4-android:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath
androidx.compose.ui:ui-test-junit4:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath
androidx.compose.ui:ui-test-manifest:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath
androidx.compose.ui:ui-test:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath
androidx.compose.ui:ui-text-android:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.compose.ui:ui-text:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.compose.ui:ui-tooling-android:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath
androidx.compose.ui:ui-tooling-data-android:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath
androidx.compose.ui:ui-tooling-data:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath
androidx.compose.ui:ui-tooling-preview-android:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.compose.ui:ui-tooling-preview:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.compose.ui:ui-tooling:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath
androidx.compose.ui:ui-unit-android:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.compose.ui:ui-unit:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.compose.ui:ui-util-android:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.compose.ui:ui-util:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.compose.ui:ui:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.compose:compose-bom:2026.06.01=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.concurrent:concurrent-futures-ktx:1.1.0=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.concurrent:concurrent-futures-ktx:1.2.0=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath
androidx.concurrent:concurrent-futures:1.1.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.concurrent:concurrent-futures:1.2.0=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath
androidx.core:core-backported-fixes:1.0.0=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.core:core-ktx:1.19.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.core:core-viewtree:1.0.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.core:core:1.19.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.cursoradapter:cursoradapter:1.0.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.customview:customview-poolingcontainer:1.0.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.customview:customview:1.0.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.documentfile:documentfile:1.0.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.drawerlayout:drawerlayout:1.0.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.dynamicanimation:dynamicanimation:1.0.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.emoji2:emoji2-views-helper:1.4.0=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.emoji2:emoji2:1.4.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.exifinterface:exifinterface:1.4.2=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.fragment:fragment:1.5.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.graphics:graphics-path:1.0.1=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.interpolator:interpolator:1.0.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.legacy:legacy-support-core-utils:1.0.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.lifecycle:lifecycle-common-java8:2.11.0=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.lifecycle:lifecycle-common-jvm:2.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.lifecycle:lifecycle-common:2.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.lifecycle:lifecycle-livedata-core-ktx:2.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.lifecycle:lifecycle-livedata-core:2.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.lifecycle:lifecycle-livedata:2.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.lifecycle:lifecycle-process:2.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.lifecycle:lifecycle-runtime-android:2.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.lifecycle:lifecycle-runtime-compose-android:2.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.lifecycle:lifecycle-runtime-compose:2.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.lifecycle:lifecycle-runtime-ktx-android:2.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.lifecycle:lifecycle-runtime-ktx:2.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.lifecycle:lifecycle-runtime:2.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.lifecycle:lifecycle-service:2.11.0=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.lifecycle:lifecycle-viewmodel-android:2.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.lifecycle:lifecycle-viewmodel-compose-android:2.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.lifecycle:lifecycle-viewmodel-compose:2.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.lifecycle:lifecycle-viewmodel-ktx:2.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.lifecycle:lifecycle-viewmodel-savedstate-android:2.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.lifecycle:lifecycle-viewmodel-savedstate:2.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.lifecycle:lifecycle-viewmodel:2.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.loader:loader:1.0.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.localbroadcastmanager:localbroadcastmanager:1.0.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.navigation:navigation-common-android:2.9.8=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.navigation:navigation-common:2.9.8=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.navigation:navigation-compose-android:2.9.8=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.navigation:navigation-compose:2.9.8=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.navigation:navigation-runtime-android:2.9.8=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.navigation:navigation-runtime:2.9.8=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.navigationevent:navigationevent-android:1.0.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.navigationevent:navigationevent-compose-android:1.0.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.navigationevent:navigationevent-compose:1.0.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.navigationevent:navigationevent:1.0.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.print:print:1.0.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.profileinstaller:profileinstaller:1.4.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.resourceinspection:resourceinspection-annotation:1.0.1=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.room:room-common:2.6.1=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.room:room-ktx:2.6.1=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.room:room-runtime:2.6.1=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.savedstate:savedstate-android:1.4.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.savedstate:savedstate-compose-android:1.4.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.savedstate:savedstate-compose:1.4.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.savedstate:savedstate-ktx:1.4.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.savedstate:savedstate:1.4.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.security:security-crypto:1.1.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.sqlite:sqlite-framework:2.4.0=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.sqlite:sqlite:2.4.0=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.startup:startup-runtime:1.1.1=debugAndroidTestCompileClasspath,debugCompileClasspath,debugUnitTestCompileClasspath,releaseCompileClasspath
androidx.startup:startup-runtime:1.2.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.test.espresso:espresso-core:3.5.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath
androidx.test.espresso:espresso-core:3.7.0=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath
androidx.test.espresso:espresso-idling-resource:3.5.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath
androidx.test.espresso:espresso-idling-resource:3.7.0=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath
androidx.test.ext:junit:1.1.5=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath
androidx.test.ext:junit:1.3.0=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath
androidx.test.services:storage:1.4.2=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath
androidx.test.services:storage:1.6.0=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath
androidx.test:annotation:1.0.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath
androidx.test:core:1.5.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath
androidx.test:core:1.7.0=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath
androidx.test:monitor:1.6.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath
androidx.test:monitor:1.8.0=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath
androidx.test:runner:1.5.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath
androidx.test:runner:1.7.0=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath
androidx.tracing:tracing-android:1.3.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.tracing:tracing-ktx:1.3.0=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.tracing:tracing:1.0.0=debugAndroidTestCompileClasspath
androidx.tracing:tracing:1.1.0=debugUnitTestCompileClasspath
androidx.tracing:tracing:1.3.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.transition:transition:1.6.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.vectordrawable:vectordrawable-animated:1.1.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.vectordrawable:vectordrawable:1.1.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.versionedparcelable:versionedparcelable:1.1.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.viewpager:viewpager:1.0.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.window:window-core-android:1.5.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.window:window-core:1.5.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.window:window:1.5.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.work:work-runtime-ktx:2.10.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.work:work-runtime:2.10.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
com.almworks.sqlite4java:sqlite4java:1.0.392=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath
com.android.tools.analytics-library:protos:32.3.1=androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle
com.android.tools.analytics-library:shared:32.3.1=androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle
com.android.tools.analytics-library:tracker:32.3.1=androidLintTool
com.android.tools.build:aapt2-proto:9.3.1-15703166=androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle
com.android.tools.build:builder-model:9.3.1=androidLintTool
com.android.tools.build:manifest-merger:32.3.1=androidLintTool
com.android.tools.ddms:ddmlib:32.3.1=androidLintTool,unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-test-plugin-result-listener-gradle,unified-test-platform-gradle-work-action
com.android.tools.emulator:proto:32.3.1=unified-test-platform-android-test-plugin-host-emulator-control
com.android.tools.external.com-intellij:intellij-core:32.3.1=androidLintTool
com.android.tools.external.com-intellij:kotlin-compiler:32.3.1=androidLintTool
com.android.tools.external.org-jetbrains:uast:32.3.1=androidLintTool
com.android.tools.layoutlib:layoutlib-api:32.3.1=androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle
com.android.tools.lint:lint-api:32.3.1=androidLintTool
com.android.tools.lint:lint-checks:32.3.1=androidLintTool
com.android.tools.lint:lint-gradle:32.3.1=androidLintTool
com.android.tools.lint:lint-model:32.3.1=androidLintTool
com.android.tools.lint:lint-typedef-remover:32.3.1=androidLintTool
com.android.tools.lint:lint:32.3.1=androidLintTool
com.android.tools.utp:android-device-provider-ddmlib-proto:32.3.1=unified-test-platform-android-device-provider-ddmlib,unified-test-platform-gradle-work-action
com.android.tools.utp:android-device-provider-ddmlib:32.3.1=unified-test-platform-android-device-provider-ddmlib
com.android.tools.utp:android-test-plugin-host-additional-test-output-proto:32.3.1=unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-gradle-work-action
com.android.tools.utp:android-test-plugin-host-additional-test-output:32.3.1=unified-test-platform-android-test-plugin-host-additional-test-output
com.android.tools.utp:android-test-plugin-host-apk-installer-proto:32.3.1=unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-gradle-work-action
com.android.tools.utp:android-test-plugin-host-apk-installer:32.3.1=unified-test-platform-android-test-plugin-host-apk-installer
com.android.tools.utp:android-test-plugin-host-coverage-proto:32.3.1=unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-gradle-work-action
com.android.tools.utp:android-test-plugin-host-coverage:32.3.1=unified-test-platform-android-test-plugin-host-coverage
com.android.tools.utp:android-test-plugin-host-device-info-proto:32.3.1=unified-test-platform-android-test-plugin-host-device-info
com.android.tools.utp:android-test-plugin-host-device-info:32.3.1=unified-test-platform-android-test-plugin-host-device-info
com.android.tools.utp:android-test-plugin-host-emulator-control-proto:32.3.1=unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-gradle-work-action
com.android.tools.utp:android-test-plugin-host-emulator-control:32.3.1=unified-test-platform-android-test-plugin-host-emulator-control
com.android.tools.utp:android-test-plugin-host-logcat-proto:32.3.1=unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-gradle-work-action
com.android.tools.utp:android-test-plugin-host-logcat:32.3.1=unified-test-platform-android-test-plugin-host-logcat
com.android.tools.utp:android-test-plugin-result-listener-gradle-proto:32.3.1=unified-test-platform-android-test-plugin-result-listener-gradle,unified-test-platform-gradle-work-action
com.android.tools.utp:android-test-plugin-result-listener-gradle:32.3.1=unified-test-platform-android-test-plugin-result-listener-gradle
com.android.tools.utp:gradle-work-action:32.3.1=unified-test-platform-gradle-work-action
com.android.tools.utp:utp-common:32.3.1=unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-logcat
com.android.tools:annotations:32.3.1=androidLintTool,unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-android-test-plugin-result-listener-gradle,unified-test-platform-gradle-work-action
com.android.tools:common:32.3.1=androidLintTool,unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-android-test-plugin-result-listener-gradle,unified-test-platform-gradle-work-action
com.android.tools:dvlib:32.3.1=androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle
com.android.tools:play-sdk-proto:32.3.1=androidLintTool
com.android.tools:repository:32.3.1=androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle
com.android.tools:sdk-common:32.3.1=androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle
com.android.tools:sdklib:32.3.1=androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle
com.google.accompanist:accompanist-permissions:0.37.3=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
com.google.android.datatransport:transport-api:2.2.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
com.google.android.datatransport:transport-backend-cct:2.3.3=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
com.google.android.datatransport:transport-runtime:2.2.6=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
com.google.android.gms:play-services-base:18.9.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
com.google.android.gms:play-services-basement:18.9.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
com.google.android.gms:play-services-location:21.4.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
com.google.android.gms:play-services-mlkit-barcode-scanning:18.3.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
com.google.android.gms:play-services-tasks:18.4.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
com.google.android.odml:image:1.0.0-beta1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
com.google.android:annotations:4.1.1.4=unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-core
com.google.api.grpc:proto-google-common-protos:2.17.0=unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-android-test-plugin-result-listener-gradle,unified-test-platform-core
com.google.api.grpc:proto-google-common-protos:2.48.0=unified-test-platform-android-test-plugin-host-emulator-control
com.google.auto.service:auto-service-annotations:1.1.1=unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-driver-instrumentation,unified-test-platform-android-test-plugin,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-android-test-plugin-result-listener-gradle
com.google.auto.service:auto-service:1.1.1=unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-driver-instrumentation,unified-test-platform-android-test-plugin,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-android-test-plugin-result-listener-gradle
com.google.auto.value:auto-value-annotations:1.11.0=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath
com.google.auto.value:auto-value-annotations:1.6.3=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
com.google.auto:auto-common:1.2.1=unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-driver-instrumentation,unified-test-platform-android-test-plugin,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-android-test-plugin-result-listener-gradle
com.google.code.findbugs:jsr305:3.0.2=androidLintTool,debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath,unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-driver-instrumentation,unified-test-platform-android-test-plugin,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-android-test-plugin-result-listener-gradle,unified-test-platform-core,unified-test-platform-gradle-work-action,unified-test-platform-launcher
com.google.code.gson:gson:2.10.1=unified-test-platform-core,unified-test-platform-gradle-work-action
com.google.code.gson:gson:2.11.0=androidLintTool,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-result-listener-gradle
com.google.code.gson:gson:2.14.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
com.google.code.gson:gson:2.8.9=unified-test-platform-android-driver-instrumentation,unified-test-platform-launcher
com.google.crypto.tink:tink-android:1.23.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
com.google.crypto.tink:tink:1.18.0=unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-gradle-work-action
com.google.dagger:dagger:2.48=unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-driver-instrumentation,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-android-test-plugin-result-listener-gradle,unified-test-platform-core,unified-test-platform-gradle-work-action
com.google.dagger:dagger:2.59=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
com.google.errorprone:error_prone_annotation:2.38.0=debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath
com.google.errorprone:error_prone_annotations:2.23.0=unified-test-platform-android-driver-instrumentation,unified-test-platform-android-test-plugin,unified-test-platform-core,unified-test-platform-launcher
com.google.errorprone:error_prone_annotations:2.36.0=androidLintTool,unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-android-test-plugin-result-listener-gradle,unified-test-platform-gradle-work-action
com.google.errorprone:error_prone_annotations:2.48.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
com.google.firebase:firebase-annotations:16.0.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
com.google.firebase:firebase-components:16.1.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
com.google.firebase:firebase-encoders-json:17.1.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
com.google.firebase:firebase-encoders:16.1.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
com.google.guava:failureaccess:1.0.1=unified-test-platform-android-driver-instrumentation,unified-test-platform-android-test-plugin,unified-test-platform-core,unified-test-platform-launcher
com.google.guava:failureaccess:1.0.2=androidLintTool,unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-android-test-plugin-result-listener-gradle,unified-test-platform-gradle-work-action
com.google.guava:failureaccess:1.0.3=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath
com.google.guava:guava:32.0.1-jre=unified-test-platform-android-driver-instrumentation,unified-test-platform-android-test-plugin,unified-test-platform-core,unified-test-platform-launcher
com.google.guava:guava:33.4.0-jre=androidLintTool,unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-android-test-plugin-result-listener-gradle,unified-test-platform-gradle-work-action
com.google.guava:guava:33.4.8-jre=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath
com.google.guava:listenablefuture:1.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=androidLintTool,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-driver-instrumentation,unified-test-platform-android-test-plugin,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-android-test-plugin-result-listener-gradle,unified-test-platform-core,unified-test-platform-gradle-work-action,unified-test-platform-launcher
com.google.j2objc:j2objc-annotations:2.8=unified-test-platform-android-driver-instrumentation,unified-test-platform-android-test-plugin,unified-test-platform-core,unified-test-platform-launcher
com.google.j2objc:j2objc-annotations:3.0.0=androidLintTool,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-android-test-plugin-result-listener-gradle,unified-test-platform-gradle-work-action
com.google.jimfs:jimfs:1.1=androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle
com.google.mlkit:barcode-scanning-common:17.0.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
com.google.mlkit:barcode-scanning:17.3.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
com.google.mlkit:common:18.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
com.google.mlkit:vision-common:17.3.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
com.google.mlkit:vision-interfaces:16.3.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
com.google.protobuf:protobuf-java-util:3.22.3=unified-test-platform-core
com.google.protobuf:protobuf-java-util:4.28.3=unified-test-platform-android-driver-instrumentation,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-gradle-work-action,unified-test-platform-launcher
com.google.protobuf:protobuf-java:3.25.5=androidLintTool
com.google.protobuf:protobuf-java:4.28.3=unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-driver-instrumentation,unified-test-platform-android-test-plugin,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-android-test-plugin-result-listener-gradle,unified-test-platform-core,unified-test-platform-gradle-work-action,unified-test-platform-launcher
com.google.protobuf:protobuf-kotlin:4.28.3=unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-driver-instrumentation,unified-test-platform-android-test-plugin,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-android-test-plugin-result-listener-gradle,unified-test-platform-core,unified-test-platform-gradle-work-action,unified-test-platform-launcher
com.google.testing.platform:android-device-provider-local:0.0.9-alpha04=unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-android-test-plugin-result-listener-gradle
com.google.testing.platform:android-driver-instrumentation:0.0.9-alpha04=unified-test-platform-android-driver-instrumentation,unified-test-platform-android-test-plugin-host-emulator-control
com.google.testing.platform:android-test-plugin:0.0.9-alpha04=unified-test-platform-android-test-plugin
com.google.testing.platform:core-proto:0.0.9-alpha04=unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-result-listener-gradle,unified-test-platform-gradle-work-action
com.google.testing.platform:core:0.0.9-alpha04=unified-test-platform-core
com.google.testing.platform:launcher:0.0.9-alpha04=unified-test-platform-gradle-work-action,unified-test-platform-launcher
com.google.testparameterinjector:test-parameter-injector:1.18=debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath
com.google.zxing:core:3.5.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
com.ibm.icu:icu4j:77.1=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath
com.squareup.okhttp3:okhttp-android:5.4.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
com.squareup.okhttp3:okhttp:5.4.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
com.squareup.okio:okio-jvm:3.17.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
com.squareup.okio:okio:3.17.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
com.squareup:javawriter:2.1.1=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath
com.sun.istack:istack-commons-runtime:3.0.8=androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle
com.sun.xml.fastinfoset:FastInfoset:1.2.16=androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle
commons-codec:commons-codec:1.17.1=androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle
commons-io:commons-io:2.16.1=androidLintTool,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-result-listener-gradle,unified-test-platform-gradle-work-action
commons-logging:commons-logging:1.2=androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle
io.grpc:grpc-api:1.57.2=unified-test-platform-core
io.grpc:grpc-api:1.69.1=unified-test-platform-android-test-plugin-host-emulator-control
io.grpc:grpc-context:1.57.2=unified-test-platform-core
io.grpc:grpc-context:1.69.1=unified-test-platform-android-test-plugin-host-emulator-control
io.grpc:grpc-core:1.57.2=unified-test-platform-core
io.grpc:grpc-core:1.69.1=unified-test-platform-android-test-plugin-host-emulator-control
io.grpc:grpc-netty:1.57.2=unified-test-platform-core
io.grpc:grpc-netty:1.69.1=unified-test-platform-android-test-plugin-host-emulator-control
io.grpc:grpc-protobuf-lite:1.57.2=unified-test-platform-core
io.grpc:grpc-protobuf-lite:1.69.1=unified-test-platform-android-test-plugin-host-emulator-control
io.grpc:grpc-protobuf:1.57.2=unified-test-platform-core
io.grpc:grpc-protobuf:1.69.1=unified-test-platform-android-test-plugin-host-emulator-control
io.grpc:grpc-services:1.57.2=unified-test-platform-core
io.grpc:grpc-stub:1.57.2=unified-test-platform-core
io.grpc:grpc-stub:1.69.1=unified-test-platform-android-test-plugin-host-emulator-control
io.grpc:grpc-util:1.69.1=unified-test-platform-android-test-plugin-host-emulator-control
io.netty:netty-buffer:4.1.110.Final=unified-test-platform-android-test-plugin-host-emulator-control
io.netty:netty-buffer:4.1.93.Final=unified-test-platform-core
io.netty:netty-codec-http2:4.1.110.Final=unified-test-platform-android-test-plugin-host-emulator-control
io.netty:netty-codec-http2:4.1.93.Final=unified-test-platform-core
io.netty:netty-codec-http:4.1.110.Final=unified-test-platform-android-test-plugin-host-emulator-control
io.netty:netty-codec-http:4.1.93.Final=unified-test-platform-core
io.netty:netty-codec-socks:4.1.110.Final=unified-test-platform-android-test-plugin-host-emulator-control
io.netty:netty-codec-socks:4.1.93.Final=unified-test-platform-core
io.netty:netty-codec:4.1.110.Final=unified-test-platform-android-test-plugin-host-emulator-control
io.netty:netty-codec:4.1.93.Final=unified-test-platform-core
io.netty:netty-common:4.1.110.Final=unified-test-platform-android-test-plugin-host-emulator-control
io.netty:netty-common:4.1.93.Final=unified-test-platform-core
io.netty:netty-handler-proxy:4.1.110.Final=unified-test-platform-android-test-plugin-host-emulator-control
io.netty:netty-handler-proxy:4.1.93.Final=unified-test-platform-core
io.netty:netty-handler:4.1.110.Final=unified-test-platform-android-test-plugin-host-emulator-control
io.netty:netty-handler:4.1.93.Final=unified-test-platform-core
io.netty:netty-resolver:4.1.110.Final=unified-test-platform-android-test-plugin-host-emulator-control
io.netty:netty-resolver:4.1.93.Final=unified-test-platform-core
io.netty:netty-transport-native-unix-common:4.1.110.Final=unified-test-platform-android-test-plugin-host-emulator-control
io.netty:netty-transport-native-unix-common:4.1.93.Final=unified-test-platform-core
io.netty:netty-transport:4.1.110.Final=unified-test-platform-android-test-plugin-host-emulator-control
io.netty:netty-transport:4.1.93.Final=unified-test-platform-core
io.opencensus:opencensus-api:0.31.0=unified-test-platform-core
io.opencensus:opencensus-proto:0.2.0=unified-test-platform-core,unified-test-platform-gradle-work-action,unified-test-platform-launcher
io.perfmark:perfmark-api:0.26.0=unified-test-platform-core
io.perfmark:perfmark-api:0.27.0=unified-test-platform-android-test-plugin-host-emulator-control
jakarta.activation:jakarta.activation-api:1.2.1=androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle
jakarta.inject:jakarta.inject-api:2.0.1=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
jakarta.xml.bind:jakarta.xml.bind-api:2.3.2=androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle
javax.annotation:javax.annotation-api:1.3.2=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,unified-test-platform-android-test-plugin-host-emulator-control
javax.inject:javax.inject:1=androidLintTool,debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath,unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-driver-instrumentation,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-android-test-plugin-result-listener-gradle,unified-test-platform-core,unified-test-platform-gradle-work-action
junit:junit:4.13.2=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath
net.bytebuddy:byte-buddy-agent:1.17.7=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath
net.bytebuddy:byte-buddy:1.17.7=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath
net.java.dev.jna:jna-platform:5.6.0=androidLintTool,unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-android-test-plugin-result-listener-gradle,unified-test-platform-gradle-work-action
net.java.dev.jna:jna:5.6.0=androidLintTool,unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-android-test-plugin-result-listener-gradle,unified-test-platform-gradle-work-action
net.sf.kxml:kxml2:2.3.0=androidLintTool,unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-test-plugin-result-listener-gradle,unified-test-platform-gradle-work-action
no.nordicsemi.android:ble:2.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
org.apache.commons:commons-compress:1.27.1=androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle
org.apache.commons:commons-lang3:3.16.0=androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle
org.apache.httpcomponents:httpclient:4.5.6=androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle
org.apache.httpcomponents:httpcore:4.4.16=androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle
org.apache.httpcomponents:httpmime:4.5.6=androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle
org.bouncycastle:bcpkix-jdk18on:1.79=androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle
org.bouncycastle:bcprov-jdk18on:1.79=androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle
org.bouncycastle:bcprov-jdk18on:1.85=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
org.bouncycastle:bcutil-jdk18on:1.79=androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle
org.checkerframework:checker-qual:3.33.0=unified-test-platform-android-driver-instrumentation,unified-test-platform-android-test-plugin,unified-test-platform-core,unified-test-platform-launcher
org.checkerframework:checker-qual:3.43.0=androidLintTool,unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-android-test-plugin-result-listener-gradle,unified-test-platform-gradle-work-action
org.codehaus.groovy:groovy:3.0.22=androidLintTool
org.codehaus.mojo:animal-sniffer-annotations:1.23=unified-test-platform-core
org.codehaus.mojo:animal-sniffer-annotations:1.24=unified-test-platform-android-test-plugin-host-emulator-control
org.conscrypt:conscrypt-openjdk-uber:2.5.2=debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath
org.glassfish.jaxb:jaxb-runtime:2.3.2=androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle
org.glassfish.jaxb:txw2:2.3.2=androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle
org.hamcrest:hamcrest-core:1.3=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath
org.hamcrest:hamcrest-integration:1.3=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath
org.hamcrest:hamcrest-library:1.3=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath
org.jetbrains.kotlin:compose-group-mapping:2.4.10=composeMappingProducerClasspath
org.jetbrains.kotlin:kotlin-build-tools-api:2.4.0=kotlinAbiValidationCompatClasspath
org.jetbrains.kotlin:kotlin-build-tools-api:2.4.10=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kotlinCompilerPluginClasspathDebug,kotlinCompilerPluginClasspathDebugAndroidTest,kotlinCompilerPluginClasspathDebugUnitTest,kotlinCompilerPluginClasspathRelease
org.jetbrains.kotlin:kotlin-build-tools-compat:2.4.10=kotlinAbiValidationCompatClasspath,kotlinBuildToolsApiClasspath,kotlinCompilerClasspath
org.jetbrains.kotlin:kotlin-build-tools-cri-impl:2.4.0=kotlinAbiValidationCompatClasspath
org.jetbrains.kotlin:kotlin-build-tools-cri-impl:2.4.10=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath
org.jetbrains.kotlin:kotlin-build-tools-impl:2.4.0=kotlinAbiValidationCompatClasspath
org.jetbrains.kotlin:kotlin-build-tools-impl:2.4.10=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath
org.jetbrains.kotlin:kotlin-compiler-embeddable:2.4.0=kotlinAbiValidationCompatClasspath
org.jetbrains.kotlin:kotlin-compiler-embeddable:2.4.10=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kotlinCompilerPluginClasspathDebug,kotlinCompilerPluginClasspathDebugAndroidTest,kotlinCompilerPluginClasspathDebugUnitTest,kotlinCompilerPluginClasspathRelease
org.jetbrains.kotlin:kotlin-compiler-runner:2.4.0=kotlinAbiValidationCompatClasspath
org.jetbrains.kotlin:kotlin-compiler-runner:2.4.10=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath
org.jetbrains.kotlin:kotlin-compose-compiler-plugin-embeddable:2.4.10=kotlin-extension,kotlinCompilerPluginClasspathDebug,kotlinCompilerPluginClasspathDebugAndroidTest,kotlinCompilerPluginClasspathDebugUnitTest,kotlinCompilerPluginClasspathRelease
org.jetbrains.kotlin:kotlin-daemon-client:2.4.0=kotlinAbiValidationCompatClasspath
org.jetbrains.kotlin:kotlin-daemon-client:2.4.10=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath
org.jetbrains.kotlin:kotlin-daemon-embeddable:2.4.0=kotlinAbiValidationCompatClasspath
org.jetbrains.kotlin:kotlin-daemon-embeddable:2.4.10=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kotlinCompilerPluginClasspathDebug,kotlinCompilerPluginClasspathDebugAndroidTest,kotlinCompilerPluginClasspathDebugUnitTest,kotlinCompilerPluginClasspathRelease
org.jetbrains.kotlin:kotlin-parcelize-compiler:2.4.10=kotlinCompilerPluginClasspathDebug,kotlinCompilerPluginClasspathDebugAndroidTest,kotlinCompilerPluginClasspathDebugUnitTest,kotlinCompilerPluginClasspathRelease
org.jetbrains.kotlin:kotlin-parcelize-runtime:2.4.10=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugRuntimeClasspathCopy,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath,releaseRuntimeClasspathCopy
org.jetbrains.kotlin:kotlin-reflect:1.6.10=kotlinAbiValidationCompatClasspath,kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kotlinCompilerPluginClasspathDebug,kotlinCompilerPluginClasspathDebugAndroidTest,kotlinCompilerPluginClasspathDebugUnitTest,kotlinCompilerPluginClasspathRelease
org.jetbrains.kotlin:kotlin-reflect:1.8.21=unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-driver-instrumentation,unified-test-platform-android-test-plugin,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-core,unified-test-platform-gradle-work-action,unified-test-platform-launcher
org.jetbrains.kotlin:kotlin-reflect:2.1.20=debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath
org.jetbrains.kotlin:kotlin-reflect:2.2.10=androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle
org.jetbrains.kotlin:kotlin-script-runtime:2.4.0=kotlinAbiValidationCompatClasspath
org.jetbrains.kotlin:kotlin-script-runtime:2.4.10=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kotlinCompilerPluginClasspathDebug,kotlinCompilerPluginClasspathDebugAndroidTest,kotlinCompilerPluginClasspathDebugUnitTest,kotlinCompilerPluginClasspathRelease
org.jetbrains.kotlin:kotlin-stdlib-common:1.8.21=unified-test-platform-android-test-plugin,unified-test-platform-core
org.jetbrains.kotlin:kotlin-stdlib-common:1.9.0=unified-test-platform-android-driver-instrumentation,unified-test-platform-launcher
org.jetbrains.kotlin:kotlin-stdlib-common:2.2.10=unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-gradle-work-action
org.jetbrains.kotlin:kotlin-stdlib-common:2.4.10=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.8.20=unified-test-platform-android-driver-instrumentation,unified-test-platform-android-test-plugin,unified-test-platform-core,unified-test-platform-launcher
org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.8.22=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
org.jetbrains.kotlin:kotlin-stdlib-jdk7:2.2.10=androidLintTool,unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-android-test-plugin-result-listener-gradle,unified-test-platform-gradle-work-action
org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.8.20=unified-test-platform-android-driver-instrumentation,unified-test-platform-android-test-plugin,unified-test-platform-core,unified-test-platform-launcher
org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.8.22=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
org.jetbrains.kotlin:kotlin-stdlib-jdk8:2.2.10=androidLintTool,unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-android-test-plugin-result-listener-gradle,unified-test-platform-gradle-work-action
org.jetbrains.kotlin:kotlin-stdlib:1.8.21=unified-test-platform-android-test-plugin,unified-test-platform-core
org.jetbrains.kotlin:kotlin-stdlib:1.9.0=unified-test-platform-android-driver-instrumentation,unified-test-platform-launcher
org.jetbrains.kotlin:kotlin-stdlib:2.2.10=androidLintTool,unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-android-test-plugin-result-listener-gradle,unified-test-platform-gradle-work-action
org.jetbrains.kotlin:kotlin-stdlib:2.2.21=composeMappingProducerClasspath
org.jetbrains.kotlin:kotlin-stdlib:2.4.0=kotlinAbiValidationCompatClasspath
org.jetbrains.kotlin:kotlin-stdlib:2.4.10=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugRuntimeClasspathCopy,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kotlinCompilerPluginClasspathDebug,kotlinCompilerPluginClasspathDebugAndroidTest,kotlinCompilerPluginClasspathDebugUnitTest,kotlinCompilerPluginClasspathRelease,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath,releaseRuntimeClasspathCopy
org.jetbrains.kotlin:kotlin-tooling-core:2.4.10=kotlinAbiValidationCompatClasspath,kotlinBuildToolsApiClasspath,kotlinCompilerClasspath
org.jetbrains.kotlinx:atomicfu-jvm:0.22.0=unified-test-platform-android-driver-instrumentation,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-gradle-work-action,unified-test-platform-launcher
org.jetbrains.kotlinx:atomicfu-jvm:0.28.0=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
org.jetbrains.kotlinx:atomicfu:0.22.0=unified-test-platform-android-driver-instrumentation,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-gradle-work-action,unified-test-platform-launcher
org.jetbrains.kotlinx:atomicfu:0.28.0=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
org.jetbrains.kotlinx:kotlinx-coroutines-android:1.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.7.3=unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-driver-instrumentation,unified-test-platform-android-test-plugin,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-core,unified-test-platform-gradle-work-action,unified-test-platform-launcher
org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.9.0=androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle
org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.7.3=unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-driver-instrumentation,unified-test-platform-android-test-plugin,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-core,unified-test-platform-gradle-work-action,unified-test-platform-launcher
org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.8.0=kotlinAbiValidationCompatClasspath,kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kotlinCompilerPluginClasspathDebug,kotlinCompilerPluginClasspathDebugAndroidTest,kotlinCompilerPluginClasspathDebugUnitTest,kotlinCompilerPluginClasspathRelease
org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.9.0=androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle
org.jetbrains.kotlinx:kotlinx-coroutines-core:1.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3=unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-core,unified-test-platform-gradle-work-action,unified-test-platform-launcher
org.jetbrains.kotlinx:kotlinx-coroutines-core:1.9.0=androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle
org.jetbrains.kotlinx:kotlinx-coroutines-test-jvm:1.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath
org.jetbrains.kotlinx:kotlinx-coroutines-test:1.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath
org.jetbrains.kotlinx:kotlinx-serialization-bom:1.7.3=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
org.jetbrains.kotlinx:kotlinx-serialization-core-jvm:1.7.3=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
org.jetbrains.kotlinx:kotlinx-serialization-core:1.7.3=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
org.jetbrains:annotations:13.0=composeMappingProducerClasspath,debugRuntimeClasspathCopy,kotlinAbiValidationCompatClasspath,kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kotlinCompilerPluginClasspathDebug,kotlinCompilerPluginClasspathDebugAndroidTest,kotlinCompilerPluginClasspathDebugUnitTest,kotlinCompilerPluginClasspathRelease,releaseRuntimeClasspathCopy
org.jetbrains:annotations:23.0.0=androidLintTool,debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath,unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-driver-instrumentation,unified-test-platform-android-test-plugin,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-android-test-plugin-result-listener-gradle,unified-test-platform-core,unified-test-platform-gradle-work-action,unified-test-platform-launcher
org.jspecify:jspecify:1.0.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
org.jvnet.staxex:stax-ex:1.8.1=androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle
org.mockito.kotlin:mockito-kotlin:6.3.0=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath
org.mockito:mockito-core:5.23.0=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath
org.nanohttpd:nanohttpd:2.3.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
org.objenesis:objenesis:3.3=debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath
org.ow2.asm:asm-analysis:9.9=androidLintTool
org.ow2.asm:asm-commons:9.8=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath
org.ow2.asm:asm-commons:9.9=androidLintTool
org.ow2.asm:asm-tree:9.8=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath
org.ow2.asm:asm-tree:9.9=androidLintTool
org.ow2.asm:asm-tree:9.9.1=composeMappingProducerClasspath
org.ow2.asm:asm:9.8=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath
org.ow2.asm:asm:9.9=androidLintTool
org.ow2.asm:asm:9.9.1=composeMappingProducerClasspath
org.robolectric:annotations:4.15=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath
org.robolectric:junit:4.15=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath
org.robolectric:nativeruntime-dist-compat:1.0.17=debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath
org.robolectric:nativeruntime:4.15=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath
org.robolectric:pluginapi:4.15=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath
org.robolectric:plugins-maven-dependency-resolver:4.15=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath
org.robolectric:resources:4.15=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath
org.robolectric:robolectric:4.15=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath
org.robolectric:sandbox:4.15=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath
org.robolectric:shadowapi:4.15=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath
org.robolectric:shadows-framework:4.15=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath
org.robolectric:utils-reflector:4.15=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath
org.robolectric:utils:4.15=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath
org.yaml:snakeyaml:2.4=debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath
empty=androidApis,androidJdkImage,androidTestUtil,coreLibraryDesugaring,debugAndroidTestAnnotationProcessorClasspath,debugAndroidTestImplementationDependenciesMetadata,debugAnnotationProcessorClasspath,debugImplementationDependenciesMetadata,debugReverseMetadataValues,debugUnitTestAnnotationProcessorClasspath,debugUnitTestImplementationDependenciesMetadata,kotlinCompilerPluginClasspath,lintChecks,lintPublish,releaseAnnotationProcessorClasspath,releaseImplementationDependenciesMetadata,releaseReverseMetadataValues

View File

@ -17,6 +17,13 @@
-keep class com.bitchat.android.nostr.** { *; }
-keep class com.bitchat.android.identity.** { *; }
# Room loads generated database implementations by name and invokes their no-argument
# constructors reflectively. R8 full-mode can otherwise optimize away WorkDatabase_Impl's
# constructor, causing AndroidX Startup to crash before Application.onCreate.
-keepclassmembers class * extends androidx.room.RoomDatabase {
<init>();
}
# Keep Tor implementation (always included)
-keep class com.bitchat.android.net.RealTorProvider { *; }

View File

@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<application>
<!-- Debug-only ADB test hook. Drives mesh operations (scan, connect,
handshake, DMs, files, broadcast, raw packets) from host scripts.
Exported intentionally so `adb shell am broadcast` can reach it;
never shipped in release builds. -->
<receiver
android:name="com.bitchat.android.testhook.TestHookReceiver"
android:exported="true"
tools:ignore="ExportedReceiver">
<intent-filter>
<action android:name="com.bitchat.droid.TEST_HOOK" />
</intent-filter>
</receiver>
</application>
</manifest>

View File

@ -0,0 +1,600 @@
package com.bitchat.android.testhook
import android.content.Context
import android.content.Intent
import android.util.Log
import com.bitchat.android.favorites.FavoritesPersistenceService
import com.bitchat.android.features.file.FileUtils
import com.bitchat.android.identity.SecureIdentityStateManager
import com.bitchat.android.mesh.MeshService
import com.bitchat.android.mesh.PrivateMediaPreparation
import com.bitchat.android.mesh.TransferProgressManager
import com.bitchat.android.model.BitchatFilePacket
import com.bitchat.android.model.RoutedPacket
import com.bitchat.android.noise.NoiseSession
import com.bitchat.android.protocol.BitchatPacket
import com.bitchat.android.service.MeshForegroundService
import com.bitchat.android.service.MeshServiceHolder
import com.bitchat.android.service.TransportBridgeService
import com.bitchat.android.services.AppStateStore
import com.bitchat.android.ui.DataManager
import com.bitchat.android.ui.PrivateMediaRecipientResolver
import com.bitchat.android.util.AppConstants
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
import kotlinx.coroutines.withTimeoutOrNull
import org.json.JSONArray
import org.json.JSONObject
import java.io.File
import java.security.MessageDigest
/**
* Headless engine behind [TestHookReceiver]. Drives the public [MeshService] API and
* observes state via [AppStateStore] flows (never touches the single-slot mesh delegate).
*/
object TestHookDriver {
private const val TAG = TestHookReceiver.TAG
private const val DEFAULT_SCAN_TIMEOUT_MS = 30_000L
private const val DEFAULT_CONNECT_TIMEOUT_MS = 30_000L
private const val DEFAULT_HANDSHAKE_TIMEOUT_MS = 30_000L
private const val DEFAULT_RECV_TIMEOUT_MS = 60_000L
private const val DEFAULT_FILE_TIMEOUT_MS = 180_000L
suspend fun execute(context: Context, cmd: String, intent: Intent): JSONObject {
Log.d(TAG, "execute cmd=$cmd")
val result = when (cmd) {
"ping" -> ok(cmd).put("pong", true).put("package", context.packageName)
"start" -> start(context)
"stop" -> stop(context)
"whoami" -> whoami(context)
"set_nickname" -> setNickname(context, intent.requiredString("name"))
"scan" -> scan(context, intent)
"peers" -> peers(context)
"connect" -> connect(intent.requiredString("peer"), intent)
"handshake" -> handshake(context, intent.requiredString("peer"), intent)
"session" -> session(context, intent.requiredString("peer"))
"announce" -> announce(context)
"broadcast_msg" -> broadcastMsg(context, intent.requiredString("content"), intent.getStringExtra("channel"))
"dm_send" -> dmSend(context, intent.requiredString("peer"), intent.requiredString("content"), intent.getStringExtra("msg_id"))
"dm_recv" -> dmRecv(context, intent)
"msg_recv" -> msgRecv(context, intent)
"favorite_set" -> favoriteSet(
context,
intent.requiredString("peer"),
intent.getBooleanExtra("enabled", true)
)
"favorite_status" -> favoriteStatus(context, intent.requiredString("peer"))
"verification_set" -> verificationSet(
context,
intent.requiredString("peer"),
intent.getBooleanExtra("enabled", true)
)
"verification_status" -> verificationStatus(context, intent.requiredString("peer"))
"file_send" -> fileSend(context, intent)
"file_recv" -> fileRecv(context, intent)
"file_cancel" -> fileCancel(context, intent.requiredString("transfer_id"))
"raw_send" -> rawSend(context, intent)
"ble" -> setBle(intent.getBooleanExtra("enabled", true))
"inject_peers" -> injectPeers(intent.getStringExtra("peers"))
"state" -> state(context)
"clear_results" -> clearResults(context)
else -> err(cmd, "unknown command: $cmd")
}
return result.put("cmd", cmd)
}
// MARK: - Lifecycle
private fun start(context: Context): JSONObject {
MeshForegroundService.start(context)
val mesh = mesh(context)
mesh.startServices()
return ok("start").put("peer_id", mesh.myPeerID)
}
private fun stop(context: Context): JSONObject {
try {
MeshServiceHolder.unifiedMeshService?.stopServices()
} catch (e: Exception) {
Log.w(TAG, "stopServices failed: ${e.message}")
}
MeshForegroundService.stop(context)
return ok("stop")
}
// MARK: - Identity
private fun whoami(context: Context): JSONObject {
val mesh = mesh(context)
return ok("whoami")
.put("peer_id", mesh.myPeerID)
.put("identity_fingerprint", mesh.getIdentityFingerprint())
.put("noise_public_key", mesh.getStaticNoisePublicKey()?.toHex())
.put("nickname", AppStateStore.nickname.value)
}
private fun setNickname(context: Context, name: String): JSONObject {
DataManager(context).saveNickname(name)
AppStateStore.setNickname(name)
mesh(context).sendBroadcastAnnounce()
return ok("set_nickname").put("nickname", name)
}
// MARK: - Discovery / connection
private suspend fun scan(context: Context, intent: Intent): JSONObject {
val timeoutMs = intent.getLongExtra("timeout_ms", DEFAULT_SCAN_TIMEOUT_MS)
val minPeers = intent.getIntExtra("min_peers", 1)
val mesh = mesh(context)
val found = withTimeoutOrNull(timeoutMs) {
AppStateStore.peers.first { it.size >= minPeers }
}
val peerIds = found ?: AppStateStore.peers.value
return ok("scan")
.put("reached_min_peers", found != null)
.put("peers", peerInfosJson(mesh, peerIds))
}
private fun peers(context: Context): JSONObject {
val mesh = mesh(context)
return ok("peers").put("peers", peerInfosJson(mesh, AppStateStore.peers.value))
}
/**
* Debug-only state injection for testing peer-list consumers such as notifications.
* A blank or missing comma-separated value restores the empty state.
*/
private fun injectPeers(commaSeparatedPeers: String?): JSONObject {
val peers = commaSeparatedPeers
.orEmpty()
.split(',')
.map(String::trim)
.filter(String::isNotEmpty)
.distinct()
AppStateStore.setPeers(peers)
return ok("inject_peers").put("peers", JSONArray(peers))
}
private suspend fun connect(peerID: String, intent: Intent): JSONObject {
val timeoutMs = intent.getLongExtra("timeout_ms", DEFAULT_CONNECT_TIMEOUT_MS)
val ble = MeshServiceHolder.meshService ?: return err("connect", "BLE service not running")
val address = ble.getDeviceAddressForPeer(peerID)
?: return err("connect", "no device address known for peer $peerID (scan first)")
val accepted = ble.connectionManager.connectToAddress(address)
if (!accepted) return err("connect", "connectToAddress($address) rejected")
val direct = withTimeoutOrNull(timeoutMs) {
AppStateStore.directPeers.first { it.contains(peerID) }
}
return ok("connect")
.put("peer", peerID)
.put("address", address)
.put("direct", direct != null)
}
// MARK: - Noise
private suspend fun handshake(context: Context, peerID: String, intent: Intent): JSONObject {
val timeoutMs = intent.getLongExtra("timeout_ms", DEFAULT_HANDSHAKE_TIMEOUT_MS)
val mesh = mesh(context)
val deadline = System.currentTimeMillis() + timeoutMs
if (!mesh.hasEstablishedSession(peerID)) {
mesh.initiateNoiseHandshake(peerID)
}
var lastState: NoiseSession.NoiseSessionState = NoiseSession.NoiseSessionState.Uninitialized
while (System.currentTimeMillis() < deadline) {
lastState = mesh.getSessionState(peerID)
when (lastState) {
is NoiseSession.NoiseSessionState.Established -> {
return ok("handshake")
.put("peer", peerID)
.put("state", lastState.toString())
.put("fingerprint", mesh.getPeerFingerprint(peerID))
}
is NoiseSession.NoiseSessionState.Failed -> {
return err("handshake", "session failed: $lastState").put("peer", peerID)
}
else -> delay(100)
}
}
return err("handshake", "timeout after ${timeoutMs}ms (last state: $lastState)").put("peer", peerID)
}
private fun session(context: Context, peerID: String): JSONObject {
val mesh = mesh(context)
return ok("session")
.put("peer", peerID)
.put("state", mesh.getSessionState(peerID).toString())
.put("established", mesh.hasEstablishedSession(peerID))
.put("fingerprint", mesh.getPeerFingerprint(peerID))
}
// MARK: - Messaging
private fun announce(context: Context): JSONObject {
mesh(context).sendBroadcastAnnounce()
return ok("announce")
}
private fun broadcastMsg(context: Context, content: String, channel: String?): JSONObject {
mesh(context).sendMessage(content, emptyList(), channel)
return ok("broadcast_msg").put("content", content).put("channel", channel)
}
private fun dmSend(context: Context, peerID: String, content: String, msgID: String?): JSONObject {
val mesh = mesh(context)
val nickname = mesh.getPeerNicknames()[peerID] ?: peerID
val id = msgID ?: "testhook-${System.currentTimeMillis()}"
mesh.sendPrivateMessage(content, peerID, nickname, id)
return ok("dm_send").put("peer", peerID).put("msg_id", id)
}
private suspend fun dmRecv(context: Context, intent: Intent): JSONObject {
val timeoutMs = intent.getLongExtra("timeout_ms", DEFAULT_RECV_TIMEOUT_MS)
val fromPeer = intent.getStringExtra("peer")
val contains = intent.getStringExtra("contains")
val startTime = System.currentTimeMillis()
val mesh = mesh(context)
val match = withTimeoutOrNull(timeoutMs) {
AppStateStore.privateMessages.first { conversations ->
conversations.values.flatten().any { msg ->
msg.timestamp.time >= startTime &&
msg.senderPeerID != mesh.myPeerID &&
(fromPeer == null || msg.senderPeerID == fromPeer) &&
(contains == null || msg.content.contains(contains))
}
}
} ?: return err("dm_recv", "timeout after ${timeoutMs}ms")
val msg = match.values.flatten().first { msg ->
msg.timestamp.time >= startTime &&
msg.senderPeerID != mesh.myPeerID &&
(fromPeer == null || msg.senderPeerID == fromPeer) &&
(contains == null || msg.content.contains(contains))
}
return ok("dm_recv")
.put("from", msg.senderPeerID)
.put("sender", msg.sender)
.put("content", msg.content)
.put("msg_id", msg.id)
}
private suspend fun msgRecv(context: Context, intent: Intent): JSONObject {
val timeoutMs = intent.getLongExtra("timeout_ms", DEFAULT_RECV_TIMEOUT_MS)
val contains = intent.getStringExtra("contains")
val channel = intent.getStringExtra("channel")
val startTime = System.currentTimeMillis()
val mesh = mesh(context)
val matches: (com.bitchat.android.model.BitchatMessage) -> Boolean = { msg ->
msg.timestamp.time >= startTime &&
msg.senderPeerID != mesh.myPeerID &&
(contains == null || msg.content.contains(contains)) &&
(channel == null || msg.channel == channel)
}
val found = withTimeoutOrNull(timeoutMs) {
if (channel != null) {
AppStateStore.channelMessages.first { m -> m.values.flatten().any(matches) }
.values.flatten().first(matches)
} else {
AppStateStore.publicMessages.first { l -> l.any(matches) }.first(matches)
}
} ?: return err("msg_recv", "timeout after ${timeoutMs}ms")
return ok("msg_recv")
.put("from", found.senderPeerID)
.put("sender", found.sender)
.put("content", found.content)
.put("channel", found.channel)
.put("msg_id", found.id)
}
// MARK: - Favorite and verification state
private fun favoriteSet(context: Context, peerID: String, enabled: Boolean): JSONObject {
val mesh = mesh(context)
val peerInfo = mesh.getPeerInfo(peerID)
?: return err("favorite_set", "peer is not known")
val noisePublicKey = peerInfo.noisePublicKey
?: return err("favorite_set", "peer Noise key is unavailable")
FavoritesPersistenceService.initialize(context)
FavoritesPersistenceService.shared.updateFavoriteStatus(
noisePublicKey = noisePublicKey,
nickname = peerInfo.nickname,
isFavorite = enabled
)
mesh.sendFavoriteNotification(peerID, enabled)
return favoriteStatus(context, peerID)
}
private fun favoriteStatus(context: Context, peerID: String): JSONObject {
val mesh = mesh(context)
FavoritesPersistenceService.initialize(context)
val relationship = FavoritesPersistenceService.shared.getFavoriteStatus(peerID)
?: mesh.getPeerInfo(peerID)?.noisePublicKey?.let {
FavoritesPersistenceService.shared.getFavoriteStatus(it)
}
val isFavorite = relationship?.isFavorite == true
val theyFavoritedUs = relationship?.theyFavoritedUs == true
return ok("favorite_status")
.put("peer", peerID)
.put("is_favorite", isFavorite)
.put("they_favorited_us", theyFavoritedUs)
.put("is_mutual", isFavorite && theyFavoritedUs)
.put(
"star_state",
when {
isFavorite -> "filled"
theyFavoritedUs -> "outlined_orange"
else -> "outlined"
}
)
}
private fun verificationSet(context: Context, peerID: String, enabled: Boolean): JSONObject {
val mesh = mesh(context)
val fingerprint = mesh.getPeerFingerprint(peerID)
?: return err("verification_set", "peer fingerprint is unavailable")
SecureIdentityStateManager(context).setVerifiedFingerprint(fingerprint, enabled)
return verificationStatus(context, peerID)
}
private fun verificationStatus(context: Context, peerID: String): JSONObject {
val fingerprint = mesh(context).getPeerFingerprint(peerID)
val verified = fingerprint != null &&
SecureIdentityStateManager(context).getVerifiedFingerprints().any {
it.equals(fingerprint, ignoreCase = true)
}
return ok("verification_status")
.put("peer", peerID)
.put("fingerprint", fingerprint ?: JSONObject.NULL)
.put("verified", verified)
}
// MARK: - File transfer
private suspend fun fileSend(context: Context, intent: Intent): JSONObject {
val path = intent.requiredString("path")
val peerID = intent.getStringExtra("peer")
val timeoutMs = intent.getLongExtra("timeout_ms", DEFAULT_FILE_TIMEOUT_MS)
val mesh = mesh(context)
val file = File(path)
if (!file.isFile) return err("file_send", "file not found: $path")
val content = withContext(Dispatchers.IO) { file.readBytes() }
if (content.size.toLong() > AppConstants.Media.MAX_FILE_SIZE_BYTES) {
return err("file_send", "file too large: ${content.size} > ${AppConstants.Media.MAX_FILE_SIZE_BYTES}")
}
val packet = BitchatFilePacket(
fileName = file.name,
fileSize = content.size.toLong(),
mimeType = intent.getStringExtra("mime") ?: FileUtils.getMimeTypeFromExtension(file.name),
content = content
)
val encoded = packet.encode() ?: return err("file_send", "failed to TLV-encode packet")
val transferId = sha256Hex(encoded)
val recipient = peerID?.let {
PrivateMediaRecipientResolver.resolve(it, mesh)
?: return err("file_send", "no active mesh route for private conversation: $it")
}
return coroutineScope {
// Subscribe on a background dispatcher before sending so synchronous
// failure events are not missed (SharedFlow has replay=0).
val completion = async(Dispatchers.Default) {
TransferProgressManager.events.first { it.transferId == transferId && it.completed }
}
delay(50)
val sendError = dispatchFileSend(
context,
intent,
mesh,
recipient?.meshPeerID,
packet,
transferId
)
if (sendError != null) {
completion.cancel()
return@coroutineScope sendError.put("cmd", "file_send")
}
val event = withTimeoutOrNull(timeoutMs) { completion.await() }
?: return@coroutineScope err("file_send", "timeout waiting for transfer completion ($transferId)")
if (event.failed) {
return@coroutineScope err("file_send", "transfer rejected/failed before send ($transferId)")
.put("transfer_id", transferId)
}
ok("file_send")
.put("transfer_id", transferId)
.put("sent", event.sent)
.put("total", event.total)
.put("bytes", content.size)
.put("peer", recipient?.meshPeerID)
.put("conversation", peerID)
}
}
private suspend fun dispatchFileSend(
context: Context,
intent: Intent,
mesh: MeshService,
peerID: String?,
packet: BitchatFilePacket,
transferId: String
): JSONObject? {
if (peerID == null) {
mesh.sendFileBroadcast(packet)
return null
}
if (!mesh.hasEstablishedSession(peerID)) {
val hs = handshake(context, peerID, intent)
if (hs.optString("status") != "ok") return hs
}
// Peer state (capabilities/identity) can lag session establishment;
// retry transient preparation states before giving up.
val prepDeadline = System.currentTimeMillis() + 30_000
while (true) {
when (val prep = mesh.prepareFilePrivate(peerID, packet, transferId, allowLegacyFallback = false)) {
is PrivateMediaPreparation.Ready -> {
return if (prep.transfer.commit()) null else err("file_send", "private transfer commit failed")
}
PrivateMediaPreparation.AwaitingPeerState,
PrivateMediaPreparation.NeedsHandshake -> {
if (System.currentTimeMillis() >= prepDeadline) {
return err("file_send", "private media preparation stuck at: $prep")
}
if (prep == PrivateMediaPreparation.NeedsHandshake) {
mesh.initiateNoiseHandshake(peerID)
}
delay(500)
}
else -> return err("file_send", "private media preparation: $prep")
}
}
}
private suspend fun fileRecv(context: Context, intent: Intent): JSONObject {
val timeoutMs = intent.getLongExtra("timeout_ms", DEFAULT_FILE_TIMEOUT_MS)
val nameContains = intent.getStringExtra("name_contains")
val startTime = System.currentTimeMillis()
val dirs = listOf(
File(context.cacheDir, "files/incoming"),
File(context.cacheDir, "images/incoming")
)
val deadline = System.currentTimeMillis() + timeoutMs
while (System.currentTimeMillis() < deadline) {
val candidate = dirs
.flatMap { it.listFiles()?.toList() ?: emptyList() }
.filter { it.lastModified() >= startTime - 5_000 }
.filter { nameContains == null || it.name.contains(nameContains) }
.maxByOrNull { it.lastModified() }
if (candidate != null) {
val size1 = candidate.length()
delay(500)
if (candidate.length() == size1 && size1 > 0) {
return ok("file_recv")
.put("path", candidate.absolutePath)
.put("name", candidate.name)
.put("bytes", size1)
.put("sha256", withContext(Dispatchers.IO) { sha256Hex(candidate.readBytes()) })
}
}
delay(250)
}
return err("file_recv", "timeout after ${timeoutMs}ms")
}
private fun fileCancel(context: Context, transferId: String): JSONObject {
val cancelled = mesh(context).cancelFileTransfer(transferId)
return ok("file_cancel").put("transfer_id", transferId).put("cancelled", cancelled)
}
// MARK: - Raw packet injection
private fun rawSend(context: Context, intent: Intent): JSONObject {
val payloadHex = intent.requiredString("payload_hex")
val typeStr = intent.requiredString("type")
val peerID = intent.getStringExtra("peer")
val ttl = intent.getIntExtra("ttl", 7)
val type = typeStr.toUIntOrNull(16)?.toUByte()
?: return err("raw_send", "invalid type hex: $typeStr")
val payload = hexToBytes(payloadHex)
?: return err("raw_send", "invalid payload_hex")
val mesh = mesh(context)
val packet = BitchatPacket(
type = type,
ttl = ttl.toUByte(),
senderID = mesh.myPeerID,
payload = payload
)
if (peerID != null) {
TransportBridgeService.sendToPeerFromLocal(peerID, packet)
} else {
TransportBridgeService.broadcastFromLocal(RoutedPacket(packet))
}
return ok("raw_send")
.put("type", typeStr)
.put("payload_bytes", payload.size)
.put("peer", peerID)
}
// MARK: - Transport / state
private fun setBle(enabled: Boolean): JSONObject {
val ble = MeshServiceHolder.meshService ?: return err("ble", "BLE service not running")
ble.setBleTransportEnabled(enabled)
return ok("ble").put("enabled", enabled)
}
private fun state(context: Context): JSONObject {
val mesh = mesh(context)
val peersJson = peerInfosJson(mesh, AppStateStore.peers.value)
val sessions = JSONObject()
AppStateStore.peers.value.forEach { peerID ->
sessions.put(peerID, mesh.getSessionState(peerID).toString())
}
return ok("state")
.put("peer_id", mesh.myPeerID)
.put("nickname", AppStateStore.nickname.value)
.put("peers", peersJson)
.put("direct_peers", JSONArray(AppStateStore.directPeers.value.toList()))
.put("sessions", sessions)
.put("device_map", JSONObject(mesh.getDeviceAddressToPeerMapping() as Map<*, *>))
.put("debug_status", mesh.getDebugStatus())
}
private fun clearResults(context: Context): JSONObject {
val dir = File(context.cacheDir, "testhook/results")
val count = dir.listFiles()?.count { it.delete() } ?: 0
return ok("clear_results").put("deleted", count)
}
// MARK: - Helpers
private fun mesh(context: Context): MeshService = MeshServiceHolder.getUnifiedOrCreate(context)
private fun peerInfosJson(mesh: MeshService, peerIds: List<String>): JSONArray {
val nicknames = mesh.getPeerNicknames()
val rssi = mesh.getPeerRSSI()
val arr = JSONArray()
peerIds.forEach { id ->
val info = mesh.getPeerInfo(id)
arr.put(JSONObject()
.put("id", id)
.put("nickname", nicknames[id] ?: info?.nickname)
.put("rssi", rssi[id])
.put("direct", AppStateStore.directPeers.value.contains(id))
.put("connected", info?.isConnected)
.put("last_seen", info?.lastSeen)
.put("session", mesh.getSessionState(id).toString())
.put("fingerprint", mesh.getPeerFingerprint(id)))
}
return arr
}
private fun ok(cmd: String) = JSONObject().put("status", "ok").put("cmd", cmd)
private fun err(cmd: String, message: String) =
JSONObject().put("status", "error").put("cmd", cmd).put("error", message)
private fun Intent.requiredString(name: String): String =
getStringExtra(name) ?: throw IllegalArgumentException("missing required extra: $name")
private fun sha256Hex(data: ByteArray): String =
MessageDigest.getInstance("SHA-256").digest(data).toHex()
private fun ByteArray.toHex(): String = joinToString("") { "%02x".format(it) }
private fun hexToBytes(hex: String): ByteArray? {
val clean = hex.replace(" ", "")
if (clean.length % 2 != 0) return null
return try {
ByteArray(clean.length / 2) { i ->
clean.substring(i * 2, i * 2 + 2).toInt(16).toByte()
}
} catch (e: Exception) {
null
}
}
}

View File

@ -0,0 +1,65 @@
package com.bitchat.android.testhook
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.util.Log
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
import org.json.JSONObject
import java.io.File
/**
* ADB-drivable test hook (debug builds only).
*
* Usage:
* adb shell am broadcast -a com.bitchat.droid.TEST_HOOK \
* --es cmd <command> --es id <cmd-id> [command extras...]
*
* Result is written to cache/testhook/results/<id>.json and logged under tag TestHook:
* adb shell run-as com.bitchat.droid cat cache/testhook/results/<id>.json
*/
class TestHookReceiver : BroadcastReceiver() {
companion object {
const val TAG = "TestHook"
const val ACTION = "com.bitchat.droid.TEST_HOOK"
private const val DEFAULT_OVERALL_TIMEOUT_MS = 180_000L
}
override fun onReceive(context: Context, intent: Intent) {
if (intent.action != ACTION) return
val cmd = intent.getStringExtra("cmd") ?: "ping"
val id = intent.getStringExtra("id") ?: "cmd-${System.currentTimeMillis()}"
val overallTimeout = intent.getLongExtra("overall_timeout_ms", DEFAULT_OVERALL_TIMEOUT_MS)
Log.i(TAG, "CMD id=$id cmd=$cmd")
val pendingResult = goAsync()
Thread {
val result = try {
runBlocking {
withTimeout(overallTimeout) {
TestHookDriver.execute(context.applicationContext, cmd, intent)
}
}
} catch (e: Exception) {
JSONObject()
.put("status", "error")
.put("cmd", cmd)
.put("error", "${e.javaClass.simpleName}: ${e.message}")
}
try {
val dir = File(context.cacheDir, "testhook/results").apply { mkdirs() }
File(dir, "$id.json").writeText(result.toString())
} catch (e: Exception) {
Log.e(TAG, "Failed to write result file for $id: ${e.message}")
}
Log.i(TAG, "RESULT id=$id $result")
}.start()
// Finish immediately: long-running commands continue on the worker thread and
// report via the result file. Holding the broadcast open past the system
// broadcast window would ANR the app.
pendingResult.finish()
}
}

View File

@ -80,11 +80,21 @@
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:localeConfig="@xml/locales_config"
android:roundIcon="@mipmap/ic_launcher_round"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@style/Theme.BitchatAndroid"
tools:targetApi="31">
tools:targetApi="33">
<service
android:name="androidx.appcompat.app.AppLocalesMetadataHolderService"
android:enabled="false"
android:exported="false">
<meta-data
android:name="autoStoreLocales"
android:value="true" />
</service>
<!-- FileProvider for sharing temp/cache files with external viewers -->
<provider
android:name="androidx.core.content.FileProvider"
@ -127,15 +137,10 @@
tools:ignore="DataExtractionRules">
</service>
<!-- Listen for in-app broadcast when POST_NOTIFICATIONS is granted -->
<receiver
android:name=".service.NotificationPermissionChangedReceiver"
android:name=".service.ConversationNotificationReceiver"
android:enabled="true"
android:exported="false">
<intent-filter>
<action android:name="com.bitchat.android.action.NOTIFICATION_PERMISSION_GRANTED" />
</intent-filter>
</receiver>
android:exported="false" />
<!-- Auto-start mesh service after boot if enabled -->
<receiver

View File

@ -1,225 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<style>
:root { --text: #333; }
html, body, #map { height: 100%; margin: 0; padding: 0; background: #ffffff; }
.leaflet-container { background: #ffffff; }
.leaflet-div-icon { background: transparent; border: none; }
.gh-label { background: transparent; border: none; pointer-events: none; filter: none; }
.gh-text {
color: #444444;
font-weight: 700;
font-size: 14px;
line-height: 1;
text-shadow: 0 0 2px #ffffff, 0 0 2px #ffffff, 0 0 2px #ffffff;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
}
.dark .gh-text {
color: #dddddd;
text-shadow: 0 0 2px #000000, 0 0 2px #000000, 0 0 2px #000000;
}
.gh-text-selected {
color: #00C851 !important;
}
</style>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
</head>
<body>
<div id="map"></div>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script>
// Minimal geohash (bounds/encode/adjacent)
(function () {
const base32 = "0123456789bcdefghjkmnpqrstuvwxyz";
function bounds(geohash) {
let evenBit = true; let latMin = -90, latMax = 90, lonMin = -180, lonMax = 180;
geohash = geohash.toLowerCase();
for (let i = 0; i < geohash.length; i++) {
const idx = base32.indexOf(geohash.charAt(i));
if (idx == -1) throw new Error("Invalid geohash");
for (let n = 4; n >= 0; n--) {
const bitN = (idx >> n) & 1;
if (evenBit) { const lonMid = (lonMin + lonMax) / 2; if (bitN == 1) lonMin = lonMid; else lonMax = lonMid; }
else { const latMid = (latMin + latMax) / 2; if (bitN == 1) latMin = latMid; else latMax = latMid; }
evenBit = !evenBit;
}
}
return { sw: { lat: latMin, lng: lonMin }, ne: { lat: latMax, lng: lonMax } };
}
function encode(lat, lon, precision) {
let idx = 0, bit = 0, evenBit = true, hash = "";
let latMin = -90, latMax = 90, lonMin = -180, lonMax = 180;
while (hash.length < precision) {
if (evenBit) { const lonMid = (lonMin + lonMax) / 2; if (lon >= lonMid) { idx = idx * 2 + 1; lonMin = lonMid; } else { idx = idx * 2; lonMax = lonMid; } }
else { const latMid = (latMin + latMax) / 2; if (lat >= latMid) { idx = idx * 2 + 1; latMin = latMid; } else { idx = idx * 2; latMax = latMid; } }
evenBit = !evenBit; if (++bit == 5) { hash += base32.charAt(idx); bit = 0; idx = 0; }
}
return hash;
}
function adjacent(hash, dir) {
const neighbour = { n:["p0r21436x8zb9dcf5h7kjnmqesgutwvy","bc01fg45238967deuvhjyznpkmstqrwx"], s:["14365h7k9dcfesgujnmqp0r2twvyx8zb","238967debc01fg45kmstqrwxuvhjyznp"], e:["bc01fg45238967deuvhjyznpkmstqrwx","p0r21436x8zb9dcf5h7kjnmqesgutwvy"], w:["238967debc01fg45kmstqrwxuvhjyznp","14365h7k9dcfesgujnmqp0r2twvyx8zb"] };
const border = { n:["prxz","bcfguvyz"], s:["028b","0145hjnp"], e:["bcfguvyz","prxz"], w:["0145hjnp","028b"] };
hash = hash.toLowerCase(); const lastCh = hash.slice(-1); let parent = hash.slice(0, -1); const type = hash.length % 2;
if (border[dir][type].indexOf(lastCh) != -1 && parent != "") parent = adjacent(parent, dir);
return parent + base32.charAt(neighbour[dir][type].indexOf(lastCh));
}
window.__geohash = { bounds, encode, adjacent };
})();
const map = L.map("map", { zoomControl: true, minZoom: 2, maxZoom: 21 }).setView([0, 0], 3);
L.tileLayer("https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}.png", { maxZoom: 21, attribution: "&copy; OpenStreetMap &copy; Carto", opacity: 1.0 }).addTo(map);
let selectedGeohash = "";
let gridLayer = L.layerGroup().addTo(map);
let pinnedPrecision = null;
let outlineColor = "#00C851";
function getNeighbors(hash) {
const neighbors = [];
// N, S, E, W
neighbors.push(window.__geohash.adjacent(hash, 'n'));
neighbors.push(window.__geohash.adjacent(hash, 's'));
neighbors.push(window.__geohash.adjacent(hash, 'e'));
neighbors.push(window.__geohash.adjacent(hash, 'w'));
// Diagonals
neighbors.push(window.__geohash.adjacent(window.__geohash.adjacent(hash, 'n'), 'e'));
neighbors.push(window.__geohash.adjacent(window.__geohash.adjacent(hash, 'n'), 'w'));
neighbors.push(window.__geohash.adjacent(window.__geohash.adjacent(hash, 's'), 'e'));
neighbors.push(window.__geohash.adjacent(window.__geohash.adjacent(hash, 's'), 'w'));
return neighbors;
}
function pickPrecisionForViewport() {
const c = map.getCenter();
const minPx = 80;
const maxPx = 240;
let chosen = 1;
let lastAboveMin = 1;
for (let p = 1; p <= 12; p++) {
const gh = window.__geohash.encode(c.lat, c.lng, p);
const b = window.__geohash.bounds(gh);
const pSw = map.latLngToLayerPoint([b.sw.lat, b.sw.lng]);
const pNe = map.latLngToLayerPoint([b.ne.lat, b.ne.lng]);
const cellPx = Math.min(Math.abs(pNe.x - pSw.x), Math.abs(pSw.y - pNe.y));
if (cellPx >= minPx && cellPx <= maxPx) { chosen = p; break; }
if (cellPx >= minPx) { lastAboveMin = p; }
if (cellPx < minPx) { chosen = lastAboveMin; break; }
if (p === 12) { chosen = 12; }
}
return chosen;
}
function notifySelection() {
if (window.Android && window.Android.onGeohashChanged && selectedGeohash) {
window.Android.onGeohashChanged(selectedGeohash);
}
}
function zoomForPrecision(p) {
if (p <= 1) return 1; if (p === 2) return 2; if (p === 3) return 3; if (p === 4) return 4;
if (p === 5) return 5; if (p === 6) return 7; if (p === 7) return 9; if (p === 8) return 11;
if (p === 9) return 13; if (p === 10) return 15; if (p === 11) return 17;
return 18;
}
function updateOverlay() {
gridLayer.clearLayers();
const c = map.getCenter();
const usePinned = pinnedPrecision !== null;
const p = usePinned ? pinnedPrecision : pickPrecisionForViewport();
selectedGeohash = window.__geohash.encode(c.lat, c.lng, p);
notifySelection();
const centerBounds = window.__geohash.bounds(selectedGeohash);
const centerLon = (centerBounds.sw.lng + centerBounds.ne.lng) / 2;
const centerLat = (centerBounds.sw.lat + centerBounds.ne.lat) / 2;
const allHashes = [selectedGeohash, ...getNeighbors(selectedGeohash)];
const filteredHashes = allHashes.filter(gh => {
if (!gh) return false;
try {
const b = window.__geohash.bounds(gh);
const lon = (b.sw.lng + b.ne.lng) / 2;
const lat = (b.sw.lat + b.ne.lat) / 2;
if (Math.abs(lon - centerLon) > 180) return false; // anti-meridian wrap
if (Math.abs(lat - centerLat) > 90) return false; // pole wrap
return true;
} catch (e) { return false; }
});
filteredHashes.forEach(gh => {
const b = window.__geohash.bounds(gh);
const sw = [b.sw.lat, b.sw.lng];
const ne = [b.ne.lat, b.ne.lng];
const isSelected = (gh === selectedGeohash);
const rect = L.rectangle([sw, ne], {
color: isSelected ? outlineColor : '#cccccc',
weight: isSelected ? 3 : 1,
fillOpacity: 0.0,
opacity: 0.9,
interactive: false
});
gridLayer.addLayer(rect);
const center = [(b.sw.lat + b.ne.lat) / 2, (b.sw.lng + b.ne.lng) / 2];
const labelClass = isSelected ? 'gh-text gh-text-selected' : 'gh-text';
const label = L.marker(center, {
icon: L.divIcon({
className: 'gh-label',
html: `<span class="${labelClass}">${gh}</span>`
}),
interactive: false
});
gridLayer.addLayer(label);
});
}
map.on("movestart", () => { pinnedPrecision = null; });
map.on("zoomstart", () => { pinnedPrecision = null; });
map.on("moveend", updateOverlay);
map.on("zoomend", updateOverlay);
function setCenter(lat, lng) { map.setView([lat, lng], map.getZoom()); }
function setPrecision(p) {
const clamped = Math.max(1, Math.min(12, p|0));
const targetZoom = zoomForPrecision(clamped);
map.setZoom(targetZoom);
}
function focusGeohash(gh) {
if (!gh || typeof gh !== 'string') return;
const g = gh.toLowerCase();
const b = window.__geohash.bounds(g);
pinnedPrecision = g.length;
map.fitBounds([[b.sw.lat, b.sw.lng],[b.ne.lat, b.ne.lng]], { animate: false, padding: [8,8] });
selectedGeohash = g;
}
function getGeohash() { return selectedGeohash; }
// Android side will call this with 'dark' or 'light'
function setMapTheme(theme) {
document.body.className = theme;
}
window.setCenter = setCenter;
window.setPrecision = setPrecision;
window.focusGeohash = focusGeohash;
window.getGeohash = getGeohash;
window.setMapTheme = setMapTheme;
function cleanup() {
try { map.off(); } catch (_) {}
try { gridLayer.clearLayers(); } catch (_) {}
try { map.remove(); } catch (_) {}
}
window.cleanup = cleanup;
map.whenReady(updateOverlay);
</script>
</body>
</html>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -33,6 +33,13 @@ class BitchatApplication : Application() {
com.bitchat.android.favorites.FavoritesPersistenceService.initialize(this)
} catch (_: Exception) { }
// Restore private conversations before background transports can deliver new messages.
// AppStateStore merges any in-flight arrivals by message ID, so startup cannot replace
// newer transport state with an older database snapshot.
try {
com.bitchat.android.services.AppStateStore.initializeConversationPersistence(this)
} catch (_: Exception) { }
// Warm up Nostr identity to ensure npub is available for favorite notifications
try {
com.bitchat.android.nostr.NostrIdentityBridge.getCurrentNostrIdentity(this)

View File

@ -428,6 +428,8 @@ class MainActivity : OrientationAwareActivity() {
if (permissionManager.isFirstTimeLaunch()) {
mainViewModel.updateOnboardingState(OnboardingState.PERMISSION_EXPLANATION)
} else if (permissionManager.getUnrequestedOptionalPermissions().isNotEmpty()) {
mainViewModel.updateOnboardingState(OnboardingState.PERMISSION_EXPLANATION)
} else if (permissionManager.areRequiredPermissionsGranted()) {
if (permissionManager.needsBackgroundLocationPermission() &&
!permissionManager.isBackgroundLocationGranted() &&

View File

@ -48,6 +48,25 @@ data class FavoriteRelationship(
}
}
internal fun FavoriteRelationship?.withPeerFavoritedUs(
noisePublicKey: ByteArray,
theyFavoritedUs: Boolean,
now: Date = Date()
): FavoriteRelationship {
return this?.copy(
theyFavoritedUs = theyFavoritedUs,
lastUpdated = now
) ?: FavoriteRelationship(
peerNoisePublicKey = noisePublicKey,
peerNostrPublicKey = null,
peerNickname = "Unknown",
isFavorite = false,
theyFavoritedUs = theyFavoritedUs,
favoritedAt = now,
lastUpdated = now
)
}
interface FavoritesChangeListener {
fun onFavoriteChanged(noiseKeyHex: String)
fun onAllCleared()
@ -167,6 +186,7 @@ class FavoritesPersistenceService private constructor(private val context: Conte
if (ContactIdentityResolver.isMeshPeerId(pid)) {
peerIdIndex[pid] = normalizedNpub
savePeerIdIndex()
notifyChanged(pid)
Log.d(TAG, "Indexed npub for peerID ${pid.take(8)}")
} else {
Log.w(TAG, "updateNostrPublicKeyForPeerID called with non-16hex peerID: $peerID")
@ -233,18 +253,13 @@ class FavoritesPersistenceService private constructor(private val context: Conte
fun updatePeerFavoritedUs(noisePublicKey: ByteArray, theyFavoritedUs: Boolean) {
val keyHex = ContactIdentityResolver.noiseKeyHex(noisePublicKey)
val existing = favorites[keyHex]
val updated = existing.withPeerFavoritedUs(noisePublicKey, theyFavoritedUs)
if (existing != null) {
val updated = existing.copy(
theyFavoritedUs = theyFavoritedUs,
lastUpdated = Date()
)
favorites[keyHex] = updated
saveFavorites()
notifyChanged(keyHex)
favorites[keyHex] = updated
saveFavorites()
notifyChanged(keyHex)
Log.d(TAG, "Updated peer favorited us for ${keyHex.take(16)}...: $theyFavoritedUs")
}
Log.d(TAG, "Updated peer favorited us for ${keyHex.take(16)}...: $theyFavoritedUs")
}
fun getMutualFavorites(): List<FavoriteRelationship> = favorites.values.filter { it.isMutual }

View File

@ -5,6 +5,8 @@ import android.net.Uri
import android.os.Environment
import android.util.Log
import androidx.core.content.FileProvider
import com.bitchat.android.model.BitchatMessage
import com.bitchat.android.model.BitchatMessageType
import java.io.File
import java.io.FileOutputStream
import java.io.InputStream
@ -323,4 +325,80 @@ object FileUtils {
Log.e(TAG, "Failed to clear media files", e)
}
}
/**
* Delete app-owned media referenced only by the conversation being removed.
*
* Canonical-path checks prevent message content from turning this into an arbitrary-file
* deletion primitive. Shared paths remain intact while any retained message still references
* them.
*/
fun deleteConversationMedia(
context: Context,
deletedMessages: Collection<BitchatMessage>,
retainedMessages: Collection<BitchatMessage>
) {
val mediaTypes = setOf(
BitchatMessageType.Audio,
BitchatMessageType.Image,
BitchatMessageType.File
)
val roots = listOf(context.filesDir, context.cacheDir)
.mapNotNull { runCatching { it.canonicalFile }.getOrNull() }
val retainedPaths = retainedMessages
.asSequence()
.filter { it.type in mediaTypes }
.mapNotNull { message ->
runCatching { File(message.content.trim()).canonicalPath }.getOrNull()
}
.toSet()
deletedMessages
.asSequence()
.filter { it.type in mediaTypes }
.mapNotNull { message ->
runCatching { File(message.content.trim()).canonicalFile }.getOrNull()
}
.distinctBy(File::getPath)
.filter { file ->
file.path !in retainedPaths &&
roots.any { root ->
file.path == root.path ||
file.path.startsWith(root.path + File.separator)
}
}
.forEach { file ->
runCatching {
if (file.isFile && !file.delete()) {
Log.w(TAG, "Unable to delete conversation media")
}
}
}
}
/**
* Deletes paths proven by the conversation database to have no remaining message reference.
* The same canonical-root boundary as explicit conversation deletion prevents arbitrary paths
* from being removed even if persisted metadata is malformed.
*/
fun deleteStoredMediaPaths(context: Context, paths: Collection<String>) {
val roots = listOf(context.filesDir, context.cacheDir)
.mapNotNull { runCatching { it.canonicalFile }.getOrNull() }
paths.asSequence()
.mapNotNull { runCatching { File(it).canonicalFile }.getOrNull() }
.distinctBy(File::getPath)
.filter { file ->
roots.any { root ->
file.path == root.path ||
file.path.startsWith(root.path + File.separator)
}
}
.forEach { file ->
runCatching {
if (file.isFile && !file.delete()) {
Log.w(TAG, "Unable to delete unreferenced conversation media")
}
}
}
}
}

View File

@ -1,6 +1,5 @@
package com.bitchat.android.hotspot
import android.Manifest
import android.content.Intent
import android.graphics.Bitmap
import android.os.Build
@ -39,9 +38,7 @@ import com.bitchat.android.ui.theme.BitchatFontFamily
import com.bitchat.android.ui.theme.BitchatTheme
import com.bitchat.android.util.UniversalApkManager
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberPermissionState
import com.google.accompanist.permissions.shouldShowRationale
import com.google.accompanist.permissions.rememberMultiplePermissionsState
import java.io.File
/**
@ -158,18 +155,10 @@ fun HotspotScreen(
@OptIn(ExperimentalPermissionsApi::class)
@Composable
fun IntroScreen(onStartHotspot: () -> Unit) {
// Determine which permission to request based on Android version
val requiredPermission = when {
Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU -> Manifest.permission.NEARBY_WIFI_DEVICES
Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q -> Manifest.permission.ACCESS_FINE_LOCATION
else -> null // No runtime permission needed on Android < 10
}
val permissionState = requiredPermission?.let {
rememberPermissionState(it) { granted ->
if (granted) {
onStartHotspot()
}
val requiredPermissions = remember { HotspotPermissions.requiredForSdk() }
val permissionState = rememberMultiplePermissionsState(requiredPermissions) { results ->
if (requiredPermissions.all { results[it] == true }) {
onStartHotspot()
}
}
@ -219,7 +208,7 @@ fun IntroScreen(onStartHotspot: () -> Unit) {
}
// Permission rationale (if needed)
if (permissionState != null && !permissionState.status.isGranted && permissionState.status.shouldShowRationale) {
if (!permissionState.allPermissionsGranted && permissionState.shouldShowRationale) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
@ -237,10 +226,13 @@ fun IntroScreen(onStartHotspot: () -> Unit) {
color = MaterialTheme.colorScheme.onPrimaryContainer
)
Text(
text = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
"BitChat needs nearby devices permission to create a Wi-Fi hotspot for sharing the app offline."
} else {
"BitChat needs location permission to create a Wi-Fi hotspot. This is required by Android for Wi-Fi scanning, but no location data is collected."
text = when {
Build.VERSION.SDK_INT >= HotspotPermissions.ANDROID_17_API_LEVEL ->
"BitChat needs nearby devices and local network access to create a Wi-Fi hotspot and serve the app to connected devices."
Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU ->
"BitChat needs nearby devices permission to create a Wi-Fi hotspot for sharing the app offline."
else ->
"BitChat needs location permission to create a Wi-Fi hotspot. This is required by Android for Wi-Fi scanning, but no location data is collected."
},
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.8f)
@ -278,12 +270,12 @@ fun IntroScreen(onStartHotspot: () -> Unit) {
Button(
onClick = {
// Check permission before starting hotspot
if (permissionState == null || permissionState.status.isGranted) {
if (permissionState.allPermissionsGranted) {
// No permission needed or already granted
onStartHotspot()
} else {
// Request permission (auto-start handled by onPermissionResult callback)
permissionState.launchPermissionRequest()
permissionState.launchMultiplePermissionRequest()
}
},
modifier = Modifier

View File

@ -6,7 +6,6 @@ import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.pm.PackageManager
import android.net.wifi.p2p.WifiP2pConfig
import android.net.wifi.p2p.WifiP2pGroup
import android.net.wifi.p2p.WifiP2pManager
@ -16,7 +15,6 @@ import android.os.Handler
import android.os.Looper
import android.os.PowerManager
import android.util.Log
import androidx.core.content.ContextCompat
import java.net.NetworkInterface
import java.security.SecureRandom
import kotlin.random.Random
@ -30,10 +28,6 @@ class HotspotManager(private val context: Context) {
companion object {
private const val TAG = "HotspotMgr"
// Retry configuration
private const val MAX_FRAMEWORK_ATTEMPTS = 5
private const val RETRY_DELAY_MILLIS = 1000L
// Group info polling interval
private const val GROUP_INFO_POLL_INTERVAL_MILLIS = 1000L
@ -41,10 +35,14 @@ class HotspotManager(private val context: Context) {
private const val GROUP_FORMATION_TIMEOUT_MILLIS = 15_000L
// SSID and password configuration
private const val SSID_PREFIX = "DIRECT-BC-" // BC for BitChat
private const val SSID_SUFFIX_LENGTH = 8
private const val PASSWORD_LENGTH = 16
// Records the group we created so a later run can tell our own orphan apart
// from a group belonging to Cast, Android Auto or Quick Share.
private const val PREFS_NAME = "hotspot"
private const val KEY_OWNED_GROUP = "owned_group_name"
// Characters to use for random generation (excluding confusing ones)
private const val RANDOM_CHARS = "ABCDEFGHJKLMNPQRTUVWXY34679" // No 0,O,5,S,1,l,I
}
@ -69,13 +67,31 @@ class HotspotManager(private val context: Context) {
private var savedSsid: String? = null
private var savedPassword: String? = null
// Last Wi-Fi P2P state seen on the broadcast, or null before the first one arrives
private var lastP2pState: Int? = null
private val prefs by lazy { context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) }
/** Network name of the last group this app created, surviving process death. */
private var ownedGroupName: String?
get() = prefs.getString(KEY_OWNED_GROUP, null)
set(value) = prefs.edit().putString(KEY_OWNED_GROUP, value).apply()
// Broadcast receiver for Wi-Fi P2P events
private val broadcastReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
when (intent.action) {
WifiP2pManager.WIFI_P2P_STATE_CHANGED_ACTION -> {
val state = intent.getIntExtra(WifiP2pManager.EXTRA_WIFI_STATE, -1)
lastP2pState = state
Log.d(TAG, "Wi-Fi P2P state changed: $state")
// Wi-Fi Direct going away is terminal for this session: without it
// the group cannot form, and any group already up is now dead.
if (state == WIFI_P2P_STATE_DISABLED && (isStarting || hasNotifiedStarted)) {
Log.w(TAG, "Wi-Fi P2P was disabled; aborting hotspot")
failStartup(HotspotStartupPolicy.P2P_DISABLED_MESSAGE)
}
}
WifiP2pManager.WIFI_P2P_CONNECTION_CHANGED_ACTION -> {
Log.d(TAG, "Wi-Fi P2P connection changed")
@ -100,12 +116,15 @@ class HotspotManager(private val context: Context) {
return
}
val missingPermission = requiredRuntimePermission()?.takeUnless {
ContextCompat.checkSelfPermission(context, it) == PackageManager.PERMISSION_GRANTED
}
if (missingPermission != null) {
Log.w(TAG, "Cannot start hotspot without $missingPermission")
callback.onError("Nearby Wi-Fi permission is required to start the hotspot")
val missingPermissions = HotspotPermissions.missingFrom(context)
if (missingPermissions.isNotEmpty()) {
Log.w(TAG, "Cannot start hotspot; missing required permissions: $missingPermissions")
val message = if (Manifest.permission.ACCESS_LOCAL_NETWORK in missingPermissions) {
"Local network permission is required to share the app over the hotspot"
} else {
"Nearby Wi-Fi permission is required to start the hotspot"
}
callback.onError(message)
return
}
@ -137,8 +156,8 @@ class HotspotManager(private val context: Context) {
Log.d(TAG, "Using saved credentials: SSID=$savedSsid")
}
// Start P2P framework with retries
startWifiP2pFramework(1)
// Start P2P framework (retries reuse this one channel)
startWifiP2pFramework()
}
/**
@ -153,14 +172,22 @@ class HotspotManager(private val context: Context) {
// Stop group info polling
handler.removeCallbacksAndMessages(null)
// Remove group
channel?.let { ch ->
wifiP2pManager?.removeGroup(ch, object : ActionListener {
// Detach the channel first so any in-flight listener sees the hotspot as stopped,
// then remove the group and close the channel once the framework has replied.
val staleChannel = channel
channel = null
if (staleChannel != null) {
wifiP2pManager?.removeGroup(staleChannel, object : ActionListener {
override fun onSuccess() {
Log.d(TAG, "Group removed successfully")
// Nothing of ours is left for a later run to clean up.
ownedGroupName = null
closeChannel(staleChannel)
}
override fun onFailure(reason: Int) {
Log.w(TAG, "Failed to remove group: $reason")
closeChannel(staleChannel)
}
})
}
@ -181,10 +208,25 @@ class HotspotManager(private val context: Context) {
}
currentGroup = null
channel = null
callback = null
}
/**
* Release the channel's binder registration with WifiP2pService. Without this the
* registration survives until the process dies, and every start/stop cycle adds
* another stale client to the framework's list.
*/
private fun closeChannel(channelToClose: Channel) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O_MR1) return
try {
channelToClose.close()
Log.d(TAG, "P2P channel closed")
} catch (e: Exception) {
Log.w(TAG, "Error closing P2P channel", e)
}
}
/**
* Get current connection information.
*/
@ -201,28 +243,103 @@ class HotspotManager(private val context: Context) {
}
/**
* Start Wi-Fi P2P framework with retry logic.
* Initialise the P2P framework once. Every retry reuses this channel calling
* initialize() per attempt registers a fresh binder with WifiP2pService that is
* never reclaimed until the process dies.
*/
private fun startWifiP2pFramework(attempt: Int) {
if (attempt > MAX_FRAMEWORK_ATTEMPTS) {
Log.e(TAG, "Failed to start P2P framework after $MAX_FRAMEWORK_ATTEMPTS attempts")
failStartup("Failed to start hotspot. Please try again.")
return
}
private fun startWifiP2pFramework() {
Log.d(TAG, "Initialising P2P channel")
Log.d(TAG, "Starting P2P framework (attempt $attempt/$MAX_FRAMEWORK_ATTEMPTS)")
val newChannel = wifiP2pManager?.initialize(context, Looper.getMainLooper(), null)
channel = wifiP2pManager?.initialize(context, Looper.getMainLooper(), null)
if (channel == null) {
if (newChannel == null) {
// The service is unobtainable; retrying will not change that.
Log.e(TAG, "Failed to initialize P2P channel")
handler.postDelayed({
startWifiP2pFramework(attempt + 1)
}, RETRY_DELAY_MILLIS)
failStartup(HotspotStartupPolicy.P2P_UNSUPPORTED_MESSAGE)
return
}
createGroup(attempt)
channel = newChannel
createGroupWhenP2pAvailable()
}
/**
* Ask the framework for the current P2P state before the first attempt.
*
* When P2P is disabled the state machine answers every createGroup with BUSY
* the same code a genuinely transient collision returns so without this check
* a permanent failure is indistinguishable from a retryable one.
*/
@SuppressLint("MissingPermission")
private fun createGroupWhenP2pAvailable() {
val ch = channel ?: return
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
clearStaleGroupThenCreate(ch, attempt = 1)
return
}
try {
wifiP2pManager?.requestP2pState(ch) { state ->
if (channel !== ch) return@requestP2pState
lastP2pState = state
clearStaleGroupThenCreate(ch, attempt = 1)
}
} catch (e: SecurityException) {
Log.e(TAG, "Wi-Fi permission was revoked while reading P2P state", e)
failStartup("A required Wi-Fi or local network permission was revoked. Grant it and try again.")
}
}
/**
* A P2P group survives the process that created it, so a previous session killed
* while hosting leaves an orphan behind. The framework then rejects createGroup
* with BUSY for as long as that group exists, which no retry can clear.
*/
@SuppressLint("MissingPermission")
private fun clearStaleGroupThenCreate(ch: Channel, attempt: Int) {
try {
wifiP2pManager?.requestGroupInfo(ch) { existingGroup ->
if (channel !== ch) return@requestGroupInfo
val action = HotspotStartupPolicy.startAction(
p2pState = lastP2pState,
existingGroupName = existingGroup?.networkName,
ownedGroupName = ownedGroupName
)
when (action) {
is HotspotStartupPolicy.StartAction.Fail -> {
Log.w(TAG, "Not attempting group creation: ${action.message}")
failStartup(action.message)
}
HotspotStartupPolicy.StartAction.Create -> createGroup(attempt)
HotspotStartupPolicy.StartAction.RemoveStaleGroupThenCreate -> {
Log.w(TAG, "Removing stale group '${existingGroup?.networkName}' before creating")
removeStaleGroup(ch, attempt)
}
}
}
} catch (e: SecurityException) {
Log.e(TAG, "Wi-Fi permission was revoked while reading group info", e)
failStartup("A required Wi-Fi or local network permission was revoked. Grant it and try again.")
}
}
private fun removeStaleGroup(ch: Channel, attempt: Int) {
wifiP2pManager?.removeGroup(ch, object : ActionListener {
override fun onSuccess() {
if (channel !== ch) return
Log.d(TAG, "Stale group removed")
createGroup(attempt)
}
override fun onFailure(reason: Int) {
if (channel !== ch) return
// Creation may still succeed, and a BUSY reply here backs off as usual.
Log.w(TAG, "Failed to remove stale group: $reason; attempting creation anyway")
createGroup(attempt)
}
})
}
/**
@ -234,6 +351,10 @@ class HotspotManager(private val context: Context) {
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
// Record before the call: if the process dies between creation and the
// first group info, the next run still knows this orphan is ours.
ownedGroupName = savedSsid
// Android 10+: Custom SSID and password
val config = WifiP2pConfig.Builder()
.setNetworkName(savedSsid!!)
@ -248,7 +369,7 @@ class HotspotManager(private val context: Context) {
}
} catch (e: SecurityException) {
Log.e(TAG, "Wi-Fi permission was revoked while creating the group", e)
failStartup("Nearby Wi-Fi permission was revoked. Grant it and try again.")
failStartup("A required Wi-Fi or local network permission was revoked. Grant it and try again.")
}
}
@ -273,7 +394,7 @@ class HotspotManager(private val context: Context) {
}
/**
* Handle group creation failure with retry logic.
* Handle group creation failure, backing off only for genuinely transient causes.
*/
private fun handleGroupCreationFailure(reason: Int, attempt: Int) {
val reasonStr = when (reason) {
@ -283,16 +404,22 @@ class HotspotManager(private val context: Context) {
else -> "UNKNOWN($reason)"
}
Log.w(TAG, "Failed to create group: $reasonStr")
Log.w(
TAG,
"Failed to create group: $reasonStr " +
"(attempt $attempt/${HotspotStartupPolicy.MAX_ATTEMPTS}, p2pState=$lastP2pState)"
)
if (reason == BUSY && attempt < MAX_FRAMEWORK_ATTEMPTS) {
// Framework is busy, retry
Log.d(TAG, "P2P framework busy, retrying...")
handler.postDelayed({
startWifiP2pFramework(attempt + 1)
}, RETRY_DELAY_MILLIS)
} else {
failStartup("Failed to create hotspot: $reasonStr")
when (val decision = HotspotStartupPolicy.decide(reason, attempt, lastP2pState)) {
is HotspotStartupPolicy.Decision.Retry -> {
Log.d(TAG, "Retrying group creation in ${decision.delayMillis}ms")
handler.postDelayed({
// Re-check for a stale group each round: BUSY is also how the
// framework reports "a group already exists".
channel?.let { clearStaleGroupThenCreate(it, attempt + 1) }
}, decision.delayMillis)
}
is HotspotStartupPolicy.Decision.Fail -> failStartup(decision.message)
}
}
@ -354,6 +481,9 @@ class HotspotManager(private val context: Context) {
savedPassword = group.passphrase
}
// Authoritative name straight from the framework
group.networkName?.let { ownedGroupName = it }
// Notify callback on FIRST successful group info retrieval
if (!hasNotifiedStarted) {
hasNotifiedStarted = true
@ -369,17 +499,7 @@ class HotspotManager(private val context: Context) {
}
} catch (e: SecurityException) {
Log.e(TAG, "Wi-Fi permission was revoked while reading group info", e)
failStartup("Nearby Wi-Fi permission was revoked. Grant it and try again.")
}
}
private fun requiredRuntimePermission(): String? {
return when {
Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU ->
Manifest.permission.NEARBY_WIFI_DEVICES
Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q ->
Manifest.permission.ACCESS_FINE_LOCATION
else -> null
failStartup("A required Wi-Fi or local network permission was revoked. Grant it and try again.")
}
}
@ -469,7 +589,7 @@ class HotspotManager(private val context: Context) {
val suffix = (1..SSID_SUFFIX_LENGTH)
.map { RANDOM_CHARS[random.nextInt(RANDOM_CHARS.length)] }
.joinToString("")
return "$SSID_PREFIX$suffix"
return "${HotspotStartupPolicy.SSID_PREFIX}$suffix"
}
/**

View File

@ -0,0 +1,35 @@
package com.bitchat.android.hotspot
import android.Manifest
import android.annotation.SuppressLint
import android.content.Context
import android.content.pm.PackageManager
import android.os.Build
import androidx.core.content.ContextCompat
internal object HotspotPermissions {
const val ANDROID_17_API_LEVEL = 37
@SuppressLint("InlinedApi")
fun requiredForSdk(sdkInt: Int = Build.VERSION.SDK_INT): List<String> {
return when {
sdkInt >= ANDROID_17_API_LEVEL -> listOf(
Manifest.permission.NEARBY_WIFI_DEVICES,
Manifest.permission.ACCESS_LOCAL_NETWORK
)
sdkInt >= Build.VERSION_CODES.TIRAMISU -> listOf(
Manifest.permission.NEARBY_WIFI_DEVICES
)
sdkInt >= Build.VERSION_CODES.Q -> listOf(
Manifest.permission.ACCESS_FINE_LOCATION
)
else -> emptyList()
}
}
fun missingFrom(context: Context): List<String> {
return requiredForSdk().filter { permission ->
ContextCompat.checkSelfPermission(context, permission) != PackageManager.PERMISSION_GRANTED
}
}
}

View File

@ -0,0 +1,91 @@
package com.bitchat.android.hotspot
import android.net.wifi.p2p.WifiP2pManager
/**
* Decides how to react to a Wi-Fi P2P group-creation failure.
*
* Kept free of Android dependencies so the retry strategy is unit testable.
*/
internal object HotspotStartupPolicy {
const val MAX_ATTEMPTS = 5
const val INITIAL_RETRY_DELAY_MILLIS = 1_000L
const val MAX_RETRY_DELAY_MILLIS = 8_000L
const val P2P_DISABLED_MESSAGE =
"Wi-Fi Direct is unavailable. Turn Wi-Fi off and back on, then try again."
/** Marks groups this app creates. Shared with [HotspotManager] so the two cannot drift. */
const val SSID_PREFIX = "DIRECT-BC-" // BC for BitChat
const val P2P_UNSUPPORTED_MESSAGE = "Wi-Fi Direct is not supported on this device."
const val FOREIGN_GROUP_MESSAGE =
"Another app is using Wi-Fi Direct. Close it and try again."
const val P2P_BUSY_MESSAGE = "Wi-Fi Direct is busy. Please try again in a moment."
const val GENERIC_FAILURE_MESSAGE = "Failed to start the hotspot. Please try again."
sealed interface Decision {
data class Retry(val delayMillis: Long) : Decision
data class Fail(val message: String) : Decision
}
sealed interface StartAction {
data object Create : StartAction
data object RemoveStaleGroupThenCreate : StartAction
data class Fail(val message: String) : StartAction
}
/**
* Decides what to do before the first group-creation attempt.
*
* A P2P group outlives the process that created it, so an app killed while
* hosting leaves an orphan behind. The framework rejects createGroup with BUSY
* while any group exists, and no amount of retrying clears it.
*
* Wi-Fi Direct is shared with Cast, Android Auto and Quick Share, so only groups
* we can show are ours get torn down.
*
* @param existingGroupName network name of the group already present, or null
* @param ownedGroupName last group name this app recorded creating, or null
*/
fun startAction(
p2pState: Int?,
existingGroupName: String?,
ownedGroupName: String?
): StartAction = when {
p2pState == WifiP2pManager.WIFI_P2P_STATE_DISABLED -> StartAction.Fail(P2P_DISABLED_MESSAGE)
existingGroupName == null -> StartAction.Create
isOurs(existingGroupName, ownedGroupName) -> StartAction.RemoveStaleGroupThenCreate
else -> StartAction.Fail(FOREIGN_GROUP_MESSAGE)
}
/**
* Primary signal is the name we recorded creating. The SSID prefix is only a
* fallback, covering orphans left by builds that predate that record.
*/
private fun isOurs(existingGroupName: String, ownedGroupName: String?): Boolean =
existingGroupName == ownedGroupName || existingGroupName.startsWith(SSID_PREFIX)
/**
* @param reason a [WifiP2pManager] failure reason from `ActionListener.onFailure`
* @param attempt 1-based attempt that just failed
* @param p2pState last known [WifiP2pManager.EXTRA_WIFI_STATE], or null if no
* state broadcast has arrived yet
*/
fun decide(reason: Int, attempt: Int, p2pState: Int?): Decision = when {
reason == WifiP2pManager.P2P_UNSUPPORTED -> Decision.Fail(P2P_UNSUPPORTED_MESSAGE)
reason != WifiP2pManager.BUSY -> Decision.Fail(GENERIC_FAILURE_MESSAGE)
// BUSY is the framework's catch-all reply when the P2P state machine is
// disabled, so retrying cannot help — surface something actionable instead.
p2pState == WifiP2pManager.WIFI_P2P_STATE_DISABLED -> Decision.Fail(P2P_DISABLED_MESSAGE)
attempt >= MAX_ATTEMPTS -> Decision.Fail(P2P_BUSY_MESSAGE)
else -> Decision.Retry(retryDelayMillis(attempt))
}
private fun retryDelayMillis(attempt: Int): Long =
(INITIAL_RETRY_DELAY_MILLIS shl (attempt - 1)).coerceAtMost(MAX_RETRY_DELAY_MILLIS)
}

View File

@ -4,6 +4,7 @@ import android.app.Application
import android.util.Log
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.bitchat.android.wifiaware.WifiAwareController
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
@ -40,6 +41,10 @@ class HotspotViewModel(application: Application) : AndroidViewModel(application)
viewModelScope.launch {
try {
// Wi-Fi Aware holds a NAN interface that blocks the P2P one; release it
// first or every createGroup comes back BUSY. Restored when we stop.
WifiAwareController.holdForHotspot()
// Start hotspot
val manager = HotspotManager(context)
hotspotManager = manager
@ -52,8 +57,7 @@ class HotspotViewModel(application: Application) : AndroidViewModel(application)
// Get connection info
val info = manager.getConnectionInfo()
if (info == null) {
manager.stopHotspot()
_state.value = HotspotState.Error("Failed to get hotspot connection info")
failWith("Failed to get hotspot connection info")
return@launch
}
@ -75,8 +79,7 @@ class HotspotViewModel(application: Application) : AndroidViewModel(application)
)
} catch (e: Exception) {
Log.e(TAG, "Failed to start web server", e)
manager.stopHotspot()
_state.value = HotspotState.Error("Failed to start web server: ${e.message}")
failWith("Failed to start web server: ${e.message}")
}
}
}
@ -92,17 +95,13 @@ class HotspotViewModel(application: Application) : AndroidViewModel(application)
}
override fun onError(message: String) {
viewModelScope.launch {
Log.e(TAG, "Hotspot error: $message")
_state.value = HotspotState.Error(message)
}
viewModelScope.launch { failWith(message) }
}
})
} catch (e: Exception) {
Log.e(TAG, "Error starting hotspot", e)
hotspotManager?.stopHotspot()
_state.value = HotspotState.Error(e.message ?: "Unknown error")
failWith(e.message ?: "Unknown error")
}
}
}
@ -112,14 +111,33 @@ class HotspotViewModel(application: Application) : AndroidViewModel(application)
*/
fun stopHotspot() {
Log.d(TAG, "Stopping hotspot")
teardown()
_state.value = HotspotState.Intro
}
/**
* Every failure after the hotspot has been requested must land here.
*
* Skipping any part of this leaves something running that shouldn't be: the web
* server keeps serving the APK on whatever network the device joins next, and the
* Wi-Fi Aware hold blocks the mesh until the user happens to retry or close the
* screen.
*/
private fun failWith(message: String) {
Log.e(TAG, "Hotspot failed: $message")
teardown()
_state.value = HotspotState.Error(message)
}
/** Releases every resource startHotspot may have acquired. Safe to call twice. */
private fun teardown() {
webServer?.stopServer()
webServer = null
hotspotManager?.stopHotspot()
hotspotManager = null
_state.value = HotspotState.Intro
WifiAwareController.releaseHotspotHold()
}
/**

View File

@ -528,4 +528,11 @@ class SecureIdentityStateManager {
}
editor.apply()
}
/** Use for panic paths that must finish the disk mutation before identity reset continues. */
fun clearSecureValuesSynchronously(vararg keys: String): Boolean {
val editor = prefs.edit()
keys.forEach(editor::remove)
return editor.commit()
}
}

View File

@ -115,8 +115,7 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
// Service-level notification manager for background (no-UI) DMs
private val serviceNotificationManager = com.bitchat.android.ui.NotificationManager(
context.applicationContext,
androidx.core.app.NotificationManagerCompat.from(context.applicationContext),
com.bitchat.android.util.NotificationIntervalManager()
androidx.core.app.NotificationManagerCompat.from(context.applicationContext)
)
// Service state management
@ -480,21 +479,13 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
// Callbacks
override fun onMessageReceived(message: BitchatMessage) {
// Always reflect into process-wide store so UI can hydrate after recreation
try {
when {
message.isPrivate -> {
val peer = message.senderPeerID ?: ""
if (peer.isNotEmpty()) com.bitchat.android.services.AppStateStore.addPrivateMessage(peer, message)
}
message.channel != null -> {
com.bitchat.android.services.AppStateStore.addChannelMessage(message.channel!!, message)
}
else -> {
com.bitchat.android.services.AppStateStore.addPublicMessage(message)
}
}
} catch (_: Exception) { }
// Private-message admission is authoritative. In particular, do not forward a
// callback or notify after panic mode rejected the message while wiping state.
if (
!com.bitchat.android.services.IncomingMessageAdmission
.admitToAppState(message)
) return
// And forward to UI delegate if attached
delegate?.didReceiveMessage(message)

View File

@ -31,7 +31,13 @@ class FragmentingPacketSender(
sendSingle: (RoutedPacket) -> Boolean
): Boolean {
val transferId = transferIdFor(routed)
val packets = packetsForTransport(routed) ?: return false
val packets = packetsForTransport(routed)
if (packets == null) {
if (transferId != null) {
TransferProgressManager.fail(transferId)
}
return false
}
val total = packets.size
if (total <= 1) {
@ -45,9 +51,13 @@ class FragmentingPacketSender(
preparedPackets = null
)
)
if (sent && transferId != null) {
TransferProgressManager.progress(transferId, 1, 1)
TransferProgressManager.complete(transferId, 1)
if (transferId != null) {
if (sent) {
TransferProgressManager.progress(transferId, 1, 1)
TransferProgressManager.complete(transferId, 1)
} else {
TransferProgressManager.fail(transferId)
}
}
return sent
}
@ -125,7 +135,12 @@ class FragmentingPacketSender(
val manager = fragmentManager ?: return listOf(packet)
return try {
val fragments = manager.createFragments(packet)
// Receivers hard-cap reassembly at MAX_FRAGMENTS_PER_ID; sending more
// fragments would be undeliverable, so reject here instead.
val fragments = manager.createFragments(
packet,
com.bitchat.android.util.AppConstants.Fragmentation.MAX_FRAGMENTS_PER_ID
)
if (fragments.isEmpty()) {
Log.e(logTag, "Fragment manager returned no packets for packet type ${packet.type}")
null

View File

@ -41,7 +41,11 @@ class MeshCore(
private val hooks: Hooks = Hooks()
) {
data class Hooks(
val onMessageReceived: ((BitchatMessage) -> Unit)? = null,
/**
* Reflects a decoded message into transport-owned state before delegate dispatch.
* Return false to suppress all downstream effects for a rejected message.
*/
val onMessageReceived: ((BitchatMessage) -> Boolean)? = null,
val onAnnounceProcessed: ((RoutedPacket, Boolean) -> Unit)? = null,
val readReceiptInterceptor: ((String, String) -> Boolean)? = null,
val onReadReceiptSent: ((String) -> Unit)? = null,
@ -392,7 +396,7 @@ class MeshCore(
}
override fun onMessageReceived(message: BitchatMessage) {
hooks.onMessageReceived?.invoke(message)
if (hooks.onMessageReceived?.invoke(message) == false) return
delegate?.didReceiveMessage(message)
}

View File

@ -335,7 +335,7 @@ class PeerManager {
// Remove stale peer IDs
stalePeerIDs.forEach { stalePeerID ->
removePeer(stalePeerID, notifyDelegate = false)
removePeer(stalePeerID, notifyPeerList = false)
}
// Check if this is a new peer announcement
@ -371,7 +371,7 @@ class PeerManager {
/**
* Remove peer
*/
fun removePeer(peerID: String, notifyDelegate: Boolean = true) {
fun removePeer(peerID: String, notifyPeerList: Boolean = true) {
val removed = peers.remove(peerID)
peerRSSI.remove(peerID)
announcedPeers.remove(peerID)
@ -380,10 +380,13 @@ class PeerManager {
// Also remove fingerprint mappings
fingerprintManager.removePeer(peerID)
if (notifyDelegate && removed != null) {
// Notify specific removal event then list update
if (removed != null) {
// Lifecycle cleanup must always run. Callers may suppress only the
// intermediate peer-list update while atomically replacing a peer.
try { delegate?.onPeerRemoved(peerID) } catch (_: Exception) {}
notifyPeerListUpdate()
if (notifyPeerList) {
notifyPeerListUpdate()
}
}
}

View File

@ -11,7 +11,8 @@ data class TransferProgressEvent(
val transferId: String,
val sent: Int,
val total: Int,
val completed: Boolean
val completed: Boolean,
val failed: Boolean = false
)
object TransferProgressManager {
@ -22,9 +23,9 @@ object TransferProgressManager {
fun start(id: String, total: Int) { emit(id, 0, total, false) }
fun progress(id: String, sent: Int, total: Int) { emit(id, sent, total, sent >= total) }
fun complete(id: String, total: Int) { emit(id, total, total, true) }
fun fail(id: String) { emit(id, 0, 0, done = true, failed = true) }
private fun emit(id: String, sent: Int, total: Int, done: Boolean) {
scope.launch { _events.emit(TransferProgressEvent(id, sent, total, done)) }
private fun emit(id: String, sent: Int, total: Int, done: Boolean, failed: Boolean = false) {
scope.launch { _events.emit(TransferProgressEvent(id, sent, total, done, failed)) }
}
}

View File

@ -132,7 +132,14 @@ class NostrDirectMessageHandler(
val favoriteControl = FavoriteControlMessage.parse(pm.content)
if (favoriteControl != null) {
handleFavoriteControl(favoriteControl, conversationID, senderNickname, timestamp, senderPubkey)
val admitted = handleFavoriteControl(
favoriteControl,
conversationID,
senderNickname,
timestamp,
senderPubkey
)
if (!admitted) return
if (!seenStore.hasDelivered(pm.messageID)) {
val nostrTransport = NostrTransport.getInstance(application)
nostrTransport.sendDeliveryAckGeohash(pm.messageID, senderPubkey, recipientIdentity)
@ -157,13 +164,14 @@ class NostrDirectMessageHandler(
val isViewing = state.getSelectedPrivateChatPeerValue() == conversationID
val suppressUnread = seenStore.hasBeenReadLocally(pm.messageID)
withContext(Dispatchers.Main) {
privateChatManager.handleIncomingPrivateMessage(
val admitted = withContext(Dispatchers.Main) {
privateChatManager.handleIncomingPrivateMessageDurably(
message = message,
suppressUnread = suppressUnread,
origin = PrivateMessageOrigin.NOSTR
)
}
if (!admitted) return
if (!seenStore.hasDelivered(pm.messageID)) {
val nostrTransport = NostrTransport.getInstance(application)
@ -215,13 +223,19 @@ class NostrDirectMessageHandler(
senderNostrPubkey = senderPubkey
)
Log.d(TAG, "📄 Saved Nostr encrypted incoming file to $savedPath (msgId=$uniqueMsgId)")
withContext(Dispatchers.Main) {
privateChatManager.handleIncomingPrivateMessage(
val admitted = withContext(Dispatchers.Main) {
privateChatManager.handleIncomingPrivateMessageDurably(
message = message,
suppressUnread = false,
origin = PrivateMessageOrigin.NOSTR
)
}
if (!admitted) {
com.bitchat.android.features.file.FileUtils.deleteStoredMediaPaths(
application,
listOf(savedPath)
)
}
} else {
Log.w(TAG, "Failed to decode Nostr file transfer from $conversationID")
}
@ -238,15 +252,15 @@ class NostrDirectMessageHandler(
senderNickname: String,
timestamp: Date,
senderPubkey: String
) {
try {
): Boolean {
return try {
val senderNpub = control.npub ?: ContactIdentityResolver.npubFromHex(senderPubkey)
val noiseKey = senderNpub?.let { FavoritesPersistenceService.shared.findNoiseKey(it) }
?: FavoritesPersistenceService.shared.findNoiseKey(senderPubkey)
if (noiseKey == null) {
Log.w(TAG, "Favorite notification from Nostr sender without known Noise key: ${senderPubkey.take(16)}...")
return
return false
}
FavoritesPersistenceService.shared.updatePeerFavoritedUs(noiseKey, control.isFavorite)
@ -278,7 +292,7 @@ class NostrDirectMessageHandler(
)
withContext(Dispatchers.Main) {
privateChatManager.handleIncomingPrivateMessage(
privateChatManager.handleIncomingPrivateMessageDurably(
message = systemMessage,
suppressUnread = true,
origin = PrivateMessageOrigin.NOSTR
@ -286,6 +300,7 @@ class NostrDirectMessageHandler(
}
} catch (e: Exception) {
Log.w(TAG, "Failed to handle Nostr favorite notification: ${e.message}")
false
}
}

View File

@ -85,9 +85,7 @@ class OnboardingCoordinator(
val missingRequired = permissionManager.getMissingPermissions()
// Optional permissions (ask, but do not block if denied)
val optionalToRequest = permissionManager
.getOptionalPermissions()
.filter { !permissionManager.isPermissionGranted(it) }
val optionalToRequest = permissionManager.getUnrequestedOptionalPermissions()
val missingPermissions = (missingRequired + optionalToRequest).distinct()
@ -101,6 +99,7 @@ class OnboardingCoordinator(
}
Log.d(TAG, "Requesting ${missingPermissions.size} permissions")
permissionManager.markOptionalPermissionsRequested(optionalToRequest)
permissionLauncher?.launch(missingPermissions.toTypedArray())
}
@ -115,7 +114,10 @@ class OnboardingCoordinator(
val allGranted = permissions.values.all { it }
val criticalPermissions = getCriticalPermissions()
val criticalGranted = criticalPermissions.all { permissions[it] == true }
// The launcher result only contains permissions requested in this round. Returning
// users may be asked for POST_NOTIFICATIONS alone, so re-check required permissions
// against package state instead of treating absent result-map entries as denials.
val criticalGranted = criticalPermissions.all(permissionManager::isPermissionGranted)
when {
criticalGranted -> {

View File

@ -19,6 +19,8 @@ class PermissionManager(private val context: Context) {
private const val TAG = "PermissionManager"
private const val PREFS_NAME = "bitchat_permissions"
private const val KEY_FIRST_TIME_COMPLETE = "first_time_onboarding_complete"
private const val KEY_OPTIONAL_PERMISSION_REQUESTED_PREFIX =
"optional_permission_requested_"
}
private val sharedPrefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
@ -149,6 +151,32 @@ class PermissionManager(private val context: Context) {
return optional
}
/**
* Optional permissions are prompted once. A denial must not trap returning users in
* onboarding, while users upgrading to a notification-permission Android version
* should still receive one contextual request.
*/
fun getUnrequestedOptionalPermissions(): List<String> {
return getOptionalPermissions().filter { permission ->
!isPermissionGranted(permission) &&
!sharedPrefs.getBoolean(optionalPermissionRequestKey(permission), false)
}
}
fun markOptionalPermissionsRequested(permissions: Collection<String>) {
if (permissions.isEmpty()) return
sharedPrefs.edit().apply {
permissions.forEach { permission ->
putBoolean(optionalPermissionRequestKey(permission), true)
}
}.apply()
}
private fun optionalPermissionRequestKey(permission: String): String {
return KEY_OPTIONAL_PERMISSION_REQUESTED_PREFIX + permission
}
/**
* Check if a specific permission is granted
*/

View File

@ -64,6 +64,12 @@ object AppShutdownCoordinator {
val torStop = async {
try { torProvider.applyMode(app, TorMode.OFF) } catch (_: Exception) { }
}
val conversationFlush = async {
try {
com.bitchat.android.services.AppStateStore
.awaitConversationPersistence()
} catch (_: Exception) { }
}
// Clear AppState in-memory store
try { com.bitchat.android.services.AppStateStore.clear() } catch (_: Exception) { }
@ -75,6 +81,7 @@ object AppShutdownCoordinator {
// Wait up to 5 seconds for shutdown tasks
withTimeoutOrNull(5000) {
try { torStop.await() } catch (_: Exception) { }
try { conversationFlush.await() } catch (_: Exception) { }
delay(100)
}

View File

@ -0,0 +1,88 @@
package com.bitchat.android.service
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import androidx.core.app.RemoteInput
import com.bitchat.android.model.BitchatMessage
import com.bitchat.android.model.DeliveryStatus
import com.bitchat.android.services.AppStateStore
import com.bitchat.android.services.ContactDirectory
import com.bitchat.android.services.MessageRouter
import com.bitchat.android.ui.NotificationManager
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import java.util.Date
import java.util.UUID
/** Handles privacy-scoped direct reply and mark-read actions from DM notifications. */
class ConversationNotificationReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
val conversationID = intent.getStringExtra(NotificationManager.EXTRA_PEER_ID)
?.let(ContactDirectory::canonicalConversationId)
?: return
val pendingResult = goAsync()
CoroutineScope(SupervisorJob() + Dispatchers.IO).launch {
try {
var acknowledged = false
when (intent.action) {
NotificationManager.ACTION_MARK_CONVERSATION_READ -> {
acknowledged =
AppStateStore.setPrivateConversationRead(conversationID, true)
}
NotificationManager.ACTION_REPLY_TO_CONVERSATION -> {
val reply = RemoteInput.getResultsFromIntent(intent)
?.getCharSequence(NotificationManager.KEY_TEXT_REPLY)
?.toString()
?.trim()
?.takeIf(String::isNotEmpty)
?: return@launch
// A notification can outlive the process/service that posted it. Promote
// the mesh runtime before dispatch so Android keeps the transport alive
// after this short-lived receiver finishes.
MeshForegroundService.start(context.applicationContext)
val mesh = MeshServiceHolder.getUnifiedOrCreate(
context.applicationContext
)
val message = BitchatMessage(
id = UUID.randomUUID().toString().uppercase(),
sender = mesh.myPeerID,
content = reply,
timestamp = Date(),
isPrivate = true,
recipientNickname = intent.getStringExtra(
NotificationManager.EXTRA_SENDER_NICKNAME
),
senderPeerID = mesh.myPeerID,
deliveryStatus = DeliveryStatus.Sending
)
val persisted = AppStateStore.addPrivateMessageDurably(
peerID = conversationID,
msg = message,
forceRead = true
)
if (persisted) {
MessageRouter.getInstance(context.applicationContext, mesh)
.sendPrivate(
content = reply,
toPeerID = conversationID,
recipientNickname = message.recipientNickname.orEmpty(),
messageID = message.id
)
acknowledged =
AppStateStore.setPrivateConversationRead(conversationID, true)
}
}
}
if (acknowledged) {
NotificationManager.acknowledgeConversation(context, conversationID)
}
} finally {
pendingResult.finish()
}
}
}
}

View File

@ -13,6 +13,8 @@ import android.os.IBinder
import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.ProcessLifecycleOwner
import com.bitchat.android.MainActivity
import com.bitchat.android.R
import com.bitchat.android.mesh.BluetoothMeshService
@ -33,7 +35,6 @@ class MeshForegroundService : Service() {
const val ACTION_STOP = "com.bitchat.android.service.STOP"
const val ACTION_QUIT = "com.bitchat.android.service.QUIT"
const val ACTION_UPDATE_NOTIFICATION = "com.bitchat.android.service.UPDATE_NOTIFICATION"
const val ACTION_NOTIFICATION_PERMISSION_GRANTED = "com.bitchat.android.action.NOTIFICATION_PERMISSION_GRANTED"
fun start(context: Context) {
val intent = Intent(context, MeshForegroundService::class.java).apply { action = ACTION_START }
@ -59,22 +60,6 @@ class MeshForegroundService : Service() {
}
}
/**
* Helper to be invoked right after POST_NOTIFICATIONS is granted to try
* promoting/starting the foreground service immediately without polling.
*/
fun onNotificationPermissionGranted(context: Context) {
// If background is enabled and permission now granted, start/promo service
if (!shouldStartAsForeground(context)) return
val intent = Intent(context, MeshForegroundService::class.java).apply { action = ACTION_UPDATE_NOTIFICATION }
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
context.startForegroundService(intent)
} else {
context.startService(intent)
}
}
fun stop(context: Context) {
val intent = Intent(context, MeshForegroundService::class.java).apply { action = ACTION_STOP }
context.startService(intent)
@ -82,8 +67,7 @@ class MeshForegroundService : Service() {
private fun shouldStartAsForeground(context: Context): Boolean {
return MeshServicePreferences.isBackgroundEnabled(true) &&
hasBluetoothPermissionsStatic(context) &&
hasNotificationPermissionStatic(context)
hasBluetoothPermissionsStatic(context)
}
private fun hasBluetoothPermissionsStatic(ctx: Context): Boolean {
@ -98,14 +82,10 @@ class MeshForegroundService : Service() {
}
}
private fun hasNotificationPermissionStatic(ctx: Context): Boolean {
return if (Build.VERSION.SDK_INT >= 33) {
androidx.core.content.ContextCompat.checkSelfPermission(ctx, android.Manifest.permission.POST_NOTIFICATIONS) == android.content.pm.PackageManager.PERMISSION_GRANTED
} else true
}
}
private lateinit var notificationManager: NotificationManagerCompat
private lateinit var peerAvailabilityNotifier: PeerAvailabilityNotifier
private var updateJob: Job? = null
private val meshService: BluetoothMeshService?
get() = MeshServiceHolder.meshService
@ -121,6 +101,7 @@ class MeshForegroundService : Service() {
override fun onCreate() {
super.onCreate()
notificationManager = NotificationManagerCompat.from(this)
peerAvailabilityNotifier = PeerAvailabilityNotifier(applicationContext)
createChannel()
// Ensure mesh service exists in holder (create if needed)
@ -139,7 +120,14 @@ class MeshForegroundService : Service() {
com.bitchat.android.services.AppStateStore.peers
.map { peers -> peers.distinct().size }
.distinctUntilChanged()
.collect {
.collect { peerCount ->
peerAvailabilityNotifier.onPeerCountChanged(
peerCount = peerCount,
isAppInBackground = !ProcessLifecycleOwner.get()
.lifecycle
.currentState
.isAtLeast(Lifecycle.State.STARTED)
)
if (isInForeground) updateNotification(force = false)
}
}
@ -156,11 +144,13 @@ class MeshForegroundService : Service() {
when (intent?.action) {
ACTION_STOP -> {
// Stop FGS and mesh cleanly
updateJob?.cancel()
updateJob = null
try { com.bitchat.android.services.MessageRouter.tryGetInstance()?.stopOutboxScheduler() } catch (_: Exception) { }
try { unifiedMeshService?.stopServices() ?: meshService?.stopServices() } catch (_: Exception) { }
try { MeshServiceHolder.clear() } catch (_: Exception) { }
try { stopForeground(true) } catch (_: Exception) { }
notificationManager.cancel(NOTIFICATION_ID)
clearMeshNotifications()
isInForeground = false
stopSelf()
return START_NOT_STICKY
@ -170,7 +160,7 @@ class MeshForegroundService : Service() {
updateJob?.cancel()
updateJob = null
try { stopForeground(true) } catch (_: Exception) { }
notificationManager.cancel(NOTIFICATION_ID)
clearMeshNotifications()
isInForeground = false
// Fully stop all background activity, stop Tor (without changing setting), then kill the app
AppShutdownCoordinator.requestFullShutdownAndKill(
@ -203,7 +193,9 @@ class MeshForegroundService : Service() {
// Ensure mesh is running (only after permissions are granted)
ensureMeshStarted()
// Promote exactly once when eligible, otherwise stay background (or stop)
// Promote exactly once when eligible, otherwise stay background (or stop).
// POST_NOTIFICATIONS is intentionally not an eligibility requirement: Android 13+
// still allows foreground services and exposes them in the system task manager.
if (MeshServicePreferences.isBackgroundEnabled(true) && hasAllRequiredPermissions() && !isInForeground) {
val count = getUnifiedActivePeerCount()
val notification = buildNotification(count)
@ -234,30 +226,35 @@ class MeshForegroundService : Service() {
private fun updateNotification(force: Boolean) {
if (isShuttingDown) {
notificationManager.cancel(NOTIFICATION_ID)
clearMeshNotifications()
return
}
val count = getUnifiedActivePeerCount()
if (MeshServicePreferences.isBackgroundEnabled(true) && hasAllRequiredPermissions()) {
if (lastNotifiedPeerCount != count) {
notificationManager.notify(NOTIFICATION_ID, buildNotification(count))
startForegroundCompat(buildNotification(count))
lastNotifiedPeerCount = count
}
} else if (force) {
// If disabled and forced, make sure to remove any prior foreground state
try { stopForeground(false) } catch (_: Exception) { }
notificationManager.cancel(NOTIFICATION_ID)
clearMeshNotifications()
isInForeground = false
lastNotifiedPeerCount = null
}
}
private fun clearMeshNotifications() {
notificationManager.cancel(NOTIFICATION_ID)
peerAvailabilityNotifier.clear()
}
private fun hasAllRequiredPermissions(): Boolean {
// For starting FGS with connectedDevice|dataSync, we need:
// - Foreground service permissions (declared in manifest)
// - One of the device-related permissions (we request BL perms at runtime)
// - On Android 13+, POST_NOTIFICATIONS to actually show notification
return hasBluetoothPermissions() && hasNotificationPermission()
// POST_NOTIFICATIONS controls notification-drawer visibility, not FGS eligibility.
return hasBluetoothPermissions()
}
private fun getUnifiedActivePeerCount(): Int {
@ -281,12 +278,6 @@ class MeshForegroundService : Service() {
}
}
private fun hasNotificationPermission(): Boolean {
return if (Build.VERSION.SDK_INT >= 33) {
androidx.core.content.ContextCompat.checkSelfPermission(this, android.Manifest.permission.POST_NOTIFICATIONS) == android.content.pm.PackageManager.PERMISSION_GRANTED
} else true
}
private fun buildNotification(activePeers: Int): Notification {
val openIntent = Intent(this, MainActivity::class.java)
val pendingIntent = PendingIntent.getActivity(

View File

@ -0,0 +1,249 @@
package com.bitchat.android.service
import android.Manifest
import android.annotation.SuppressLint
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat
import androidx.core.content.edit
import com.bitchat.android.MainActivity
import com.bitchat.android.R
internal enum class PeerAvailabilityAction {
NONE,
SHOW,
CLEAR
}
internal interface PeerAvailabilityAlertHistory {
var lastAlertAtMillis: Long?
}
internal class SharedPreferencesPeerAvailabilityAlertHistory(
context: Context
) : PeerAvailabilityAlertHistory {
companion object {
internal const val PREFERENCES_NAME = "peer_availability_notifications"
private const val KEY_LAST_ALERT_AT_MILLIS = "last_alert_at_millis"
}
private val preferences =
context.getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE)
override var lastAlertAtMillis: Long?
get() = if (preferences.contains(KEY_LAST_ALERT_AT_MILLIS)) {
preferences.getLong(KEY_LAST_ALERT_AT_MILLIS, 0L)
} else {
null
}
set(value) {
preferences.edit {
if (value == null) {
remove(KEY_LAST_ALERT_AT_MILLIS)
} else {
putLong(KEY_LAST_ALERT_AT_MILLIS, value)
}
}
}
}
/**
* Tracks mesh availability epochs with two anti-flapping gates:
* - no more than one alert per persisted cooldown window;
* - after an alert, the mesh must remain empty before another epoch can re-arm.
*/
internal class PeerAvailabilityTracker(
private val alertHistory: PeerAvailabilityAlertHistory,
private val nowMillis: () -> Long = System::currentTimeMillis,
private val alertCooldownMs: Long = ALERT_COOLDOWN_MS,
private val emptyRearmDelayMs: Long = EMPTY_REARM_DELAY_MS
) {
companion object {
internal const val ALERT_COOLDOWN_MS = 5 * 60_000L
internal const val EMPTY_REARM_DELAY_MS = 30_000L
}
private var previousPeerCount = 0
private var isArmed = true
private var emptySinceMillis: Long? = null
init {
require(alertCooldownMs >= 0) { "alertCooldownMs must not be negative" }
require(emptyRearmDelayMs >= 0) { "emptyRearmDelayMs must not be negative" }
}
fun update(peerCount: Int, isAppInBackground: Boolean): PeerAvailabilityAction {
require(peerCount >= 0) { "peerCount must not be negative" }
val now = nowMillis()
if (peerCount == 0) {
if (previousPeerCount > 0 || emptySinceMillis == null) {
emptySinceMillis = now
}
previousPeerCount = 0
return PeerAvailabilityAction.CLEAR
}
val transitionedFromEmpty = previousPeerCount == 0
previousPeerCount = peerCount
if (!transitionedFromEmpty) return PeerAvailabilityAction.NONE
if (!isArmed) {
val emptySince = emptySinceMillis
val remainedEmptyLongEnough =
emptySince != null && now - emptySince >= emptyRearmDelayMs
if (!remainedEmptyLongEnough) {
emptySinceMillis = null
return PeerAvailabilityAction.NONE
}
isArmed = true
}
emptySinceMillis = null
val lastAlertAt = alertHistory.lastAlertAtMillis
val cooldownElapsed =
lastAlertAt == null || now - lastAlertAt >= alertCooldownMs
return if (isAppInBackground && cooldownElapsed) {
PeerAvailabilityAction.SHOW
} else {
PeerAvailabilityAction.NONE
}
}
/**
* Called only after NotificationManager accepts the post. Failed or disabled posts
* do not consume the cooldown or require the mesh to re-arm.
*/
fun markAlertShown() {
alertHistory.lastAlertAtMillis = nowMillis()
isArmed = false
}
}
internal interface PeerAvailabilityTextProvider {
fun title(): String
fun body(peerCount: Int): String
}
private class AndroidPeerAvailabilityTextProvider(
private val context: Context
) : PeerAvailabilityTextProvider {
override fun title(): String {
return context.getString(R.string.notification_active_peers_title)
}
override fun body(peerCount: Int): String {
return if (peerCount == 1) {
context.getString(R.string.notification_active_peers_one)
} else {
context.getString(R.string.notification_active_peers_many, peerCount)
}
}
}
/**
* Owns the user-visible "bitchatters nearby" notification independently of the UI delegate.
*/
internal class PeerAvailabilityNotifier(
private val context: Context,
private val notificationManager: NotificationManagerCompat =
NotificationManagerCompat.from(context),
private val tracker: PeerAvailabilityTracker = PeerAvailabilityTracker(
SharedPreferencesPeerAvailabilityAlertHistory(context)
),
private val textProvider: PeerAvailabilityTextProvider =
AndroidPeerAvailabilityTextProvider(context),
private val canPostNotifications: () -> Boolean = {
notificationManager.areNotificationsEnabled() &&
(
Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
ContextCompat.checkSelfPermission(
context,
Manifest.permission.POST_NOTIFICATIONS
) == PackageManager.PERMISSION_GRANTED
)
}
) {
companion object {
internal const val CHANNEL_ID = "bitchat_peer_availability_notifications"
internal const val NOTIFICATION_ID = 997
private const val TAG = "PeerAvailability"
}
init {
createNotificationChannel()
}
fun onPeerCountChanged(peerCount: Int, isAppInBackground: Boolean) {
when (tracker.update(peerCount, isAppInBackground)) {
PeerAvailabilityAction.NONE -> Unit
PeerAvailabilityAction.CLEAR -> clear()
PeerAvailabilityAction.SHOW -> showNotification(peerCount)
}
}
fun clear() {
notificationManager.cancel(NOTIFICATION_ID)
}
private fun createNotificationChannel() {
val channel = NotificationChannel(
CHANNEL_ID,
textProvider.title(),
NotificationManager.IMPORTANCE_HIGH
).apply {
enableVibration(true)
setShowBadge(false)
}
val systemManager =
context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
systemManager.createNotificationChannel(channel)
}
@SuppressLint("MissingPermission")
private fun showNotification(peerCount: Int) {
if (!canPostNotifications()) {
Log.i(TAG, "Skipping peer availability notification because notifications are disabled")
return
}
val openAppIntent = Intent(context, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
}
val pendingIntent = PendingIntent.getActivity(
context,
NOTIFICATION_ID,
openAppIntent,
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
)
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle(textProvider.title())
.setContentText(textProvider.body(peerCount))
.setContentIntent(pendingIntent)
.setAutoCancel(true)
.setOnlyAlertOnce(true)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setCategory(NotificationCompat.CATEGORY_SOCIAL)
.setShowWhen(true)
.setWhen(System.currentTimeMillis())
.build()
try {
notificationManager.notify(NOTIFICATION_ID, notification)
tracker.markAlertShown()
Log.i(TAG, "Posted peer availability notification for $peerCount peer(s)")
} catch (error: SecurityException) {
Log.w(TAG, "Notification permission changed before peer alert was posted", error)
}
}
}

View File

@ -1,5 +1,6 @@
package com.bitchat.android.services
import android.content.Context
import com.bitchat.android.model.BitchatMessage
import com.bitchat.android.model.DeliveryStatus
import kotlinx.coroutines.flow.MutableStateFlow
@ -13,8 +14,10 @@ import kotlinx.coroutines.flow.asStateFlow
object AppStateStore {
// Global de-dup set by message id to avoid duplicate keys in Compose lists
private val seenMessageIds = mutableSetOf<String>()
private val reservedPrivateMessageIds = mutableSetOf<String>()
private val seenPublicMessageKeys = mutableSetOf<String>()
private val peerIdsByTransport = mutableMapOf<String, Set<String>>()
private var privateWritesSinceGlobalPrune = 0
// Direct (single-hop) peer IDs per transport, used to gossip a unified neighbor set.
private val directPeerIdsByTransport = mutableMapOf<String, Set<String>>()
private val _directPeers = MutableStateFlow<Set<String>>(emptySet())
@ -30,6 +33,20 @@ object AppStateStore {
// Private messages by peerID
private val _privateMessages = MutableStateFlow<Map<String, List<BitchatMessage>>>(emptyMap())
val privateMessages: StateFlow<Map<String, List<BitchatMessage>>> = _privateMessages.asStateFlow()
private val _readPrivateMessageIDs = MutableStateFlow<Set<String>>(emptySet())
val readPrivateMessageIDs: StateFlow<Set<String>> = _readPrivateMessageIDs.asStateFlow()
private val _unreadPrivateMessageCounts = MutableStateFlow<Map<String, Int>>(emptyMap())
val unreadPrivateMessageCounts: StateFlow<Map<String, Int>> =
_unreadPrivateMessageCounts.asStateFlow()
private val _privateConversationDisplayNames =
MutableStateFlow<Map<String, String>>(emptyMap())
val privateConversationDisplayNames: StateFlow<Map<String, String>> =
_privateConversationDisplayNames.asStateFlow()
@Volatile
private var conversationRepository: ConversationRepository? = null
private var privateConversationWritesSuspended = false
private var privateConversationGeneration = 0L
private val _nickname = MutableStateFlow("")
val nickname: StateFlow<String> = _nickname.asStateFlow()
@ -55,6 +72,69 @@ object AppStateStore {
_selectedPrivateChatPeer.value = peerID
}
fun initializeConversationPersistence(context: Context) {
val repository = ConversationRepository.getInstance(context.applicationContext)
conversationRepository = repository
repository.initialize(::restorePrivateConversations)
}
/**
* Restores database state again for a newly created UI, even if Android reused this process
* after a controlled shutdown cleared the process-wide state.
*/
fun reloadConversationPersistence(context: Context) {
val repository = ConversationRepository.getInstance(context.applicationContext)
conversationRepository = repository
repository.reload(::restorePrivateConversations)
}
internal fun setConversationRepositoryForTest(repository: ConversationRepository?) {
conversationRepository = repository
}
suspend fun awaitConversationPersistence() {
conversationRepository?.awaitPendingWrites()
}
suspend fun loadPrivateConversationHistory(conversationID: String): Boolean {
val repository = conversationRepository ?: return false
val snapshot = repository.loadConversationAndWait(
ContactDirectory.canonicalConversationId(conversationID)
) ?: return false
restorePrivateConversations(snapshot)
return true
}
/**
* Drops an opened conversation's full payloads from memory while retaining its summary row.
* The complete bounded history remains encrypted in SQLite and is loaded again on demand.
*/
fun releasePrivateConversationHistory(conversationID: String) {
synchronized(this) {
val canonicalID = ContactDirectory.canonicalConversationId(conversationID)
val matching = _privateMessages.value.entries.filter { (id, _) ->
ContactDirectory.canonicalConversationId(id)
.equals(canonicalID, ignoreCase = true)
}
val latest = matching
.flatMap { it.value }
.distinctBy { it.id }
.maxWithOrNull(
compareBy<BitchatMessage> {
PrivateMessageArrivalOrder.sequenceOf(it.id) ?: Long.MIN_VALUE
}.thenBy { it.timestamp.time }
)
?: return
val compacted = _privateMessages.value.toMutableMap()
matching.forEach { compacted.remove(it.key) }
compacted[canonicalID] = listOf(latest)
_privateMessages.value = compacted
}
}
val conversationStoreState: StateFlow<ConversationStoreState>
get() = conversationRepository?.storeState ?: EMPTY_CONVERSATION_STORE_STATE
fun setTransportPeers(transportId: String, ids: List<String>) {
synchronized(this) {
peerIdsByTransport[transportId] = ids.toSet()
@ -116,17 +196,155 @@ object AppStateStore {
}
}
fun addPrivateMessage(peerID: String, msg: BitchatMessage) {
synchronized(this) {
if (seenMessageIds.contains(msg.id)) return
seenMessageIds.add(msg.id)
PrivateMessageArrivalOrder.record(msg.id)
val conversationID = ContactDirectory.canonicalConversationId(peerID)
val map = _privateMessages.value.toMutableMap()
val list = (map[conversationID] ?: emptyList()) + msg
map[conversationID] = list
_privateMessages.value = ContactDirectory.canonicalizePrivateChats(map)
fun addPrivateMessage(
peerID: String,
msg: BitchatMessage,
forceRead: Boolean = false
): Boolean = synchronized(this) {
addPrivateMessageLocked(peerID, msg, forceRead, persistAsynchronously = true)
}
/**
* Persists an incoming private message before it is admitted to UI, unread, haptic, or
* notification state. Transport callbacks invoke this from their background worker.
*/
suspend fun addPrivateMessageDurably(
peerID: String,
msg: BitchatMessage,
forceRead: Boolean = false
): Boolean {
val persistence = synchronized(this) {
if (privateConversationWritesSuspended) return false
if (seenMessageIds.contains(msg.id) || !reservedPrivateMessageIds.add(msg.id)) {
return false
}
privateMessagePersistence(peerID, msg, forceRead)
}
val repository = persistence.repository
if (repository == null) {
synchronized(this) { reservedPrivateMessageIds.remove(msg.id) }
return false
}
val persisted = repository.upsertMessageAndWait(
conversationID = persistence.conversationID,
aliases = persistence.aliases,
displayName = persistence.displayName,
message = msg,
isRead = persistence.isRead
)
return synchronized(this) {
reservedPrivateMessageIds.remove(msg.id)
if (
!persisted ||
privateConversationWritesSuspended ||
persistence.generation != privateConversationGeneration ||
seenMessageIds.contains(msg.id)
) {
return@synchronized false
}
addPrivateMessageLocked(
peerID = peerID,
msg = msg,
forceRead = forceRead,
persistAsynchronously = false
)
}
}
private fun addPrivateMessageLocked(
peerID: String,
msg: BitchatMessage,
forceRead: Boolean,
persistAsynchronously: Boolean
): Boolean {
if (privateConversationWritesSuspended) return false
if (seenMessageIds.contains(msg.id)) return false
seenMessageIds.add(msg.id)
PrivateMessageArrivalOrder.record(msg.id)
val conversationID = ContactDirectory.canonicalConversationId(peerID)
val map = _privateMessages.value.toMutableMap()
val list = (map[conversationID] ?: emptyList()) + msg
map[conversationID] = list
_privateMessages.value = ContactDirectory.canonicalizePrivateChats(map)
val isRead = forceRead ||
msg.sender == "system" ||
msg.sender == _nickname.value ||
_selectedPrivateChatPeer.value
?.let(ContactDirectory::canonicalConversationId)
?.equals(conversationID, ignoreCase = true) == true
if (isRead) {
_readPrivateMessageIDs.value = _readPrivateMessageIDs.value + msg.id
} else {
val counts = _unreadPrivateMessageCounts.value.toMutableMap()
counts[conversationID] = (counts[conversationID] ?: 0) + 1
_unreadPrivateMessageCounts.value = counts
}
val aliases = runCatching {
ContactDirectory.aliasesForConversation(peerID) +
ContactDirectory.aliasesForConversation(conversationID) +
listOfNotNull(msg.senderPeerID)
}.getOrDefault(setOf(peerID, conversationID))
val displayName = ContactDirectory.resolve(conversationID).displayName
?: msg.sender.takeUnless {
it.isBlank() || it == "system" || it == _nickname.value
}
displayName
?.takeUnless {
it.isBlank() || it.equals("Unknown", ignoreCase = true)
}
?.let { updateConversationDisplayNameLocked(conversationID, it) }
if (persistAsynchronously) {
conversationRepository?.upsertMessage(
conversationID = conversationID,
aliases = aliases,
displayName = displayName,
message = msg,
isRead = isRead
)
}
prunePrivateMessagesLocked(conversationID)
return true
}
private fun privateMessagePersistence(
peerID: String,
msg: BitchatMessage,
forceRead: Boolean
): PendingPrivateMessagePersistence {
val conversationID = ContactDirectory.canonicalConversationId(peerID)
val existingMessages = _privateMessages.value[conversationID].orEmpty()
val isRead = forceRead ||
msg.sender == "system" ||
msg.sender == _nickname.value ||
_selectedPrivateChatPeer.value
?.let(ContactDirectory::canonicalConversationId)
?.equals(conversationID, ignoreCase = true) == true
val aliases = runCatching {
ContactDirectory.aliasesForConversation(peerID) +
ContactDirectory.aliasesForConversation(conversationID) +
listOfNotNull(msg.senderPeerID)
}.getOrDefault(setOf(peerID, conversationID))
val displayName = ContactDirectory.resolve(conversationID).displayName
?: (existingMessages + msg)
.lastOrNull { candidate ->
candidate.sender.isNotBlank() &&
candidate.sender != "system" &&
candidate.sender != _nickname.value
}
?.sender
return PendingPrivateMessagePersistence(
repository = conversationRepository,
conversationID = conversationID,
aliases = aliases,
displayName = displayName,
isRead = isRead,
generation = privateConversationGeneration
)
}
fun hasSeenMessage(messageID: String): Boolean = synchronized(this) {
messageID in seenMessageIds
}
private fun statusPriority(status: DeliveryStatus?): Int = when (status) {
@ -141,6 +359,7 @@ object AppStateStore {
fun updatePrivateMessageStatus(messageID: String, status: DeliveryStatus) {
synchronized(this) {
if (privateConversationWritesSuspended) return
val map = _privateMessages.value.toMutableMap()
var changed = false
map.keys.toList().forEach { peer ->
@ -149,7 +368,14 @@ object AppStateStore {
if (idx >= 0) {
val current = list[idx].deliveryStatus
// Do not downgrade (e.g., Read -> Delivered)
if (statusPriority(status) >= statusPriority(current)) {
val mayReplace = when {
status is DeliveryStatus.Failed ->
current !is DeliveryStatus.Delivered &&
current !is DeliveryStatus.Read
current is DeliveryStatus.Failed -> true
else -> statusPriority(status) >= statusPriority(current)
}
if (mayReplace) {
list[idx] = list[idx].copy(deliveryStatus = status)
map[peer] = list
changed = true
@ -159,13 +385,26 @@ object AppStateStore {
if (changed) {
_privateMessages.value = map
}
// Full histories are unloaded after a chat closes, so the message may only exist in
// SQLite. Always offer the update to the repository; it safely ignores unknown IDs
// and enforces the same monotonic status rules as the in-memory path.
conversationRepository?.updateDeliveryStatus(messageID, status)
}
}
fun unifyPrivateChatsIntoPeer(targetPeerID: String, keysToMerge: List<String>) {
if (keysToMerge.isEmpty()) return
synchronized(this) {
if (privateConversationWritesSuspended) return
val targetConversationID = ContactDirectory.canonicalConversationId(targetPeerID)
val persistenceAliases = (keysToMerge + targetPeerID + targetConversationID)
.flatMap { key ->
runCatching {
ContactDirectory.aliasesForConversation(key).toList()
}.getOrDefault(listOf(key))
}
.toSet()
conversationRepository?.mergeAliases(targetConversationID, persistenceAliases)
val map = _privateMessages.value.toMutableMap()
val targetList = (map[targetConversationID] ?: emptyList()).toMutableList()
val targetIds = targetList.map { it.id }.toMutableSet()
@ -199,20 +438,330 @@ object AppStateStore {
} else {
map[targetConversationID] = targetList
}
_privateMessages.value = ContactDirectory.canonicalizePrivateChats(map)
_privateMessages.value = map
}
canonicalizePrivateConversationStateLocked()
}
}
fun canonicalizePrivateChats() {
synchronized(this) {
val canonical = ContactDirectory.canonicalizePrivateChats(_privateMessages.value)
if (canonical != _privateMessages.value) {
_privateMessages.value = canonical
canonicalizePrivateConversationStateLocked()
}
}
/**
* Applies current peer announcements to retained conversations and persists the latest name
* independently of message history. A nickname change must not require another message to
* survive process death.
*/
fun updatePrivateConversationDisplayNames(peerNicknames: Map<String, String>) {
if (peerNicknames.isEmpty()) return
synchronized(this) {
if (privateConversationWritesSuspended || _privateMessages.value.isEmpty()) return
canonicalizePrivateConversationStateLocked()
val conversationIDs = _privateMessages.value.keys
.mapTo(mutableSetOf()) { it.lowercase() }
peerNicknames.forEach { (peerID, nickname) ->
val usableName = nickname.takeUnless {
it.isBlank() || it.equals("Unknown", ignoreCase = true)
} ?: return@forEach
val canonicalID = ContactDirectory.canonicalConversationId(peerID)
if (canonicalID.lowercase() !in conversationIDs) {
return@forEach
}
updateConversationDisplayNameLocked(canonicalID, usableName)
val aliases = runCatching {
ContactDirectory.aliasesForConversation(peerID) +
ContactDirectory.aliasesForConversation(canonicalID)
}.getOrDefault(setOf(peerID, canonicalID))
conversationRepository?.updateConversationIdentity(
conversationID = canonicalID,
aliases = aliases,
displayName = usableName
)
}
}
}
fun markPrivateMessageRead(messageID: String) {
synchronized(this) {
if (privateConversationWritesSuspended) return
if (messageID in _readPrivateMessageIDs.value) return
canonicalizePrivateConversationStateLocked()
_readPrivateMessageIDs.value = _readPrivateMessageIDs.value + messageID
val conversationID = _privateMessages.value.entries
.firstOrNull { (_, messages) -> messages.any { it.id == messageID } }
?.key
if (conversationID != null) {
val counts = _unreadPrivateMessageCounts.value.toMutableMap()
val remaining = ((counts[conversationID] ?: 0) - 1).coerceAtLeast(0)
if (remaining == 0) counts.remove(conversationID) else {
counts[conversationID] = remaining
}
_unreadPrivateMessageCounts.value = counts
}
conversationRepository?.markRead(messageID)
}
}
fun isPrivateMessageRead(messageID: String): Boolean =
messageID in _readPrivateMessageIDs.value
suspend fun setPrivateConversationRead(
conversationID: String,
isRead: Boolean
): Boolean {
val canonicalID = ContactDirectory.canonicalConversationId(conversationID)
val repository = conversationRepository ?: return false
val result = repository.setConversationReadAndWait(canonicalID, isRead)
if (!result.success) return false
synchronized(this) {
val messageIDs = _privateMessages.value
.filterKeys { key ->
ContactDirectory.canonicalConversationId(key)
.equals(canonicalID, ignoreCase = true)
}
.values
.flatten()
.mapTo(linkedSetOf(), BitchatMessage::id)
if (isRead) {
_readPrivateMessageIDs.value = _readPrivateMessageIDs.value + messageIDs
_unreadPrivateMessageCounts.value =
_unreadPrivateMessageCounts.value.filterKeys { key ->
!ContactDirectory.canonicalConversationId(key)
.equals(canonicalID, ignoreCase = true)
}
} else {
result.affectedMessageID?.let { latestMessageID ->
_readPrivateMessageIDs.value =
_readPrivateMessageIDs.value - latestMessageID
_unreadPrivateMessageCounts.value =
_unreadPrivateMessageCounts.value + (canonicalID to 1)
}
}
}
return true
}
fun deletePrivateConversation(peerOrConversationID: String): Set<String> {
synchronized(this) {
val canonicalID = ContactDirectory.canonicalConversationId(peerOrConversationID)
val matchingKeys = _privateMessages.value.keys.filterTo(linkedSetOf()) { key ->
ContactDirectory.canonicalConversationId(key)
.equals(canonicalID, ignoreCase = true)
}
val aliases = (matchingKeys + peerOrConversationID + canonicalID)
.flatMap { key ->
runCatching {
ContactDirectory.aliasesForConversation(key).toList()
}.getOrDefault(listOf(key))
}
.toSet()
val messageIDs = matchingKeys
.flatMapTo(linkedSetOf()) { _privateMessages.value[it].orEmpty().map { it.id } }
// Queue the database deletion while holding the same lock used by addPrivateMessage.
// A genuinely new arrival is therefore queued after the delete and starts a fresh chat.
conversationRepository?.deleteConversation(canonicalID, aliases)
val updated = _privateMessages.value.toMutableMap()
matchingKeys.forEach(updated::remove)
_privateMessages.value = updated
removeConversationDisplayNamesLocked(canonicalID)
_readPrivateMessageIDs.value = _readPrivateMessageIDs.value - messageIDs
_unreadPrivateMessageCounts.value =
_unreadPrivateMessageCounts.value - matchingKeys - canonicalID
if (
_selectedPrivateChatPeer.value
?.let(ContactDirectory::canonicalConversationId)
?.equals(canonicalID, ignoreCase = true) == true
) {
_selectedPrivateChatPeer.value = null
}
return messageIDs
}
}
internal suspend fun deletePrivateConversationAndWait(
peerOrConversationID: String
): DeletedPrivateConversation? {
loadPrivateConversationHistory(peerOrConversationID)
val deletion = synchronized(this) {
if (privateConversationWritesSuspended) return null
buildDeletedConversationLocked(peerOrConversationID)
}
val repository = conversationRepository ?: return null
if (!repository.deleteConversationAndWait(deletion.conversationID, deletion.aliases)) {
return null
}
synchronized(this) {
val updated = _privateMessages.value.toMutableMap()
updated.keys.toList().forEach { key ->
if (
ContactDirectory.canonicalConversationId(key)
.equals(deletion.conversationID, ignoreCase = true)
) {
val remaining = updated[key].orEmpty().filterNot {
it.id in deletion.messageIDs
}
if (remaining.isEmpty()) updated.remove(key) else updated[key] = remaining
}
}
_privateMessages.value = updated
removeConversationDisplayNamesLocked(deletion.conversationID)
_readPrivateMessageIDs.value =
_readPrivateMessageIDs.value - deletion.messageIDs
canonicalizePrivateConversationStateLocked()
val counts = _unreadPrivateMessageCounts.value.toMutableMap()
val currentCount = counts[deletion.conversationID] ?: 0
val remainingUnread = (currentCount - deletion.unreadMessageCount).coerceAtLeast(0)
if (remainingUnread == 0) counts.remove(deletion.conversationID) else {
counts[deletion.conversationID] = remainingUnread
}
_unreadPrivateMessageCounts.value = counts
if (
_selectedPrivateChatPeer.value
?.let(ContactDirectory::canonicalConversationId)
?.equals(deletion.conversationID, ignoreCase = true) == true
) {
_selectedPrivateChatPeer.value = null
}
}
return deletion
}
internal suspend fun restoreDeletedConversation(
deletion: DeletedPrivateConversation
): Boolean {
val repository = conversationRepository ?: return false
val restoredDisplayName =
ContactDirectory.resolve(deletion.conversationID).displayName
?: deletion.displayName
if (
!repository.restoreConversationAndWait(
conversationID = deletion.conversationID,
aliases = deletion.aliases,
displayName = restoredDisplayName,
messages = deletion.messages,
readMessageIDs = deletion.readMessageIDs
)
) {
return false
}
synchronized(this) {
seenMessageIds.removeAll(deletion.messageIDs)
deletion.messages.forEach { message ->
addPrivateMessageLocked(
peerID = deletion.conversationID,
msg = message,
forceRead = message.id in deletion.readMessageIDs,
persistAsynchronously = false
)
}
restoredDisplayName?.let {
updateConversationDisplayNameLocked(deletion.conversationID, it)
}
}
return true
}
private fun buildDeletedConversationLocked(
peerOrConversationID: String
): DeletedPrivateConversation {
val canonicalID = ContactDirectory.canonicalConversationId(peerOrConversationID)
val matchingKeys = _privateMessages.value.keys.filterTo(linkedSetOf()) { key ->
ContactDirectory.canonicalConversationId(key)
.equals(canonicalID, ignoreCase = true)
}
val aliases = (matchingKeys + peerOrConversationID + canonicalID)
.flatMap { key ->
runCatching {
ContactDirectory.aliasesForConversation(key).toList()
}.getOrDefault(listOf(key))
}
.toSet()
val messages = matchingKeys
.flatMap { _privateMessages.value[it].orEmpty() }
.distinctBy(BitchatMessage::id)
val messageIDs = messages.mapTo(linkedSetOf(), BitchatMessage::id)
val readIDs = _readPrivateMessageIDs.value.intersect(messageIDs)
return DeletedPrivateConversation(
conversationID = canonicalID,
aliases = aliases,
displayName = _privateConversationDisplayNames.value.entries
.firstOrNull { (key, _) ->
ContactDirectory.canonicalConversationId(key)
.equals(canonicalID, ignoreCase = true)
}
?.value
?: ContactDirectory.resolve(canonicalID).displayName,
messages = messages,
readMessageIDs = readIDs,
unreadMessageCount = messages.count { message ->
message.id !in readIDs &&
message.sender != "system" &&
message.sender != _nickname.value
}
)
}
fun removePrivateMessage(messageID: String) {
synchronized(this) {
val updated = _privateMessages.value.toMutableMap()
var changed = false
updated.keys.toList().forEach { conversationID ->
val messages = updated[conversationID].orEmpty()
if (messages.any { it.id == messageID }) {
val remaining = messages.filterNot { it.id == messageID }
if (remaining.isEmpty()) {
updated.remove(conversationID)
} else {
updated[conversationID] = remaining
}
changed = true
}
}
if (!changed) return
conversationRepository?.deleteMessage(messageID)
_privateMessages.value = updated
val retainedConversationIDs = updated.keys
.mapTo(mutableSetOf()) {
ContactDirectory.canonicalConversationId(it).lowercase()
}
_privateConversationDisplayNames.value =
_privateConversationDisplayNames.value.filterKeys {
ContactDirectory.canonicalConversationId(it).lowercase() in
retainedConversationIDs
}
_readPrivateMessageIDs.value = _readPrivateMessageIDs.value - messageID
}
}
/**
* Atomically hides all conversations, rejects in-flight transport deliveries, then waits for
* every earlier database write and the panic wipe itself to finish.
*/
suspend fun panicClearPrivateConversations(): Boolean {
val repository = synchronized(this) {
privateConversationWritesSuspended = true
privateConversationGeneration += 1
_privateMessages.value = emptyMap()
_readPrivateMessageIDs.value = emptySet()
_unreadPrivateMessageCounts.value = emptyMap()
_privateConversationDisplayNames.value = emptyMap()
_selectedPrivateChatPeer.value = null
conversationRepository
}
return repository?.clearAllAndWait() ?: true
}
fun resumePrivateConversationsAfterPanic() {
synchronized(this) {
privateConversationWritesSuspended = false
}
}
fun addChannelMessage(channel: String, msg: BitchatMessage) {
synchronized(this) {
if (seenMessageIds.contains(msg.id)) return
@ -228,14 +777,20 @@ object AppStateStore {
fun clear() {
synchronized(this) {
seenMessageIds.clear()
reservedPrivateMessageIds.clear()
privateConversationGeneration += 1
seenPublicMessageKeys.clear()
PrivateMessageArrivalOrder.clear()
privateWritesSinceGlobalPrune = 0
peerIdsByTransport.clear()
directPeerIdsByTransport.clear()
_peers.value = emptyList()
_directPeers.value = emptySet()
_publicMessages.value = emptyList()
_privateMessages.value = emptyMap()
_readPrivateMessageIDs.value = emptySet()
_unreadPrivateMessageCounts.value = emptyMap()
_privateConversationDisplayNames.value = emptyMap()
_channelMessages.value = emptyMap()
_nickname.value = ""
_selectedPrivateChatPeer.value = null
@ -252,4 +807,253 @@ object AppStateStore {
msg.content
).joinToString("\u001F")
}
internal fun restorePrivateConversations(snapshot: PersistedConversationSnapshot) {
synchronized(this) {
if (privateConversationWritesSuspended) return
val liveChats = _privateMessages.value
val liveMessageIDs = liveChats.values.flatten().map { it.id }
PrivateMessageArrivalOrder.restore(
snapshot.arrivalOrder,
liveMessageIDs,
snapshot.receivedAtByMessageID,
snapshot.arrivalSequenceByMessageID
)
val merged = linkedMapOf<String, MutableList<BitchatMessage>>()
snapshot.chats.forEach { (conversationID, messages) ->
merged.getOrPut(conversationID) { mutableListOf() }.addAll(messages)
}
liveChats.forEach { (conversationID, messages) ->
val target = merged.getOrPut(conversationID) { mutableListOf() }
messages
.filterNot { it.id in snapshot.deletedMessageIDs }
.forEach { live ->
val existingIndex = target.indexOfFirst { it.id == live.id }
if (existingIndex >= 0) {
target[existingIndex] = live
} else {
target.add(live)
}
}
}
merged.values.flatten().forEach { seenMessageIds.add(it.id) }
seenMessageIds.addAll(snapshot.deletedMessageIDs)
_readPrivateMessageIDs.value =
(snapshot.readMessageIDs + _readPrivateMessageIDs.value) -
snapshot.deletedMessageIDs
val unreadCounts = _unreadPrivateMessageCounts.value.toMutableMap()
snapshot.unreadCounts.forEach { (conversationID, count) ->
if (count > 0) unreadCounts[conversationID] = count
else unreadCounts.remove(conversationID)
}
_unreadPrivateMessageCounts.value = unreadCounts
_privateConversationDisplayNames.value =
snapshot.displayNames + _privateConversationDisplayNames.value
_privateMessages.value = ContactDirectory.canonicalizePrivateChats(
merged.mapValues { (_, messages) ->
PrivateMessageArrivalOrder.order(messages.distinctBy { it.id })
}
)
canonicalizePrivateConversationStateLocked()
}
}
private fun updateConversationDisplayNameLocked(
conversationID: String,
displayName: String
) {
val canonicalID = ContactDirectory.canonicalConversationId(conversationID)
val updated = _privateConversationDisplayNames.value
.filterKeys { key ->
!ContactDirectory.canonicalConversationId(key)
.equals(canonicalID, ignoreCase = true)
}
.toMutableMap()
updated[canonicalID] = displayName
_privateConversationDisplayNames.value = updated
}
private fun removeConversationDisplayNamesLocked(conversationID: String) {
val canonicalID = ContactDirectory.canonicalConversationId(conversationID)
_privateConversationDisplayNames.value =
_privateConversationDisplayNames.value.filterKeys { key ->
!ContactDirectory.canonicalConversationId(key)
.equals(canonicalID, ignoreCase = true)
}
}
/**
* Identity mappings can become richer after a Noise handshake or favorite/Nostr update.
* Keep every process-wide projection on the same canonical key so unread/read/delete updates
* cannot leave a stale alias behind.
*/
private fun canonicalizePrivateConversationStateLocked() {
val canonicalChats = ContactDirectory.canonicalizePrivateChats(_privateMessages.value)
if (canonicalChats != _privateMessages.value) {
_privateMessages.value = canonicalChats
}
val canonicalUnreadCounts = linkedMapOf<String, Int>()
_unreadPrivateMessageCounts.value.forEach { (conversationID, count) ->
if (count <= 0) return@forEach
val canonicalID = ContactDirectory.canonicalConversationId(conversationID)
canonicalUnreadCounts[canonicalID] =
(canonicalUnreadCounts[canonicalID] ?: 0) + count
}
if (canonicalUnreadCounts != _unreadPrivateMessageCounts.value) {
_unreadPrivateMessageCounts.value = canonicalUnreadCounts
}
val canonicalDisplayNames = linkedMapOf<String, String>()
_privateConversationDisplayNames.value.forEach { (conversationID, displayName) ->
if (displayName.isBlank()) return@forEach
canonicalDisplayNames[
ContactDirectory.canonicalConversationId(conversationID)
] = displayName
}
if (canonicalDisplayNames != _privateConversationDisplayNames.value) {
_privateConversationDisplayNames.value = canonicalDisplayNames
}
_selectedPrivateChatPeer.value?.let { selected ->
val canonicalSelected = ContactDirectory.canonicalConversationId(selected)
if (canonicalSelected != selected) {
_selectedPrivateChatPeer.value = canonicalSelected
}
}
}
private fun prunePrivateMessagesLocked(recentConversationID: String) {
val chats = _privateMessages.value.toMutableMap()
val removedIDs = linkedSetOf<String>()
val recentKey = chats.keys.firstOrNull { key ->
ContactDirectory.canonicalConversationId(key)
.equals(recentConversationID, ignoreCase = true)
}
if (recentKey != null) {
val messages = chats[recentKey].orEmpty()
val excess =
messages.size - ConversationDatabase.MAX_MESSAGES_PER_CONVERSATION
if (excess > 0) {
val removable = messages
.dropLast(1)
.sortedWith(privateMessagePruneComparator())
.take(excess)
.mapTo(linkedSetOf()) { it.id }
removedIDs.addAll(removable)
chats[recentKey] = messages.filterNot { it.id in removable }
}
}
privateWritesSinceGlobalPrune += 1
if (privateWritesSinceGlobalPrune >= 64) {
privateWritesSinceGlobalPrune = 0
var totalMessages = chats.values.sumOf { it.size }
var totalPayloadBytes = chats.values
.asSequence()
.flatten()
.sumOf(::privateMessagePayloadBytes)
if (
totalMessages > ConversationDatabase.MAX_MESSAGES_TOTAL ||
totalPayloadBytes > ConversationDatabase.MAX_PAYLOAD_BYTES
) {
val candidates = chats.values
.asSequence()
.flatMap { messages -> messages.dropLast(1).asSequence() }
.sortedWith(privateMessagePruneComparator())
.iterator()
while (
candidates.hasNext() &&
(
totalMessages > ConversationDatabase.MAX_MESSAGES_TOTAL ||
totalPayloadBytes > ConversationDatabase.MAX_PAYLOAD_BYTES
)
) {
val candidate = candidates.next()
if (!removedIDs.add(candidate.id)) continue
totalMessages -= 1
totalPayloadBytes -= privateMessagePayloadBytes(candidate)
}
if (
totalMessages > ConversationDatabase.MAX_MESSAGES_TOTAL ||
totalPayloadBytes > ConversationDatabase.MAX_PAYLOAD_BYTES
) {
// Enforce the hard bound even when every conversation contains only its
// newest message. Read conversations still sort ahead of unread ones.
val latestCandidates = chats.values
.asSequence()
.flatten()
.filterNot { it.id in removedIDs }
.sortedWith(privateMessagePruneComparator())
.iterator()
while (
latestCandidates.hasNext() &&
(
totalMessages > ConversationDatabase.MAX_MESSAGES_TOTAL ||
totalPayloadBytes > ConversationDatabase.MAX_PAYLOAD_BYTES
)
) {
val candidate = latestCandidates.next()
if (!removedIDs.add(candidate.id)) continue
totalMessages -= 1
totalPayloadBytes -= privateMessagePayloadBytes(candidate)
}
}
if (removedIDs.isNotEmpty()) {
chats.keys.toList().forEach { key ->
chats[key] = chats[key].orEmpty().filterNot {
it.id in removedIDs
}
}
}
}
}
if (removedIDs.isNotEmpty()) {
_privateMessages.value = chats.filterValues { it.isNotEmpty() }
_readPrivateMessageIDs.value = _readPrivateMessageIDs.value - removedIDs
}
}
private fun privateMessagePruneComparator(): Comparator<BitchatMessage> =
compareByDescending<BitchatMessage> {
it.id in _readPrivateMessageIDs.value
}.thenBy {
PrivateMessageArrivalOrder.sequenceOf(it.id) ?: Long.MAX_VALUE
}
private fun privateMessagePayloadBytes(message: BitchatMessage): Long =
message.content.toByteArray(Charsets.UTF_8).size.toLong() +
(message.encryptedContent?.size ?: 0) +
message.mentions.orEmpty().sumOf {
it.toByteArray(Charsets.UTF_8).size
}
}
private data class PendingPrivateMessagePersistence(
val repository: ConversationRepository?,
val conversationID: String,
val aliases: Set<String>,
val displayName: String?,
val isRead: Boolean,
val generation: Long
)
internal data class DeletedPrivateConversation(
val conversationID: String,
val aliases: Set<String>,
val displayName: String?,
val messages: List<BitchatMessage>,
val readMessageIDs: Set<String>,
val unreadMessageCount: Int,
val wasPinned: Boolean = false,
val wasMuted: Boolean = false,
val draft: String? = null
) {
val messageIDs: Set<String> = messages.mapTo(linkedSetOf(), BitchatMessage::id)
}
private val EMPTY_CONVERSATION_STORE_STATE =
MutableStateFlow<ConversationStoreState>(ConversationStoreState.Ready).asStateFlow()

View File

@ -81,9 +81,19 @@ object ContactDirectory {
conversationID = conversationID,
meshPeerID = liveMeshPeerID,
noisePublicKey = noiseKey ?: liveMeshPeerID?.let { meshProvider?.invoke()?.getPeerInfo(it)?.noisePublicKey },
nostrPubkey = favorite?.peerNostrPublicKey,
displayName = favorite?.peerNickname?.takeIf { it.isNotBlank() && !it.equals("Unknown", ignoreCase = true) }
?: liveMeshPeerID?.let { meshProvider?.invoke()?.getPeerInfo(it)?.nickname }
nostrPubkey = favorite?.peerNostrPublicKey
?: liveMeshPeerID?.let {
runCatching {
FavoritesPersistenceService.shared.findNostrPubkeyForPeerID(it)
}.getOrNull()
},
// A connected peer's current announcement is authoritative. Favorite and fingerprint
// records are offline fallbacks and can legitimately contain an older nickname.
displayName = liveMeshPeerID?.let { meshProvider?.invoke()?.getPeerInfo(it)?.nickname }
?.takeIf { it.isNotBlank() && !it.equals("Unknown", ignoreCase = true) }
?: favorite?.peerNickname?.takeIf {
it.isNotBlank() && !it.equals("Unknown", ignoreCase = true)
}
?: contactFingerprint?.let { cachedFingerprintNickname(it) },
isMutualFavorite = favorite?.isMutual == true
)

View File

@ -0,0 +1,194 @@
package com.bitchat.android.services
import android.content.Context
import com.bitchat.android.identity.SecureIdentityStateManager
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import org.json.JSONArray
import org.json.JSONObject
/**
* Small encrypted, immediately observable preferences for conversation-list organization.
*
* Message history remains in SQLite; these compact sets and drafts belong in the app's existing
* Keystore-backed preference store. Panic clearing the identity store also removes these values.
*/
internal class ConversationListPreferences private constructor(
private val stateManager: SecureIdentityStateManager,
private val canonicalize: (String) -> String
) {
private constructor(context: Context) : this(
SecureIdentityStateManager(context.applicationContext),
ContactDirectory::canonicalConversationId
)
internal constructor(
stateManager: SecureIdentityStateManager,
testOnly: Boolean,
canonicalize: (String) -> String = ContactDirectory::canonicalConversationId
) : this(stateManager, canonicalize) {
require(testOnly) { "Injected conversation preferences are test-only" }
}
companion object {
private const val PINNED_KEY = "conversation_pinned_v1"
private const val MUTED_KEY = "conversation_muted_v1"
private const val DRAFTS_KEY = "conversation_drafts_v1"
private const val MAX_DRAFT_CHARS = 8_000
private const val MAX_DRAFTS = 50
private const val MAX_DRAFT_CHARS_TOTAL = 128_000
@Volatile
private var instance: ConversationListPreferences? = null
fun getInstance(context: Context): ConversationListPreferences =
instance ?: synchronized(this) {
instance ?: ConversationListPreferences(context.applicationContext).also {
instance = it
}
}
}
private val _pinned = MutableStateFlow(loadSet(PINNED_KEY))
val pinned: StateFlow<Set<String>> = _pinned.asStateFlow()
private val _muted = MutableStateFlow(loadSet(MUTED_KEY))
val muted: StateFlow<Set<String>> = _muted.asStateFlow()
private val _drafts = MutableStateFlow(loadDrafts())
val drafts: StateFlow<Map<String, String>> = _drafts.asStateFlow()
fun togglePinned(conversationID: String) {
_pinned.value = _pinned.value.toggle(normalize(conversationID))
saveSet(PINNED_KEY, _pinned.value)
}
fun toggleMuted(conversationID: String) {
_muted.value = _muted.value.toggle(normalize(conversationID))
saveSet(MUTED_KEY, _muted.value)
}
fun isMuted(conversationID: String): Boolean =
normalize(conversationID) in _muted.value
fun isPinned(conversationID: String): Boolean =
normalize(conversationID) in _pinned.value
fun draftFor(conversationID: String): String? =
_drafts.value[normalize(conversationID)]
fun setDraft(conversationID: String, text: String) {
val key = normalize(conversationID)
val updated = _drafts.value.toMutableMap()
// Reinsert edited drafts at the end so bounded eviction approximates least-recently-used.
updated.remove(key)
val bounded = text.take(MAX_DRAFT_CHARS)
if (bounded.isNotBlank()) updated[key] = bounded
val retained = boundDrafts(updated)
_drafts.value = retained
saveDrafts(retained)
}
fun removeConversation(conversationID: String) {
val key = normalize(conversationID)
_pinned.value = _pinned.value - key
_muted.value = _muted.value - key
_drafts.value = _drafts.value - key
saveSet(PINNED_KEY, _pinned.value)
saveSet(MUTED_KEY, _muted.value)
saveDrafts(_drafts.value)
}
/**
* Re-key list preferences when a transient mesh ID becomes a stable contact identity.
* Without this, pin, mute, and draft state appears to disappear after a Noise/favorite update.
*/
fun canonicalizeAliases() {
val canonicalPinned = _pinned.value.mapTo(linkedSetOf(), ::normalize)
val canonicalMuted = _muted.value.mapTo(linkedSetOf(), ::normalize)
val canonicalDrafts = linkedMapOf<String, String>()
_drafts.value.forEach { (conversationID, draft) ->
canonicalDrafts[normalize(conversationID)] = draft
}
if (canonicalPinned != _pinned.value) {
_pinned.value = canonicalPinned
saveSet(PINNED_KEY, canonicalPinned)
}
if (canonicalMuted != _muted.value) {
_muted.value = canonicalMuted
saveSet(MUTED_KEY, canonicalMuted)
}
if (canonicalDrafts != _drafts.value) {
val bounded = boundDrafts(canonicalDrafts)
_drafts.value = bounded
saveDrafts(bounded)
}
}
fun clearInMemory() {
_pinned.value = emptySet()
_muted.value = emptySet()
_drafts.value = emptyMap()
}
fun clearAll(): Boolean {
val cleared = stateManager.clearSecureValuesSynchronously(
PINNED_KEY,
MUTED_KEY,
DRAFTS_KEY
)
clearInMemory()
return cleared
}
private fun loadSet(key: String): Set<String> = runCatching {
val array = JSONArray(stateManager.getSecureValue(key) ?: return emptySet())
buildSet {
for (index in 0 until array.length()) add(normalize(array.getString(index)))
}
}.getOrDefault(emptySet())
private fun saveSet(key: String, values: Set<String>) {
stateManager.storeSecureValue(key, JSONArray(values.sorted()).toString())
}
private fun loadDrafts(): Map<String, String> = runCatching {
val json = JSONObject(stateManager.getSecureValue(DRAFTS_KEY) ?: return emptyMap())
val loaded = buildMap {
json.keys().forEach { key ->
json.optString(key).takeIf(String::isNotBlank)?.let {
put(normalize(key), it.take(MAX_DRAFT_CHARS))
}
}
}
boundDrafts(loaded)
}.getOrDefault(emptyMap())
private fun saveDrafts(values: Map<String, String>) {
stateManager.storeSecureValue(
DRAFTS_KEY,
JSONObject().apply {
values.forEach { (key, value) -> put(key, value) }
}.toString()
)
}
private fun Set<String>.toggle(value: String): Set<String> =
if (value in this) this - value else this + value
private fun normalize(value: String): String =
canonicalize(value).lowercase()
private fun boundDrafts(values: Map<String, String>): Map<String, String> {
val retained = LinkedHashMap(values)
while (
retained.size > MAX_DRAFTS ||
retained.values.sumOf(String::length) > MAX_DRAFT_CHARS_TOTAL
) {
val oldest = retained.keys.firstOrNull() ?: break
retained.remove(oldest)
}
return retained
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,98 @@
package com.bitchat.android.services
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import java.security.KeyStore
import javax.crypto.Cipher
import javax.crypto.KeyGenerator
import javax.crypto.SecretKey
import javax.crypto.spec.GCMParameterSpec
/**
* Encrypts private-conversation payloads with a key that never leaves Android Keystore.
*
* Every value is bound to its database identity through AES-GCM associated data. This prevents an
* encrypted payload copied from one message or conversation row from being accepted in another.
* Deleting the dedicated alias provides practical cryptographic erasure before SQLite pages, WAL
* records, and filesystem blocks are reclaimed.
*/
internal interface ConversationStorageCipher {
fun encrypt(plaintext: ByteArray, associatedData: ByteArray): ByteArray
fun decrypt(envelope: ByteArray, associatedData: ByteArray): ByteArray
fun destroyKey()
}
internal class AndroidConversationStorageCipher(
private val keyAlias: String = DEFAULT_KEY_ALIAS
) : ConversationStorageCipher {
companion object {
internal const val DEFAULT_KEY_ALIAS = "bitchat_conversation_storage_v1"
private const val KEYSTORE_PROVIDER = "AndroidKeyStore"
private const val TRANSFORMATION = "AES/GCM/NoPadding"
private const val ENVELOPE_VERSION: Byte = 1
private const val GCM_TAG_BITS = 128
private const val IV_BYTES = 12
}
private val keyLock = Any()
@Volatile
private var cachedKey: SecretKey? = null
override fun encrypt(plaintext: ByteArray, associatedData: ByteArray): ByteArray {
val cipher = Cipher.getInstance(TRANSFORMATION)
cipher.init(Cipher.ENCRYPT_MODE, getOrCreateKey())
cipher.updateAAD(associatedData)
val ciphertext = cipher.doFinal(plaintext)
check(cipher.iv.size == IV_BYTES) { "Unexpected AES-GCM IV length" }
return byteArrayOf(ENVELOPE_VERSION) + cipher.iv + ciphertext
}
override fun decrypt(envelope: ByteArray, associatedData: ByteArray): ByteArray {
require(envelope.size > 1 + IV_BYTES) { "Conversation payload envelope is truncated" }
require(envelope[0] == ENVELOPE_VERSION) {
"Unsupported conversation payload envelope version"
}
val iv = envelope.copyOfRange(1, 1 + IV_BYTES)
val ciphertext = envelope.copyOfRange(1 + IV_BYTES, envelope.size)
val cipher = Cipher.getInstance(TRANSFORMATION)
cipher.init(Cipher.DECRYPT_MODE, getOrCreateKey(), GCMParameterSpec(GCM_TAG_BITS, iv))
cipher.updateAAD(associatedData)
return cipher.doFinal(ciphertext)
}
override fun destroyKey() {
synchronized(keyLock) {
cachedKey = null
val keyStore = KeyStore.getInstance(KEYSTORE_PROVIDER).apply { load(null) }
if (keyStore.containsAlias(keyAlias)) {
keyStore.deleteEntry(keyAlias)
}
}
}
private fun getOrCreateKey(): SecretKey =
cachedKey ?: synchronized(keyLock) {
cachedKey ?: run {
val keyStore = KeyStore.getInstance(KEYSTORE_PROVIDER).apply { load(null) }
(keyStore.getKey(keyAlias, null) as? SecretKey) ?: generateKey()
}.also { cachedKey = it }
}
private fun generateKey(): SecretKey {
val generator = KeyGenerator.getInstance(
KeyProperties.KEY_ALGORITHM_AES,
KEYSTORE_PROVIDER
)
generator.init(
KeyGenParameterSpec.Builder(
keyAlias,
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
)
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
.setKeySize(256)
.setRandomizedEncryptionRequired(true)
.build()
)
return generator.generateKey()
}
}

View File

@ -0,0 +1,42 @@
package com.bitchat.android.services
import com.bitchat.android.model.BitchatMessage
import kotlinx.coroutines.runBlocking
/**
* Reflects an incoming transport message into process-wide state before any downstream effects.
*
* Private-message admission is authoritative: a duplicate or a message rejected while panic mode
* is wiping state must not continue to UI delegates, unread tracking, haptics, or notifications.
* Public and channel messages retain their existing best-effort behavior if state reflection fails.
*/
internal object IncomingMessageAdmission {
fun admitToAppState(message: BitchatMessage): Boolean = try {
when {
message.isPrivate -> {
val peerID = message.senderPeerID?.takeIf(String::isNotBlank)
?: return false
// Mesh transport callbacks run on their background service workers. Wait for the
// serialized SQLite transaction so a notification can never advertise a message
// that an immediate process death would lose.
runBlocking {
AppStateStore.addPrivateMessageDurably(peerID, message)
}
}
message.channel != null -> {
AppStateStore.addChannelMessage(message.channel, message)
true
}
else -> {
AppStateStore.addPublicMessage(message)
true
}
}
} catch (_: Exception) {
// Preserve the pre-existing best-effort dispatch for public/channel messages, but never
// bypass private-message admission when persistence or canonicalization fails.
!message.isPrivate
}
}

View File

@ -11,16 +11,56 @@ import com.bitchat.android.model.BitchatMessage
*/
internal object PrivateMessageArrivalOrder {
private val sequenceByMessageID = mutableMapOf<String, Long>()
private val receivedAtByMessageID = mutableMapOf<String, Long>()
private var nextSequence = 0L
fun record(messageID: String) {
fun record(messageID: String, receivedAt: Long = System.currentTimeMillis()) {
synchronized(this) {
if (messageID !in sequenceByMessageID) {
sequenceByMessageID[messageID] = nextSequence++
receivedAtByMessageID[messageID] = receivedAt
}
}
}
fun restore(
persistedOrder: List<String>,
liveMessageIDs: List<String>,
persistedReceivedAt: Map<String, Long> = emptyMap(),
persistedSequences: Map<String, Long> = emptyMap()
) {
synchronized(this) {
val previousSequences = sequenceByMessageID.toMap()
val previousReceivedAt = receivedAtByMessageID.toMap()
sequenceByMessageID.clear()
receivedAtByMessageID.clear()
nextSequence = (persistedSequences.values.maxOrNull() ?: -1L) + 1L
(persistedOrder + liveMessageIDs).forEach { messageID ->
if (messageID !in sequenceByMessageID) {
val sequence = persistedSequences[messageID]
?: previousSequences[messageID]
?: nextSequence++
sequenceByMessageID[messageID] = sequence
(persistedReceivedAt[messageID] ?: previousReceivedAt[messageID])?.let {
receivedAtByMessageID[messageID] = it
}
}
}
nextSequence = maxOf(
nextSequence,
(sequenceByMessageID.values.maxOrNull() ?: -1L) + 1L
)
}
}
fun sequenceOf(messageID: String): Long? = synchronized(this) {
sequenceByMessageID[messageID]
}
fun receivedAtOf(messageID: String): Long? = synchronized(this) {
receivedAtByMessageID[messageID]
}
fun order(messages: List<BitchatMessage>): List<BitchatMessage> {
synchronized(this) {
if (messages.size < 2 || messages.any { it.id !in sequenceByMessageID }) {
@ -33,6 +73,7 @@ internal object PrivateMessageArrivalOrder {
fun clear() {
synchronized(this) {
sequenceByMessageID.clear()
receivedAtByMessageID.clear()
nextSequence = 0L
}
}

View File

@ -1,5 +1,6 @@
package com.bitchat.android.services
import android.annotation.SuppressLint
import android.content.Context
import android.util.Log
import com.bitchat.android.identity.SecureIdentityStateManager
@ -20,6 +21,8 @@ class SeenMessageStore private constructor(private val context: Context) {
private const val STORAGE_KEY = "seen_message_store_v1"
private const val MAX_IDS = com.bitchat.android.util.AppConstants.Services.SEEN_MESSAGE_MAX_IDS
// The constructor always receives applicationContext, so process lifetime is intentional.
@SuppressLint("StaticFieldLeak")
@Volatile private var INSTANCE: SeenMessageStore? = null
fun getInstance(appContext: Context): SeenMessageStore {
return INSTANCE ?: synchronized(this) {
@ -54,6 +57,7 @@ class SeenMessageStore private constructor(private val context: Context) {
locallyRead.add(id)
trim(locallyRead)
}
AppStateStore.markPrivateMessageRead(id)
persist()
}
@ -65,6 +69,14 @@ class SeenMessageStore private constructor(private val context: Context) {
persist()
}
@Synchronized fun remove(ids: Set<String>) {
if (ids.isEmpty()) return
delivered.removeAll(ids)
locallyRead.removeAll(ids)
readReceiptsSent.removeAll(ids)
persist()
}
@Synchronized fun clear() {
delivered.clear()
locallyRead.clear()

View File

@ -67,6 +67,11 @@ class MeshGraphService private constructor() {
nicknames.remove(peerID)
announcements.remove(peerID)
lastUpdate.remove(peerID)
announcements.keys.toList().forEach { originPeerID ->
announcements.computeIfPresent(originPeerID) { _, neighbors ->
neighbors - peerID
}
}
publishSnapshot()
}
}

View File

@ -20,6 +20,7 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
@ -35,11 +36,13 @@ import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.filled.ChevronRight
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.CloudDownload
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material.icons.filled.Public
import androidx.compose.material.icons.filled.Share
import androidx.compose.material.icons.filled.UnfoldMore
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material.icons.filled.Wifi
import androidx.compose.material.icons.outlined.Info
@ -52,6 +55,8 @@ import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@ -70,6 +75,7 @@ import com.bitchat.android.nostr.NostrProofOfWork
import com.bitchat.android.nostr.PoWPreferenceManager
import com.bitchat.android.ui.theme.BitchatMotion
import com.bitchat.android.ui.theme.LocalBitchatPalette
import com.bitchat.android.util.ShareableApkVariant
import com.bitchat.android.util.UniversalApkManager
/**
@ -120,6 +126,69 @@ private fun ThemeChip(
}
}
@Composable
private fun LanguageSettingsRow(
selectedLanguageName: String,
onClick: () -> Unit,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(horizontal = 16.dp, vertical = 15.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = stringResource(R.string.about_app_language),
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.weight(1f),
)
Text(
text = selectedLanguageName,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.primary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
textAlign = TextAlign.End,
modifier = Modifier.weight(1f),
)
Spacer(modifier = Modifier.width(8.dp))
Icon(
imageVector = Icons.Filled.UnfoldMore,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(18.dp),
)
}
}
@Composable
private fun LanguageMenuItem(
label: String,
selected: Boolean,
onClick: () -> Unit,
) {
DropdownMenuItem(
text = {
Text(
text = label,
style = MaterialTheme.typography.bodyMedium,
)
},
onClick = onClick,
trailingIcon = {
if (selected) {
Icon(
imageVector = Icons.Filled.Check,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
)
}
},
)
}
/**
* Unified settings toggle row with icon, title, subtitle, and switch
* Apple-like design with proper spacing
@ -259,6 +328,13 @@ fun AboutSheet(
val colorScheme = MaterialTheme.colorScheme
val palette = LocalBitchatPalette.current
var selectedTab by remember { mutableStateOf(AboutTab.Info) }
val supportedLanguages = remember(context) {
LanguagePreferenceManager.supportedLanguages(context)
}
var selectedLanguageTag by remember {
mutableStateOf(LanguagePreferenceManager.currentLanguageTag())
}
var showLanguagePicker by remember { mutableStateOf(false) }
if (isPresented) {
BitchatBottomSheet(
@ -342,6 +418,64 @@ fun AboutSheet(
}
}
item(key = "language") {
val selectedLanguageName = supportedLanguages
.firstOrNull { it.languageTag == selectedLanguageTag }
?.endonym
?: stringResource(R.string.about_system_default)
Column {
AboutSectionLabel(text = stringResource(R.string.about_language))
BoxWithConstraints(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = AboutHorizontalPadding),
) {
Surface(
modifier = Modifier.fillMaxWidth(),
color = colorScheme.surface,
shape = AboutCardShape,
) {
LanguageSettingsRow(
selectedLanguageName = selectedLanguageName,
onClick = { showLanguagePicker = true },
)
}
DropdownMenu(
expanded = showLanguagePicker,
onDismissRequest = { showLanguagePicker = false },
modifier = Modifier.width(maxWidth),
) {
LanguageMenuItem(
label = stringResource(R.string.about_system_default),
selected = selectedLanguageTag.isEmpty(),
onClick = {
showLanguagePicker = false
if (selectedLanguageTag.isNotEmpty()) {
selectedLanguageTag = ""
LanguagePreferenceManager.setLanguage("")
}
},
)
HorizontalDivider()
supportedLanguages.forEach { language ->
LanguageMenuItem(
label = language.endonym,
selected = selectedLanguageTag == language.languageTag,
onClick = {
showLanguagePicker = false
if (language.languageTag != selectedLanguageTag) {
selectedLanguageTag = language.languageTag
LanguagePreferenceManager.setLanguage(language.languageTag)
}
},
)
}
}
}
}
}
// Settings Section - Unified Card with Toggles
item(key = "settings") {
LaunchedEffect(Unit) { PoWPreferenceManager.init(context) }
@ -512,10 +646,13 @@ fun AboutSheet(
is ApkPreparationStatus.Loading -> stringResource(R.string.checking)
is ApkPreparationStatus.NotDownloaded -> stringResource(R.string.prepare_apk_status_not_downloaded)
is ApkPreparationStatus.Ready -> {
val source = if (status.source == UniversalApkManager.ApkSource.INSTALLED) {
stringResource(R.string.prepare_apk_source_installed)
} else {
stringResource(R.string.prepare_apk_source_github)
val source = when {
status.source == UniversalApkManager.ApkSource.GITHUB ->
stringResource(R.string.prepare_apk_source_github)
status.variant == ShareableApkVariant.ARM64 ->
stringResource(R.string.prepare_apk_source_installed_arm64)
else ->
stringResource(R.string.prepare_apk_source_installed)
}
stringResource(R.string.prepare_apk_status_ready) +
"${status.version}${status.sizeMB} MB\n$source"
@ -545,14 +682,36 @@ fun AboutSheet(
)
}
is ApkPreparationStatus.Ready -> {
if (apkStatus.source == UniversalApkManager.ApkSource.GITHUB) {
if (apkStatus.variant == ShareableApkVariant.ARM64) {
TextButton(
onClick = {
apkViewModel.onEvent(
ApkUiEvent.DownloadUniversalClicked
)
}
) {
Icon(
imageVector = Icons.Default.CloudDownload,
contentDescription = null,
modifier = Modifier.size(18.dp)
)
Spacer(modifier = Modifier.width(4.dp))
Text(
stringResource(
R.string.prepare_apk_get_universal
)
)
}
} else if (apkStatus.source == UniversalApkManager.ApkSource.GITHUB) {
androidx.compose.material3.IconButton(
onClick = { apkViewModel.onEvent(ApkUiEvent.DeleteClicked) },
modifier = Modifier.size(32.dp)
modifier = Modifier.size(48.dp)
) {
Icon(
imageVector = Icons.Default.Delete,
contentDescription = "Delete",
contentDescription = stringResource(
R.string.prepare_apk_delete_confirm
),
tint = colorScheme.error,
modifier = Modifier.size(20.dp)
)
@ -562,11 +721,13 @@ fun AboutSheet(
is ApkPreparationStatus.UpdateAvailable -> {
androidx.compose.material3.IconButton(
onClick = { apkViewModel.onEvent(ApkUiEvent.DeleteClicked) },
modifier = Modifier.size(32.dp)
modifier = Modifier.size(48.dp)
) {
Icon(
imageVector = Icons.Default.Delete,
contentDescription = "Delete",
contentDescription = stringResource(
R.string.prepare_apk_delete_confirm
),
tint = colorScheme.error,
modifier = Modifier.size(20.dp)
)

View File

@ -7,6 +7,7 @@ import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.bitchat.android.R
import com.bitchat.android.util.ApkDownloader
import com.bitchat.android.util.ShareableApkVariant
import com.bitchat.android.util.UniversalApkManager
import com.bitchat.android.util.WorkManagerApkDownloader
import kotlinx.coroutines.Dispatchers
@ -27,7 +28,8 @@ sealed class ApkPreparationStatus {
data class Ready(
val version: String,
val sizeMB: Int,
val source: UniversalApkManager.ApkSource
val source: UniversalApkManager.ApkSource,
val variant: ShareableApkVariant
) : ApkPreparationStatus()
data class UpdateAvailable(
val currentVersion: String,
@ -52,6 +54,7 @@ data class ApkUiState(
sealed class ApkUiEvent {
object CheckStatus : ApkUiEvent()
object PrepareRowClicked : ApkUiEvent()
object DownloadUniversalClicked : ApkUiEvent()
object ConfirmDownload : ApkUiEvent()
object DismissPrepareDialog : ApkUiEvent()
object DeleteClicked : ApkUiEvent()
@ -99,6 +102,7 @@ class ApkDownloadViewModel(application: Application) : AndroidViewModel(applicat
when (event) {
is ApkUiEvent.CheckStatus -> checkStatus()
is ApkUiEvent.PrepareRowClicked -> onPrepareRowClicked()
is ApkUiEvent.DownloadUniversalClicked -> onDownloadUniversalClicked()
is ApkUiEvent.ConfirmDownload -> onConfirmDownload()
is ApkUiEvent.DismissPrepareDialog -> _state.update { it.copy(showPrepareDialog = false) }
is ApkUiEvent.DeleteClicked -> _state.update { it.copy(showDeleteDialog = true) }
@ -131,6 +135,15 @@ class ApkDownloadViewModel(application: Application) : AndroidViewModel(applicat
startDownload()
}
private fun onDownloadUniversalClicked() {
val status = _state.value.apkStatus
if (status is ApkPreparationStatus.Ready &&
status.variant == ShareableApkVariant.ARM64
) {
_state.update { it.copy(showPrepareDialog = true) }
}
}
private fun onConfirmDelete() {
_state.update { it.copy(showDeleteDialog = false) }
downloader.cancelDownload()
@ -234,26 +247,47 @@ class ApkDownloadViewModel(application: Application) : AndroidViewModel(applicat
_state.update {
it.copy(
apkStatus = ApkPreparationStatus.Ready(
version = downloadState.version,
sizeMB = downloadState.sizeMB,
source = info?.source ?: UniversalApkManager.ApkSource.GITHUB
version = info?.version ?: downloadState.version,
sizeMB = info?.let { cached ->
(cached.size / 1024 / 1024).toInt()
} ?: downloadState.sizeMB,
source = info?.source ?: UniversalApkManager.ApkSource.GITHUB,
variant = info?.variant ?: ShareableApkVariant.UNIVERSAL
),
downloadProgress = 100
)
}
}
is ApkDownloader.DownloadState.Failed -> {
_state.update {
if (downloadState.resumablePercent != null) {
val localArm64 = apkManager.getCachedApkInfo()
?.takeIf { it.variant == ShareableApkVariant.ARM64 }
if (localArm64 != null) {
_state.update {
it.copy(
apkStatus = ApkPreparationStatus.Resumable(
progressPercent = downloadState.resumablePercent,
message = downloadState.message
),
downloadProgress = downloadState.resumablePercent
apkStatus = ApkPreparationStatus.Ready(
version = localArm64.version,
sizeMB = (localArm64.size / 1024 / 1024).toInt(),
source = localArm64.source,
variant = localArm64.variant
)
)
} else {
it.copy(apkStatus = ApkPreparationStatus.Error(downloadState.message))
}
_effect.send(ApkUiEffect.ShowToast(downloadState.message))
} else {
_state.update {
if (downloadState.resumablePercent != null) {
it.copy(
apkStatus = ApkPreparationStatus.Resumable(
progressPercent = downloadState.resumablePercent,
message = downloadState.message
),
downloadProgress = downloadState.resumablePercent
)
} else {
it.copy(
apkStatus = ApkPreparationStatus.Error(downloadState.message)
)
}
}
}
}
@ -295,7 +329,8 @@ class ApkDownloadViewModel(application: Application) : AndroidViewModel(applicat
ApkPreparationStatus.Ready(
version = info.version,
sizeMB = (info.size / 1024 / 1024).toInt(),
source = info.source
source = info.source,
variant = info.variant
)
} else {
ApkPreparationStatus.Error("Cached APK info not found")
@ -316,7 +351,8 @@ class ApkDownloadViewModel(application: Application) : AndroidViewModel(applicat
ApkPreparationStatus.Ready(
version = info.version,
sizeMB = (info.size / 1024 / 1024).toInt(),
source = info.source
source = info.source,
variant = info.variant
)
} else {
val partial = apkManager.getPartialDownloadProgress()

View File

@ -0,0 +1,287 @@
package com.bitchat.android.ui
import com.google.gson.JsonParser
import java.net.URI
import java.net.URLEncoder
import java.nio.charset.StandardCharsets
import java.math.BigDecimal
import java.util.Base64
import java.util.Currency
import java.util.Locale
/**
* Bounded, display-only Cashu token decoder. Tokens are bearer instruments, so
* this class never contacts a mint or attempts to hold/redeem funds.
*/
object CashuTokenDecoder {
const val MAX_TOKEN_LENGTH = 60_000
private const val MAX_AMOUNT = 2_100_000_000_000_000L
data class TokenInfo(
val version: Char,
val amount: Long?,
val unit: String?,
val mintHost: String?,
val memo: String?
) {
val displayAmount: String?
get() = amount?.let { value ->
val displayUnit = unit ?: "sat"
val minorDigits = minorUnitDigits(displayUnit)
val formatted = if (minorDigits == null || minorDigits == 0) {
value.toString()
} else {
BigDecimal.valueOf(value, minorDigits).setScale(minorDigits).toPlainString()
}
"$formatted $displayUnit"
}
}
fun bareToken(raw: String): String? {
var token = raw.trim()
if ('%' in token) token = percentDecode(token) ?: return null
token = when {
token.startsWith("cashu://", ignoreCase = true) -> token.substring(8)
token.startsWith("cashu:", ignoreCase = true) -> token.substring(6)
else -> token
}
if (token.length !in 12..MAX_TOKEN_LENGTH) return null
if (!token.startsWith("cashuA") && !token.startsWith("cashuB")) return null
if (token.any { !it.isLetterOrDigit() && it !in "-_+/=" }) return null
return token
}
/**
* Permissive decoding is suitable for display: unsupported but plausible
* v4 CBOR still gets a generic chip. Strict decoding is required before
* sending and accepts only a fully parsed token with a positive amount.
*/
fun decode(raw: String, strict: Boolean = false): TokenInfo? {
val token = bareToken(raw) ?: return null
val payload = decodeBase64Url(token.substring(6)) ?: return null
if (payload.isEmpty()) return null
val info = when (token[5]) {
'A' -> decodeV3(payload)
'B' -> decodeV4(payload) ?: if (strict) null else TokenInfo('B', null, null, null, null)
else -> null
} ?: return null
return if (!strict || (info.amount != null && info.amount > 0)) info else null
}
fun extractTokens(text: String, max: Int = 3): List<String> {
if (text.isEmpty() || max <= 0) return emptyList()
val matches = TOKEN_REGEX.findAll(text)
val result = LinkedHashSet<String>()
for (match in matches) {
bareToken(match.value)?.let(result::add)
if (result.size == max) break
}
return result.toList()
}
fun walletUri(token: String): String? = bareToken(token)?.let { "cashu:${encodeUriComponent(it)}" }
fun webRedeemUri(token: String): String? =
bareToken(token)?.let { "https://redeem.cashu.me/?token=${encodeUriComponent(it)}" }
private fun encodeUriComponent(value: String): String =
URLEncoder.encode(value, StandardCharsets.UTF_8.name()).replace("+", "%20")
/** Percent-decodes URI input without URLDecoder's form-specific '+' → space conversion. */
private fun percentDecode(value: String): String? {
val output = StringBuilder(value.length)
var index = 0
while (index < value.length) {
if (value[index] != '%') {
output.append(value[index++])
continue
}
val bytes = ArrayList<Byte>()
while (index < value.length && value[index] == '%') {
if (index + 2 >= value.length) return null
val byte = value.substring(index + 1, index + 3).toIntOrNull(16) ?: return null
bytes += byte.toByte()
index += 3
}
output.append(String(bytes.toByteArray(), StandardCharsets.UTF_8))
}
return output.toString()
}
private fun decodeBase64Url(input: String): ByteArray? {
val normalized = input.replace('-', '+').replace('_', '/').trimEnd('=')
if (normalized.length % 4 == 1) return null
val padded = normalized + "=".repeat((4 - normalized.length % 4) % 4)
return runCatching { Base64.getDecoder().decode(padded) }.getOrNull()
}
private fun decodeV3(payload: ByteArray): TokenInfo? = runCatching {
val root = JsonParser.parseString(String(payload, StandardCharsets.UTF_8)).asJsonObject
val entries = root.getAsJsonArray("token")?.takeIf { it.size() > 0 } ?: return null
var total = 0L
var sawAmount = false
var mintHost: String? = null
for (entryElement in entries) {
val entry = entryElement.takeIf { it.isJsonObject }?.asJsonObject ?: continue
if (mintHost == null) mintHost = sanitizeHost(entry.get("mint")?.takeIf { it.isJsonPrimitive }?.asString)
val proofs = entry.getAsJsonArray("proofs") ?: continue
for (proofElement in proofs) {
val amountElement = proofElement.takeIf { it.isJsonObject }?.asJsonObject?.get("amount") ?: continue
if (!amountElement.isJsonPrimitive || !amountElement.asJsonPrimitive.isNumber) continue
val value = runCatching { amountElement.asBigDecimal.longValueExact() }.getOrNull() ?: continue
if (value <= 0 || value > MAX_AMOUNT) continue
if (total > MAX_AMOUNT - value) return null
total += value
sawAmount = true
}
}
TokenInfo(
version = 'A',
amount = total.takeIf { sawAmount },
unit = sanitizeUnit(root.get("unit")?.takeIf { it.isJsonPrimitive }?.asString),
mintHost = mintHost,
memo = sanitizeMemo(root.get("memo")?.takeIf { it.isJsonPrimitive }?.asString)
)
}.getOrNull()
private fun decodeV4(payload: ByteArray): TokenInfo? {
val root = CborReader(payload).parseComplete() as? CborValue.MapValue ?: return null
var total = 0L
var sawAmount = false
var mintHost: String? = null
var unit: String? = null
var memo: String? = null
for ((key, value) in root.pairs) {
when ((key as? CborValue.Text)?.value) {
"m" -> mintHost = sanitizeHost((value as? CborValue.Text)?.value)
"u" -> unit = sanitizeUnit((value as? CborValue.Text)?.value)
"d" -> memo = sanitizeMemo((value as? CborValue.Text)?.value)
"t" -> for (group in (value as? CborValue.ArrayValue)?.values.orEmpty()) {
for ((groupKey, groupValue) in (group as? CborValue.MapValue)?.pairs.orEmpty()) {
if ((groupKey as? CborValue.Text)?.value != "p") continue
for (proof in (groupValue as? CborValue.ArrayValue)?.values.orEmpty()) {
for ((proofKey, proofValue) in (proof as? CborValue.MapValue)?.pairs.orEmpty()) {
if ((proofKey as? CborValue.Text)?.value != "a") continue
val amount = (proofValue as? CborValue.Unsigned)?.value ?: continue
if (amount == 0L || amount > MAX_AMOUNT) continue
if (total > MAX_AMOUNT - amount) return null
total += amount
sawAmount = true
}
}
}
}
}
}
return TokenInfo('B', total.takeIf { sawAmount }, unit, mintHost, memo)
}
private fun sanitizeHost(value: String?): String? = value
?.takeIf { it.length <= 512 }
?.let { runCatching { URI(it).host }.getOrNull() }
?.takeIf { it.isNotEmpty() }
?.lowercase()
?.take(48)
private fun sanitizeUnit(value: String?): String? =
value?.takeIf { it.isNotEmpty() && it.length <= 12 && it.all(Char::isLetterOrDigit) }
private fun sanitizeMemo(value: String?): String? {
if (value == null || value.length > 512) return null
return value.filterNot(Char::isISOControl).trim().take(80).takeIf(String::isNotEmpty)
}
/** ISO-4217 values use their currency's minor unit; custom units stay integer-denominated. */
private fun minorUnitDigits(unit: String): Int? {
return runCatching {
Currency.getInstance(unit.uppercase(Locale.ROOT)).defaultFractionDigits
}.getOrNull()?.takeIf { it >= 0 }
}
private val TOKEN_REGEX = Regex("""(?i:cashu:(?://)?)?cashu[AB][A-Za-z0-9_+/%=-]{6,}""")
}
private sealed interface CborValue {
data class Unsigned(val value: Long) : CborValue
data class Text(val value: String) : CborValue
data class ArrayValue(val values: List<CborValue>) : CborValue
data class MapValue(val pairs: List<Pair<CborValue, CborValue>>) : CborValue
data object Opaque : CborValue
}
private class CborReader(private val bytes: ByteArray) {
private var index = 0
private var itemBudget = 50_000
fun parseComplete(): CborValue? {
val value = parseValue(0) ?: return null
return value.takeIf { index == bytes.size }
}
private fun parseValue(depth: Int): CborValue? {
if (depth >= 16 || itemBudget-- <= 0) return null
val (major, argument) = readHead() ?: return null
return when (major) {
0 -> CborValue.Unsigned(argument.takeIf { it <= Long.MAX_VALUE }?.toLong() ?: return null)
1 -> CborValue.Opaque
2 -> if (readBytes(argument) != null) CborValue.Opaque else null
3 -> readBytes(argument)?.toString(StandardCharsets.UTF_8)?.let(CborValue::Text)
4 -> parseContainer(argument, depth) { CborValue.ArrayValue(it) }
5 -> {
if (argument > 10_000 || argument > itemBudget / 2) return null
val pairs = ArrayList<Pair<CborValue, CborValue>>(argument.coerceAtMost(64).toInt())
repeat(argument.toInt()) {
pairs += (parseValue(depth + 1) ?: return null) to (parseValue(depth + 1) ?: return null)
}
CborValue.MapValue(pairs)
}
6 -> parseValue(depth + 1)
7 -> CborValue.Opaque
else -> null
}
}
private fun parseContainer(
count: Long,
depth: Int,
wrap: (List<CborValue>) -> CborValue
): CborValue? {
if (count > 10_000 || count > itemBudget) return null
val values = ArrayList<CborValue>(count.coerceAtMost(64).toInt())
repeat(count.toInt()) { values += parseValue(depth + 1) ?: return null }
return wrap(values)
}
private fun readHead(): Pair<Int, Long>? {
if (index >= bytes.size) return null
val head = bytes[index++].toInt() and 0xff
val major = head ushr 5
val info = head and 0x1f
val argument = when (info) {
in 0..23 -> info.toLong()
24 -> readUInt(1)
25 -> readUInt(2)
26 -> readUInt(4)
27 -> readUInt(8)
else -> null
} ?: return null
return major to argument
}
private fun readUInt(width: Int): Long? {
if (bytes.size - index < width) return null
var value = 0L
repeat(width) {
val next = bytes[index++].toLong() and 0xff
if (value > (Long.MAX_VALUE - next) ushr 8) return null
value = (value shl 8) or next
}
return value
}
private fun readBytes(count: Long): ByteArray? {
if (count < 0 || count > bytes.size - index) return null
val end = index + count.toInt()
return bytes.copyOfRange(index, end).also { index = end }
}
}

View File

@ -475,6 +475,19 @@ fun ConversationHeaderAction(
content = content
)
/** A read-only status slot matching the footprint of [ConversationHeaderAction]. */
@Composable
fun ConversationHeaderStatus(
modifier: Modifier = Modifier,
content: @Composable () -> Unit
) {
Box(
modifier = modifier.size(HeaderTapTarget),
contentAlignment = Alignment.Center,
content = { content() }
)
}
@Composable
fun NicknameEditor(
value: String,

View File

@ -1,5 +1,6 @@
package com.bitchat.android.ui
import android.os.Build
import com.bitchat.android.ui.theme.BitchatFontFamily
// [Goose] Bridge file share events to ViewModel via dispatcher is installed in ChatScreen composition
@ -98,6 +99,14 @@ fun ChatScreen(viewModel: ChatViewModel) {
var forceScrollToBottom by remember { mutableStateOf(false) }
var isScrolledUp by remember { mutableStateOf(false) }
LaunchedEffect(selectedPrivatePeer) {
messageText = TextFieldValue(
selectedPrivatePeer
?.let(viewModel::conversationDraft)
.orEmpty()
)
}
// Show password dialog when needed
LaunchedEffect(showPasswordPrompt) {
showPasswordDialog = showPasswordPrompt
@ -251,8 +260,19 @@ fun ChatScreen(viewModel: ChatViewModel) {
Column(
modifier = Modifier
.fillMaxSize()
.windowInsetsPadding(WindowInsets.ime) // This handles keyboard insets
.windowInsetsPadding(WindowInsets.navigationBars) // Add bottom padding when keyboard is not expanded
.then(
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
// Android 11+: Handle both IME and navigation bar insets in Compose
Modifier.windowInsetsPadding(
WindowInsets.ime.union(WindowInsets.navigationBars)
)
} else {
// Android 10 and below: Window is resized by the system (adjustResize),
// so only account for the navigation bar.
Modifier.windowInsetsPadding(WindowInsets.navigationBars)
}
)
) {
Box(modifier = Modifier.weight(1f)) {
// Messages area - takes up available space, will compress when keyboard appears
@ -356,14 +376,19 @@ fun ChatScreen(viewModel: ChatViewModel) {
messageText = messageText,
onMessageTextChange = { newText: TextFieldValue ->
messageText = newText
viewModel.setConversationDraft(selectedPrivatePeer, newText.text)
viewModel.updateCommandSuggestions(newText.text)
viewModel.updateMentionSuggestions(newText.text)
},
onSend = {
if (messageText.text.trim().isNotEmpty()) {
viewModel.sendMessage(messageText.text.trim())
messageText = TextFieldValue("")
forceScrollToBottom = !forceScrollToBottom // Toggle to trigger scroll
viewModel.sendMessage(messageText.text.trim()) { accepted ->
if (accepted) {
messageText = TextFieldValue("")
viewModel.setConversationDraft(selectedPrivatePeer, "")
forceScrollToBottom = !forceScrollToBottom
}
}
}
},
onSendVoiceNote = { peer, onionOrChannel, path ->

View File

@ -5,6 +5,7 @@ import android.util.Log
import androidx.core.app.NotificationManagerCompat
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.bitchat.android.favorites.FavoritesChangeListener
import com.bitchat.android.favorites.FavoritesPersistenceService
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.StateFlow
@ -14,6 +15,8 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.Job
import com.bitchat.android.mesh.BluetoothMeshDelegate
import com.bitchat.android.mesh.BluetoothMeshService
import com.bitchat.android.mesh.MeshService
@ -27,7 +30,6 @@ import com.bitchat.android.protocol.BitchatPacket
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import com.bitchat.android.util.NotificationIntervalManager
import kotlinx.coroutines.delay
import java.util.Date
import kotlin.random.Random
@ -38,6 +40,12 @@ import com.bitchat.android.services.ContactDirectory
import com.bitchat.android.services.ContactIdentityResolver
import com.bitchat.android.util.hexEncodedString
private data class ConversationLiveIdentityState(
val connectedPeerIDs: List<String>,
val peerNicknames: Map<String, String>,
val persistedDisplayNames: Map<String, String>
)
/**
* Refactored ChatViewModel - Main coordinator for bitchat functionality
* Delegates specific responsibilities to specialized managers while maintaining 100% iOS compatibility
@ -58,6 +66,7 @@ class ChatViewModel(
companion object {
private const val TAG = "ChatViewModel"
private const val CONVERSATION_DISCONNECT_GRACE_MS = 3_000L
}
fun sendVoiceNote(toPeerIDOrNull: String?, channelOrNull: String?, filePath: String) {
@ -109,6 +118,8 @@ class ChatViewModel(
private val seenMessageStore by lazy {
com.bitchat.android.services.SeenMessageStore.getInstance(getApplication())
}
private val conversationListPreferences =
com.bitchat.android.services.ConversationListPreferences.getInstance(getApplication())
private val messageManager = MessageManager(state)
private val channelManager = ChannelManager(state, messageManager, dataManager, viewModelScope)
@ -131,11 +142,16 @@ class ChatViewModel(
seenMessageStore.markReadLocally(messageID)
}
)
private val commandProcessor = CommandProcessor(state, messageManager, channelManager, privateChatManager)
private val commandProcessor = CommandProcessor(
state,
messageManager,
channelManager,
privateChatManager,
viewModelScope
)
private val notificationManager = NotificationManager(
application.applicationContext,
NotificationManagerCompat.from(application.applicationContext),
NotificationIntervalManager()
NotificationManagerCompat.from(application.applicationContext)
)
private val verificationHandler = VerificationHandler(
@ -193,20 +209,73 @@ class ChatViewModel(
val privateChats: StateFlow<Map<String, List<BitchatMessage>>> = state.privateChats
val selectedPrivateChatPeer: StateFlow<String?> = state.selectedPrivateChatPeer
val unreadPrivateMessages: StateFlow<Set<String>> = state.unreadPrivateMessages
internal val unreadConversations: StateFlow<List<UnreadConversationSummary>> = combine(
internal val conversationStoreState =
com.bitchat.android.services.AppStateStore.conversationStoreState
private val conversationPresencePeers = MutableStateFlow<List<String>>(emptyList())
private val conversationPresenceRemovalJobs = mutableMapOf<String, Job>()
private val conversationDirectoryRevision = MutableStateFlow(0L)
private var favoriteRelationshipListenerRegistered = false
private val favoriteRelationshipChangeListener = object : FavoritesChangeListener {
override fun onFavoriteChanged(noiseKeyHex: String) {
refreshConversationDirectoryState()
}
override fun onAllCleared() {
refreshConversationDirectoryState()
}
}
private fun refreshConversationDirectoryState() {
viewModelScope.launch {
refreshPeerFavoritedUs()
conversationListPreferences.canonicalizeAliases()
conversationDirectoryRevision.update { it + 1L }
}
}
private val conversationLiveIdentityState = combine(
conversationPresencePeers,
state.peerNicknames,
state.peerFingerprints,
conversationDirectoryRevision,
com.bitchat.android.services.AppStateStore.privateConversationDisplayNames
) { connectedPeerIDs, peerNicknames, _, _, persistedDisplayNames ->
ConversationLiveIdentityState(
connectedPeerIDs = connectedPeerIDs,
peerNicknames = peerNicknames,
persistedDisplayNames = persistedDisplayNames
.mapKeys { (conversationID, _) -> conversationID.lowercase() }
)
}
private val baseConversations = combine(
state.unreadPrivateMessages,
state.privateChats,
state.nickname,
state.connectedPeers
) { unreadConversationIDs, chats, currentNickname, connectedPeerIDs ->
conversationLiveIdentityState,
com.bitchat.android.services.AppStateStore.unreadPrivateMessageCounts
) { unreadConversationIDs, chats, currentNickname, liveIdentity, unreadCounts ->
val seenStore = seenMessageStore
val connectedPeerIDSet = connectedPeerIDs.mapTo(mutableSetOf()) { it.lowercase() }
buildUnreadConversationSummaries(
val connectedPeerByIdentity = buildMap {
liveIdentity.connectedPeerIDs.forEach { peerID ->
val identities = runCatching {
ContactDirectory.aliasesForConversation(peerID) +
ContactDirectory.canonicalConversationId(peerID)
}.getOrDefault(setOf(peerID))
identities.forEach { identity ->
putIfAbsent(identity.lowercase(), peerID)
}
}
}
buildConversationSummaries(
unreadConversationIDs = unreadConversationIDs,
privateChats = chats,
currentUserIdentifiers = setOf(currentNickname, mesh.myPeerID),
canonicalize = ContactDirectory::canonicalConversationId,
isMessageRead = { message -> seenStore.hasBeenReadLocally(message.id) }
isMessageRead = { message ->
com.bitchat.android.services.AppStateStore.isPrivateMessageRead(message.id) ||
seenStore.hasBeenReadLocally(message.id)
},
persistedUnreadCounts = unreadCounts
).map { summary ->
val resolution = ContactDirectory.resolve(summary.conversationID)
val resolvedNostrPubkey = summary.nostrPubkey
@ -221,16 +290,34 @@ class ChatViewModel(
?.let(ContactIdentityResolver::nostrAliasForPubkey)
?.let(::add)
}.mapTo(mutableSetOf()) { it.lowercase() }
val connectedPeerID = aliases
.asSequence()
.mapNotNull(connectedPeerByIdentity::get)
.firstOrNull()
val persistedDisplayName = liveIdentity.persistedDisplayNames[
summary.conversationID.lowercase()
] ?: aliases
.asSequence()
.mapNotNull(liveIdentity.persistedDisplayNames::get)
.firstOrNull()
summary.copy(
displayName = resolution.displayName
?.takeUnless {
it.isBlank() || it.equals("Unknown", ignoreCase = true)
}
?: summary.displayName,
displayName = resolveConversationDisplayName(
fallbackName = summary.displayName,
connectedPeerID = connectedPeerID,
peerNicknames = liveIdentity.peerNicknames,
resolvedContactName = resolution.displayName,
persistedDisplayName = persistedDisplayName
),
nostrPubkey = resolvedNostrPubkey,
transport = if (resolvedNostrPubkey != null) {
DirectMessageTransport.NOSTR
} else {
summary.transport
},
identityAliases = aliases,
isConnected = aliases.any(connectedPeerIDSet::contains),
isConnected = connectedPeerID != null,
connectedPeerID = connectedPeerID,
sourceGeohash = aliases
.asSequence()
.mapNotNull(GeohashConversationRegistry::get)
@ -238,6 +325,24 @@ class ChatViewModel(
)
}
}
internal val conversations: StateFlow<List<ConversationSummary>> = combine(
baseConversations,
conversationListPreferences.pinned,
conversationListPreferences.muted,
conversationListPreferences.drafts
) { summaries, pinned, muted, drafts ->
sortConversationSummaries(
summaries.map { summary ->
val key = summary.conversationID.lowercase()
summary.copy(
isPinned = key in pinned,
isMuted = key in muted,
draft = drafts[key]
)
}
)
}
.flowOn(Dispatchers.IO)
.stateIn(
scope = viewModelScope,
@ -290,10 +395,18 @@ class ChatViewModel(
}
init {
observeConversationPresenceWithDisconnectGrace()
// Note: Mesh service delegate is now set by MainActivity
loadAndInitialize()
ContactDirectory.initialize(getApplication()) { mesh }
com.bitchat.android.services.AppStateStore.canonicalizePrivateChats()
observeConversationDisplayNames()
// Application startup performs the initial restore. Repeat it for every new UI owner
// because a quick reopen can reuse a process whose in-memory state was cleared during
// controlled shutdown.
com.bitchat.android.services.AppStateStore.reloadConversationPersistence(
getApplication()
)
// Mark queued private messages as failed when the router gives up on them
try {
com.bitchat.android.services.MessageRouter.getInstance(getApplication(), mesh).onMessageExpired = { messageID ->
@ -317,7 +430,12 @@ class ChatViewModel(
} } catch (_: Exception) { }
}
viewModelScope.launch {
try { com.bitchat.android.services.AppStateStore.privateMessages.collect { byPeer ->
try {
combine(
com.bitchat.android.services.AppStateStore.privateMessages,
com.bitchat.android.services.AppStateStore.unreadPrivateMessageCounts
) { byPeer, unreadCounts -> byPeer to unreadCounts }
.collect { (byPeer, unreadCounts) ->
val (canonicalChats, unreadConversationIDs) = withContext(Dispatchers.IO) {
val canonical = ContactDirectory.canonicalizePrivateChats(byPeer)
val unread = try {
@ -327,10 +445,14 @@ class ChatViewModel(
messages.any { message ->
message.sender != myNick &&
message.sender != "system" &&
!com.bitchat.android.services.AppStateStore
.isPrivateMessageRead(message.id) &&
!seenMessageStore.hasBeenReadLocally(message.id)
}
}
.keys
.keys + unreadCounts
.filterValues { it > 0 }
.keys
} catch (_: Exception) {
state.getUnreadPrivateMessagesValue()
}
@ -357,6 +479,60 @@ class ChatViewModel(
// Removed background location notes subscription. Notes now load only when sheet opens.
}
/**
* Mesh discovery can briefly drop a peer while transports hand over. Preserve its online
* treatment for a short grace window to keep conversation rows from jumping between sections.
* New connections still appear immediately.
*/
private fun observeConversationPresenceWithDisconnectGrace() {
viewModelScope.launch {
state.connectedPeers.collect { connected ->
val current = connected.toSet()
current.forEach { peerID ->
conversationPresenceRemovalJobs.remove(peerID)?.cancel()
}
val displayed = conversationPresencePeers.value.toMutableList()
connected.forEach { peerID ->
if (peerID !in displayed) displayed.add(peerID)
}
if (displayed != conversationPresencePeers.value) {
conversationPresencePeers.value = displayed
}
(displayed.toSet() - current).forEach { peerID ->
if (peerID in conversationPresenceRemovalJobs) return@forEach
conversationPresenceRemovalJobs[peerID] = launch {
delay(CONVERSATION_DISCONNECT_GRACE_MS)
if (peerID !in state.connectedPeers.value) {
conversationPresencePeers.value =
conversationPresencePeers.value - peerID
}
conversationPresenceRemovalJobs.remove(peerID)
}
}
}
}
}
private fun observeConversationDisplayNames() {
viewModelScope.launch {
combine(
state.peerNicknames,
state.connectedPeers,
state.peerFingerprints
) { peerNicknames, connectedPeers, _ ->
connectedPeers.mapNotNull { peerID ->
peerNicknames[peerID]?.let { peerID to it }
}.toMap()
}.collect { connectedNames ->
conversationListPreferences.canonicalizeAliases()
com.bitchat.android.services.AppStateStore
.updatePrivateConversationDisplayNames(connectedNames)
}
}
}
fun cancelMediaSend(messageId: String) {
// Delegate to MediaSendingManager which tracks transfer IDs and cleans up UI state
mediaSendingManager.cancelMediaSend(messageId)
@ -420,11 +596,9 @@ class ChatViewModel(
refreshPeerFavoritedUs()
try {
com.bitchat.android.favorites.FavoritesPersistenceService.shared.addListener(
object : com.bitchat.android.favorites.FavoritesChangeListener {
override fun onFavoriteChanged(noiseKeyHex: String) = refreshPeerFavoritedUs()
override fun onAllCleared() = refreshPeerFavoritedUs()
}
favoriteRelationshipChangeListener
)
favoriteRelationshipListenerRegistered = true
} catch (_: Exception) { }
// Load verified fingerprints from secure storage
@ -443,9 +617,16 @@ class ChatViewModel(
}
override fun onCleared() {
if (favoriteRelationshipListenerRegistered) {
runCatching {
FavoritesPersistenceService.shared.removeListener(
favoriteRelationshipChangeListener
)
}
favoriteRelationshipListenerRegistered = false
}
geohashViewModel.shutdownUiSubscriptions()
com.bitchat.android.services.AppStateStore.setSelectedPrivateChatPeer(null)
super.onCleared()
// Note: Mesh service lifecycle is now managed by MainActivity
}
@ -489,6 +670,13 @@ class ChatViewModel(
val (conversationID, success) = withContext(Dispatchers.IO) {
val canonicalID = ContactDirectory.canonicalConversationId(peerID)
com.bitchat.android.services.AppStateStore
.loadPrivateConversationHistory(canonicalID)
state.setPrivateChats(
ContactDirectory.canonicalizePrivateChats(
com.bitchat.android.services.AppStateStore.privateMessages.value
)
)
val unreadAliases = matchingUnreadAliases(
unreadConversationIDs = state.getUnreadPrivateMessagesValue(),
canonicalConversationID = canonicalID,
@ -509,7 +697,17 @@ class ChatViewModel(
}
fun endPrivateChat() {
val conversationID = state.getSelectedPrivateChatPeerValue()
privateChatManager.endPrivateChat()
if (conversationID != null) {
com.bitchat.android.services.AppStateStore
.releasePrivateConversationHistory(conversationID)
state.setPrivateChats(
ContactDirectory.canonicalizePrivateChats(
com.bitchat.android.services.AppStateStore.privateMessages.value
)
)
}
// Notify notification manager that no private chat is active
setCurrentPrivateChatPeer(null)
// Clear mesh mention notifications since user is now back in mesh chat
@ -518,6 +716,133 @@ class ChatViewModel(
hidePrivateChatSheet()
}
internal suspend fun deletePrivateConversation(
peerOrConversationID: String
): com.bitchat.android.services.DeletedPrivateConversation? {
val canonicalID = ContactDirectory.canonicalConversationId(peerOrConversationID)
val wasPinned = conversationListPreferences.isPinned(canonicalID)
val wasMuted = conversationListPreferences.isMuted(canonicalID)
val draft = conversationListPreferences.draftFor(canonicalID)
val unreadAliases = matchingUnreadAliases(
unreadConversationIDs = state.getUnreadPrivateMessagesValue(),
canonicalConversationID = canonicalID,
canonicalize = ContactDirectory::canonicalConversationId
)
val deletion = withContext(Dispatchers.IO) {
com.bitchat.android.services.AppStateStore
.deletePrivateConversationAndWait(canonicalID)
}?.copy(
wasPinned = wasPinned,
wasMuted = wasMuted,
draft = draft
) ?: return null
conversationListPreferences.removeConversation(canonicalID)
state.setPrivateChats(
ContactDirectory.canonicalizePrivateChats(
com.bitchat.android.services.AppStateStore.privateMessages.value
)
)
state.setUnreadPrivateMessages(
state.getUnreadPrivateMessagesValue() - unreadAliases
)
seenMessageStore.remove(deletion.messageIDs)
val selected = state.getSelectedPrivateChatPeerValue()
if (
selected != null &&
ContactDirectory.canonicalConversationId(selected)
.equals(canonicalID, ignoreCase = true)
) {
privateChatManager.endPrivateChat()
setCurrentPrivateChatPeer(null)
}
val sheetPeer = state.getPrivateChatSheetPeerValue()
if (
sheetPeer != null &&
ContactDirectory.canonicalConversationId(sheetPeer)
.equals(canonicalID, ignoreCase = true)
) {
hidePrivateChatSheet()
}
clearNotificationsForSender(canonicalID)
notificationManager.removeConversationShortcut(canonicalID)
return deletion
}
internal suspend fun restoreDeletedConversation(
deletion: com.bitchat.android.services.DeletedPrivateConversation
): Boolean {
val restored = withContext(Dispatchers.IO) {
com.bitchat.android.services.AppStateStore
.restoreDeletedConversation(deletion)
}
if (!restored) return false
if (deletion.wasPinned != conversationListPreferences.isPinned(deletion.conversationID)) {
conversationListPreferences.togglePinned(deletion.conversationID)
}
if (deletion.wasMuted != conversationListPreferences.isMuted(deletion.conversationID)) {
conversationListPreferences.toggleMuted(deletion.conversationID)
}
deletion.draft?.let {
conversationListPreferences.setDraft(deletion.conversationID, it)
}
state.setPrivateChats(
ContactDirectory.canonicalizePrivateChats(
com.bitchat.android.services.AppStateStore.privateMessages.value
)
)
if (deletion.unreadMessageCount > 0) {
state.setUnreadPrivateMessages(
state.getUnreadPrivateMessagesValue() + deletion.conversationID
)
}
return true
}
internal suspend fun setConversationRead(
conversationID: String,
isRead: Boolean
): Boolean {
val canonicalID = ContactDirectory.canonicalConversationId(conversationID)
val updated = withContext(Dispatchers.IO) {
com.bitchat.android.services.AppStateStore
.setPrivateConversationRead(canonicalID, isRead)
}
if (!updated) return false
state.setUnreadPrivateMessages(
if (isRead) {
state.getUnreadPrivateMessagesValue().filterNotTo(mutableSetOf()) {
ContactDirectory.canonicalConversationId(it)
.equals(canonicalID, ignoreCase = true)
}
} else {
state.getUnreadPrivateMessagesValue() + canonicalID
}
)
return true
}
internal fun toggleConversationPinned(conversationID: String) {
conversationListPreferences.togglePinned(conversationID)
}
internal fun toggleConversationMuted(conversationID: String) {
conversationListPreferences.toggleMuted(conversationID)
}
internal fun conversationDraft(conversationID: String?): String =
conversationID
?.let(ContactDirectory::canonicalConversationId)
?.lowercase()
?.let(conversationListPreferences.drafts.value::get)
.orEmpty()
internal fun setConversationDraft(conversationID: String?, text: String) {
if (conversationID.isNullOrBlank()) return
conversationListPreferences.setDraft(conversationID, text)
}
// MARK: - Open Latest Unread Private Chat
fun openLatestUnreadPrivateChat() {
@ -574,8 +899,14 @@ class ChatViewModel(
// MARK: - Message Sending
fun sendMessage(content: String) {
if (content.isEmpty()) return
fun sendMessage(
content: String,
onAccepted: (Boolean) -> Unit = {}
) {
if (content.isEmpty()) {
onAccepted(false)
return
}
// Check for commands
if (content.startsWith("/")) {
@ -589,10 +920,25 @@ class ChatViewModel(
mesh.myPeerID,
state.getNicknameValue()
)
} else if (channel != null && channelManager.hasChannelKey(channel)) {
channelManager.sendEncryptedChannelMessage(
messageContent,
mentions,
channel,
state.getNicknameValue(),
mesh.myPeerID,
onEncryptedPayload = {
mesh.sendMessage(messageContent, mentions, channel)
},
onFallback = {
mesh.sendMessage(messageContent, mentions, channel)
}
)
} else {
mesh.sendMessage(messageContent, mentions, channel)
}
}, this)
onAccepted(true)
return
}
@ -621,18 +967,33 @@ class ChatViewModel(
}
// Send private message
val recipientNickname = nicknameForPeer(selectedPeer)
privateChatManager.sendPrivateMessage(
content,
selectedPeer,
recipientNickname,
state.getNicknameValue(),
mesh.myPeerID
) { messageContent, peerID, recipientNicknameParam, messageId ->
val router = com.bitchat.android.services.MessageRouter.getInstance(getApplication(), mesh)
val route = router.sendPrivate(messageContent, peerID, recipientNicknameParam, messageId)
if (route == com.bitchat.android.services.MessageRouter.RouteResult.NOSTR) {
messageManager.updateMessageDeliveryStatus(messageId, com.bitchat.android.model.DeliveryStatus.Sent)
val destination = selectedPeer
viewModelScope.launch {
val accepted = privateChatManager.sendPrivateMessageDurably(
content,
destination,
recipientNickname,
state.getNicknameValue(),
mesh.myPeerID
) { messageContent, peerID, recipientNicknameParam, messageId ->
val router = com.bitchat.android.services.MessageRouter.getInstance(
getApplication(),
mesh
)
val route = router.sendPrivate(
messageContent,
peerID,
recipientNicknameParam,
messageId
)
if (route == com.bitchat.android.services.MessageRouter.RouteResult.NOSTR) {
messageManager.updateMessageDeliveryStatus(
messageId,
com.bitchat.android.model.DeliveryStatus.Sent
)
}
}
onAccepted(accepted)
}
} else {
// Check if we're in a location channel
@ -678,6 +1039,7 @@ class ChatViewModel(
mesh.sendMessage(content, mentions, null)
}
}
onAccepted(true)
}
}
@ -1041,8 +1403,22 @@ class ChatViewModel(
}
// MARK: - Emergency Clear
private var panicClearInProgress = false
fun panicClearAllData() {
if (panicClearInProgress) return
panicClearInProgress = true
viewModelScope.launch {
try {
performPanicClearAllData()
} finally {
panicClearInProgress = false
}
}
}
private suspend fun performPanicClearAllData() {
Log.w(TAG, "🚨 PANIC MODE ACTIVATED - Clearing all sensitive data")
try {
com.bitchat.android.geohash.LocationChannelManager
@ -1053,26 +1429,32 @@ class ChatViewModel(
// A pending one-shot downgrade confirmation must not survive panic or
// become actionable against the fresh post-wipe identity.
mediaSendingManager.clearPendingPrivateMediaConsent()
// Stop all message admission before wiping storage. The AppStateStore gate also rejects
// any transport callback already in flight until the fresh identity is ready.
clearAllMeshServiceData()
val conversationsCleared =
com.bitchat.android.services.AppStateStore
.panicClearPrivateConversations()
// Clear all UI managers
com.bitchat.android.services.AppStateStore.clear()
messageManager.clearAllMessages()
channelManager.clearAllChannels()
privateChatManager.clearAllPrivateChats()
dataManager.clearAllData()
conversationListPreferences.clearAll()
// Clear seen message store
try {
com.bitchat.android.services.SeenMessageStore.getInstance(getApplication()).clear()
} catch (_: Exception) { }
// Clear all mesh service data
clearAllMeshServiceData()
// Clear all cryptographic data
clearAllCryptographicData()
// Clear all notifications
notificationManager.clearAllNotifications()
notificationManager.clearAllNotifications(removeConversationShortcuts = true)
// Clear all media files
com.bitchat.android.features.file.FileUtils.clearAllMedia(getApplication())
@ -1099,8 +1481,17 @@ class ChatViewModel(
val newNickname = "anon${Random.nextInt(1000, 9999)}"
state.setNickname(newNickname)
dataManager.saveNickname(newNickname)
if (!conversationsCleared) {
// Privacy wins over availability: keep private-message admission and transports
// stopped if SQLite could not prove that the conversation history was erased.
Log.e(TAG, "🚨 PANIC MODE INCOMPLETE - conversation database wipe failed")
return
}
// Recreate mesh service with fresh identity
com.bitchat.android.services.AppStateStore
.resumePrivateConversationsAfterPanic()
recreateMeshServiceAfterPanic()
Log.w(TAG, "🚨 PANIC MODE COMPLETED - New identity: ${mesh.myPeerID}")

View File

@ -4,6 +4,8 @@ import com.bitchat.android.mesh.MeshService
import com.bitchat.android.model.BitchatMessage
import java.util.Date
import java.util.Locale
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
/**
* Handles processing of IRC-style commands
@ -12,7 +14,8 @@ class CommandProcessor(
private val state: ChatState,
private val messageManager: MessageManager,
private val channelManager: ChannelManager,
private val privateChatManager: PrivateChatManager
private val privateChatManager: PrivateChatManager,
private val coroutineScope: CoroutineScope? = null
) {
// Available commands list
@ -23,6 +26,7 @@ class CommandProcessor(
CommandSuggestion("/hug", emptyList(), "<nickname>", "send someone a warm hug"),
CommandSuggestion("/j", listOf("/join"), "<channel>", "join or create a channel"),
CommandSuggestion("/m", listOf("/msg"), "<nickname> [message]", "send private message"),
CommandSuggestion("/pay", emptyList(), "<token> [public]", "send a Cashu ecash token"),
CommandSuggestion("/slap", emptyList(), "<nickname>", "slap someone with a trout"),
CommandSuggestion("/unblock", emptyList(), "<nickname>", "unblock a peer"),
CommandSuggestion("/w", emptyList(), null, "see who's online")
@ -38,6 +42,7 @@ class CommandProcessor(
when (cmd) {
"/j", "/join" -> handleJoinCommand(parts, myPeerID)
"/m", "/msg" -> handleMessageCommand(parts, meshService, viewModel)
"/pay" -> handlePayCommand(command, meshService, myPeerID, onSendMessage, viewModel)
"/w" -> handleWhoCommand(meshService, viewModel)
"/clear" -> handleClearCommand()
"/pass" -> handlePassCommand(parts, myPeerID)
@ -90,15 +95,15 @@ class CommandProcessor(
if (parts.size > 2) {
val messageContent = parts.drop(2).joinToString(" ")
val recipientNickname = getPeerNickname(peerID, meshService)
privateChatManager.sendPrivateMessage(
sendPrivateMessage(
messageContent,
peerID,
recipientNickname,
state.getNicknameValue(),
getMyPeerID(meshService)
) { content, peerIdParam, recipientNicknameParam, messageId ->
sendPrivateMessageVia(meshService, content, peerIdParam, recipientNicknameParam, messageId, viewModel)
}
getMyPeerID(meshService),
meshService,
viewModel
)
} else {
val systemMessage = BitchatMessage(
sender = "system",
@ -189,6 +194,9 @@ class CommandProcessor(
// Clear private chat
val peerID = state.getSelectedPrivateChatPeerValue()!!
messageManager.clearPrivateMessages(peerID)
// `/clear` removes history but should not navigate away from the chat the
// command was issued in. A later message will repopulate this conversation.
state.setSelectedPrivateChatPeer(peerID)
}
state.getCurrentChannelValue() != null -> {
// Clear channel messages
@ -300,15 +308,15 @@ class CommandProcessor(
// Send as regular message
if (state.getSelectedPrivateChatPeerValue() != null) {
val peerID = state.getSelectedPrivateChatPeerValue()!!
privateChatManager.sendPrivateMessage(
sendPrivateMessage(
actionMessage,
peerID,
getPeerNickname(peerID, meshService),
state.getNicknameValue(),
myPeerID
) { content, peerIdParam, recipientNicknameParam, messageId ->
sendPrivateMessageVia(meshService, content, peerIdParam, recipientNicknameParam, messageId, viewModel)
}
myPeerID,
meshService,
viewModel
)
} else if (isInLocationChannel) {
// Let the transport layer add the echo; just send it out
onSendMessage(actionMessage, emptyList(), null)
@ -357,6 +365,95 @@ class CommandProcessor(
)
messageManager.addMessage(systemMessage)
}
private fun handlePayCommand(
command: String,
meshService: MeshService,
myPeerID: String,
onSendMessage: (String, List<String>, String?) -> Unit,
viewModel: ChatViewModel?
) {
val args = command.trim().split(Regex("\\s+")).drop(1)
if (args.isEmpty()) {
addSystemMessage("usage: /pay <cashu token> [public] — Cashu tokens are bearer instruments")
return
}
val publicConfirmed = args.lastOrNull()?.equals("public", ignoreCase = true) == true
val rawToken = if (publicConfirmed) args.dropLast(1).joinToString(" ") else args.joinToString(" ")
val token = CashuTokenDecoder.bareToken(rawToken)
val info = token?.let { CashuTokenDecoder.decode(it, strict = true) }
if (token == null || info == null) {
addSystemMessage("invalid cashu token — not sending it")
return
}
val selectedPeer = state.getSelectedPrivateChatPeerValue()
if (selectedPeer != null) {
privateChatManager.sendPrivateMessage(
token,
selectedPeer,
getPeerNickname(selectedPeer, meshService),
state.getNicknameValue(),
myPeerID
) { content, peerID, recipientNickname, messageId ->
sendPrivateMessageVia(meshService, content, peerID, recipientNickname, messageId, viewModel)
}
} else {
if (!publicConfirmed) {
addSystemMessage(
"Cashu tokens are bearer instruments. Anyone here can redeem this token. " +
"Confirm with: /pay <token> public"
)
return
}
val isLocationChannel =
state.selectedLocationChannel.value is com.bitchat.android.geohash.ChannelID.Location
if (!isLocationChannel) {
val message = BitchatMessage(
sender = state.getNicknameValue() ?: myPeerID,
content = token,
timestamp = Date(),
isRelay = false,
senderPeerID = myPeerID,
channel = state.getCurrentChannelValue()
)
val channel = state.getCurrentChannelValue()
if (channel != null) channelManager.addChannelMessage(channel, message, myPeerID)
else messageManager.addMessage(message)
}
onSendMessage(token, emptyList(), state.getCurrentChannelValue())
}
addSystemMessage(
"sent ${info.displayAmount ?: "Cashu token"} — bearer token; first redeemer wins"
)
}
private fun addSystemMessage(content: String) {
val message = BitchatMessage(
sender = "system",
content = content,
timestamp = Date(),
isRelay = false
)
val selectedPeer = state.getSelectedPrivateChatPeerValue()
val selectedLocationChannel = state.selectedLocationChannel.value
val channel = state.getCurrentChannelValue()
when {
selectedPeer != null -> {
messageManager.addPrivateMessageNoUnread(selectedPeer, message.copy(isPrivate = true))
}
selectedLocationChannel is com.bitchat.android.geohash.ChannelID.Location -> {
messageManager.addChannelMessage(
"geo:${selectedLocationChannel.channel.geohash}",
message
)
}
channel != null -> channelManager.addChannelMessage(channel, message, null)
else -> messageManager.addMessage(message)
}
}
private fun handleUnknownCommand(cmd: String) {
val systemMessage = BitchatMessage(
@ -404,7 +501,12 @@ class CommandProcessor(
emptyList()
}
return baseCommands + channelCommands
val isPublicGeohash =
state.getSelectedPrivateChatPeerValue() == null &&
state.selectedLocationChannel.value is com.bitchat.android.geohash.ChannelID.Location
return (baseCommands + channelCommands).filterNot {
isPublicGeohash && it.command == "/pay"
}
}
private fun filterCommands(commands: List<CommandSuggestion>, input: String): List<CommandSuggestion> {
@ -524,7 +626,51 @@ class CommandProcessor(
private fun getMyPeerID(meshService: MeshService): String {
return meshService.myPeerID
}
private fun sendPrivateMessage(
content: String,
peerID: String,
recipientNickname: String?,
senderNickname: String?,
myPeerID: String,
meshService: MeshService,
viewModel: ChatViewModel?
) {
val send: (String, String, String, String) -> Unit =
{ messageContent, peerIdParam, recipientNicknameParam, messageId ->
sendPrivateMessageVia(
meshService,
messageContent,
peerIdParam,
recipientNicknameParam,
messageId,
viewModel
)
}
val scope = coroutineScope
if (scope == null) {
privateChatManager.sendPrivateMessage(
content,
peerID,
recipientNickname,
senderNickname,
myPeerID,
send
)
} else {
scope.launch {
privateChatManager.sendPrivateMessageDurably(
content,
peerID,
recipientNickname,
senderNickname,
myPeerID,
send
)
}
}
}
private fun sendPrivateMessageVia(
meshService: MeshService,
content: String,

View File

@ -0,0 +1,17 @@
package com.bitchat.android.ui
/**
* Resolves the Noise session shown for a private conversation.
*
* Persistent conversations use a canonical contact ID, while live Noise sessions are keyed by
* the currently connected mesh peer ID. Prefer that live identity and retain the conversation ID
* as a fallback for peers whose IDs are already identical.
*/
internal fun resolveConversationSessionState(
conversationID: String,
activeMeshPeerID: String?,
peerSessionStates: Map<String, String>
): String? {
return activeMeshPeerID?.let(peerSessionStates::get)
?: peerSessionStates[conversationID]
}

View File

@ -0,0 +1,189 @@
package com.bitchat.android.ui
import com.bitchat.android.model.BitchatMessage
import com.bitchat.android.model.BitchatMessageType
import com.bitchat.android.model.DeliveryStatus
import com.bitchat.android.services.PrivateMessageArrivalOrder
/**
* Presence-independent presentation state for every retained private conversation.
*/
internal data class ConversationSummary(
val conversationID: String,
val displayName: String,
val unreadCount: Int,
val latestMessageAt: Long,
val latestActivityOrder: Long,
val latestMessageType: BitchatMessageType,
val latestMessagePreview: String,
val latestMessageIsOutgoing: Boolean = false,
val latestDeliveryStatus: DeliveryStatus? = null,
val transport: DirectMessageTransport,
val nostrPubkey: String?,
val identityAliases: Set<String>,
val isConnected: Boolean = false,
val connectedPeerID: String? = null,
val sourceGeohash: String? = null,
val isPinned: Boolean = false,
val isMuted: Boolean = false,
val draft: String? = null
)
internal fun buildConversationSummaries(
unreadConversationIDs: Set<String>,
privateChats: Map<String, List<BitchatMessage>>,
currentUserIdentifiers: Set<String>,
canonicalize: (String) -> String,
isMessageRead: (BitchatMessage) -> Boolean,
persistedUnreadCounts: Map<String, Int> = emptyMap()
): List<ConversationSummary> {
if (privateChats.isEmpty()) return emptyList()
val currentUsers = currentUserIdentifiers
.filter(String::isNotBlank)
.mapTo(mutableSetOf()) { it.lowercase() }
val unreadCanonicalIDs = unreadConversationIDs
.mapTo(mutableSetOf()) { canonicalize(it).lowercase() }
val unreadCountsByCanonicalID = persistedUnreadCounts.entries
.groupingBy { canonicalize(it.key).lowercase() }
.fold(0) { total, entry -> total + entry.value }
val aliasesByCanonicalID = linkedMapOf<String, MutableSet<String>>()
val messagesByCanonicalID = linkedMapOf<String, MutableList<BitchatMessage>>()
val displayCanonicalIDByNormalized = linkedMapOf<String, String>()
privateChats.forEach { (sourceID, messages) ->
val canonicalID = canonicalize(sourceID)
val normalizedID = canonicalID.lowercase()
displayCanonicalIDByNormalized.putIfAbsent(normalizedID, canonicalID)
aliasesByCanonicalID
.getOrPut(normalizedID) { linkedSetOf() }
.add(sourceID)
messagesByCanonicalID
.getOrPut(normalizedID) { mutableListOf() }
.addAll(messages)
}
return messagesByCanonicalID.mapNotNull { (normalizedConversationID, sourceMessages) ->
val conversationID =
displayCanonicalIDByNormalized.getValue(normalizedConversationID)
val messages = sourceMessages.distinctBy { it.id }
if (messages.isEmpty()) return@mapNotNull null
fun activityOrder(message: BitchatMessage): Long =
PrivateMessageArrivalOrder.sequenceOf(message.id) ?: message.timestamp.time
val latest = messages.maxWithOrNull(
compareBy<BitchatMessage>(::activityOrder).thenBy { it.id }
) ?: return@mapNotNull null
fun isOutgoing(message: BitchatMessage): Boolean =
message.sender.lowercase() in currentUsers ||
message.senderPeerID?.lowercase() in currentUsers
val incoming = messages.filterNot {
isOutgoing(it) || it.sender == "system"
}
val latestIncoming = incoming.maxWithOrNull(
compareBy<BitchatMessage>(::activityOrder).thenBy { it.id }
)
val canonicalUnread = conversationID.lowercase() in unreadCanonicalIDs
val persistedUnreadCount = unreadCountsByCanonicalID[conversationID.lowercase()] ?: 0
val unreadCount = maxOf(
persistedUnreadCount,
if (canonicalUnread && incoming.isNotEmpty()) {
incoming.count { !isMessageRead(it) }.coerceAtLeast(1)
} else {
0
}
)
val aliases = aliasesByCanonicalID[normalizedConversationID].orEmpty()
val nostrPubkey = latestIncoming?.senderNostrPubkey ?: latest.senderNostrPubkey
val isNostrConversation = nostrPubkey != null ||
aliases.any(::isNostrConversationKey) ||
isNostrConversationKey(conversationID)
val displayName = latestIncoming
?.sender
?.takeIf { it.isNotBlank() }
?: latest.recipientNickname
?.takeIf { it.isNotBlank() }
?: latest.sender.takeIf {
it.isNotBlank() && it !in currentUsers && it != "system"
}
?: conversationID.take(12)
ConversationSummary(
conversationID = conversationID,
displayName = displayName,
unreadCount = unreadCount,
latestMessageAt =
PrivateMessageArrivalOrder.receivedAtOf(latest.id) ?: latest.timestamp.time,
latestActivityOrder = activityOrder(latest),
latestMessageType = latest.type,
latestMessagePreview = latest.conversationPreview(),
latestMessageIsOutgoing = isOutgoing(latest),
latestDeliveryStatus = latest.deliveryStatus,
transport = if (isNostrConversation) {
DirectMessageTransport.NOSTR
} else {
DirectMessageTransport.MESH
},
nostrPubkey = nostrPubkey,
identityAliases = (aliases + conversationID)
.mapTo(mutableSetOf()) { it.lowercase() }
)
}
}
internal fun resolveConversationDisplayName(
fallbackName: String,
connectedPeerID: String?,
peerNicknames: Map<String, String>,
resolvedContactName: String?,
persistedDisplayName: String?
): String {
fun String?.usableName(): String? = this?.takeUnless {
it.isBlank() || it.equals("Unknown", ignoreCase = true)
}
val liveName = connectedPeerID?.let { peerID ->
peerNicknames[peerID]
?: peerNicknames.entries
.firstOrNull { (candidateID, _) ->
candidateID.equals(peerID, ignoreCase = true)
}
?.value
}
return liveName.usableName()
?: resolvedContactName.usableName()
?: persistedDisplayName.usableName()
?: fallbackName
}
internal fun sortConversationSummaries(
conversations: List<ConversationSummary>
): List<ConversationSummary> = conversations.sortedWith(
compareByDescending<ConversationSummary> { it.isConnected }
.thenByDescending { it.isPinned }
.thenByDescending { it.unreadCount > 0 }
.thenByDescending { it.latestActivityOrder }
.thenBy { it.displayName.lowercase() }
.thenBy { it.conversationID }
)
private fun isNostrConversationKey(value: String): Boolean =
value.startsWith("nostr_") || value.startsWith("nostr:")
private fun BitchatMessage.conversationPreview(): String {
val preview = when (type) {
BitchatMessageType.File -> content
.substringAfterLast('/')
.substringAfterLast('\\')
else -> content
}
return preview
.replace(CONVERSATION_PREVIEW_WHITESPACE, " ")
.trim()
.take(MAX_CONVERSATION_PREVIEW_LENGTH)
}
private val CONVERSATION_PREVIEW_WHITESPACE = Regex("\\s+")
private const val MAX_CONVERSATION_PREVIEW_LENGTH = 240

View File

@ -281,6 +281,7 @@ private fun GeohashPersonItem(
onTap: () -> Unit
) {
val palette = LocalBitchatPalette.current
val colorScheme = MaterialTheme.colorScheme
val statusIconRes =
if (isTeleported) R.drawable.ic_spec_teleport
@ -298,25 +299,14 @@ private fun GeohashPersonItem(
Row(
modifier = Modifier
.fillMaxWidth()
// Exact height, not padding: a row that sizes to its content makes the card change
// height whenever the list reorders.
.height(SheetRowHeight)
.clickable(onClick = onTap)
.padding(horizontal = SheetRowHorizontal),
.padding(horizontal = SheetRowHorizontal, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically
) {
Box(
modifier = Modifier.size(SheetRowLeadingSlot),
contentAlignment = Alignment.Center
) {
if (hasUnreadDM) {
Icon(
imageVector = Icons.Filled.Email,
contentDescription = stringResource(R.string.cd_unread_message),
modifier = Modifier.size(22.dp),
tint = palette.accentOrange
)
} else {
PeerAvatar(
name = baseNameRaw,
color = baseColor,
badge = {
Icon(
painter = painterResource(statusIconRes),
contentDescription = if (isTeleported) {
@ -324,13 +314,13 @@ private fun GeohashPersonItem(
} else {
stringResource(R.string.section_on_location)
},
modifier = Modifier.size(22.dp),
tint = baseColor
modifier = Modifier.size(13.dp),
tint = if (isTeleported) palette.accentPurple else colorScheme.primary
)
}
}
)
Spacer(modifier = Modifier.width(SheetRowLeadingGutter))
Spacer(modifier = Modifier.width(12.dp))
Row(
modifier = Modifier.weight(1f),
@ -365,5 +355,16 @@ private fun GeohashPersonItem(
)
}
}
if (hasUnreadDM) {
Icon(
imageVector = Icons.Filled.Email,
contentDescription = stringResource(R.string.cd_unread_message),
modifier = Modifier
.padding(start = 8.dp)
.size(18.dp),
tint = palette.accentOrange
)
}
}
}

View File

@ -1,50 +1,48 @@
package com.bitchat.android.ui
import android.app.Activity
import android.content.Intent
import android.os.Bundle
import androidx.activity.compose.setContent
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Remove
import android.annotation.SuppressLint
import android.app.Activity
import android.content.Intent
import android.content.res.Configuration
import android.os.Bundle
import android.view.ViewGroup
import android.webkit.JavascriptInterface
import android.webkit.WebChromeClient
import android.webkit.WebSettings
import android.webkit.WebView
import android.webkit.WebResourceRequest
import android.webkit.WebViewClient
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.luminance
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.bitchat.android.ui.theme.BitchatTheme
import androidx.compose.ui.unit.sp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.compose.ui.res.stringResource
import com.bitchat.android.ui.theme.BitchatFontFamily
import androidx.core.content.res.ResourcesCompat
import com.bitchat.android.R
import androidx.core.view.updateLayoutParams
import com.bitchat.android.geohash.Geohash
import com.bitchat.android.geohash.GeohashChannelLevel
import com.bitchat.android.geohash.LocationChannelManager
import com.bitchat.android.ui.globe.GlobeColors
import com.bitchat.android.ui.globe.GlobeState
import com.bitchat.android.ui.globe.GlobeView
import com.bitchat.android.ui.globe.LandData
import com.bitchat.android.ui.theme.BASE_FONT_SIZE
import com.bitchat.android.ui.theme.BitchatFontFamily
import com.bitchat.android.ui.theme.BitchatTheme
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
@OptIn(ExperimentalMaterial3Api::class)
class GeohashPickerActivity : OrientationAwareActivity() {
companion object {
@ -52,13 +50,13 @@ class GeohashPickerActivity : OrientationAwareActivity() {
const val EXTRA_RESULT_GEOHASH = "result_geohash"
}
@SuppressLint("SetJavaScriptEnabled")
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val initialGeohash = intent.getStringExtra(EXTRA_INITIAL_GEOHASH)?.trim()?.lowercase()
var geohashToFocus: String? = null
var (initLat, initLon) = 0.0 to 0.0
var initLat = 20.0
var initLon = 0.0
if (!initialGeohash.isNullOrEmpty()) {
geohashToFocus = initialGeohash
@ -84,201 +82,206 @@ class GeohashPickerActivity : OrientationAwareActivity() {
}
}
val initialPrecision = geohashToFocus?.length ?: 5
val initialPrecision = (geohashToFocus?.length ?: 2).coerceIn(1, 12)
val targetLat = initLat
val targetLon = initLon
setContent {
BitchatTheme {
var currentGeohash by remember { mutableStateOf(geohashToFocus ?: "") }
var precision by remember { mutableStateOf(initialPrecision.coerceIn(1, 12)) }
var webViewRef by remember { mutableStateOf<WebView?>(null) }
val context = LocalContext.current
val scope = rememberCoroutineScope()
val globeState = remember {
GlobeState(
targetLat = targetLat,
targetLon = targetLon,
initialPrecision = initialPrecision,
startZoomedOut = true
).apply {
introTarget = Triple(targetLat, targetLon, initialPrecision)
}
}
LaunchedEffect(globeState) { globeState.attach(scope) }
val land by produceState<List<LandData.Ring>?>(initialValue = null) {
value = withContext(Dispatchers.IO) { LandData.load(context) }
}
val borders by produceState<List<LandData.Ring>>(initialValue = emptyList()) {
value = withContext(Dispatchers.IO) { LandData.loadBorders(context) }
}
val cities by produceState<List<LandData.City>>(initialValue = emptyList()) {
value = withContext(Dispatchers.IO) { LandData.loadCities(context) }
}
val colorScheme = MaterialTheme.colorScheme
val standardGreen = colorScheme.primary
Scaffold { padding ->
Box(Modifier.fillMaxSize()) {
AndroidView(
factory = { context ->
WebView(context).apply {
settings.javaScriptEnabled = true
settings.domStorageEnabled = true
settings.cacheMode = WebSettings.LOAD_DEFAULT
settings.mixedContentMode = WebSettings.MIXED_CONTENT_NEVER_ALLOW
settings.allowFileAccess = true
settings.allowContentAccess = true
webChromeClient = WebChromeClient()
webViewClient = object : WebViewClient() {
override fun shouldOverrideUrlLoading(view: WebView?, request: WebResourceRequest?): Boolean {
val url = request?.url?.toString() ?: return true
// Block navigation away from the local geohash picker asset
return !url.startsWith("file:///android_asset/geohash_picker.html")
}
override fun onPageFinished(view: WebView?, url: String?) {
super.onPageFinished(view, url)
// Initialize to last/initial geohash if provided, otherwise center
if (!geohashToFocus.isNullOrEmpty()) {
evaluateJavascript(
"window.focusGeohash('${geohashToFocus}')",
null
)
} else {
evaluateJavascript(
"window.setCenter(${initLat}, ${initLon})",
null
)
}
// Apply theme to map tiles
val nightModeFlags = resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK
val theme = if (nightModeFlags == Configuration.UI_MODE_NIGHT_YES) "dark" else "light"
evaluateJavascript("window.setMapTheme('" + theme + "')", null)
}
}
addJavascriptInterface(object {
@JavascriptInterface
fun onGeohashChanged(geohash: String) {
runOnUiThread {
currentGeohash = geohash
}
}
}, "Android")
loadUrl("file:///android_asset/geohash_picker.html")
}
},
modifier = Modifier
.fillMaxSize()
.padding(padding),
update = { webView ->
webViewRef = webView
// ensure it fills parent
webView.updateLayoutParams<ViewGroup.LayoutParams> {
width = ViewGroup.LayoutParams.MATCH_PARENT
height = ViewGroup.LayoutParams.MATCH_PARENT
}
},
onRelease = { webView ->
// Best-effort cleanup to avoid leaks and timers
try { webView.evaluateJavascript("window.cleanup && window.cleanup()", null) } catch (_: Throwable) {}
try { webView.stopLoading() } catch (_: Throwable) {}
try { webView.clearHistory() } catch (_: Throwable) {}
try { webView.clearCache(true) } catch (_: Throwable) {}
try { webView.loadUrl("about:blank") } catch (_: Throwable) {}
try { webView.removeAllViews() } catch (_: Throwable) {}
try { webView.destroy() } catch (_: Throwable) {}
}
val dark = colorScheme.background.luminance() < 0.5f
val globeColors = remember(colorScheme, dark) {
if (dark) {
GlobeColors(
accent = colorScheme.primary,
land = Color(0xFF16241B),
coastline = colorScheme.primary.copy(alpha = 0.45f),
border = colorScheme.onSurfaceVariant.copy(alpha = 0.5f),
oceanCenter = Color(0xFF0A1410),
oceanEdge = Color(0xFF020604),
atmosphere = colorScheme.primary,
graticule = colorScheme.onSurface.copy(alpha = 0.055f),
grid = colorScheme.onSurfaceVariant.copy(alpha = 0.6f),
label = colorScheme.onSurfaceVariant,
labelHalo = colorScheme.background,
star = colorScheme.onSurface
)
} else {
GlobeColors(
accent = colorScheme.primary,
land = Color(0xFFBCD2C0),
coastline = colorScheme.primary.copy(alpha = 0.5f),
border = colorScheme.onSurfaceVariant.copy(alpha = 0.6f),
oceanCenter = Color(0xFFEAF2EC),
oceanEdge = Color(0xFFD4E2D7),
atmosphere = colorScheme.primary,
graticule = colorScheme.onSurface.copy(alpha = 0.08f),
grid = colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
label = colorScheme.onSurfaceVariant,
labelHalo = colorScheme.background,
star = colorScheme.onSurfaceVariant
)
}
}
// Floating info pill
Surface(
val labelTypeface = remember { ResourcesCompat.getFont(context, R.font.geist_mono_medium) }
val labelTypefaceBold = remember { ResourcesCompat.getFont(context, R.font.geist_mono_semibold) }
Box(
Modifier
.fillMaxSize()
.background(colorScheme.background)
) {
land?.let { rings ->
GlobeView(
state = globeState,
colors = globeColors,
land = rings,
borders = borders,
cities = cities,
labelTypeface = labelTypeface,
labelTypefaceBold = labelTypefaceBold,
modifier = Modifier.fillMaxSize()
)
}
// Floating info pill
Surface(
modifier = Modifier
.align(Alignment.TopCenter)
.statusBarsPadding()
.padding(top = 20.dp)
.fillMaxWidth(0.8f),
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.85f),
shape = RoundedCornerShape(12.dp),
tonalElevation = 3.dp,
shadowElevation = 6.dp
) {
Text(
text = stringResource(R.string.pan_zoom_instruction),
fontSize = 12.sp,
textAlign = TextAlign.Center,
fontFamily = BitchatFontFamily,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier
.align(Alignment.TopCenter)
.padding(top = 20.dp)
.fillMaxWidth(0.75f),
.padding(horizontal = 14.dp, vertical = 10.dp)
)
}
// Floating bottom controls
Column(
modifier = Modifier
.align(Alignment.BottomCenter)
.navigationBarsPadding()
.padding(bottom = 20.dp, start = 16.dp, end = 16.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
// Geohash label (monospace, app style)
Surface(
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.85f),
shape = RoundedCornerShape(12.dp),
tonalElevation = 3.dp,
shadowElevation = 6.dp
) {
Text(
text = stringResource(R.string.pan_zoom_instruction),
fontSize = 12.sp,
textAlign = TextAlign.Center,
fontFamily = BitchatFontFamily,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier
.padding(horizontal = 14.dp, vertical = 10.dp)
)
}
// Floating bottom controls
Column(
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(bottom = 20.dp, start = 16.dp, end = 16.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
// Geohash label (monospace, app style)
Surface(
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.85f),
shape = RoundedCornerShape(12.dp),
tonalElevation = 3.dp,
shadowElevation = 6.dp
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
) {
Text(
text = if (currentGeohash.isNotEmpty()) "#${currentGeohash}" else "select location",
text = if (globeState.selectedGeohash.isNotEmpty()) "#${globeState.selectedGeohash}" else "select location",
fontSize = BASE_FONT_SIZE.sp,
fontFamily = BitchatFontFamily,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier
.padding(horizontal = 14.dp, vertical = 10.dp)
color = MaterialTheme.colorScheme.onSurface
)
if (globeState.selectedGeohash.isNotEmpty()) {
Text(
text = "${levelForLength(globeState.precision).displayName}${coverageString(globeState.precision)}",
fontSize = (BASE_FONT_SIZE - 4).sp,
fontFamily = BitchatFontFamily,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
// Button row
Row(
horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalAlignment = Alignment.CenterVertically
) {
// Decrease precision
Button(
onClick = { globeState.animatePrecision(globeState.precision - 1) },
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.12f),
contentColor = MaterialTheme.colorScheme.primary
)
) {
Icon(Icons.Filled.Remove, contentDescription = stringResource(R.string.cd_decrease_precision))
}
// Button row
Row(
horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalAlignment = Alignment.CenterVertically
// Increase precision
Button(
onClick = { globeState.animatePrecision(globeState.precision + 1) },
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.12f),
contentColor = MaterialTheme.colorScheme.primary
)
) {
// Decrease precision
Button(
onClick = {
precision = (precision - 1).coerceAtLeast(1)
webViewRef?.evaluateJavascript("window.setPrecision($precision)", null)
},
colors = ButtonDefaults.buttonColors(
containerColor = standardGreen.copy(alpha = 0.12f),
contentColor = standardGreen
)
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.Filled.Remove, contentDescription = stringResource(R.string.cd_decrease_precision))
}
}
Icon(Icons.Filled.Add, contentDescription = stringResource(R.string.cd_increase_precision))
}
// Increase precision
Button(
onClick = {
precision = (precision + 1).coerceAtMost(12)
webViewRef?.evaluateJavascript("window.setPrecision($precision)", null)
},
colors = ButtonDefaults.buttonColors(
containerColor = standardGreen.copy(alpha = 0.12f),
contentColor = standardGreen
)
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.Filled.Add, contentDescription = stringResource(R.string.cd_increase_precision))
// Select button
Button(
onClick = {
val gh = globeState.selectedGeohash
if (gh.isNotEmpty()) {
val result = Intent().apply { putExtra(EXTRA_RESULT_GEOHASH, gh) }
setResult(Activity.RESULT_OK, result)
finish()
}
}
// Select button
Button(
onClick = {
webViewRef?.evaluateJavascript("window.getGeohash()") { value ->
val gh = value?.trim('"') ?: currentGeohash
val result = Intent().apply { putExtra(EXTRA_RESULT_GEOHASH, gh) }
setResult(Activity.RESULT_OK, result)
finish()
}
},
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.secondary.copy(alpha = 0.12f),
contentColor = MaterialTheme.colorScheme.onSurface
)
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.Filled.Check, contentDescription = stringResource(R.string.cd_select_geohash))
Spacer(Modifier.width(6.dp))
Text(
text = stringResource(R.string.select),
fontSize = (BASE_FONT_SIZE - 2).sp,
fontFamily = BitchatFontFamily
)
}
}
},
enabled = globeState.selectedGeohash.isNotEmpty(),
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.secondary.copy(alpha = 0.12f),
contentColor = MaterialTheme.colorScheme.onSurface
)
) {
Icon(Icons.Filled.Check, contentDescription = stringResource(R.string.cd_select_geohash))
Spacer(Modifier.width(6.dp))
Text(
text = stringResource(R.string.select),
fontSize = (BASE_FONT_SIZE - 2).sp,
fontFamily = BitchatFontFamily
)
}
}
}
@ -286,4 +289,36 @@ class GeohashPickerActivity : OrientationAwareActivity() {
}
}
}
private fun levelForLength(length: Int): GeohashChannelLevel {
return when (length) {
in 0..2 -> GeohashChannelLevel.REGION
in 3..4 -> GeohashChannelLevel.PROVINCE
5 -> GeohashChannelLevel.CITY
6 -> GeohashChannelLevel.NEIGHBORHOOD
7 -> GeohashChannelLevel.BLOCK
else -> GeohashChannelLevel.BUILDING
}
}
private fun coverageString(precision: Int): String {
val maxMeters = when (precision) {
2 -> 1_250_000.0
3 -> 156_000.0
4 -> 39_100.0
5 -> 4_890.0
6 -> 1_220.0
7 -> 153.0
8 -> 38.2
9 -> 4.77
10 -> 1.19
else -> if (precision <= 1) 5_000_000.0 else 1.19 * Math.pow(0.25, (precision - 10).toDouble())
}
val km = maxMeters / 1000.0
return when {
km >= 100 -> "~${String.format(java.util.Locale.US, "%.0f", km)} km"
km >= 1 -> "~${String.format(java.util.Locale.US, "%.1f", km)} km"
else -> "~${String.format(java.util.Locale.US, "%.0f", maxMeters)} m"
}
}
}

View File

@ -1,5 +1,6 @@
package com.bitchat.android.ui
import android.view.HapticFeedbackConstants
import com.bitchat.android.ui.theme.BitchatFontFamily
// [Goose] TODO: Replace inline file attachment stub with FilePickerButton abstraction that dispatches via FileShareDispatcher
@ -13,7 +14,9 @@ import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.animateContentSize
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.animateOffsetAsState
import androidx.compose.animation.core.spring
import androidx.compose.animation.core.tween
import androidx.compose.animation.expandHorizontally
@ -43,6 +46,11 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.scale
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.graphics.Color
@ -57,7 +65,10 @@ import androidx.compose.ui.text.input.OffsetMapping
import androidx.compose.ui.text.input.TransformedText
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.toSize
import kotlin.math.roundToInt
import androidx.compose.ui.unit.sp
import com.bitchat.android.R
import androidx.compose.ui.focus.onFocusChanged
@ -316,22 +327,72 @@ fun MessageInput(
var isRecording by remember { mutableStateOf(false) }
var elapsedMs by remember { mutableStateOf(0L) }
var amplitude by remember { mutableStateOf(0) }
val cashuToken = remember(value.text) {
CashuTokenDecoder.bareToken(value.text)
}
// Recording is the one state worth shouting about, so it overrides focus.
// Slide-to-cancel: while recording, the mic button streams the finger position (root
// coords) up here; the cancel disc beside it reports its bounds. Approaching the disc
// makes it lean toward the finger and blush red; only entering it activates cancel.
var cancelBounds by remember { mutableStateOf<Rect?>(null) }
var cancelFinger by remember { mutableStateOf<Offset?>(null) }
val density = LocalDensity.current
val cancelSlackPx = with(density) { 8.dp.toPx() }
val cancelHover = cancelFinger != null &&
cancelBounds?.inflate(cancelSlackPx)?.contains(cancelFinger!!) == true
val cancelCenter = cancelBounds?.center
val cancelProximity: Float
val cancelPull: Offset
val trackedFinger = cancelFinger
if (trackedFinger != null && cancelCenter != null) {
val toFinger = trackedFinger - cancelCenter
val dist = toFinger.getDistance()
val outer = with(density) { 36.dp.toPx() }
val inner = with(density) { 18.dp.toPx() }
cancelProximity = ((outer - dist) / (outer - inner)).coerceIn(0f, 1f)
cancelPull = if (dist > 1f) {
toFinger * (cancelProximity * with(density) { 12.dp.toPx() } / dist)
} else Offset.Zero
} else {
cancelProximity = 0f
cancelPull = Offset.Zero
}
// A firm, physical click each time the finger enters or leaves the cancel target.
val view = LocalView.current
var cancelHoverHapticState by remember { mutableStateOf(false) }
LaunchedEffect(cancelHover, isRecording) {
if (!isRecording) {
cancelHoverHapticState = false
} else if (cancelHover != cancelHoverHapticState) {
view.performHapticFeedback(HapticFeedbackConstants.CONTEXT_CLICK)
cancelHoverHapticState = cancelHover
}
}
// Recording is the one state worth shouting about, so it overrides focus. While recording
// the outline also firms up slightly in the same fast sweep — present, but muted.
val borderColor by animateColorAsState(
targetValue = when {
isRecording -> colorScheme.error.copy(alpha = 0.7f)
isRecording -> colorScheme.error.copy(alpha = 0.65f)
isFocused.value -> palette.inputOutlineFocused
else -> palette.inputOutline
},
animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing),
label = "composerBorder"
)
// A barely-there lift on focus. Enough to register, not enough to look like a different
// component. Slightly translucent so the messages scrolling underneath stay faintly visible.
val borderWidth by animateDpAsState(
targetValue = if (isRecording) 1.5.dp else 1.dp,
animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing),
label = "composerBorderWidth"
)
// A barely-there lift on focus. While recording the pill turns into a neutral grey slab
// (NOT the brand-tinted elevation color) so it protrudes from the flat black chat.
val containerColor by animateColorAsState(
targetValue = (if (isFocused.value) palette.inputSurfaceFocused else palette.inputSurface)
.copy(alpha = ComposerFillAlpha),
targetValue = when {
isRecording -> colorScheme.surfaceVariant.copy(alpha = 0.97f)
else -> (if (isFocused.value) palette.inputSurfaceFocused else palette.inputSurface)
.copy(alpha = ComposerFillAlpha)
},
animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing),
label = "composerContainer"
)
@ -351,7 +412,7 @@ fun MessageInput(
animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing)
)
.background(containerColor, ComposerShape)
.border(1.dp, borderColor, ComposerShape),
.border(borderWidth, borderColor, ComposerShape),
verticalAlignment = Alignment.Bottom
) {
Box(
@ -367,16 +428,17 @@ fun MessageInput(
// user is composing rather than reading, and green-on-black is tiring to
// type into.
textStyle = MaterialTheme.typography.bodyMedium.copy(
color = colorScheme.onSurface,
color = if (cashuToken == null) colorScheme.onSurface else Color.Transparent,
fontFamily = BitchatFontFamily
),
cursorBrush = SolidColor(
if (isRecording) Color.Transparent else colorScheme.onSurface
if (isRecording || cashuToken != null) Color.Transparent else colorScheme.onSurface
),
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send),
keyboardActions = KeyboardActions(onSend = {
if (hasText) onSend()
}),
singleLine = cashuToken != null,
// Cap the growth so a pasted wall of text cannot swallow the message list.
maxLines = 6,
visualTransformation = remember(
@ -405,6 +467,14 @@ fun MessageInput(
}
)
cashuToken?.let { token ->
CashuPaymentChip(
token = token,
onClick = { focusRequester.requestFocus() },
showActions = false,
)
}
// Placeholder fades rather than blinking, which matters because it reappears
// every time a message is sent.
val placeholderAlpha by animateFloatAsState(
@ -436,25 +506,28 @@ fun MessageInput(
Row(
modifier = Modifier
.fillMaxWidth()
// Same content height as the single-line text field, so the pill
// (and the separator above it) does not change size when the
// recording visualizer replaces the field.
.height(22.dp)
.alpha(waveformAlpha),
verticalAlignment = Alignment.CenterVertically
) {
RealtimeScrollingWaveform(
modifier = Modifier.weight(1f).height(22.dp),
amplitudeNorm = normalizeAmplitudeSample(amplitude)
)
Spacer(Modifier.width(12.dp))
// Timestamp on the left, clear of the thumb resting on the record
// button; the waveform keeps the remaining width and its history
// scrolls off the left edge while live data streams in from the right.
val secs = (elapsedMs / 1000).toInt()
val maxSecs = 10 // 10 second max recording time
Text(
text = String.format(
"%02d:%02d / %02d:%02d",
secs / 60, secs % 60, maxSecs / 60, maxSecs % 60
),
text = String.format("%02d:%02d", secs / 60, secs % 60),
fontFamily = BitchatFontFamily,
color = colorScheme.error,
fontSize = (BASE_FONT_SIZE - 4).sp
)
Spacer(Modifier.width(12.dp))
RealtimeScrollingWaveform(
modifier = Modifier.weight(1f).height(22.dp),
amplitudeNorm = normalizeAmplitudeSample(amplitude)
)
}
}
}
@ -528,8 +601,37 @@ fun MessageInput(
)
}
// The slide-to-cancel target sits well clear of the record
// button (camera's slot plus a gap), rests as a cancel disc,
// leans toward an approaching finger and snaps red on hover.
AnimatedVisibility(
visible = isRecording,
enter = fadeIn(tween(BitchatMotion.STANDARD_MS)) +
expandHorizontally(
tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing)
),
exit = fadeOut(tween(BitchatMotion.QUICK_MS)) +
shrinkHorizontally(
tween(BitchatMotion.QUICK_MS, easing = FastOutSlowInEasing)
)
) {
Row(verticalAlignment = Alignment.CenterVertically) {
RecordingCancelButton(
hover = cancelHover,
proximity = cancelProximity,
pull = cancelPull,
onBounds = { cancelBounds = it }
)
Spacer(Modifier.width(24.dp))
}
}
VoiceRecordButton(
isRecording = isRecording,
shouldCancel = { pos ->
cancelBounds?.inflate(cancelSlackPx)?.contains(pos) == true
},
onTrackFinger = { cancelFinger = it },
onStart = {
isRecording = true
elapsedMs = 0L
@ -583,6 +685,76 @@ fun MessageInput(
// Auto-stop handled inside VoiceRecordButton
}
/**
* Slide-to-cancel target shown beside the record button while capturing. It always shows the
* cancel glyph so the destination is unambiguous; as the finger approaches it leans toward
* it (magnetic pull) and blushes red, and on contact it blooms. Release there cancels;
* sliding back out returns to send mode. All motion is spring-driven so it stays fluid.
*/
@Composable
private fun RecordingCancelButton(
hover: Boolean,
proximity: Float,
pull: Offset,
onBounds: (Rect) -> Unit,
modifier: Modifier = Modifier
) {
val palette = LocalBitchatPalette.current
val colorScheme = MaterialTheme.colorScheme
val pullAnim by animateOffsetAsState(
targetValue = pull,
animationSpec = spring(
dampingRatio = Spring.DampingRatioMediumBouncy,
stiffness = Spring.StiffnessMedium
),
label = "cancelPull"
)
val scale by animateFloatAsState(
targetValue = if (hover) 1.28f else 1f + 0.1f * proximity,
animationSpec = spring(
dampingRatio = Spring.DampingRatioMediumBouncy,
stiffness = Spring.StiffnessMedium
),
label = "cancelScale"
)
val container = androidx.compose.ui.graphics.lerp(
palette.inputButton,
colorScheme.error,
if (hover) 1f else proximity * 0.85f
)
val tint = androidx.compose.ui.graphics.lerp(
colorScheme.onSurfaceVariant,
colorScheme.onError,
if (hover) 1f else proximity * 0.6f
)
Box(
modifier = modifier
.onGloballyPositioned { coords ->
onBounds(Rect(coords.localToRoot(Offset.Zero), coords.size.toSize()))
}
.size(ComposerButtonSize),
contentAlignment = Alignment.Center
) {
Box(
modifier = Modifier
.size(ComposerButtonDisc)
.scale(scale)
.offset { IntOffset(pullAnim.x.roundToInt(), pullAnim.y.roundToInt()) }
.background(container, CircleShape),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = Icons.Filled.Close,
contentDescription = "Cancel recording",
tint = tint,
modifier = Modifier.size(ComposerIconSize)
)
}
}
}
/**
* Send affordance. Only rendered when there is something to send, so its mere presence is the
* signal; it does not need to shout in the terminal's full-brightness green as well.

View File

@ -0,0 +1,61 @@
package com.bitchat.android.ui
import android.content.Context
import androidx.annotation.XmlRes
import androidx.appcompat.app.AppCompatDelegate
import androidx.core.os.LocaleListCompat
import com.bitchat.android.R
import org.xmlpull.v1.XmlPullParser
import java.util.Locale
data class AppLanguage(
val languageTag: String,
val endonym: String,
)
object LanguagePreferenceManager {
fun currentLanguageTag(): String =
AppCompatDelegate.getApplicationLocales().toLanguageTags()
fun setLanguage(languageTag: String) {
AppCompatDelegate.setApplicationLocales(
LocaleListCompat.forLanguageTags(languageTag)
)
}
fun supportedLanguages(
context: Context,
@XmlRes localeConfig: Int = R.xml.locales_config,
): List<AppLanguage> = readLanguageTags(context, localeConfig)
.map { languageTag ->
val locale = Locale.forLanguageTag(languageTag)
AppLanguage(
languageTag = languageTag,
endonym = locale.getDisplayName(locale)
.replaceFirstChar { first ->
if (first.isLowerCase()) first.titlecase(locale) else first.toString()
},
)
}
.sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { it.endonym })
internal fun readLanguageTags(
context: Context,
@XmlRes localeConfig: Int,
): List<String> {
val parser = context.resources.getXml(localeConfig)
return buildList {
var event = parser.eventType
while (event != XmlPullParser.END_DOCUMENT) {
if (event == XmlPullParser.START_TAG && parser.name == "locale") {
parser.getAttributeValue(ANDROID_NAMESPACE, "name")
?.takeIf(String::isNotBlank)
?.let(::add)
}
event = parser.next()
}
}
}
private const val ANDROID_NAMESPACE = "http://schemas.android.com/apk/res/android"
}

View File

@ -57,7 +57,8 @@ class MediaSendingManager(
private data class PendingPrivateMedia(
val request: LegacyPrivateMediaConsentRequest,
val peerID: String,
val conversationID: String,
val recipientMeshPeerID: String,
val filePacket: BitchatFilePacket,
val filePath: String,
val messageType: BitchatMessageType,
@ -68,7 +69,8 @@ class MediaSendingManager(
private data class PendingAutomaticPrivateMedia(
val requestId: String,
val peerID: String,
val conversationID: String,
val recipientMeshPeerID: String,
val filePacket: BitchatFilePacket,
val filePath: String,
val messageType: BitchatMessageType,
@ -299,10 +301,19 @@ class MediaSendingManager(
val transferId = withContext(mediaWorkDispatcher) {
sha256Hex(payload)
}
val recipient = PrivateMediaRecipientResolver.resolve(toPeerID, meshService)
?: run {
addPrivateMediaSystemMessage(
toPeerID,
"Private media was not sent because this conversation has no active mesh route."
)
return
}
val pending = PendingAutomaticPrivateMedia(
requestId = UUID.randomUUID().toString(),
peerID = toPeerID,
conversationID = recipient.conversationID,
recipientMeshPeerID = recipient.meshPeerID,
filePacket = filePacket,
filePath = filePath,
messageType = messageType,
@ -311,7 +322,7 @@ class MediaSendingManager(
)
if (!reserveAutomaticPending(pending)) {
addPrivateMediaSystemMessage(
toPeerID,
recipient.conversationID,
"Private media was not sent because another secure media send is still pending."
)
return
@ -334,7 +345,8 @@ class MediaSendingManager(
val pending = consumePendingConsent(requestId) ?: return
val automatic = PendingAutomaticPrivateMedia(
requestId = UUID.randomUUID().toString(),
peerID = pending.peerID,
conversationID = pending.conversationID,
recipientMeshPeerID = pending.recipientMeshPeerID,
filePacket = pending.filePacket,
filePath = pending.filePath,
messageType = pending.messageType,
@ -343,7 +355,7 @@ class MediaSendingManager(
)
if (!reserveAutomaticPending(automatic)) {
addPrivateMediaSystemMessage(
pending.peerID,
pending.conversationID,
"Private media was not sent because another secure media send is still pending."
)
return
@ -374,7 +386,7 @@ class MediaSendingManager(
private suspend fun retryPendingPrivateMediaOnScope(peerID: String) {
val pending = synchronized(pendingConsentLock) {
pendingAutomaticPrivateMedia
?.takeIf { it.peerID == peerID }
?.takeIf { it.recipientMeshPeerID == peerID }
} ?: return
evaluateAutomaticPending(pending)
}
@ -397,7 +409,7 @@ class MediaSendingManager(
val preparation = try {
withContext(mediaWorkDispatcher) {
meshService.prepareFilePrivate(
recipientPeerID = pending.peerID,
recipientPeerID = pending.recipientMeshPeerID,
file = pending.filePacket,
transferId = pending.transferId,
allowLegacyFallback = pending.allowLegacyFallback
@ -429,7 +441,7 @@ class MediaSendingManager(
}
}
private fun handlePrivatePreparation(
private suspend fun handlePrivatePreparation(
preparation: PrivateMediaPreparation,
pending: PendingAutomaticPrivateMedia
) {
@ -438,7 +450,8 @@ class MediaSendingManager(
clearAutomaticPending(pending.requestId)
commitPreparedPrivateFile(
preparation,
pending.peerID,
pending.conversationID,
pending.recipientMeshPeerID,
pending.filePath,
pending.messageType,
pending.transferId
@ -450,16 +463,16 @@ class MediaSendingManager(
if (pending.allowLegacyFallback) {
Log.w(TAG, "Legacy consent was consumed but policy still requested consent; send aborted")
addPrivateMediaSystemMessage(
pending.peerID,
pending.conversationID,
"Private media was not sent because its security policy changed."
)
return
}
val nickname = try {
meshService.getPeerNicknames()[pending.peerID]
meshService.getPeerNicknames()[pending.recipientMeshPeerID]
} catch (_: Exception) {
null
} ?: pending.peerID.take(8)
} ?: pending.recipientMeshPeerID.take(8)
val request = LegacyPrivateMediaConsentRequest(
requestId = UUID.randomUUID().toString(),
recipientNickname = nickname,
@ -473,7 +486,8 @@ class MediaSendingManager(
}
pendingPrivateMedia = PendingPrivateMedia(
request,
pending.peerID,
pending.conversationID,
pending.recipientMeshPeerID,
pending.filePacket,
pending.filePath,
pending.messageType,
@ -487,7 +501,7 @@ class MediaSendingManager(
ensureAutomaticPendingTimeout(pending)
Log.d(TAG, "Private media needs a Noise handshake; retaining first-send intent")
try {
meshService.initiateNoiseHandshake(pending.peerID)
meshService.initiateNoiseHandshake(pending.recipientMeshPeerID)
} catch (e: Exception) {
Log.w(TAG, "Could not initiate private-media Noise handshake: ${e.message}")
}
@ -502,7 +516,7 @@ class MediaSendingManager(
clearAutomaticPending(pending.requestId)
Log.w(TAG, "Private media not sent: ${preparation.reason}")
addPrivateMediaSystemMessage(
pending.peerID,
pending.conversationID,
"Private media was not sent: ${preparation.reason}"
)
}
@ -542,7 +556,7 @@ class MediaSendingManager(
}
if (expired) {
addPrivateMediaSystemMessage(
pending.peerID,
pending.conversationID,
"Private media was not sent because secure session setup timed out."
)
}
@ -585,9 +599,10 @@ class MediaSendingManager(
}
}
private fun commitPreparedPrivateFile(
private suspend fun commitPreparedPrivateFile(
preparation: PrivateMediaPreparation.Ready,
toPeerID: String,
conversationID: String,
recipientMeshPeerID: String,
filePath: String,
messageType: BitchatMessageType,
transferId: String
@ -605,13 +620,24 @@ class MediaSendingManager(
timestamp = Date(),
isRelay = false,
isPrivate = true,
recipientNickname = try { meshService.getPeerNicknames()[toPeerID] } catch (_: Exception) { null },
recipientNickname = try {
meshService.getPeerNicknames()[recipientMeshPeerID]
} catch (_: Exception) {
null
},
senderPeerID = meshService.myPeerID
)
// Preparation already built and admitted the exact final packet. Map
// progress before commit so the first asynchronous event cannot race us.
messageManager.addPrivateMessage(toPeerID, msg)
if (!messageManager.addPrivateMessageDurably(conversationID, msg, forceRead = true)) {
Log.e(TAG, "Prepared private-media message could not be persisted; send aborted")
addPrivateMediaSystemMessage(
conversationID,
"Private media was not sent because the conversation could not be saved."
)
return
}
synchronized(transferMessageMap) {
transferMessageMap[transferId] = msg.id
messageTransferMap[msg.id] = transferId
@ -622,14 +648,19 @@ class MediaSendingManager(
)
if (!preparation.transfer.commit()) {
messageManager.removeMessageById(msg.id)
synchronized(transferMessageMap) {
transferMessageMap.remove(transferId)
messageTransferMap.remove(msg.id)
}
Log.w(TAG, "Prepared private-media commit failed; local echo rolled back")
messageManager.updateMessageDeliveryStatus(
msg.id,
com.bitchat.android.model.DeliveryStatus.Failed(
"Prepared transfer could not be committed"
)
)
Log.w(TAG, "Prepared private-media commit failed; local echo marked failed")
addPrivateMediaSystemMessage(
toPeerID,
conversationID,
"Private media was not sent because the prepared transfer could not be committed."
)
return
@ -739,7 +770,16 @@ class MediaSendingManager(
fun handleTransferProgressEvent(evt: com.bitchat.android.mesh.TransferProgressEvent) {
val msgId = synchronized(transferMessageMap) { transferMessageMap[evt.transferId] }
if (msgId != null) {
if (evt.completed) {
if (evt.failed) {
messageManager.updateMessageDeliveryStatus(
msgId,
com.bitchat.android.model.DeliveryStatus.Failed("transfer could not be sent")
)
synchronized(transferMessageMap) {
val msgIdRemoved = transferMessageMap.remove(evt.transferId)
if (msgIdRemoved != null) messageTransferMap.remove(msgIdRemoved)
}
} else if (evt.completed) {
messageManager.updateMessageDeliveryStatus(
msgId,
com.bitchat.android.model.DeliveryStatus.Delivered(to = "mesh", at = java.util.Date())

View File

@ -109,7 +109,6 @@ class MeshDelegateHandler(
private suspend fun processPeerUpdate(mergedPeers: List<String>) {
state.setConnectedPeers(mergedPeers)
state.setIsConnected(mergedPeers.isNotEmpty())
notificationManager.showActiveUserNotification(mergedPeers)
// Flush router outbox for any peers that just connected (and their noiseHex aliases)
runCatching { com.bitchat.android.services.MessageRouter.tryGetInstance()?.onPeersUpdated(mergedPeers) }

File diff suppressed because it is too large Load Diff

View File

@ -1,5 +1,11 @@
package com.bitchat.android.ui
import android.content.ActivityNotFoundException
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.content.Intent
import android.net.Uri
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.animation.AnimatedContent
@ -15,7 +21,9 @@ import androidx.compose.animation.fadeOut
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@ -25,13 +33,17 @@ import androidx.compose.foundation.layout.calculateEndPadding
import androidx.compose.foundation.layout.calculateStartPadding
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ColorScheme
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
@ -53,6 +65,8 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
@ -599,6 +613,24 @@ fun MessageItem(
return
}
val cashuTokens = remember(message.content) {
CashuTokenDecoder.extractTokens(message.content)
}
if (cashuTokens.isNotEmpty() && message.sender != "system") {
CashuMessageContent(
message = message,
tokens = cashuTokens,
currentUserNickname = currentUserNickname,
meshService = meshService,
colorScheme = colorScheme,
timeFormatter = timeFormatter,
onNicknameClick = onNicknameClick,
onMessageLongPress = onMessageLongPress,
modifier = modifier
)
return
}
if (message.sender == "system") {
// Background narration: `// Tor started. Routing all chats…`
val annotatedText = remember(message, colorScheme.onSurface) {
@ -756,6 +788,149 @@ internal fun TextMessageLayout(
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun CashuMessageContent(
message: BitchatMessage,
tokens: List<String>,
currentUserNickname: String,
meshService: MeshService,
colorScheme: ColorScheme,
timeFormatter: SimpleDateFormat,
onNicknameClick: ((String) -> Unit)?,
onMessageLongPress: ((BitchatMessage) -> Unit)?,
modifier: Modifier = Modifier
) {
val remainingText = tokens.fold(message.content) { text, token ->
text.replace("cashu://$token", "", ignoreCase = true)
.replace("cashu:$token", "", ignoreCase = true)
.replace(token, "")
}.trim()
Column(
modifier = modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
TextMessageLayout(
message = message,
currentUserNickname = currentUserNickname,
meshService = meshService,
colorScheme = colorScheme,
timeFormatter = timeFormatter,
onNicknameClick = onNicknameClick,
onMessageLongPress = onMessageLongPress,
bodyContent = remainingText,
)
tokens.forEach { token -> CashuPaymentChip(token) }
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
internal fun CashuPaymentChip(
token: String,
modifier: Modifier = Modifier,
onClick: (() -> Unit)? = null,
showActions: Boolean = true,
) {
val context = LocalContext.current
val info = remember(token) { CashuTokenDecoder.decode(token) }
val primaryLabel = listOfNotNull(info?.displayAmount, info?.mintHost)
.joinToString(" · ")
.ifEmpty { stringResource(R.string.cashu_pay_via) }
var showMenu by remember { mutableStateOf(false) }
Box {
Row(
modifier = modifier
.border(1.dp, MaterialTheme.colorScheme.primary.copy(alpha = 0.25f), RoundedCornerShape(12.dp))
.background(
MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.45f),
RoundedCornerShape(12.dp)
)
.combinedClickable(
onClick = onClick ?: { redeemCashu(context, token, preferWallet = true) },
onLongClick = if (showActions) {
{ showMenu = true }
} else {
null
}
)
.semantics {
contentDescription = buildString {
append(context.getString(R.string.cashu_payment_description))
append(": ")
append(primaryLabel)
info?.memo?.let { append(", $it") }
}
}
.heightIn(min = 48.dp)
.padding(horizontal = 12.dp, vertical = 6.dp),
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text("🥜")
Column {
Text(
primaryLabel,
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSecondaryContainer
)
info?.memo?.let {
Text(
it,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSecondaryContainer.copy(alpha = 0.7f),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
}
DropdownMenu(
expanded = showActions && showMenu,
onDismissRequest = { showMenu = false }
) {
DropdownMenuItem(
text = { Text(stringResource(R.string.cashu_copy_token)) },
onClick = {
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
clipboard.setPrimaryClip(ClipData.newPlainText("Cashu token", token))
showMenu = false
}
)
DropdownMenuItem(
text = { Text(stringResource(R.string.cashu_redeem_wallet)) },
onClick = {
showMenu = false
redeemCashu(context, token, preferWallet = true)
}
)
DropdownMenuItem(
text = { Text(stringResource(R.string.cashu_redeem_web)) },
onClick = {
showMenu = false
redeemCashu(context, token, preferWallet = false)
}
)
}
}
}
private fun redeemCashu(context: Context, token: String, preferWallet: Boolean) {
val wallet = CashuTokenDecoder.walletUri(token)
val web = CashuTokenDecoder.webRedeemUri(token) ?: return
if (preferWallet && wallet != null) {
val walletIntent = Intent(Intent.ACTION_VIEW, Uri.parse(wallet))
try {
context.startActivity(walletIntent)
return
} catch (_: ActivityNotFoundException) {
// No wallet registered for cashu:, so use the explicit web fallback.
}
}
runCatching { context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(web))) }
}
@Composable
fun DeliveryStatusIcon(status: DeliveryStatus) {
val colorScheme = MaterialTheme.colorScheme

View File

@ -3,7 +3,6 @@ package com.bitchat.android.ui
import com.bitchat.android.model.BitchatMessage
import com.bitchat.android.model.DeliveryStatus
import com.bitchat.android.services.ContactDirectory
import com.bitchat.android.services.PrivateMessageArrivalOrder
import java.util.*
import java.util.Collections
@ -103,6 +102,49 @@ class MessageManager(private val state: ChatState) {
fun addPrivateMessage(peerID: String, message: BitchatMessage) {
val conversationID = ContactDirectory.canonicalConversationId(peerID)
val accepted = try {
com.bitchat.android.services.AppStateStore.addPrivateMessage(
conversationID,
message
)
} catch (_: Exception) {
false
}
if (!accepted) return
publishAcceptedPrivateMessage(conversationID, message, forceRead = false)
}
/**
* Adds a private message only after its database transaction has completed.
*
* Outgoing sends and non-mesh transports use this path so the local echo is never shown or
* transmitted unless it can survive an immediate process death.
*/
suspend fun addPrivateMessageDurably(
peerID: String,
message: BitchatMessage,
forceRead: Boolean = false
): Boolean {
val conversationID = ContactDirectory.canonicalConversationId(peerID)
val accepted = try {
com.bitchat.android.services.AppStateStore.addPrivateMessageDurably(
peerID = conversationID,
msg = message,
forceRead = forceRead
)
} catch (_: Exception) {
false
}
if (!accepted) return false
publishAcceptedPrivateMessage(conversationID, message, forceRead)
return true
}
private fun publishAcceptedPrivateMessage(
conversationID: String,
message: BitchatMessage,
forceRead: Boolean
) {
val currentPrivateChats = state.getPrivateChatsValue().toMutableMap()
if (!currentPrivateChats.containsKey(conversationID)) {
currentPrivateChats[conversationID] = mutableListOf()
@ -111,14 +153,16 @@ class MessageManager(private val state: ChatState) {
val chatMessages = currentPrivateChats[conversationID]?.toMutableList() ?: mutableListOf()
chatMessages.add(message)
currentPrivateChats[conversationID] = chatMessages
// Record the local arrival sequence before canonicalizing UI aliases.
PrivateMessageArrivalOrder.record(message.id)
state.setPrivateChats(ContactDirectory.canonicalizePrivateChats(currentPrivateChats))
// Reflect into process-wide store
try { com.bitchat.android.services.AppStateStore.addPrivateMessage(conversationID, message) } catch (_: Exception) { }
// Mark as unread if not currently viewing this chat
if (state.getSelectedPrivateChatPeerValue() != conversationID && message.sender != state.getNicknameValue()) {
val selectedConversationID = state.getSelectedPrivateChatPeerValue()
?.let(ContactDirectory::canonicalConversationId)
if (
!forceRead &&
selectedConversationID != conversationID &&
message.sender != state.getNicknameValue()
) {
val currentUnread = state.getUnreadPrivateMessagesValue().toMutableSet()
currentUnread.add(conversationID)
state.setUnreadPrivateMessages(currentUnread)
@ -128,25 +172,29 @@ class MessageManager(private val state: ChatState) {
// Variant that does not mark unread (used when we know the message has been read already, e.g., persisted Nostr read store)
fun addPrivateMessageNoUnread(peerID: String, message: BitchatMessage) {
val conversationID = ContactDirectory.canonicalConversationId(peerID)
val currentPrivateChats = state.getPrivateChatsValue().toMutableMap()
if (!currentPrivateChats.containsKey(conversationID)) {
currentPrivateChats[conversationID] = mutableListOf()
val accepted = try {
com.bitchat.android.services.AppStateStore.addPrivateMessage(
peerID = conversationID,
msg = message,
forceRead = true
)
} catch (_: Exception) {
false
}
val chatMessages = currentPrivateChats[conversationID]?.toMutableList() ?: mutableListOf()
chatMessages.add(message)
currentPrivateChats[conversationID] = chatMessages
// Record the local arrival sequence before canonicalizing UI aliases.
PrivateMessageArrivalOrder.record(message.id)
state.setPrivateChats(ContactDirectory.canonicalizePrivateChats(currentPrivateChats))
// Reflect into process-wide store
try { com.bitchat.android.services.AppStateStore.addPrivateMessage(conversationID, message) } catch (_: Exception) { }
if (!accepted) return
publishAcceptedPrivateMessage(conversationID, message, forceRead = true)
}
fun clearPrivateMessages(peerID: String) {
val conversationID = ContactDirectory.canonicalConversationId(peerID)
com.bitchat.android.services.AppStateStore.deletePrivateConversation(conversationID)
val updatedChats = state.getPrivateChatsValue().toMutableMap()
updatedChats[conversationID] = emptyList()
updatedChats.keys.removeAll { key ->
ContactDirectory.canonicalConversationId(key)
.equals(conversationID, ignoreCase = true)
}
state.setPrivateChats(updatedChats)
clearPrivateUnreadMessages(conversationID)
}
fun initializePrivateChat(peerID: String) {
@ -239,12 +287,19 @@ class MessageManager(private val state: ChatState) {
is DeliveryStatus.PartiallyDelivered -> 3
is DeliveryStatus.Delivered -> 4
is DeliveryStatus.Read -> 5
is DeliveryStatus.Failed -> 0 // treat as lowest for UI check marks ordering
is DeliveryStatus.Failed -> 0
}
private fun chooseStatus(old: DeliveryStatus?, new: DeliveryStatus): DeliveryStatus? {
// Never downgrade (e.g., Read -> Delivered). Keep the higher priority.
return if (statusPriority(new) >= statusPriority(old)) new else old
// A send failure may replace an in-flight state, but never a confirmed delivery/read.
return when {
new is DeliveryStatus.Failed &&
old !is DeliveryStatus.Delivered &&
old !is DeliveryStatus.Read -> new
old is DeliveryStatus.Failed -> new
statusPriority(new) >= statusPriority(old) -> new
else -> old
}
}
fun updateMessageDeliveryStatus(messageID: String, status: DeliveryStatus) {
@ -325,7 +380,10 @@ class MessageManager(private val state: ChatState) {
changed = true
}
}
if (changed) state.setPrivateChats(chats)
if (changed) {
state.setPrivateChats(chats.filterValues { it.isNotEmpty() })
com.bitchat.android.services.AppStateStore.removePrivateMessage(messageID)
}
}
// Channels
run {

View File

@ -1,19 +1,30 @@
package com.bitchat.android.ui
import android.Manifest
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.NotificationManager as AndroidNotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.core.app.Person
import androidx.core.app.NotificationManagerCompat
import androidx.core.app.RemoteInput
import androidx.core.content.ContextCompat
import androidx.core.content.pm.ShortcutInfoCompat
import androidx.core.content.pm.ShortcutManagerCompat
import androidx.core.content.LocusIdCompat
import androidx.core.graphics.drawable.IconCompat
import com.bitchat.android.MainActivity
import com.bitchat.android.R
import com.bitchat.android.service.ConversationNotificationReceiver
import com.bitchat.android.services.ContactDirectory
import com.bitchat.android.util.NotificationIntervalManager
import com.bitchat.android.services.ConversationListPreferences
import java.util.Collections
import java.util.WeakHashMap
import java.util.concurrent.ConcurrentHashMap
/**
@ -24,12 +35,10 @@ import java.util.concurrent.ConcurrentHashMap
* - Support for mention notifications in geohash chats
* - Support for first message notifications in geohash chats
* - Proper notification management and cleanup
* - Active peers notification
*/
class NotificationManager(
private val context: Context,
private val notificationManager: NotificationManagerCompat,
private val notificationIntervalManager: NotificationIntervalManager
private val notificationManager: NotificationManagerCompat
) {
companion object {
@ -42,8 +51,7 @@ class NotificationManager(
private const val GEOHASH_NOTIFICATION_REQUEST_CODE = 2000
private const val SUMMARY_NOTIFICATION_ID = 999
private const val GEOHASH_SUMMARY_NOTIFICATION_ID = 998
private const val ACTIVE_PEERS_NOTIFICATION_ID = 997
private const val ACTIVE_PEERS_NOTIFICATION_TIME_INTERVAL = com.bitchat.android.util.AppConstants.UI.ACTIVE_PEERS_NOTIFICATION_INTERVAL_MS
private const val MAX_MESSAGES_IN_NOTIFICATION = 25
// Intent extras for notification handling
const val EXTRA_OPEN_PRIVATE_CHAT = "open_private_chat"
@ -51,9 +59,33 @@ class NotificationManager(
const val EXTRA_PEER_ID = "peer_id"
const val EXTRA_SENDER_NICKNAME = "sender_nickname"
const val EXTRA_GEOHASH = "geohash"
const val ACTION_REPLY_TO_CONVERSATION =
"com.bitchat.android.action.REPLY_TO_CONVERSATION"
const val ACTION_MARK_CONVERSATION_READ =
"com.bitchat.android.action.MARK_CONVERSATION_READ"
const val KEY_TEXT_REPLY = "conversation_reply_text"
private val liveManagers: MutableSet<NotificationManager> =
Collections.newSetFromMap(WeakHashMap<NotificationManager, Boolean>())
/**
* Synchronizes notification action receivers with every manager instance in this process.
* Without this, an old in-memory MessagingStyle history could reappear on the next DM.
*/
fun acknowledgeConversation(context: Context, conversationID: String) {
val canonicalID = ContactDirectory.canonicalConversationId(conversationID)
val managers = synchronized(liveManagers) { liveManagers.toList() }
managers.forEach { it.clearNotificationsForSender(canonicalID) }
if (managers.isEmpty()) {
NotificationManagerCompat.from(context).cancel(canonicalID.hashCode())
}
}
}
private val systemNotificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
private val systemNotificationManager =
context.getSystemService(Context.NOTIFICATION_SERVICE) as AndroidNotificationManager
private val conversationPreferences =
ConversationListPreferences.getInstance(context.applicationContext)
// Track pending notifications per sender to enable grouping
private val pendingNotifications = ConcurrentHashMap<String, MutableList<PendingNotification>>()
@ -88,15 +120,17 @@ class NotificationManager(
)
init {
synchronized(liveManagers) { liveManagers.add(this) }
createNotificationChannel()
}
private fun createNotificationChannel() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
// DM notifications channel
val dmName = "Direct Messages"
val dmDescriptionText = "Notifications for private messages from other users"
val dmImportance = NotificationManager.IMPORTANCE_HIGH
val dmName = context.getString(R.string.notification_channel_direct_messages)
val dmDescriptionText =
context.getString(R.string.notification_channel_direct_messages_description)
val dmImportance = AndroidNotificationManager.IMPORTANCE_HIGH
val dmChannel = NotificationChannel(CHANNEL_ID, dmName, dmImportance).apply {
description = dmDescriptionText
enableVibration(true)
@ -105,9 +139,10 @@ class NotificationManager(
systemNotificationManager.createNotificationChannel(dmChannel)
// Geohash notifications channel
val geohashName = "Geohash Chats"
val geohashDescriptionText = "Notifications for mentions and messages in geohash location channels"
val geohashImportance = NotificationManager.IMPORTANCE_HIGH
val geohashName = context.getString(R.string.notification_channel_geohash)
val geohashDescriptionText =
context.getString(R.string.notification_channel_geohash_description)
val geohashImportance = AndroidNotificationManager.IMPORTANCE_HIGH
val geohashChannel = NotificationChannel(GEOHASH_CHANNEL_ID, geohashName, geohashImportance).apply {
description = geohashDescriptionText
enableVibration(true)
@ -146,6 +181,10 @@ class NotificationManager(
*/
fun showPrivateMessageNotification(senderPeerID: String, senderNickname: String, messageContent: String) {
val conversationID = ContactDirectory.canonicalConversationId(senderPeerID)
if (conversationPreferences.isMuted(conversationID)) {
Log.d(TAG, "Skipping muted conversation notification")
return
}
// Only show notifications if app is in background OR user is not viewing this specific chat
val shouldNotify = isAppInBackground ||
(!isAppInBackground && currentPrivateChatPeer != conversationID)
@ -176,22 +215,6 @@ class NotificationManager(
}
}
fun showActiveUserNotification(peers: List<String>) {
val currentTime = System.currentTimeMillis()
val activePeerNotificationIntervalExceeded =
(currentTime - notificationIntervalManager.lastNetworkNotificationTime) > ACTIVE_PEERS_NOTIFICATION_TIME_INTERVAL
val newPeers = peers - notificationIntervalManager.recentlySeenPeers
if (isAppInBackground && activePeerNotificationIntervalExceeded && newPeers.isNotEmpty()) {
Log.d(TAG, "Showing notification for active peers")
showNotificationForActivePeers(peers.size)
notificationIntervalManager.setLastNetworkNotificationTime(currentTime)
notificationIntervalManager.recentlySeenPeers.addAll(newPeers)
} else {
Log.d(TAG, "Skipping notification - app in foreground or it has been less than 5 minutes since last active peer notification")
return
}
}
private fun showNotificationForSender(senderPeerID: String) {
val notifications = pendingNotifications[senderPeerID] ?: return
if (notifications.isEmpty()) return
@ -219,6 +242,12 @@ class NotificationManager(
.setName(latestNotification.senderNickname)
.setKey(senderPeerID)
.build()
val shortcutID = conversationShortcutID(senderPeerID)
publishConversationShortcut(
shortcutID = shortcutID,
person = person,
contentIntent = intent
)
// Build notification content
val contentText = if (messageCount == 1) {
@ -242,47 +271,110 @@ class NotificationManager(
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setCategory(NotificationCompat.CATEGORY_MESSAGE)
.addPerson(person)
.setShortcutId(shortcutID)
.setLocusId(LocusIdCompat(shortcutID))
.setVisibility(NotificationCompat.VISIBILITY_PRIVATE)
.setShowWhen(true)
.setWhen(latestNotification.timestamp)
val markReadIntent = Intent(context, ConversationNotificationReceiver::class.java).apply {
action = ACTION_MARK_CONVERSATION_READ
putExtra(EXTRA_PEER_ID, senderPeerID)
}
val markReadPendingIntent = PendingIntent.getBroadcast(
context,
NOTIFICATION_REQUEST_CODE + senderPeerID.hashCode() + 1,
markReadIntent,
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
)
val replyIntent = Intent(context, ConversationNotificationReceiver::class.java).apply {
action = ACTION_REPLY_TO_CONVERSATION
putExtra(EXTRA_PEER_ID, senderPeerID)
putExtra(EXTRA_SENDER_NICKNAME, latestNotification.senderNickname)
}
val replyPendingIntent = PendingIntent.getBroadcast(
context,
NOTIFICATION_REQUEST_CODE + senderPeerID.hashCode() + 2,
replyIntent,
PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
)
val remoteInput = RemoteInput.Builder(KEY_TEXT_REPLY)
.setLabel(context.getString(R.string.notification_reply))
.build()
builder
.addAction(
NotificationCompat.Action.Builder(
R.drawable.ic_notification,
context.getString(R.string.notification_mark_read),
markReadPendingIntent
).build()
)
.addAction(
NotificationCompat.Action.Builder(
R.drawable.ic_notification,
context.getString(R.string.notification_reply),
replyPendingIntent
)
.addRemoteInput(remoteInput)
.setAllowGeneratedReplies(true)
.build()
)
.setPublicVersion(
NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle(context.getString(R.string.notification_private_message))
.setContentText(context.getString(R.string.notification_content_hidden))
.build()
)
// Add to notification group if we have multiple senders
if (pendingNotifications.size > 1) {
builder.setGroup(GROUP_KEY_DM)
}
// Add style for multiple messages
if (messageCount > 1) {
val style = NotificationCompat.InboxStyle()
.setBigContentTitle(contentTitle)
// Show last few messages in expanded view
notifications.takeLast(5).forEach { notif ->
style.addLine(notif.messageContent)
}
if (messageCount > 5) {
val extra = messageCount - 5
style.setSummaryText(context.resources.getQuantityString(
R.plurals.notification_and_more, extra, extra
))
}
builder.setStyle(style)
} else {
// Single message - use BigTextStyle for long messages
builder.setStyle(
NotificationCompat.BigTextStyle()
.bigText(latestNotification.messageContent)
val self = Person.Builder()
.setName(context.getString(R.string.you))
.setKey("bitchat-self")
.build()
val messagingStyle = NotificationCompat.MessagingStyle(self)
.setGroupConversation(false)
notifications.takeLast(MAX_MESSAGES_IN_NOTIFICATION).forEach { notification ->
messagingStyle.addMessage(
notification.messageContent,
notification.timestamp,
person
)
}
builder.setStyle(messagingStyle)
// Use sender peer ID hash as notification ID to group messages from same sender
val notificationId = senderPeerID.hashCode()
notificationManager.notify(notificationId, builder.build())
notifySafely(notificationId, builder.build())
Log.d(TAG, "Displayed notification for $contentTitle with ID $notificationId")
}
private fun conversationShortcutID(conversationID: String): String =
"dm_" + java.util.UUID.nameUUIDFromBytes(
conversationID.lowercase().toByteArray(Charsets.UTF_8)
).toString()
private fun publishConversationShortcut(
shortcutID: String,
person: Person,
contentIntent: Intent
) {
val shortcut = ShortcutInfoCompat.Builder(context, shortcutID)
.setShortLabel(person.name?.toString()?.take(40).orEmpty())
.setLongLived(true)
.setPerson(person)
.setLocusId(LocusIdCompat(shortcutID))
.setIcon(IconCompat.createWithResource(context, R.drawable.ic_notification))
.setIntent(Intent(contentIntent).apply { action = Intent.ACTION_VIEW })
.build()
ShortcutManagerCompat.pushDynamicShortcut(context, shortcut)
}
fun showVerificationNotification(title: String, body: String, peerID: String? = null) {
val intent = Intent(context, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
@ -311,44 +403,12 @@ class NotificationManager(
.setShowWhen(true)
.setWhen(System.currentTimeMillis())
notificationManager.notify((System.currentTimeMillis() and 0x7FFFFFFF).toInt(), builder.build())
}
private fun showNotificationForActivePeers(peersSize: Int) {
// Create intent to open the app
val intent = Intent(context, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
}
val pendingIntent = PendingIntent.getActivity(
context,
ACTIVE_PEERS_NOTIFICATION_ID,
intent,
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
notifySafely(
(System.currentTimeMillis() and 0x7FFFFFFF).toInt(),
builder.build()
)
// Build notification content
val contentTitle = context.getString(R.string.notification_active_peers_title)
val contentText = if (peersSize == 1) {
context.getString(R.string.notification_active_peers_one)
} else {
context.getString(R.string.notification_active_peers_many, peersSize)
}
val builder = NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle(contentTitle)
.setContentText(contentText)
.setContentIntent(pendingIntent)
.setAutoCancel(true)
.setPriority(NotificationCompat.PRIORITY_MIN)
.setCategory(NotificationCompat.CATEGORY_MESSAGE)
.setShowWhen(true)
.setWhen(System.currentTimeMillis())
notificationManager.notify(ACTIVE_PEERS_NOTIFICATION_ID, builder.build())
Log.d(TAG, "Displayed notification for $contentTitle with ID $ACTIVE_PEERS_NOTIFICATION_ID")
}
private fun showSummaryNotification() {
if (pendingNotifications.isEmpty()) return
@ -398,7 +458,7 @@ class NotificationManager(
builder.setStyle(style)
notificationManager.notify(SUMMARY_NOTIFICATION_ID, builder.build())
notifySafely(SUMMARY_NOTIFICATION_ID, builder.build())
Log.d(TAG, "Displayed summary notification for $senderCount senders")
}
@ -431,6 +491,15 @@ class NotificationManager(
Log.d(TAG, "Cleared notifications for conversation: $conversationID")
}
fun removeConversationShortcut(conversationID: String) {
val shortcutIDs = listOf(conversationShortcutID(conversationID))
ShortcutManagerCompat.removeDynamicShortcuts(context, shortcutIDs)
ShortcutManagerCompat.removeLongLivedShortcuts(
context,
shortcutIDs
)
}
/**
* Show a notification for a geohash message with mention or first message
*/
@ -559,7 +628,7 @@ class NotificationManager(
// Use geohash hash as notification ID to group messages from same geohash
val notificationId = 3000 + geohash.hashCode()
notificationManager.notify(notificationId, builder.build())
notifySafely(notificationId, builder.build())
Log.d(TAG, "Displayed geohash notification for $contentTitle with ID $notificationId")
}
@ -626,7 +695,7 @@ class NotificationManager(
builder.setStyle(style)
notificationManager.notify(GEOHASH_SUMMARY_NOTIFICATION_ID, builder.build())
notifySafely(GEOHASH_SUMMARY_NOTIFICATION_ID, builder.build())
Log.d(TAG, "Displayed geohash summary notification for $geohashCount locations")
}
@ -767,7 +836,7 @@ class NotificationManager(
// Use a special notification ID for mesh mentions
val notificationId = 4000 // Different from DM and geohash IDs
notificationManager.notify(notificationId, builder.build())
notifySafely(notificationId, builder.build())
Log.d(TAG, "Displayed mesh mention notification: $contentTitle")
}
@ -799,13 +868,37 @@ class NotificationManager(
/**
* Clear all pending notifications
*/
fun clearAllNotifications() {
fun clearAllNotifications(removeConversationShortcuts: Boolean = false) {
pendingNotifications.clear()
notificationManager.cancelAll()
pendingGeohashNotifications.clear()
if (removeConversationShortcuts) {
val shortcutIDs = ShortcutManagerCompat.getDynamicShortcuts(context).map { it.id }
ShortcutManagerCompat.removeAllDynamicShortcuts(context)
if (shortcutIDs.isNotEmpty()) {
ShortcutManagerCompat.removeLongLivedShortcuts(context, shortcutIDs)
}
}
Log.d(TAG, "Cleared all notifications")
}
private fun notifySafely(notificationID: Int, notification: android.app.Notification) {
if (
Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
ContextCompat.checkSelfPermission(
context,
Manifest.permission.POST_NOTIFICATIONS
) != PackageManager.PERMISSION_GRANTED
) {
return
}
try {
notificationManager.notify(notificationID, notification)
} catch (error: SecurityException) {
Log.w(TAG, "Notification permission was revoked: ${error.message}")
}
}
/**
* Get pending notification count for UI badging
*/

View File

@ -2,14 +2,14 @@ package com.bitchat.android.ui
import android.content.pm.ActivityInfo
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.appcompat.app.AppCompatActivity
import com.bitchat.android.utils.DeviceUtils
/**
* Base activity that automatically sets orientation based on device type.
* Tablets can rotate to landscape, phones are locked to portrait.
*/
abstract class OrientationAwareActivity : ComponentActivity() {
abstract class OrientationAwareActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)

View File

@ -0,0 +1,131 @@
package com.bitchat.android.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Verified
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.bitchat.android.R
import com.bitchat.android.ui.theme.BitchatFontFamily
import com.bitchat.android.ui.theme.LocalBitchatPalette
internal val PeerAvatarBadgeSize = 18.dp
private val PeerAvatarStarSize = 16.dp
private val PeerAvatarVerifiedSize = 16.dp
@Composable
internal fun PeerAvatar(
name: String,
color: Color,
modifier: Modifier = Modifier,
isFavorite: Boolean = false,
theyFavoritedUs: Boolean = false,
isVerified: Boolean = false,
badge: (@Composable () -> Unit)? = null
) {
val palette = LocalBitchatPalette.current
val colorScheme = MaterialTheme.colorScheme
Box(
modifier = modifier.size(42.dp),
contentAlignment = Alignment.Center
) {
Box(
modifier = Modifier
.size(38.dp)
.background(color.copy(alpha = 0.16f), CircleShape),
contentAlignment = Alignment.Center
) {
Text(
text = name.trim().firstOrNull()?.uppercase() ?: "#",
style = MaterialTheme.typography.titleMedium.copy(
fontFamily = BitchatFontFamily,
fontWeight = FontWeight.SemiBold
),
color = color
)
}
if (badge != null) {
Surface(
modifier = Modifier
.size(PeerAvatarBadgeSize)
.align(Alignment.BottomEnd),
shape = CircleShape,
color = colorScheme.surface,
tonalElevation = 1.dp
) {
Box(contentAlignment = Alignment.Center) {
badge()
}
}
}
if (isFavorite || theyFavoritedUs) {
Surface(
modifier = Modifier
.size(PeerAvatarStarSize)
.align(Alignment.TopEnd),
shape = CircleShape,
color = colorScheme.surface,
tonalElevation = 1.dp
) {
Box(contentAlignment = Alignment.Center) {
Icon(
painter = painterResource(
if (isFavorite) {
R.drawable.ic_spec_star_filled
} else {
R.drawable.ic_spec_star
}
),
contentDescription = stringResource(
if (isFavorite) {
R.string.cd_favorite
} else {
R.string.cd_favorited_you
}
),
modifier = Modifier.size(10.dp),
tint = palette.accentOrange
)
}
}
}
if (isVerified) {
Surface(
modifier = Modifier
.size(PeerAvatarVerifiedSize)
.align(Alignment.TopStart),
shape = CircleShape,
color = colorScheme.surface,
tonalElevation = 1.dp
) {
Box(contentAlignment = Alignment.Center) {
Icon(
imageVector = Icons.Filled.Verified,
contentDescription = stringResource(
R.string.fingerprint_verified_label
),
modifier = Modifier.size(12.dp),
tint = colorScheme.primary
)
}
}
}
}
}

View File

@ -134,6 +134,50 @@ class PrivateChatManager(
return true
}
/**
* Persists the local echo before handing the payload to a transport.
*
* A failed database write deliberately aborts the send: otherwise the remote peer could
* receive a message that disappears from the sender's conversation after process death.
*/
suspend fun sendPrivateMessageDurably(
content: String,
peerID: String,
recipientNickname: String?,
senderNickname: String?,
myPeerID: String,
onSendMessage: (String, String, String, String) -> Unit
): Boolean {
val conversationID = ContactDirectory.canonicalConversationId(peerID)
if (isPeerBlocked(peerID)) {
val systemMessage = BitchatMessage(
sender = "system",
content = "cannot send message to $recipientNickname: user is blocked.",
timestamp = Date(),
isRelay = false
)
messageManager.addMessage(systemMessage)
return false
}
val message = BitchatMessage(
sender = senderNickname ?: myPeerID,
content = content,
timestamp = Date(),
isRelay = false,
isPrivate = true,
recipientNickname = recipientNickname,
senderPeerID = myPeerID,
deliveryStatus = DeliveryStatus.Sending
)
if (!messageManager.addPrivateMessageDurably(conversationID, message, forceRead = true)) {
return false
}
onSendMessage(content, conversationID, recipientNickname ?: "", message.id)
return true
}
// MARK: - Peer Management
fun isPeerBlocked(peerID: String): Boolean {
@ -375,6 +419,51 @@ class PrivateChatManager(
}
}
/**
* Durable admission for transports that do not pass through the mesh admission pipeline.
*
* Nostr acknowledgements are emitted by the caller only after this returns true, which lets a
* failed write be retried rather than silently acknowledging a message that was never saved.
*/
suspend fun handleIncomingPrivateMessageDurably(
message: BitchatMessage,
suppressUnread: Boolean,
origin: PrivateMessageOrigin
): Boolean {
val senderPeerID = message.senderPeerID
val conversationID = senderPeerID
?.let(ContactDirectory::canonicalConversationId)
?: state.getSelectedPrivateChatPeerValue()
?: return false
if (senderPeerID != null && isPeerBlocked(senderPeerID)) return false
messageManager.initializePrivateChat(conversationID)
val shouldPersistHere = origin == PrivateMessageOrigin.NOSTR || senderPeerID == null
if (shouldPersistHere) {
val accepted = messageManager.addPrivateMessageDurably(
peerID = conversationID,
message = message,
forceRead = suppressUnread || !trackUnreadMessages
)
if (!accepted) return false
}
if (
senderPeerID != null &&
trackUnreadMessages &&
!suppressUnread &&
state.getSelectedPrivateChatPeerValue() != conversationID
) {
val unreadList = unreadReceivedMessages.getOrPut(conversationID) { mutableListOf() }
unreadList.add(message)
val currentUnread = state.getUnreadPrivateMessagesValue().toMutableSet()
currentUnread.add(conversationID)
state.setUnreadPrivateMessages(currentUnread)
}
return true
}
/**
* Send read receipts for all unread messages from a specific peer
* Called when the user focuses on a private chat

View File

@ -0,0 +1,49 @@
package com.bitchat.android.ui
import com.bitchat.android.mesh.MeshService
import com.bitchat.android.services.ContactDirectory
import com.bitchat.android.services.ContactIdentityResolver
internal data class PrivateMediaRecipient(
val conversationID: String,
val meshPeerID: String
)
/**
* Private-chat state is keyed by a stable contact/conversation ID, while mesh
* encryption and transport APIs require the current 16-hex peer ID.
*/
internal object PrivateMediaRecipientResolver {
fun resolve(requestedRecipientID: String, meshService: MeshService): PrivateMediaRecipient? {
val requested = requestedRecipientID.trim()
val conversationID = ContactDirectory.canonicalConversationId(requested)
val directoryPeerID = runCatching {
ContactDirectory.resolve(requested).meshPeerID
}.getOrNull()
val directPeerID = requested.takeIf(ContactIdentityResolver::isMeshPeerId)
val expectedFingerprint =
ContactIdentityResolver.fingerprintFromContactConversationId(conversationID)
?: requested
.takeIf(ContactIdentityResolver::isNoiseKeyHex)
?.let(ContactIdentityResolver::bytesFromHex)
?.let(ContactIdentityResolver::fingerprintHex)
val discoveredPeerID = expectedFingerprint?.let { fingerprint ->
runCatching {
meshService.getPeerNicknames().keys.firstOrNull { candidatePeerID ->
val info = meshService.getPeerInfo(candidatePeerID)
val noisePublicKey = info?.noisePublicKey
info?.isConnected == true &&
noisePublicKey != null &&
ContactIdentityResolver.fingerprintHex(noisePublicKey)
.equals(fingerprint, ignoreCase = true)
}
}.getOrNull()
}
val meshPeerID = (directoryPeerID ?: directPeerID ?: discoveredPeerID)
?.takeIf(ContactIdentityResolver::isMeshPeerId)
?: return null
return PrivateMediaRecipient(conversationID, meshPeerID)
}
}

View File

@ -48,6 +48,7 @@ import com.bitchat.android.R
import com.bitchat.android.core.ui.component.button.CloseButton
import com.bitchat.android.core.ui.component.sheet.LocalSheetDismiss
import com.bitchat.android.core.ui.component.sheet.BitchatBottomSheet
import com.bitchat.android.services.ContactDirectory
private data class SecurityStatusInfo(
val text: String,
@ -100,7 +101,12 @@ fun SecurityVerificationSheet(
val displayName = viewModel.resolvePeerDisplayNameForFingerprint(selectedPeerID)
val fingerprint = viewModel.getPeerFingerprintForDisplay(selectedPeerID)
val isVerified = fingerprint != null && verifiedFingerprints.contains(fingerprint)
val sessionState = peerSessionStates[selectedPeerID]
val activeMeshPeerID = ContactDirectory.resolve(selectedPeerID).meshPeerID
val sessionState = resolveConversationSessionState(
conversationID = selectedPeerID,
activeMeshPeerID = activeMeshPeerID,
peerSessionStates = peerSessionStates
)
val statusInfo = buildStatusInfo(
isVerified = isVerified,
sessionState = sessionState,

View File

@ -1,18 +1,25 @@
package com.bitchat.android.ui
import android.Manifest
import android.view.HapticFeedbackConstants
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.gestures.waitForUpOrCancellation
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Mic
import android.Manifest
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Icon
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.LayoutCoordinates
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.res.stringResource
import com.bitchat.android.features.voice.VoiceRecorder
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.PermissionStatus
@ -22,6 +29,7 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeoutOrNull
import java.io.File
/**
* How long the button must be held before a recording starts.
@ -59,24 +67,37 @@ fun VoiceRecordButton(
* pill's border change together instead of one lagging the other.
*/
isRecording: Boolean = false,
/**
* Consulted the instant the finger lifts, with the final pointer position in root
* coordinates: when it lands inside the slide-to-cancel target, the recording is
* discarded instead of sent. Receiving the position here (instead of reading composed
* state) keeps the verdict exact even for a slide-and-lift within a single frame.
*/
shouldCancel: (Offset) -> Boolean = { false },
/**
* Finger position in root coordinates while a capture is live (drives the magnetic
* cancel target); null once the gesture ends.
*/
onTrackFinger: (Offset?) -> Unit = {},
onStart: () -> Unit,
onAmplitude: (amplitude: Int, elapsedMs: Long) -> Unit,
onFinish: (filePath: String) -> Unit,
/**
* Invoked whenever a recording ends without producing a file permission denied, recorder
* failure, or the button being torn down mid-capture. The caller needs this to clear its own
* recording state; without it a failed capture left the composer stuck in recording mode.
* failure, the button being torn down mid-capture, or a deliberate slide-to-cancel.
*/
onCancel: () -> Unit = {}
) {
val context = LocalContext.current
val haptic = LocalHapticFeedback.current
val view = LocalView.current
val micPermission = rememberPermissionState(Manifest.permission.RECORD_AUDIO)
var isCapturing by remember { mutableStateOf(false) }
var recorder by remember { mutableStateOf<VoiceRecorder?>(null) }
var recordedFilePath by remember { mutableStateOf<String?>(null) }
var recordingStart by remember { mutableStateOf(0L) }
var buttonCoords by remember { mutableStateOf<LayoutCoordinates?>(null) }
val scope = rememberCoroutineScope()
var ampJob by remember { mutableStateOf<Job?>(null) }
@ -86,6 +107,8 @@ fun VoiceRecordButton(
val latestOnAmplitude = rememberUpdatedState(onAmplitude)
val latestOnFinish = rememberUpdatedState(onFinish)
val latestOnCancel = rememberUpdatedState(onCancel)
val latestShouldCancel = rememberUpdatedState(shouldCancel)
val latestOnTrackFinger = rememberUpdatedState(onTrackFinger)
// Set when this instance was composed, so presses inherited from whatever occupied this spot
// beforehand can be rejected.
@ -110,6 +133,7 @@ fun VoiceRecordButton(
runCatching { recorder?.stop() }
recorder = null
recordedFilePath = null
latestOnTrackFinger.value(null)
latestOnCancel.value()
}
}
@ -120,99 +144,123 @@ fun VoiceRecordButton(
isActive = isRecording || isCapturing,
isPressed = isCapturing,
modifier = modifier
.onGloballyPositioned { buttonCoords = it }
.pointerInput(Unit) {
detectTapGestures(
onPress = {
// Guard 1: ignore anything arriving before the swap animation settled.
if (System.currentTimeMillis() - composedAt < ArmDelayMs) {
return@detectTapGestures
}
// Guard 2: never start a second capture on top of a live one.
if (isCapturing) return@detectTapGestures
awaitEachGesture {
val down = awaitFirstDown(requireUnconsumed = false)
// Guard 1: ignore anything arriving before the swap animation settled.
if (System.currentTimeMillis() - composedAt < ArmDelayMs) {
return@awaitEachGesture
}
// Guard 2: never start a second capture on top of a live one.
if (isCapturing) return@awaitEachGesture
if (micPermission.status !is PermissionStatus.Granted) {
micPermission.launchPermissionRequest()
return@detectTapGestures
}
if (micPermission.status !is PermissionStatus.Granted) {
micPermission.launchPermissionRequest()
return@awaitEachGesture
}
// Guard 3: require a deliberate hold. `tryAwaitRelease` returns true on
// release and false on cancellation; either way the press was not a hold,
// so nothing should happen. Only a timeout means the finger is still down.
val stillHeld = withTimeoutOrNull(HoldToRecordMs) {
tryAwaitRelease()
} == null
if (!stillHeld) return@detectTapGestures
// Guard 3: require a deliberate hold. An up (or a stolen pointer) inside the
// arm window means the press was never a hold; only the timeout means the
// finger is still down.
var stolenDuringArm = false
val releasedEarly = withTimeoutOrNull(HoldToRecordMs) {
waitForUpOrCancellation().also { if (it == null) stolenDuringArm = true }
}
if (releasedEarly != null || stolenDuringArm) return@awaitEachGesture
val rec = VoiceRecorder(context)
val startedFile = rec.start()
if (startedFile == null) {
// Recorder refused to start; make sure the caller does not sit in a
// recording state that never began.
runCatching { rec.stop() }
latestOnCancel.value()
return@detectTapGestures
}
val rec = VoiceRecorder(context)
val startedFile = rec.start()
if (startedFile == null) {
// Recorder refused to start; make sure the caller does not sit in a
// recording state that never began.
runCatching { rec.stop() }
latestOnCancel.value()
return@awaitEachGesture
}
recorder = rec
recordedFilePath = startedFile.absolutePath
recordingStart = System.currentTimeMillis()
isCapturing = true
latestOnStart.value()
buzz()
recorder = rec
recordedFilePath = startedFile.absolutePath
recordingStart = System.currentTimeMillis()
isCapturing = true
latestOnStart.value()
buzz()
ampJob?.cancel()
ampJob = scope.launch {
while (isActive && isCapturing) {
val amp = recorder?.pollAmplitude() ?: 0
val elapsed =
(System.currentTimeMillis() - recordingStart).coerceAtLeast(0L)
latestOnAmplitude.value(amp, elapsed)
ampJob?.cancel()
ampJob = scope.launch {
while (isActive && isCapturing) {
val amp = recorder?.pollAmplitude() ?: 0
val elapsed =
(System.currentTimeMillis() - recordingStart).coerceAtLeast(0L)
latestOnAmplitude.value(amp, elapsed)
if (elapsed >= MaxRecordingMs && isCapturing) {
val file = recorder?.stop()
isCapturing = false
recorder = null
val path = file?.absolutePath ?: recordedFilePath
recordedFilePath = null
buzz()
// Always report the outcome, even when the file is unusable,
// or the caller stays stuck showing the waveform.
if (!path.isNullOrBlank()) {
latestOnFinish.value(path)
} else {
latestOnCancel.value()
}
break
}
delay(80)
}
}
try {
tryAwaitRelease()
} finally {
if (isCapturing) {
// Keep going briefly past the release so the tail is not clipped.
delay(ReleaseTailMs)
}
if (isCapturing) {
if (elapsed >= MaxRecordingMs && isCapturing) {
val file = recorder?.stop()
isCapturing = false
recorder = null
val path = file?.absolutePath ?: recordedFilePath
recordedFilePath = null
latestOnTrackFinger.value(null)
buzz()
// Always report the outcome, even when the file is unusable,
// or the caller stays stuck showing the waveform.
if (!path.isNullOrBlank()) {
latestOnFinish.value(path)
} else {
latestOnCancel.value()
}
break
}
ampJob?.cancel()
ampJob = null
delay(80)
}
}
)
// Track the finger in root coordinates until it lifts, so the composer can
// run the magnetic slide-to-cancel target. A cancelled pointer (stolen by a
// scroller) ends the capture the same way a lift does.
var finalPos: Offset? = null
while (true) {
val event = awaitPointerEvent()
val change = event.changes.firstOrNull { it.id == down.id } ?: continue
finalPos = buttonCoords?.localToRoot(change.position)
finalPos?.let { latestOnTrackFinger.value(it) }
if (!change.pressed) break
}
// Cancelling discards immediately; sending keeps a short tail so the last
// syllable is not clipped (an early pointer event simply ends the tail).
// The verdict is computed from the final pointer coordinate directly —
// reading recomposed state here could be one frame stale.
val cancel = finalPos?.let { latestShouldCancel.value(it) } == true
latestOnTrackFinger.value(null)
if (isCapturing && !cancel) {
withTimeoutOrNull(ReleaseTailMs) { awaitPointerEvent() }
}
if (isCapturing) {
val file = recorder?.stop()
isCapturing = false
recorder = null
val path = file?.absolutePath ?: recordedFilePath
recordedFilePath = null
if (cancel) {
path?.let { runCatching { File(it).delete() } }
try {
view.performHapticFeedback(HapticFeedbackConstants.REJECT)
} catch (_: Exception) {
}
latestOnCancel.value()
} else {
buzz()
if (!path.isNullOrBlank()) {
latestOnFinish.value(path)
} else {
latestOnCancel.value()
}
}
}
ampJob?.cancel()
ampJob = null
}
}
) { tint ->
Icon(

View File

@ -0,0 +1,100 @@
package com.bitchat.android.ui.globe
import kotlin.math.*
/**
* Orthographic globe projection and geohash zoom math for the 3D globe picker.
*
* The globe is rendered as a disc of radius R centered on screen. Points are
* projected with an orthographic projection around a view center lat/lon.
*/
object GlobeMath {
data class Projection(val x: Float, val y: Float, val cosC: Float)
/**
* Projects (lat, lon) onto the view disc of a globe centered at (centerLat, centerLon).
* Returns x/y in units of globe radius (screen y down). [Projection.cosC] is negative
* when the point is on the far side of the sphere.
*/
fun projectRaw(latDeg: Double, lonDeg: Double, centerLatDeg: Double, centerLonDeg: Double): Projection {
val phi = Math.toRadians(latDeg)
val phi0 = Math.toRadians(centerLatDeg)
val dLambda = Math.toRadians(normalizeLon(lonDeg - centerLonDeg))
val cosC = sin(phi0) * sin(phi) + cos(phi0) * cos(phi) * cos(dLambda)
val x = cos(phi) * sin(dLambda)
val y = -(cos(phi0) * sin(phi) - sin(phi0) * cos(phi) * cos(dLambda))
return Projection(x.toFloat(), y.toFloat(), cosC.toFloat())
}
/** Like [projectRaw] but null when the point is behind the limb. */
fun project(latDeg: Double, lonDeg: Double, centerLatDeg: Double, centerLonDeg: Double): Projection? {
val p = projectRaw(latDeg, lonDeg, centerLatDeg, centerLonDeg)
return if (p.cosC >= 0f) p else null
}
/**
* Inverse projection: disc coordinates (units of radius, screen y down) back to lat/lon.
* Returns null if the point lies outside the disc.
*/
fun unproject(x: Double, y: Double, centerLatDeg: Double, centerLonDeg: Double): Pair<Double, Double>? {
val rho = sqrt(x * x + y * y)
if (rho > 1.0) return null
val c = asin(rho.coerceIn(-1.0, 1.0))
val phi0 = Math.toRadians(centerLatDeg)
val sinC = sin(c)
val cosC = cos(c)
val lat: Double
val lonOffset: Double
if (rho < 1e-9) {
lat = centerLatDeg
lonOffset = 0.0
} else {
lat = Math.toDegrees(asin(cosC * sin(phi0) + (-y) * sinC * cos(phi0) / rho))
lonOffset = Math.toDegrees(atan2(x * sinC, rho * cos(phi0) * cosC - (-y) * sin(phi0) * sinC))
}
return lat to normalizeLon(centerLonDeg + lonOffset)
}
fun normalizeLon(lon: Double): Double {
var x = lon % 360.0
if (x > 180.0) x -= 360.0
if (x < -180.0) x += 360.0
return x
}
/** Longitude span of a geohash cell in degrees. */
fun cellSpanLon(precision: Int): Double = 360.0 / 2.0.pow(ceil(5.0 * precision / 2.0))
/** Latitude span of a geohash cell in degrees. */
fun cellSpanLat(precision: Int): Double = 180.0 / 2.0.pow(floor(5.0 * precision / 2.0))
/**
* Picks the geohash precision whose cells render at a comfortable on-screen size
* for the current zoom: the largest precision whose cell is at least ~22% of the
* screen's smallest dimension.
*/
fun autoPrecision(globeRadiusPx: Float, screenMinPx: Float): Int {
val targetPx = screenMinPx * 0.22f
var best = 1
for (p in 1..MAX_PRECISION) {
val spanPx = (cellSpanLat(p) * (Math.PI / 180.0) * globeRadiusPx).toFloat()
if (spanPx >= targetPx) best = p else break
}
return best.coerceIn(1, MAX_PRECISION)
}
/**
* Zoom factor (globe radius multiplier over the fit-to-screen base radius) that frames
* a cell of the given precision nicely: the cell spans ~1/3 of the screen.
*/
fun zoomForPrecision(precision: Int, baseRadiusPx: Float, screenMinPx: Float): Float {
val spanRad = cellSpanLat(precision) * (Math.PI / 180.0)
val targetRadius = (screenMinPx / 3.0) / spanRad
return (targetRadius / baseRadiusPx).toFloat().coerceIn(MIN_ZOOM, MAX_ZOOM)
}
const val MIN_ZOOM = 1f
const val MAX_ZOOM = 120000f
const val MAX_PRECISION = 12
}

View File

@ -0,0 +1,185 @@
package com.bitchat.android.ui.globe
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.tween
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import com.bitchat.android.geohash.Geohash
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlin.math.abs
import kotlin.math.pow
/**
* Hoisted state for the interactive 3D globe: view center, zoom, geohash precision
* and the current selection. All mutation funnels through this class so rendering,
* gestures and buttons stay in sync.
*/
class GlobeState(
targetLat: Double,
targetLon: Double,
initialPrecision: Int,
startZoomedOut: Boolean
) {
var centerLat by mutableFloatStateOf(if (startZoomedOut) (targetLat * 0.4).toFloat() else targetLat.toFloat())
private set
var centerLon by mutableFloatStateOf(if (startZoomedOut) GlobeMath.normalizeLon(targetLon - 70.0).toFloat() else targetLon.toFloat())
private set
var zoom by mutableFloatStateOf(if (startZoomedOut) GlobeMath.MIN_ZOOM else 1f)
private set
var precision by mutableIntStateOf(initialPrecision.coerceIn(1, GlobeMath.MAX_PRECISION))
private set
var selectedGeohash by mutableStateOf("")
private set
var isInteracting by mutableStateOf(false)
internal set
internal var baseRadiusPx by mutableFloatStateOf(0f)
internal var screenMinPx by mutableFloatStateOf(0f)
private var scope: CoroutineScope? = null
private var animJob: Job? = null
/** Pending cinematic intro target (lat, lon, precision); consumed when played. */
var introTarget: Triple<Double, Double, Int>? = null
private var introPlayed = false
fun playPendingIntroIfAny() {
if (introPlayed) return
val target = introTarget ?: return
introPlayed = true
introTarget = null
playIntro(target.first, target.second, target.third)
}
fun attach(scope: CoroutineScope) {
this.scope = scope
}
fun setViewport(baseRadiusPx: Float, screenMinPx: Float) {
if (baseRadiusPx <= 0f || screenMinPx <= 0f) return
this.baseRadiusPx = baseRadiusPx
this.screenMinPx = screenMinPx
syncSelection()
}
val globeRadiusPx: Float get() = baseRadiusPx * zoom
/** Direct rotation from drag gestures. Deltas are in screen px. */
fun rotateBy(dxPx: Float, dyPx: Float) {
val r = globeRadiusPx
if (r <= 0f) return
val degPerPx = 180.0 / (Math.PI * r)
centerLon = GlobeMath.normalizeLon(centerLon - dxPx * degPerPx).toFloat()
centerLat = (centerLat + dyPx * degPerPx).toFloat().coerceIn(MIN_LAT, MAX_LAT)
syncSelection()
}
/** Continuous zoom from pinch gestures; precision follows automatically. */
fun zoomBy(factor: Float) {
if (factor == 1f) return
zoom = (zoom * factor).coerceIn(GlobeMath.MIN_ZOOM, GlobeMath.MAX_ZOOM)
syncPrecisionFromZoom()
syncSelection()
}
private fun syncPrecisionFromZoom() {
if (baseRadiusPx <= 0f) return
precision = GlobeMath.autoPrecision(globeRadiusPx, screenMinPx)
}
/** Animate the view to a lat/lon, optionally to a zoom and precision. */
fun animateTo(
lat: Double,
lon: Double,
targetZoom: Float? = null,
targetPrecision: Int? = null,
durationMs: Int = 550
) {
val s = scope ?: return
animJob?.cancel()
val startLat = centerLat
val startLon = centerLon
val dLon = GlobeMath.normalizeLon(lon - startLon)
val startZoom = zoom
val endZoom = (targetZoom ?: zoom).coerceIn(GlobeMath.MIN_ZOOM, GlobeMath.MAX_ZOOM)
animJob = s.launch {
val anim = Animatable(0f)
anim.animateTo(1f, tween(durationMs, easing = FastOutSlowInEasing)) {
val t = value
centerLat = (startLat + (lat.toFloat() - startLat) * t).coerceIn(MIN_LAT, MAX_LAT)
centerLon = GlobeMath.normalizeLon(startLon + dLon * t).toFloat()
// exponential interpolation feels natural for zoom
zoom = startZoom * (endZoom / startZoom).pow(t)
if (targetPrecision != null) {
precision = targetPrecision.coerceIn(1, GlobeMath.MAX_PRECISION)
} else {
syncPrecisionFromZoom()
}
syncSelection()
}
}
}
/** Button-driven precision change: adjusts precision and animates zoom to frame it. */
fun animatePrecision(newPrecision: Int) {
val p = newPrecision.coerceIn(1, GlobeMath.MAX_PRECISION)
if (p == precision || baseRadiusPx <= 0f) return
val targetZoom = GlobeMath.zoomForPrecision(p, baseRadiusPx, screenMinPx)
animateTo(centerLat.toDouble(), centerLon.toDouble(), targetZoom, p, durationMs = 450)
}
/** Cinematic intro: spin and zoom from a far view into the target location. */
private fun playIntro(targetLat: Double, targetLon: Double, targetPrecision: Int) {
if (baseRadiusPx <= 0f) {
// viewport not ready yet; retry once attached to layout via caller
return
}
val targetZoom = GlobeMath.zoomForPrecision(targetPrecision, baseRadiusPx, screenMinPx)
animateTo(targetLat, targetLon, targetZoom, targetPrecision, durationMs = 1400)
}
/** Inertial spin after a fling. Velocities are in px/ms. */
fun fling(velocityX: Float, velocityY: Float) {
val s = scope ?: return
if (abs(velocityX) < 0.05f && abs(velocityY) < 0.05f) return
animJob?.cancel()
animJob = s.launch {
var vx = velocityX
var vy = velocityY
var lastTime = System.nanoTime()
while (abs(vx) > 0.02f || abs(vy) > 0.02f) {
val now = System.nanoTime()
val dtMs = ((now - lastTime) / 1_000_000f).coerceAtMost(50f)
lastTime = now
rotateBy(vx * dtMs, vy * dtMs)
val decay = 0.94f.pow(dtMs / 16f)
vx *= decay
vy *= decay
kotlinx.coroutines.delay(16)
}
}
}
fun cancelAnimations() {
animJob?.cancel()
}
private companion object {
// Close to the projection limit so polar geohash cells remain selectable;
// clamping tighter would make syncSelection() encode the wrong cell.
const val MIN_LAT = -89f
const val MAX_LAT = 89f
}
private fun syncSelection() {
if (baseRadiusPx <= 0f) return
val gh = Geohash.encode(centerLat.toDouble(), centerLon.toDouble(), precision)
if (gh != selectedGeohash) selectedGeohash = gh
}
}

View File

@ -0,0 +1,841 @@
package com.bitchat.android.ui.globe
import android.graphics.Paint
import android.graphics.Typeface
import android.os.SystemClock
import android.view.HapticFeedbackConstants
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.gestures.calculatePan
import androidx.compose.foundation.gestures.calculateZoom
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.nativeCanvas
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.input.pointer.positionChanged
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.sp
import com.bitchat.android.geohash.Geohash
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.first
import kotlin.math.ceil
import kotlin.math.min
import kotlin.math.sqrt
/** Color palette for the globe, derived from the app theme by the caller. */
data class GlobeColors(
val accent: Color,
val land: Color,
val coastline: Color,
val border: Color,
val oceanCenter: Color,
val oceanEdge: Color,
val atmosphere: Color,
val graticule: Color,
val grid: Color,
val label: Color,
val labelHalo: Color,
val star: Color
)
private class Star(val x: Float, val y: Float, val radius: Float, val alpha: Float)
@Composable
fun GlobeView(
state: GlobeState,
colors: GlobeColors,
land: List<LandData.Ring>,
borders: List<LandData.Ring>,
cities: List<LandData.City>,
labelTypeface: Typeface?,
labelTypefaceBold: Typeface?,
modifier: Modifier = Modifier
) {
val view = LocalView.current
val density = LocalDensity.current
val stars = remember {
val rnd = kotlin.random.Random(42)
List(160) {
Star(
x = rnd.nextFloat(),
y = rnd.nextFloat(),
radius = 0.6f + rnd.nextFloat() * 1.5f,
alpha = 0.15f + rnd.nextFloat() * 0.55f
)
}
}
val maxRingPoints = remember(land) { land.maxOfOrNull { it.size } ?: 0 }
val scratch = remember(maxRingPoints) { FloatArray(maxOf(1, maxRingPoints) * 3) }
val maxBorderPoints = remember(borders) { borders.maxOfOrNull { it.size } ?: 0 }
val borderScratch = remember(maxBorderPoints) { FloatArray(maxOf(1, maxBorderPoints) * 3) }
val labelPaint = remember {
Paint(Paint.ANTI_ALIAS_FLAG).apply { textAlign = Paint.Align.CENTER }
}
val haloPaint = remember {
Paint(Paint.ANTI_ALIAS_FLAG).apply {
textAlign = Paint.Align.CENTER
style = Paint.Style.STROKE
}
}
val pulse by rememberInfiniteTransition(label = "crosshair").animateFloat(
initialValue = 0.45f,
targetValue = 1f,
animationSpec = infiniteRepeatable(tween(1100), RepeatMode.Reverse),
label = "crosshairAlpha"
)
LaunchedEffect(state) {
// Fire intro once the viewport size is known.
snapshotFlow { state.baseRadiusPx }.first { it > 0f }
state.playPendingIntroIfAny()
}
LaunchedEffect(state) {
snapshotFlow { state.selectedGeohash }
.drop(1)
.collect {
view.performHapticFeedback(
HapticFeedbackConstants.KEYBOARD_TAP,
HapticFeedbackConstants.FLAG_IGNORE_GLOBAL_SETTING
)
}
}
val labelTextSize = with(density) { 12.5.sp.toPx() }
val labelTextSizeSmall = with(density) { 10.sp.toPx() }
Canvas(
modifier = modifier
.onSizeChanged { size: IntSize ->
val minDim = min(size.width, size.height).toFloat()
state.setViewport(minDim * 0.44f, minDim)
}
.pointerInput(state) {
var lastTapTime = 0L
var lastTapPos = Offset.Zero
awaitEachGesture {
val down = awaitFirstDown()
state.cancelAnimations()
state.isInteracting = true
val downTime = SystemClock.uptimeMillis()
val downPos = down.position
var maxPointers = 1
var moved = Offset.Zero
val panTimes = ArrayDeque<Long>()
val panVec = ArrayDeque<Offset>()
while (true) {
val event = awaitPointerEvent()
val pressed = event.changes.filter { it.pressed }
if (pressed.isEmpty()) break
maxPointers = maxOf(maxPointers, pressed.size)
val pan = event.calculatePan()
val zoomChange = event.calculateZoom()
if (pan != Offset.Zero) {
state.rotateBy(pan.x, pan.y)
moved += pan
val now = SystemClock.uptimeMillis()
panTimes.addLast(now)
panVec.addLast(pan)
while (panTimes.isNotEmpty() && now - panTimes.first() > 120) {
panTimes.removeFirst()
panVec.removeFirst()
}
}
if (zoomChange != 1f) {
state.zoomBy(zoomChange)
}
event.changes.forEach { if (it.positionChanged()) it.consume() }
}
state.isInteracting = false
val upTime = SystemClock.uptimeMillis()
val isTap = maxPointers == 1 &&
upTime - downTime < 400 &&
moved.getDistance() < viewConfiguration.touchSlop
if (isTap) {
val cx = size.width / 2f
val cy = size.height / 2f
val r = state.globeRadiusPx
if (r > 0f) {
val latLon = GlobeMath.unproject(
((downPos.x - cx) / r).toDouble(),
((downPos.y - cy) / r).toDouble(),
state.centerLat.toDouble(),
state.centerLon.toDouble()
)
if (latLon != null) {
val now = upTime
val lastTap = lastTapTime
val isDouble = now - lastTap < 350 &&
(downPos - lastTapPos).getDistance() < viewConfiguration.touchSlop * 4
lastTapTime = now
lastTapPos = downPos
if (isDouble) {
val targetZoom = (state.zoom * 1.9f).coerceAtMost(GlobeMath.MAX_ZOOM)
state.animateTo(latLon.first, latLon.second, targetZoom, null, 500)
} else {
state.animateTo(latLon.first, latLon.second, null, null, 450)
}
}
}
} else if (panVec.isNotEmpty()) {
var sx = 0f; var sy = 0f
panVec.forEach { sx += it.x; sy += it.y }
val windowMs = (SystemClock.uptimeMillis() - panTimes.first()).coerceAtLeast(1)
state.fling(sx / windowMs, sy / windowMs)
}
}
}
) {
val cx = size.width / 2f
val cy = size.height / 2f
val baseR = state.baseRadiusPx
if (baseR <= 0f) return@Canvas
val r = state.globeRadiusPx
val cLat = state.centerLat.toDouble()
val cLon = state.centerLon.toDouble()
// Starfield
stars.forEach { s ->
drawCircle(
color = colors.star.copy(alpha = s.alpha),
radius = s.radius * density.density,
center = Offset(s.x * size.width, s.y * size.height)
)
}
// Atmosphere glow
drawCircle(
brush = Brush.radialGradient(
0f to colors.atmosphere.copy(alpha = 0.30f),
0.75f to colors.atmosphere.copy(alpha = 0.12f),
1f to Color.Transparent,
center = Offset(cx, cy),
radius = r * 1.22f
),
radius = r * 1.22f,
center = Offset(cx, cy)
)
// Ocean sphere
drawCircle(
brush = Brush.radialGradient(
0f to colors.oceanCenter,
0.7f to colors.oceanCenter,
1f to colors.oceanEdge,
center = Offset(cx - r * 0.32f, cy - r * 0.38f),
radius = r * 1.5f
),
radius = r,
center = Offset(cx, cy)
)
val clip = ClipRect(-size.width, -size.height, size.width * 2f, size.height * 2f)
// Graticule
drawGraticule(cx, cy, r, cLat, cLon, colors.graticule, clip)
// Landmasses
for (ring in land) {
drawLandRing(ring, scratch, cx, cy, r, cLat, cLon, colors, clip)
}
// Country borders
for (line in borders) {
drawBorderLine(line, borderScratch, cx, cy, r, cLat, cLon, colors, clip)
}
// Sphere shading: dark limb + night side for 3D depth
drawCircle(
brush = Brush.radialGradient(
0f to Color.Transparent,
0.62f to Color.Transparent,
0.88f to Color.Black.copy(alpha = 0.34f),
1f to Color.Black.copy(alpha = 0.72f),
center = Offset(cx - r * 0.25f, cy - r * 0.3f),
radius = r * 1.35f
),
radius = r + 1,
center = Offset(cx, cy)
)
drawCircle(
brush = Brush.linearGradient(
0f to Color.Transparent,
0.55f to Color.Transparent,
1f to Color.Black.copy(alpha = 0.42f),
start = Offset(cx - r * 0.7f, cy - r * 0.7f),
end = Offset(cx + r * 0.75f, cy + r * 0.8f)
),
radius = r + 1,
center = Offset(cx, cy)
)
// Cities (dots + names) over the shaded sphere
drawCities(
cities, state, cx, cy, r, cLat, cLon, colors,
labelPaint, haloPaint, labelTypeface, labelTextSizeSmall, density.density
)
// Geohash cells
if (state.selectedGeohash.isNotEmpty()) {
drawGeohashGrid(state, cx, cy, r, cLat, cLon, colors, clip)
}
// Labels
if (state.selectedGeohash.isNotEmpty()) {
drawGeohashLabels(
state, cx, cy, r, cLat, cLon, colors,
labelPaint, haloPaint, labelTypeface, labelTypefaceBold,
labelTextSize, labelTextSizeSmall
)
}
// Center crosshair
val crossAlpha = if (state.isInteracting) 0.9f else pulse
val crossColor = colors.accent.copy(alpha = crossAlpha)
val gap = 5 * density.density
val len = 9 * density.density
val strokeW = 1.6f * density.density
drawLine(crossColor, Offset(cx - gap - len, cy), Offset(cx - gap, cy), strokeW)
drawLine(crossColor, Offset(cx + gap, cy), Offset(cx + gap + len, cy), strokeW)
drawLine(crossColor, Offset(cx, cy - gap - len), Offset(cx, cy - gap), strokeW)
drawLine(crossColor, Offset(cx, cy + gap), Offset(cx, cy + gap + len), strokeW)
drawCircle(crossColor, radius = 1.8f * density.density, center = Offset(cx, cy))
}
}
private fun DrawScope.drawGraticule(
cx: Float, cy: Float, r: Float, cLat: Double, cLon: Double, color: Color, clip: ClipRect
) {
val path = Path()
val step = 4.0
fun strokeSegment(x0: Float, y0: Float, x1: Float, y1: Float) {
val seg = clipSegment(x0, y0, x1, y1, clip) ?: return
path.moveTo(seg.first.first, seg.first.second)
path.lineTo(seg.second.first, seg.second.second)
}
// latitude lines
var lat = -75.0
while (lat <= 75.0) {
var prev: GlobeMath.Projection? = null
var lon = -180.0
while (lon <= 180.0) {
val p = GlobeMath.project(lat, lon, cLat, cLon)
if (p != null && p.cosC > 0.02f) {
val pp = prev
if (pp != null) strokeSegment(cx + pp.x * r, cy + pp.y * r, cx + p.x * r, cy + p.y * r)
prev = p
} else prev = null
lon += step
}
lat += 15.0
}
// longitude lines
var lon = -180.0
while (lon < 180.0) {
var prev: GlobeMath.Projection? = null
var la = -90.0
while (la <= 90.0) {
val p = GlobeMath.project(la, lon, cLat, cLon)
if (p != null && p.cosC > 0.02f) {
val pp = prev
if (pp != null) strokeSegment(cx + pp.x * r, cy + pp.y * r, cx + p.x * r, cy + p.y * r)
prev = p
} else prev = null
la += step
}
lon += 15.0
}
drawPath(path, color, style = Stroke(width = 1f))
}
private data class DiscPt(val x: Float, val y: Float, val front: Boolean)
private fun limbPoint(behind: DiscPt, front: DiscPt): Pair<Float, Float> {
var lo = 0f; var hi = 1f
repeat(14) {
val t = (lo + hi) / 2f
val x = behind.x + (front.x - behind.x) * t
val y = behind.y + (front.y - behind.y) * t
if (x * x + y * y < 1f) lo = t else hi = t
}
val t = (lo + hi) / 2f
return (behind.x + (front.x - behind.x) * t) to (behind.y + (front.y - behind.y) * t)
}
/**
* Splits a projected polygon into front-facing runs. Runs that touch the limb are
* padded with the horizon intersection point so they can be closed along the horizon.
* Pass [closed] = false for open polylines (border lines).
*/
private fun buildFrontRuns(pts: List<DiscPt>, closed: Boolean = true): List<MutableList<Pair<Float, Float>>> {
if (pts.isEmpty()) return emptyList()
val n = pts.size
val runs = mutableListOf<MutableList<Pair<Float, Float>>>()
var run = mutableListOf<Pair<Float, Float>>()
var prev: DiscPt? = if (closed) pts[n - 1] else null
for (i in 0 until n) {
val cur = pts[i]
val p = prev
if (cur.front) {
if (run.isEmpty() && p != null && !p.front) run.add(limbPoint(p, cur))
// Antimeridian wrap: consecutive front points can jump across the whole
// disc when a polygon crosses ±180° — break the run instead of drawing
// a chord through the view.
if (run.isNotEmpty()) {
val last = run.last()
val dx = cur.x - last.first
val dy = cur.y - last.second
if (dx * dx + dy * dy > 1.2f) {
runs.add(run)
run = mutableListOf()
}
}
run.add(cur.x to cur.y)
} else {
if (run.isNotEmpty() && p != null) {
run.add(limbPoint(p, cur))
runs.add(run)
run = mutableListOf()
}
}
prev = cur
}
if (run.isNotEmpty()) {
if (closed && runs.isNotEmpty() && pts[0].front && pts[n - 1].front) {
runs[0] = (run + runs[0]).toMutableList()
} else {
runs.add(run)
}
}
return runs
}
// Skia's edge rasterizer loses precision with path coordinates beyond ~32767px,
// which happens at high globe zoom. All geometry is clipped to an expanded
// viewport rect in screen space before being handed to a Path.
private class ClipRect(val left: Float, val top: Float, val right: Float, val bottom: Float)
private fun clipPolygon(pts: List<Pair<Float, Float>>, rect: ClipRect): List<Pair<Float, Float>> {
fun clipEdge(
input: List<Pair<Float, Float>>,
inside: (Pair<Float, Float>) -> Boolean,
intersect: (Pair<Float, Float>, Pair<Float, Float>) -> Pair<Float, Float>
): List<Pair<Float, Float>> {
if (input.isEmpty()) return input
val result = mutableListOf<Pair<Float, Float>>()
var s = input.last()
for (e in input) {
val eIn = inside(e)
val sIn = inside(s)
if (eIn) {
if (!sIn) result.add(intersect(s, e))
result.add(e)
} else if (sIn) {
result.add(intersect(s, e))
}
s = e
}
return result
}
var out = pts
out = clipEdge(out, { it.first >= rect.left }) { a, b ->
val t = (rect.left - a.first) / (b.first - a.first)
rect.left to (a.second + t * (b.second - a.second))
}
out = clipEdge(out, { it.first <= rect.right }) { a, b ->
val t = (rect.right - a.first) / (b.first - a.first)
rect.right to (a.second + t * (b.second - a.second))
}
out = clipEdge(out, { it.second >= rect.top }) { a, b ->
val t = (rect.top - a.second) / (b.second - a.second)
(a.first + t * (b.first - a.first)) to rect.top
}
out = clipEdge(out, { it.second <= rect.bottom }) { a, b ->
val t = (rect.bottom - a.second) / (b.second - a.second)
(a.first + t * (b.first - a.first)) to rect.bottom
}
return out
}
/** LiangBarsky clip of a segment to the rect; returns clipped endpoints or null. */
private fun clipSegment(
x0: Float, y0: Float, x1: Float, y1: Float, rect: ClipRect
): Pair<Pair<Float, Float>, Pair<Float, Float>>? {
val dx = x1 - x0
val dy = y1 - y0
var u1 = 0f
var u2 = 1f
fun test(p: Float, q: Float): Boolean {
if (p == 0f) return q >= 0f
val r = q / p
if (p < 0f) {
if (r > u2) return false
if (r > u1) u1 = r
} else {
if (r < u1) return false
if (r < u2) u2 = r
}
return true
}
if (!test(-dx, x0 - rect.left)) return null
if (!test(dx, rect.right - x0)) return null
if (!test(-dy, y0 - rect.top)) return null
if (!test(dy, rect.bottom - y0)) return null
return ((x0 + u1 * dx) to (y0 + u1 * dy)) to ((x0 + u2 * dx) to (y0 + u2 * dy))
}
/**
* Builds a fill polygon for a projected ring by clamping back-facing points onto the
* limb and inserting horizon arc steps between consecutive limb points, so the result
* approximates (polygon visible disc) without self-intersecting chords.
*/
private fun buildFillPolygon(
pts: List<DiscPt>,
cx: Float, cy: Float, r: Float
): List<Pair<Float, Float>> {
val out = ArrayList<Pair<Float, Float>>(pts.size + 128)
var prevLimbAngle: Float? = null
var firstLimbAngle: Float? = null
fun addArc(from: Float, to: Float) {
var d = to - from
while (d > Math.PI) d -= (2 * Math.PI).toFloat()
while (d < -Math.PI) d += (2 * Math.PI).toFloat()
val steps = (kotlin.math.abs(d) / 0.04f).toInt().coerceIn(1, 64)
for (s in 1 until steps) {
val a = from + d * s / steps
out.add((cx + kotlin.math.cos(a) * r) to (cy + kotlin.math.sin(a) * r))
}
}
for (p in pts) {
if (p.front) {
out.add((cx + p.x * r) to (cy + p.y * r))
prevLimbAngle = null
} else {
val len = kotlin.math.sqrt(p.x * p.x + p.y * p.y)
val lx: Float; val ly: Float
if (len > 1e-6f) { lx = p.x / len; ly = p.y / len } else { lx = 0f; ly = -1f }
val ang = kotlin.math.atan2(ly, lx)
prevLimbAngle?.let { addArc(it, ang) }
if (firstLimbAngle == null) firstLimbAngle = ang
out.add((cx + lx * r) to (cy + ly * r))
prevLimbAngle = ang
}
}
// wrap-around arc if the ring ends and starts on the limb
val lastAng = prevLimbAngle
val firstAng = firstLimbAngle
if (lastAng != null && firstAng != null && pts.isNotEmpty() && !pts[0].front) {
addArc(lastAng, firstAng)
}
return out
}
private fun DrawScope.fillPolygonClipped(
pts: List<DiscPt>,
cx: Float, cy: Float, r: Float,
color: Color,
clip: ClipRect
) {
if (pts.none { it.front }) return
val poly = buildFillPolygon(pts, cx, cy, r)
if (poly.size < 3) return
val clipped = clipPolygon(poly, clip)
if (clipped.size < 3) return
val path = Path()
path.moveTo(clipped[0].first, clipped[0].second)
for (k in 1 until clipped.size) {
path.lineTo(clipped[k].first, clipped[k].second)
}
path.close()
drawPath(path, color)
}
private fun DrawScope.strokeRuns(
runs: List<MutableList<Pair<Float, Float>>>,
cx: Float, cy: Float, r: Float,
color: Color,
width: Float,
clip: ClipRect
) {
for (run in runs) {
if (run.size < 2) continue
val path = Path()
for (k in 1 until run.size) {
val seg = clipSegment(
cx + run[k - 1].first * r, cy + run[k - 1].second * r,
cx + run[k].first * r, cy + run[k].second * r,
clip
) ?: continue
path.moveTo(seg.first.first, seg.first.second)
path.lineTo(seg.second.first, seg.second.second)
}
drawPath(path, color, style = Stroke(width = width))
}
}
private fun DrawScope.drawBorderLine(
line: LandData.Ring,
scratch: FloatArray,
cx: Float, cy: Float, r: Float,
cLat: Double, cLon: Double,
colors: GlobeColors,
clip: ClipRect
) {
val n = line.size
if (n < 2 || n * 3 > scratch.size) return
var anyFront = false
val pts = ArrayList<DiscPt>(n)
var i = 0
while (i < n) {
val lat = line.coords[i * 2].toDouble()
val lon = line.coords[i * 2 + 1].toDouble()
val p = GlobeMath.projectRaw(lat, lon, cLat, cLon)
val front = p.cosC > 0.005f
pts.add(DiscPt(p.x, p.y, front))
if (p.cosC >= 0f) anyFront = true
i++
}
if (!anyFront) return
val runs = buildFrontRuns(pts, closed = false)
strokeRuns(runs, cx, cy, r, colors.border, 1.2f, clip)
}
private fun DrawScope.drawCities(
cities: List<LandData.City>,
state: GlobeState,
cx: Float, cy: Float, r: Float,
cLat: Double, cLon: Double,
colors: GlobeColors,
labelPaint: Paint,
haloPaint: Paint,
typeface: Typeface?,
textSize: Float,
density: Float
) {
if (cities.isEmpty()) return
val zoom = state.zoom
val maxRank = when {
zoom < 2f -> 1
zoom < 8f -> 3
zoom < 40f -> 4
else -> 10
}
val canvas = drawContext.canvas.nativeCanvas
for (city in cities) {
if (city.rank > maxRank) continue
val p = GlobeMath.project(city.lat.toDouble(), city.lon.toDouble(), cLat, cLon) ?: continue
if (p.cosC < 0.03f) continue
val sx = cx + p.x * r
val sy = cy + p.y * r
if (sx < -50 || sx > size.width + 50 || sy < -50 || sy > size.height + 50) continue
val alpha = p.cosC.coerceIn(0.25f, 1f)
val important = city.capital || city.megacity
val dotRadius = (if (important) 2.6f else 1.8f) * density
val dotColor = if (city.capital) colors.accent.copy(alpha = alpha)
else colors.label.copy(alpha = alpha * 0.85f)
drawCircle(dotColor, radius = dotRadius, center = Offset(sx, sy))
if (zoom >= 6f || (important && zoom >= 2.5f)) {
labelPaint.textSize = textSize
labelPaint.typeface = typeface
labelPaint.textAlign = Paint.Align.LEFT
labelPaint.color = android.graphics.Color.argb(
(200 * alpha).toInt(),
(colors.label.red * 255).toInt(), (colors.label.green * 255).toInt(), (colors.label.blue * 255).toInt()
)
haloPaint.textSize = textSize
haloPaint.typeface = typeface
haloPaint.textAlign = Paint.Align.LEFT
haloPaint.strokeWidth = textSize * 0.16f
haloPaint.color = android.graphics.Color.argb(
(140 * alpha).toInt(),
(colors.labelHalo.red * 255).toInt(), (colors.labelHalo.green * 255).toInt(), (colors.labelHalo.blue * 255).toInt()
)
val tx = sx + dotRadius + 3 * density
val ty = sy - ((labelPaint.descent() + labelPaint.ascent()) / 2f)
canvas.drawText(city.name, tx, ty, haloPaint)
canvas.drawText(city.name, tx, ty, labelPaint)
}
}
labelPaint.textAlign = Paint.Align.CENTER
haloPaint.textAlign = Paint.Align.CENTER
}
private fun DrawScope.drawLandRing(
ring: LandData.Ring,
scratch: FloatArray,
cx: Float, cy: Float, r: Float,
cLat: Double, cLon: Double,
colors: GlobeColors,
clip: ClipRect
) {
val n = ring.size
if (n < 3 || n * 3 > scratch.size) return
var anyFront = false
var i = 0
while (i < n) {
val lat = ring.coords[i * 2].toDouble()
val lon = ring.coords[i * 2 + 1].toDouble()
val p = GlobeMath.projectRaw(lat, lon, cLat, cLon)
scratch[i * 3] = p.x
scratch[i * 3 + 1] = p.y
scratch[i * 3 + 2] = p.cosC
if (p.cosC >= 0f) anyFront = true
i++
}
if (!anyFront) return
val pts = ArrayList<DiscPt>(n)
i = 0
while (i < n) {
pts.add(DiscPt(scratch[i * 3], scratch[i * 3 + 1], scratch[i * 3 + 2] > 0.005f))
i++
}
val runs = buildFrontRuns(pts)
fillPolygonClipped(pts, cx, cy, r, colors.land, clip)
strokeRuns(runs, cx, cy, r, colors.coastline, 1.4f, clip)
}
private fun DrawScope.drawGeohashGrid(
state: GlobeState,
cx: Float, cy: Float, r: Float,
cLat: Double, cLon: Double,
colors: GlobeColors,
clip: ClipRect
) {
val selected = state.selectedGeohash
val cells = linkedSetOf(selected)
cells.addAll(Geohash.neighborsSamePrecision(selected))
for (cell in cells) {
val isSelected = cell == selected
val b = Geohash.decodeToBounds(cell)
val spanLat = b.latMax - b.latMin
val spanLon = b.lonMax - b.lonMin
val steps = ceil(spanLat / 1.5).toInt().coerceIn(4, 48)
// Sample the cell boundary: N edge l->r, E edge t->b, S edge r->l, W edge b->t
val pts = ArrayList<Triple<Float, Float, Boolean>>(steps * 4 + 4)
fun addPt(lat: Double, lonRaw: Double) {
var lon = lonRaw
// keep boundary continuous across the antimeridian relative to the view
val ref = cLon
while (lon - ref > 180.0) lon -= 360.0
while (lon - ref < -180.0) lon += 360.0
val p = GlobeMath.projectRaw(lat, lon, cLat, cLon)
pts.add(Triple(p.x, p.y, p.cosC >= 0.005f))
}
for (s in 0..steps) {
val t = s.toDouble() / steps
addPt(b.latMax, b.lonMin + spanLon * t)
}
for (s in 1..steps) {
val t = s.toDouble() / steps
addPt(b.latMax - spanLat * t, b.lonMax)
}
for (s in 1..steps) {
val t = s.toDouble() / steps
addPt(b.latMin, b.lonMax - spanLon * t)
}
for (s in 1 until steps) {
val t = s.toDouble() / steps
addPt(b.latMin + spanLat * t, b.lonMin)
}
if (pts.none { it.third }) continue
val discPts = pts.map { DiscPt(it.first, it.second, it.third) }
val runs = buildFrontRuns(discPts)
if (isSelected) {
fillPolygonClipped(discPts, cx, cy, r, colors.accent.copy(alpha = 0.20f), clip)
strokeRuns(runs, cx, cy, r, colors.accent.copy(alpha = 0.35f), 7f, clip)
strokeRuns(runs, cx, cy, r, colors.accent, 3.2f, clip)
} else {
fillPolygonClipped(discPts, cx, cy, r, colors.grid.copy(alpha = 0.05f), clip)
strokeRuns(runs, cx, cy, r, colors.grid, 1.6f, clip)
}
}
}
private fun DrawScope.drawGeohashLabels(
state: GlobeState,
cx: Float, cy: Float, r: Float,
cLat: Double, cLon: Double,
colors: GlobeColors,
labelPaint: Paint,
haloPaint: Paint,
labelTypeface: Typeface?,
labelTypefaceBold: Typeface?,
selectedSize: Float,
neighborSize: Float
) {
val selected = state.selectedGeohash
val cells = linkedSetOf(selected)
cells.addAll(Geohash.neighborsSamePrecision(selected))
val canvas = drawContext.canvas.nativeCanvas
for (cell in cells) {
val isSelected = cell == selected
val (lat, lon) = Geohash.decodeToCenter(cell)
val p = GlobeMath.project(lat, lon, cLat, cLon) ?: continue
if (p.cosC < 0.08f) continue
val sx = cx + p.x * r
val sy = cy + p.y * r
val paint = labelPaint
paint.textSize = if (isSelected) selectedSize else neighborSize
paint.typeface = if (isSelected) (labelTypefaceBold ?: labelTypeface) else labelTypeface
paint.color = if (isSelected) {
android.graphics.Color.argb(255, (colors.accent.red * 255).toInt(), (colors.accent.green * 255).toInt(), (colors.accent.blue * 255).toInt())
} else {
android.graphics.Color.argb(
(160 * p.cosC.coerceIn(0.4f, 1f)).toInt(),
(colors.label.red * 255).toInt(), (colors.label.green * 255).toInt(), (colors.label.blue * 255).toInt()
)
}
val baseline = sy - ((paint.descent() + paint.ascent()) / 2f)
haloPaint.textSize = paint.textSize
haloPaint.typeface = paint.typeface
haloPaint.strokeWidth = paint.textSize * 0.18f
haloPaint.color = android.graphics.Color.argb(
if (isSelected) 200 else 120,
(colors.labelHalo.red * 255).toInt(), (colors.labelHalo.green * 255).toInt(), (colors.labelHalo.blue * 255).toInt()
)
canvas.drawText(cell, sx, baseline, haloPaint)
canvas.drawText(cell, sx, baseline, paint)
}
}

View File

@ -0,0 +1,130 @@
package com.bitchat.android.ui.globe
import android.content.Context
import org.json.JSONObject
/**
* Loads the bundled Natural Earth 110m land polygons (public domain) from assets
* and exposes them as flat rings of lat/lon pairs for vector globe rendering.
*/
object LandData {
data class Ring(val coords: FloatArray, val size: Int)
data class City(val name: String, val lat: Float, val lon: Float, val rank: Int, val capital: Boolean, val megacity: Boolean)
@Volatile
private var cached: List<Ring>? = null
@Volatile
private var cachedBorders: List<Ring>? = null
@Volatile
private var cachedCities: List<City>? = null
/** Returns land polygon rings; each ring is a flat array of (lat, lon) pairs. */
fun load(context: Context): List<Ring> {
cached?.let { return it }
synchronized(this) {
cached?.let { return it }
val rings = mutableListOf<Ring>()
val text = context.assets.open("world_land.geojson").bufferedReader().use { it.readText() }
val root = JSONObject(text)
val geometries = root.getJSONArray("geometries")
for (i in 0 until geometries.length()) {
val geom = geometries.getJSONObject(i)
when (geom.getString("type")) {
"Polygon" -> parsePolygon(geom.getJSONArray("coordinates"), rings)
"MultiPolygon" -> {
val polys = geom.getJSONArray("coordinates")
for (j in 0 until polys.length()) {
parsePolygon(polys.getJSONArray(j), rings)
}
}
}
}
cached = rings
return rings
}
}
/** Returns country border lines (Natural Earth admin-0 boundary lines, public domain). */
fun loadBorders(context: Context): List<Ring> {
cachedBorders?.let { return it }
synchronized(this) {
cachedBorders?.let { return it }
val lines = mutableListOf<Ring>()
val text = context.assets.open("world_borders.geojson").bufferedReader().use { it.readText() }
val root = JSONObject(text)
val geometries = root.getJSONArray("geometries")
for (i in 0 until geometries.length()) {
val geom = geometries.getJSONObject(i)
when (geom.getString("type")) {
"LineString" -> parseLine(geom.getJSONArray("coordinates"), lines)
"MultiLineString" -> {
val parts = geom.getJSONArray("coordinates")
for (j in 0 until parts.length()) {
parseLine(parts.getJSONArray(j), lines)
}
}
}
}
cachedBorders = lines
return lines
}
}
/** Returns populated places (Natural Earth 50m, public domain) with name and scale rank. */
fun loadCities(context: Context): List<City> {
cachedCities?.let { return it }
synchronized(this) {
cachedCities?.let { return it }
val cities = mutableListOf<City>()
val text = context.assets.open("world_cities.geojson").bufferedReader().use { it.readText() }
val root = JSONObject(text)
val arr = root.getJSONArray("cities")
for (i in 0 until arr.length()) {
val c = arr.getJSONObject(i)
cities.add(
City(
name = c.getString("n"),
lat = c.getDouble("lat").toFloat(),
lon = c.getDouble("lon").toFloat(),
rank = c.getInt("r"),
capital = c.optInt("cap", 0) == 1,
megacity = c.optInt("mega", 0) == 1
)
)
}
cachedCities = cities
return cities
}
}
private fun parseLine(lineJson: org.json.JSONArray, out: MutableList<Ring>) {
val n = lineJson.length()
if (n < 2) return
val coords = FloatArray(n * 2)
for (p in 0 until n) {
val pt = lineJson.getJSONArray(p)
coords[p * 2] = pt.getDouble(1).toFloat() // lat
coords[p * 2 + 1] = pt.getDouble(0).toFloat() // lon
}
out.add(Ring(coords, n))
}
private fun parsePolygon(ringsJson: org.json.JSONArray, out: MutableList<Ring>) {
for (r in 0 until ringsJson.length()) {
val ringJson = ringsJson.getJSONArray(r)
val n = ringJson.length()
if (n < 3) continue
val coords = FloatArray(n * 2)
for (p in 0 until n) {
val pt = ringJson.getJSONArray(p)
coords[p * 2] = pt.getDouble(1).toFloat() // lat
coords[p * 2 + 1] = pt.getDouble(0).toFloat() // lon
}
out.add(Ring(coords, n))
}
}
}

View File

@ -125,7 +125,6 @@ object AppConstants {
const val BASE_FONT_SIZE_SP: Int = 14
const val MESSAGE_DEDUP_TIMEOUT_MS: Long = 30_000L
const val SYSTEM_EVENT_DEDUP_TIMEOUT_MS: Long = 5_000L
const val ACTIVE_PEERS_NOTIFICATION_INTERVAL_MS: Long = 300_000L
const val ACTION_FORCE_FINISH: String = "com.bitchat.android.ACTION_FORCE_FINISH"
const val PERMISSION_FORCE_FINISH: String = "com.bitchat.android.permission.FORCE_FINISH"
}

View File

@ -31,18 +31,21 @@ object DistributionInfoProvider {
val splitApks = applicationInfo.splitSourceDirs.orEmpty()
val installerPackage = installerPackageName(context)
val certificateSha256 = signingCertificateSha256(packageInfo)
val installedApkCanBeSharedUniversally = splitApks.isEmpty() &&
isUniversalApk(File(applicationInfo.sourceDir))
val installedApkVariant = if (splitApks.isEmpty()) {
shareableApkVariant(File(applicationInfo.sourceDir))
} else {
null
}
return DistributionInfo(
installSource = installSourceLabel(installerPackage),
installerPackage = installerPackage,
packageFormat = if (splitApks.isEmpty()) "Standalone APK" else "Split APK set",
architecture = architectureLabel(applicationInfo.sourceDir, splitApks),
sharingSource = if (installedApkCanBeSharedUniversally) {
"Current installed APK"
} else {
"Verified GitHub universal APK"
sharingSource = when (installedApkVariant) {
ShareableApkVariant.UNIVERSAL -> "Current installed APK"
ShareableApkVariant.ARM64 -> "Current installed APK (ARM64)"
null -> "Verified GitHub universal APK"
},
versionName = packageInfo.versionName ?: BuildConfig.VERSION_NAME,
versionCode = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
@ -112,6 +115,21 @@ object DistributionInfoProvider {
return packagedAbis.isEmpty() || packagedAbis.containsAll(UNIVERSAL_RELEASE_ABIS)
}
/**
* Returns the compatibility of an APK that is safe to offer for sharing.
* ARM64 is intentionally the only architecture-limited release variant
* supported because it is the project's primary per-ABI build.
*/
fun shareableApkVariant(apk: File): ShareableApkVariant? {
val packagedAbis = nativeAbisInApk(apk)
return when {
packagedAbis.isEmpty() || packagedAbis.containsAll(UNIVERSAL_RELEASE_ABIS) ->
ShareableApkVariant.UNIVERSAL
packagedAbis == setOf("arm64-v8a") -> ShareableApkVariant.ARM64
else -> null
}
}
internal fun nativeAbisInApk(apk: File): Set<String> {
if (!apk.isFile) return emptySet()
return try {
@ -189,3 +207,8 @@ object DistributionInfoProvider {
val certificateSha256: String?
)
}
enum class ShareableApkVariant {
UNIVERSAL,
ARM64
}

View File

@ -1,13 +0,0 @@
package com.bitchat.android.util
class NotificationIntervalManager {
private var _lastNetworkNotificationTime = 0L
val lastNetworkNotificationTime: Long
get() = _lastNetworkNotificationTime
val recentlySeenPeers: MutableSet<String> = mutableSetOf()
fun setLastNetworkNotificationTime(notificationTime: Long) {
_lastNetworkNotificationTime = notificationTime
}
}

View File

@ -24,7 +24,7 @@ import java.nio.file.StandardCopyOption
import java.security.MessageDigest
/**
* Manages downloading, caching, and verifying the universal APK for offline sharing.
* Manages local and downloaded APK artifacts for offline sharing.
*/
class UniversalApkManager(private val context: Context) {
@ -54,7 +54,7 @@ class UniversalApkManager(private val context: Context) {
.build()
/**
* Get information about the cached universal APK, if it exists.
* Get information about the cached sharing APK, if it exists.
*/
fun getCachedApkInfo(): ApkInfo? {
return try {
@ -81,6 +81,13 @@ class UniversalApkManager(private val context: Context) {
Log.w(TAG, "Metadata exists but APK file not found: ${apkFile.path}")
return null
}
val variant = runCatching {
ShareableApkVariant.valueOf(json.optString("variant"))
}.getOrNull() ?: DistributionInfoProvider.shareableApkVariant(apkFile)
if (variant == null) {
Log.w(TAG, "Cached APK is not a supported sharing variant")
return null
}
ApkInfo(
version = version,
@ -88,7 +95,8 @@ class UniversalApkManager(private val context: Context) {
downloadDate = downloadDate,
size = size,
file = apkFile,
source = source
source = source,
variant = variant
)
} catch (e: Exception) {
Log.e(TAG, "Error reading cached APK info", e)
@ -125,11 +133,10 @@ class UniversalApkManager(private val context: Context) {
*/
suspend fun checkForUpdate(): UpdateStatus = withContext(Dispatchers.IO) {
try {
// A genuinely universal standalone APK is already an installable
// sharing artifact. Architecture-specific standalone APKs and split
// installs still need the universal GitHub artifact.
// A supported standalone APK is already an installable sharing
// artifact. Split installs still need the universal GitHub artifact.
val installedApkInfo = cacheInstalledApkIfPreferred()
if (installedApkInfo != null) {
if (installedApkInfo?.source == ApkSource.INSTALLED) {
return@withContext UpdateStatus.UpToDate(installedApkInfo.version)
}
@ -330,7 +337,8 @@ class UniversalApkManager(private val context: Context) {
checksum = release.universalApkSha256 ?: "",
size = finalFile.length(),
fileName = finalFileName,
source = ApkSource.GITHUB
source = ApkSource.GITHUB,
variant = ShareableApkVariant.UNIVERSAL
)
cleanupOldApks(except = finalFile)
@ -467,9 +475,8 @@ class UniversalApkManager(private val context: Context) {
}
/**
* Cache the APK this process was installed from only when it is both
* standalone and universal. A base APK from a split install is incomplete,
* while an ABI-specific APK would unnecessarily limit recipients.
* Cache the APK this process was installed from when it is a standalone
* universal or ARM64 artifact. A base APK from a split install is incomplete.
*/
private fun cacheInstalledApkIfPreferred(): ApkInfo? {
return try {
@ -482,15 +489,25 @@ class UniversalApkManager(private val context: Context) {
if (!installedApk.isFile || installedApk.length() <= 0L) {
return null
}
if (!DistributionInfoProvider.isUniversalApk(installedApk)) {
Log.d(TAG, "Installed APK is architecture-specific; using GitHub universal APK")
discardArchitectureLimitedInstalledCache()
val installedVariant = DistributionInfoProvider.shareableApkVariant(installedApk)
if (installedVariant == null) {
Log.d(TAG, "Installed APK is not a supported sharing variant")
return null
}
val installedVersion = installedVersionName()
val cachedInfo = getCachedApkInfo()
// Downloading the universal release is an explicit compatibility
// choice. Keep it even when the running ARM64 build is newer; the
// user can delete it from the UI to return to the local artifact.
if (installedVariant == ShareableApkVariant.ARM64 &&
cachedInfo?.source == ApkSource.GITHUB &&
cachedInfo.variant == ShareableApkVariant.UNIVERSAL
) {
return cachedInfo
}
// Keep an already cached artifact if it is the same version or
// newer. Otherwise prefer the running build so sharing cannot
// silently downgrade recipients to an older GitHub release.
@ -502,7 +519,11 @@ class UniversalApkManager(private val context: Context) {
checkDiskSpace(installedApk.length())
val safeVersion = installedVersion.replace(Regex("[^A-Za-z0-9._-]"), "_")
val finalFileName = "$APK_FILE_PREFIX$safeVersion.apk"
val variantSuffix = when (installedVariant) {
ShareableApkVariant.UNIVERSAL -> ""
ShareableApkVariant.ARM64 -> "-arm64-v8a"
}
val finalFileName = "$APK_FILE_PREFIX$safeVersion$variantSuffix.apk"
val finalFile = File(cacheDir, finalFileName)
val pendingFile = File(cacheDir, "$finalFileName.new")
@ -519,7 +540,8 @@ class UniversalApkManager(private val context: Context) {
checksum = checksum,
size = finalFile.length(),
fileName = finalFileName,
source = ApkSource.INSTALLED
source = ApkSource.INSTALLED,
variant = installedVariant
)
cleanupOldApks(except = finalFile)
@ -531,19 +553,6 @@ class UniversalApkManager(private val context: Context) {
}
}
private fun discardArchitectureLimitedInstalledCache() {
val cachedInfo = getCachedApkInfo() ?: return
if (cachedInfo.source != ApkSource.INSTALLED ||
DistributionInfoProvider.isUniversalApk(cachedInfo.file)
) {
return
}
cachedInfo.file.delete()
metadataFile.delete()
Log.d(TAG, "Removed architecture-specific installed APK from universal sharing cache")
}
private fun installedVersionName(): String {
return context.packageManager
.getPackageInfo(context.packageName, 0)
@ -729,7 +738,8 @@ class UniversalApkManager(private val context: Context) {
checksum: String,
size: Long,
fileName: String,
source: ApkSource
source: ApkSource,
variant: ShareableApkVariant
) {
val json = JSONObject().apply {
put("version", version)
@ -738,6 +748,7 @@ class UniversalApkManager(private val context: Context) {
put("size", size)
put("fileName", fileName)
put("source", source.name)
put("variant", variant.name)
}
val pendingMetadata = File(cacheDir, "$METADATA_FILE_NAME.new")
@ -802,7 +813,8 @@ class UniversalApkManager(private val context: Context) {
val downloadDate: Long,
val size: Long,
val file: File,
val source: ApkSource
val source: ApkSource,
val variant: ShareableApkVariant
)
enum class ApkSource {

View File

@ -30,6 +30,15 @@ object WifiAwareController {
private var awareReceiverRegistered = false
private var lastBlockedReason: String? = null
/**
* Set while a Wi-Fi Direct hotspot is hosting. Wi-Fi Aware (NAN) and Wi-Fi Direct
* (P2P) cannot hold interfaces at the same time on common chipsets the HAL fails
* to create the P2P iface and every createGroup is answered with BUSY. The hold
* also blocks [startIfPossible], so a resume or mesh-service restart cannot bring
* Aware back while the hotspot is up.
*/
private val hotspotHold = AtomicBoolean(false)
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private val _enabled = MutableStateFlow(false)
@ -87,9 +96,30 @@ object WifiAwareController {
if (value) startIfPossible() else stop()
}
/**
* Releases the Wi-Fi radio so a Wi-Fi Direct hotspot can create its P2P interface,
* and prevents Aware restarting until [releaseHotspotHold] is called.
*/
fun holdForHotspot() {
if (!hotspotHold.compareAndSet(false, true)) return
Log.i(TAG, "Holding Wi-Fi Aware down so the hotspot can use the radio")
stop()
}
/** Drops the hold and restores Aware if the user still has it enabled. */
fun releaseHotspotHold() {
if (!hotspotHold.compareAndSet(true, false)) return
Log.i(TAG, "Hotspot finished; restoring Wi-Fi Aware if enabled")
restartIfStillEnabled()
}
fun startIfPossible() {
val reusableService = synchronized(lifecycleLock) {
if (!_enabled.value) return
if (hotspotHold.get()) {
Log.d(TAG, "Not starting Wi-Fi Aware: held down for the hotspot")
return
}
val existing = service
if (existing?.isRunning() == true) {
_running.value = true
@ -149,7 +179,7 @@ object WifiAwareController {
return
}
}
if (!_enabled.value) {
if (!_enabled.value || hotspotHold.get()) {
synchronized(lifecycleLock) { starting = false }
return
}
@ -159,23 +189,40 @@ object WifiAwareController {
WifiAwareMeshService(ctx)
}
startedService.startServices()
if (startedService.isRunning()) {
synchronized(lifecycleLock) {
// Test the hold inside the same lock that publishes the service, and that
// stop() takes. Testing it outside leaves a window where holdForHotspot()
// sets the flag and stop() finds nothing published yet, and this block then
// publishes anyway — resurrecting NAN while the hotspot owns the radio.
// Ordering holds because holdForHotspot() sets the flag before calling
// stop(): either we see the flag here, or stop() sees our published service.
val published = synchronized(lifecycleLock) {
val canPublish = !hotspotHold.get() && startedService.isRunning()
if (canPublish) {
service = startedService
_running.value = true
} else {
if (service === startedService) service = null
_running.value = false
}
canPublish
}
if (published) {
try { com.bitchat.android.service.MeshServiceHolder.unifiedMeshService?.refreshDelegates() } catch (_: Exception) { }
clearBlockedDebugMessage()
try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().addDebugMessage(com.bitchat.android.ui.debug.DebugMessage.SystemMessage("WiFi Aware started")) } catch (_: Exception) {}
} else {
if (reusableService == null) {
// stopServices() can block, so keep it out of the lock.
val heldForHotspot = hotspotHold.get()
if (heldForHotspot || reusableService == null) {
try { startedService.stopServices() } catch (_: Exception) { }
}
synchronized(lifecycleLock) {
if (service === startedService) service = null
_running.value = false
if (heldForHotspot) {
Log.i(TAG, "Abandoned Wi-Fi Aware start: hotspot claimed the radio")
}
try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().addDebugMessage(com.bitchat.android.ui.debug.DebugMessage.SystemMessage("WiFi Aware did not start")) } catch (_: Exception) {}
val detail = if (heldForHotspot) "held down for the hotspot" else "did not start"
try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().addDebugMessage(com.bitchat.android.ui.debug.DebugMessage.SystemMessage("WiFi Aware $detail")) } catch (_: Exception) {}
}
} catch (e: Throwable) {
Log.e(TAG, "Failed to start WifiAwareMeshService", e)

View File

@ -93,8 +93,7 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
// Service-level notification manager for background (no-UI) DMs
private val serviceNotificationManager = com.bitchat.android.ui.NotificationManager(
context.applicationContext,
androidx.core.app.NotificationManagerCompat.from(context.applicationContext),
com.bitchat.android.util.NotificationIntervalManager()
androidx.core.app.NotificationManagerCompat.from(context.applicationContext)
)
// Wi-Fi Aware transport
@ -188,21 +187,13 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
fragmentingSender = FragmentingPacketSender(serviceScope, meshCore.fragmentManager, TAG)
}
private fun handleMessageReceived(message: BitchatMessage) {
try {
when {
message.isPrivate -> {
val peer = message.senderPeerID ?: ""
if (peer.isNotEmpty()) com.bitchat.android.services.AppStateStore.addPrivateMessage(peer, message)
}
message.channel != null -> {
com.bitchat.android.services.AppStateStore.addChannelMessage(message.channel!!, message)
}
else -> {
com.bitchat.android.services.AppStateStore.addPublicMessage(message)
}
}
} catch (_: Exception) { }
private fun handleMessageReceived(message: BitchatMessage): Boolean {
// Match BLE admission semantics: a private message rejected during panic or as a
// duplicate must not create a notification after the conversation state was cleared.
if (
!com.bitchat.android.services.IncomingMessageAdmission
.admitToAppState(message)
) return false
if (delegate == null && message.isPrivate) {
try {
@ -215,6 +206,7 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
}
} catch (_: Exception) { }
}
return true
}
/**

Some files were not shown because too many files have changed in this diff Show More