mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-08-29 07:16:08 +00:00
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:
commit
5cc4ba344a
255
.agents/skills/android-ui-visual-review/SKILL.md
Normal file
255
.agents/skills/android-ui-visual-review/SKILL.md
Normal 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
|
||||
|
||||
@ -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."
|
||||
]
|
||||
}
|
||||
@ -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 |
|
||||
|---|---|
|
||||
|  |  |
|
||||
|
||||
Fixture disclosure: FIXTURE_DESCRIPTION_OR_NONE.
|
||||
|
||||
41
.agents/skills/android-ui-visual-review/evals/evals.json
Normal file
41
.agents/skills/android-ui-visual-review/evals/evals.json
Normal 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."
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -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.
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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.
|
||||
201
.agents/skills/android-ui-visual-review/scripts/create_review_worktree.sh
Executable file
201
.agents/skills/android-ui-visual-review/scripts/create_review_worktree.sh
Executable 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"
|
||||
217
.agents/skills/android-ui-visual-review/scripts/upload_pr_images.py
Executable file
217
.agents/skills/android-ui-visual-review/scripts/upload_pr_images.py
Executable 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)
|
||||
|
||||
168
.agents/skills/android-ui-visual-review/scripts/validate_capture_manifest.py
Executable file
168
.agents/skills/android-ui-visual-review/scripts/validate_capture_manifest.py
Executable 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)
|
||||
|
||||
224
.agents/skills/mesh-lab/SKILL.md
Normal file
224
.agents/skills/mesh-lab/SKILL.md
Normal 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.
|
||||
41
.agents/skills/mesh-lab/evals/evals.json
Normal file
41
.agents/skills/mesh-lab/evals/evals.json
Normal 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
6
.dockerignore
Normal file
@ -0,0 +1,6 @@
|
||||
.git
|
||||
.gradle
|
||||
.reproducible-build
|
||||
**/build
|
||||
local.properties
|
||||
tools/arti-build/.arti-source
|
||||
184
.github/workflows/android-build.yml
vendored
184
.github/workflows/android-build.yml
vendored
@ -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"
|
||||
|
||||
6
.github/workflows/fetch-georelays.yml
vendored
6
.github/workflows/fetch-georelays.yml
vendored
@ -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 }}
|
||||
|
||||
180
.github/workflows/release.yml
vendored
180
.github/workflows/release.yml
vendored
@ -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
2
.gitignore
vendored
@ -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
1
.java-version
Normal file
@ -0,0 +1 @@
|
||||
21.0.11
|
||||
@ -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`
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
463
app/gradle.lockfile
Normal 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
|
||||
7
app/proguard-rules.pro
vendored
7
app/proguard-rules.pro
vendored
@ -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 { *; }
|
||||
|
||||
|
||||
19
app/src/debug/AndroidManifest.xml
Normal file
19
app/src/debug/AndroidManifest.xml
Normal 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>
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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: "© OpenStreetMap © 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>
|
||||
1
app/src/main/assets/world_borders.geojson
Normal file
1
app/src/main/assets/world_borders.geojson
Normal file
File diff suppressed because one or more lines are too long
1
app/src/main/assets/world_cities.geojson
Normal file
1
app/src/main/assets/world_cities.geojson
Normal file
File diff suppressed because one or more lines are too long
1
app/src/main/assets/world_land.geojson
Normal file
1
app/src/main/assets/world_land.geojson
Normal file
File diff suppressed because one or more lines are too long
@ -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)
|
||||
|
||||
@ -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() &&
|
||||
|
||||
@ -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 }
|
||||
|
||||
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
@ -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()
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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)) }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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 -> {
|
||||
|
||||
@ -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
|
||||
*/
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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(
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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()
|
||||
|
||||
@ -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
|
||||
)
|
||||
|
||||
@ -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
@ -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()
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
)
|
||||
|
||||
@ -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()
|
||||
|
||||
287
app/src/main/java/com/bitchat/android/ui/CashuTokenDecoder.kt
Normal file
287
app/src/main/java/com/bitchat/android/ui/CashuTokenDecoder.kt
Normal 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 }
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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 ->
|
||||
|
||||
@ -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}")
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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]
|
||||
}
|
||||
189
app/src/main/java/com/bitchat/android/ui/ConversationSummary.kt
Normal file
189
app/src/main/java/com/bitchat/android/ui/ConversationSummary.kt
Normal 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
|
||||
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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"
|
||||
}
|
||||
@ -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())
|
||||
|
||||
@ -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
@ -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
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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
|
||||
*/
|
||||
|
||||
@ -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)
|
||||
|
||||
131
app/src/main/java/com/bitchat/android/ui/PeerAvatar.kt
Normal file
131
app/src/main/java/com/bitchat/android/ui/PeerAvatar.kt
Normal 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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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(
|
||||
|
||||
100
app/src/main/java/com/bitchat/android/ui/globe/GlobeMath.kt
Normal file
100
app/src/main/java/com/bitchat/android/ui/globe/GlobeMath.kt
Normal 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
|
||||
}
|
||||
185
app/src/main/java/com/bitchat/android/ui/globe/GlobeState.kt
Normal file
185
app/src/main/java/com/bitchat/android/ui/globe/GlobeState.kt
Normal 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
|
||||
}
|
||||
}
|
||||
841
app/src/main/java/com/bitchat/android/ui/globe/GlobeView.kt
Normal file
841
app/src/main/java/com/bitchat/android/ui/globe/GlobeView.kt
Normal 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
|
||||
}
|
||||
|
||||
/** Liang–Barsky 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)
|
||||
}
|
||||
}
|
||||
130
app/src/main/java/com/bitchat/android/ui/globe/LandData.kt
Normal file
130
app/src/main/java/com/bitchat/android/ui/globe/LandData.kt
Normal 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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
@ -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("Wi‑Fi 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("Wi‑Fi 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("Wi‑Fi Aware $detail")) } catch (_: Exception) {}
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
Log.e(TAG, "Failed to start WifiAwareMeshService", e)
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user