mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-08-15 06:56:30 +00:00
Merge main into fix/766-header-location-control
This commit is contained in:
commit
57cc39dbdf
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
|
||||
@ -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 { *; }
|
||||
|
||||
|
||||
@ -3,7 +3,9 @@ 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
|
||||
@ -63,11 +65,24 @@ object TestHookDriver {
|
||||
"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")
|
||||
@ -132,6 +147,21 @@ object TestHookDriver {
|
||||
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")
|
||||
@ -262,6 +292,68 @@ object TestHookDriver {
|
||||
.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 {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -28,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
|
||||
|
||||
@ -39,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
|
||||
}
|
||||
@ -67,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")
|
||||
@ -138,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()
|
||||
}
|
||||
|
||||
/**
|
||||
@ -154,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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@ -182,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.
|
||||
*/
|
||||
@ -202,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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
@ -235,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!!)
|
||||
@ -274,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) {
|
||||
@ -284,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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -355,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
|
||||
@ -460,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,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)
|
||||
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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
|
||||
@ -121,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
|
||||
@ -260,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(
|
||||
@ -343,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) }
|
||||
|
||||
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 }
|
||||
}
|
||||
}
|
||||
@ -488,6 +488,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,
|
||||
|
||||
@ -98,6 +98,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
|
||||
@ -356,14 +364,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,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"
|
||||
}
|
||||
@ -441,7 +441,7 @@ class MediaSendingManager(
|
||||
}
|
||||
}
|
||||
|
||||
private fun handlePrivatePreparation(
|
||||
private suspend fun handlePrivatePreparation(
|
||||
preparation: PrivateMediaPreparation,
|
||||
pending: PendingAutomaticPrivateMedia
|
||||
) {
|
||||
@ -599,7 +599,7 @@ class MediaSendingManager(
|
||||
}
|
||||
}
|
||||
|
||||
private fun commitPreparedPrivateFile(
|
||||
private suspend fun commitPreparedPrivateFile(
|
||||
preparation: PrivateMediaPreparation.Ready,
|
||||
conversationID: String,
|
||||
recipientMeshPeerID: String,
|
||||
@ -630,7 +630,14 @@ class MediaSendingManager(
|
||||
|
||||
// Preparation already built and admitted the exact final packet. Map
|
||||
// progress before commit so the first asynchronous event cannot race us.
|
||||
messageManager.addPrivateMessage(conversationID, 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
|
||||
@ -641,12 +648,17 @@ 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(
|
||||
conversationID,
|
||||
"Private media was not sent because the prepared transfer could not be committed."
|
||||
|
||||
@ -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)
|
||||
|
||||
105
app/src/main/java/com/bitchat/android/ui/PeerAvatar.kt
Normal file
105
app/src/main/java/com/bitchat/android/ui/PeerAvatar.kt
Normal file
@ -0,0 +1,105 @@
|
||||
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.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
|
||||
|
||||
@Composable
|
||||
internal fun PeerAvatar(
|
||||
name: String,
|
||||
color: Color,
|
||||
modifier: Modifier = Modifier,
|
||||
isFavorite: Boolean = false,
|
||||
theyFavoritedUs: 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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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"
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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.
Binary file not shown.
@ -46,7 +46,7 @@
|
||||
<string name="notification_summary_more">و%1$d إضافية</string>
|
||||
<string name="notification_messages_from_people">%1$d رسالة من %2$d أشخاص</string>
|
||||
<string name="notification_more_conversations">و%1$d محادثات أخرى</string>
|
||||
<string name="notification_active_peers_title">👥 مستخدمون قريبون!</string>
|
||||
<string name="notification_active_peers_title">مستخدمون قريبون!</string>
|
||||
<string name="notification_active_peers_one">شخص واحد قريب</string>
|
||||
<string name="notification_active_peers_many">%1$d أشخاص قريبون</string>
|
||||
<string name="notification_new_messages">رسائل جديدة</string>
|
||||
@ -390,4 +390,8 @@
|
||||
<string name="nearby_notes_reveal">تحقّق من الملاحظات المتروكة هنا</string>
|
||||
<string name="nearby_notes_one">تُركت ملاحظة واحدة هنا — انقر للقراءة</string>
|
||||
<string name="nearby_notes_many">تُركت %d ملاحظات هنا — انقر للقراءة</string>
|
||||
<string name="about_language">اللغة</string>
|
||||
<string name="about_app_language">لغة التطبيق</string>
|
||||
<string name="about_system_default">إعداد النظام الافتراضي</string>
|
||||
<string name="about_select_language">اختر اللغة</string>
|
||||
</resources>
|
||||
|
||||
@ -46,7 +46,7 @@
|
||||
<string name="notification_summary_more">এবং %1$d আরো</string>
|
||||
<string name="notification_messages_from_people">%2$d জন থেকে %1$d বার্তা</string>
|
||||
<string name="notification_more_conversations">এবং %1$d আরো কথোপকথন</string>
|
||||
<string name="notification_active_peers_title">👥 কাছাকাছি bitchatter!</string>
|
||||
<string name="notification_active_peers_title">কাছাকাছি bitchatter!</string>
|
||||
<string name="notification_active_peers_one">কাছাকাছি ১ জন</string>
|
||||
<string name="notification_active_peers_many">কাছাকাছি %1$d জন</string>
|
||||
<string name="notification_new_messages">নতুন বার্তা</string>
|
||||
@ -377,4 +377,8 @@
|
||||
<string name="nearby_notes_reveal">এখানে রাখা নোট আছে কি না দেখুন</string>
|
||||
<string name="nearby_notes_one">এখানে 1টি নোট রাখা আছে — পড়তে ট্যাপ করুন</string>
|
||||
<string name="nearby_notes_many">এখানে %dটি নোট রাখা আছে — পড়তে ট্যাপ করুন</string>
|
||||
<string name="about_language">ভাষা</string>
|
||||
<string name="about_app_language">অ্যাপের ভাষা</string>
|
||||
<string name="about_system_default">সিস্টেম ডিফল্ট</string>
|
||||
<string name="about_select_language">ভাষা নির্বাচন করুন</string>
|
||||
</resources>
|
||||
|
||||
@ -46,7 +46,7 @@
|
||||
<string name="notification_summary_more">und %1$d weitere</string>
|
||||
<string name="notification_messages_from_people">%1$d Nachrichten von %2$d Personen</string>
|
||||
<string name="notification_more_conversations">und %1$d weitere Unterhaltungen</string>
|
||||
<string name="notification_active_peers_title">👥 bitchatter in der Nähe!</string>
|
||||
<string name="notification_active_peers_title">bitchatter in der Nähe!</string>
|
||||
<string name="notification_active_peers_one">1 Person in der Nähe</string>
|
||||
<string name="notification_active_peers_many">%1$d Personen in der Nähe</string>
|
||||
<string name="notification_new_messages">Neue Nachrichten</string>
|
||||
@ -391,4 +391,8 @@
|
||||
<string name="nearby_notes_reveal">nachsehen, ob hier notizen hinterlassen wurden</string>
|
||||
<string name="nearby_notes_one">1 notiz hier hinterlassen — tippen zum lesen</string>
|
||||
<string name="nearby_notes_many">%d notizen hier hinterlassen — tippen zum lesen</string>
|
||||
<string name="about_language">Sprache</string>
|
||||
<string name="about_app_language">App-Sprache</string>
|
||||
<string name="about_system_default">Systemstandard</string>
|
||||
<string name="about_select_language">Sprache auswählen</string>
|
||||
</resources>
|
||||
|
||||
@ -46,7 +46,7 @@
|
||||
<string name="notification_summary_more">y %1$d más</string>
|
||||
<string name="notification_messages_from_people">%1$d mensajes de %2$d personas</string>
|
||||
<string name="notification_more_conversations">y %1$d conversaciones más</string>
|
||||
<string name="notification_active_peers_title">👥 ¡bitchatters cerca!</string>
|
||||
<string name="notification_active_peers_title">¡bitchatters cerca!</string>
|
||||
<string name="notification_active_peers_one">1 persona cerca</string>
|
||||
<string name="notification_active_peers_many">%1$d personas cerca</string>
|
||||
<string name="notification_new_messages">Nuevos mensajes</string>
|
||||
@ -390,4 +390,8 @@
|
||||
<string name="nearby_notes_reveal">buscar notas dejadas aquí</string>
|
||||
<string name="nearby_notes_one">1 nota dejada aquí — toca para leer</string>
|
||||
<string name="nearby_notes_many">%d notas dejadas aquí — toca para leer</string>
|
||||
<string name="about_language">Idioma</string>
|
||||
<string name="about_app_language">Idioma de la app</string>
|
||||
<string name="about_system_default">Predeterminado del sistema</string>
|
||||
<string name="about_select_language">Seleccionar idioma</string>
|
||||
</resources>
|
||||
|
||||
@ -46,7 +46,7 @@
|
||||
<string name="notification_summary_more">و %1$d مورد دیگر</string>
|
||||
<string name="notification_messages_from_people">%1$d پیام از %2$d نفر</string>
|
||||
<string name="notification_more_conversations">و %1$d گفتوگوی دیگر</string>
|
||||
<string name="notification_active_peers_title">👥 کاربران bitchat در نزدیکی!</string>
|
||||
<string name="notification_active_peers_title">کاربران bitchat در نزدیکی!</string>
|
||||
<string name="notification_active_peers_one">۱ نفر در نزدیکی</string>
|
||||
<string name="notification_active_peers_many">%1$d نفر در نزدیکی</string>
|
||||
<string name="notification_new_messages">پیامهای جدید</string>
|
||||
@ -377,4 +377,8 @@
|
||||
<string name="nearby_notes_reveal">یادداشتهای باقیمانده در اینجا را بررسی کنید</string>
|
||||
<string name="nearby_notes_one">۱ یادداشت اینجا باقی مانده — برای خواندن ضربه بزنید</string>
|
||||
<string name="nearby_notes_many">%d یادداشت اینجا باقی مانده — برای خواندن ضربه بزنید</string>
|
||||
<string name="about_language">زبان</string>
|
||||
<string name="about_app_language">زبان برنامه</string>
|
||||
<string name="about_system_default">پیشفرض سیستم</string>
|
||||
<string name="about_select_language">انتخاب زبان</string>
|
||||
</resources>
|
||||
|
||||
@ -46,7 +46,7 @@
|
||||
<string name="notification_summary_more">at %1$d pa</string>
|
||||
<string name="notification_messages_from_people">%1$d mensahe mula sa %2$d tao</string>
|
||||
<string name="notification_more_conversations">at %1$d pang usapan</string>
|
||||
<string name="notification_active_peers_title">👥 mga bitchatter na malapit!</string>
|
||||
<string name="notification_active_peers_title">mga bitchatter na malapit!</string>
|
||||
<string name="notification_active_peers_one">1 tao sa paligid</string>
|
||||
<string name="notification_active_peers_many">%1$d tao sa paligid</string>
|
||||
<string name="notification_new_messages">Mga bagong mensahe</string>
|
||||
@ -394,4 +394,8 @@
|
||||
<string name="nearby_notes_reveal">tingnan kung may mga note na naiwan dito</string>
|
||||
<string name="nearby_notes_one">1 note ang naiwan dito — i-tap para basahin</string>
|
||||
<string name="nearby_notes_many">%d note ang naiwan dito — i-tap para basahin</string>
|
||||
<string name="about_language">Wika</string>
|
||||
<string name="about_app_language">Wika ng app</string>
|
||||
<string name="about_system_default">Default ng system</string>
|
||||
<string name="about_select_language">Pumili ng wika</string>
|
||||
</resources>
|
||||
|
||||
@ -46,7 +46,7 @@
|
||||
<string name="notification_summary_more">et %1$d de plus</string>
|
||||
<string name="notification_messages_from_people">%1$d messages de %2$d personnes</string>
|
||||
<string name="notification_more_conversations">et %1$d conversations de plus</string>
|
||||
<string name="notification_active_peers_title">👥 bitchatters à proximité !</string>
|
||||
<string name="notification_active_peers_title">bitchatters à proximité !</string>
|
||||
<string name="notification_active_peers_one">1 personne à proximité</string>
|
||||
<string name="notification_active_peers_many">%1$d personnes à proximité</string>
|
||||
<string name="notification_new_messages">Nouveaux messages</string>
|
||||
@ -408,4 +408,8 @@
|
||||
<string name="nearby_notes_reveal">vérifier s\'il y a des notes laissées ici</string>
|
||||
<string name="nearby_notes_one">1 note laissée ici — appuyez pour lire</string>
|
||||
<string name="nearby_notes_many">%d notes laissées ici — appuyez pour lire</string>
|
||||
<string name="about_language">Langue</string>
|
||||
<string name="about_app_language">Langue de l’appli</string>
|
||||
<string name="about_system_default">Langue du système</string>
|
||||
<string name="about_select_language">Choisir la langue</string>
|
||||
</resources>
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- TODO: Hebrew translations -->
|
||||
<!-- Machine-translated (Claude) — pending native-speaker review -->
|
||||
<plurals name="location_notes_title">
|
||||
<item quantity="one">#%1$s ± 1 • %2$d הערה</item>
|
||||
<item quantity="other">#%1$s ± 1 • %2$d הערות</item>
|
||||
@ -14,48 +15,388 @@
|
||||
<string name="location_notes_empty_desc">היה הראשון להוסיף הערה למקום זה.</string>
|
||||
<string name="dismiss">סגור</string>
|
||||
<string name="location_notes_input_placeholder">הוסף הערה למקום זה</string>
|
||||
<string name="skip">Skip</string>
|
||||
<string name="bluetooth_recommended">Bluetooth Recommended</string>
|
||||
<string name="skip">דלג</string>
|
||||
<string name="bluetooth_recommended">מומלץ Bluetooth</string>
|
||||
<string name="mesh_service_notification_content">רשת Mesh פועלת — %1$d עמיתים</string>
|
||||
|
||||
<string name="verify_title">verify</string>
|
||||
<string name="verify_my_qr_title">scan to verify me</string>
|
||||
<string name="verify_scan_prompt_friend">scan someone elses qr</string>
|
||||
<string name="verify_scan_someone">scan someone elses qr</string>
|
||||
<string name="verify_show_my_qr">show my qr</string>
|
||||
<string name="verify_remove">remove verification</string>
|
||||
<string name="verify_qr_unavailable">qr unavailable</string>
|
||||
<string name="verify_camera_permission">camera permission is needed to scan qr codes</string>
|
||||
<string name="verify_request_camera">enable camera</string>
|
||||
<string name="verify_paste_label">paste verification url</string>
|
||||
<string name="verify_validate">validate</string>
|
||||
<string name="verify_scanned">verification requested</string>
|
||||
<string name="security_verification_title">security verification</string>
|
||||
<string name="fingerprint_their">their fingerprint</string>
|
||||
<string name="fingerprint_yours">your fingerprint</string>
|
||||
<string name="fingerprint_pending">handshake pending</string>
|
||||
<string name="fingerprint_no_peer">open a private chat to view fingerprints</string>
|
||||
<string name="fingerprint_status_verified">encrypted & verified</string>
|
||||
<string name="fingerprint_status_encrypted">encrypted</string>
|
||||
<string name="fingerprint_status_handshaking">handshaking</string>
|
||||
<string name="fingerprint_status_failed">handshake failed</string>
|
||||
<string name="fingerprint_status_uninitialized">not encrypted</string>
|
||||
<string name="fingerprint_verified_label">verified</string>
|
||||
<string name="fingerprint_verified_message">you have verified this persons identity.</string>
|
||||
<string name="fingerprint_not_verified_label">not verified</string>
|
||||
<string name="fingerprint_not_verified_message_fmt">compare these fingerprints with %1$s using a secure channel.</string>
|
||||
<string name="fingerprint_mark_verified">mark as verified</string>
|
||||
<string name="fingerprint_start_handshake">start handshake</string>
|
||||
<string name="fingerprint_copy">copy</string>
|
||||
<string name="verify_mutual_match_title">Mutual verification</string>
|
||||
<string name="verify_mutual_match_body">You and %1$s verified each other</string>
|
||||
<string name="verify_mutual_system_message">mutual verification with %1$s</string>
|
||||
<string name="verify_success_title">Verified</string>
|
||||
<string name="verify_success_body">You verified %1$s</string>
|
||||
<string name="verify_success_system_message">verified %1$s</string>
|
||||
<string name="cd_open_about">פתיחת אודות</string>
|
||||
<string name="verify_title">אימות</string>
|
||||
<string name="verify_my_qr_title">סרוק כדי לאמת אותי</string>
|
||||
<string name="verify_scan_prompt_friend">סרוק קוד QR של מישהו אחר</string>
|
||||
<string name="verify_scan_someone">סרוק קוד QR של מישהו אחר</string>
|
||||
<string name="verify_show_my_qr">הצג את קוד ה-QR שלי</string>
|
||||
<string name="verify_remove">הסר אימות</string>
|
||||
<string name="verify_qr_unavailable">קוד QR אינו זמין</string>
|
||||
<string name="verify_camera_permission">נדרשת הרשאת מצלמה כדי לסרוק קודי QR</string>
|
||||
<string name="verify_request_camera">אפשר מצלמה</string>
|
||||
<string name="verify_paste_label">הדבק כתובת אימות</string>
|
||||
<string name="verify_validate">אמת</string>
|
||||
<string name="verify_scanned">בקשת אימות נשלחה</string>
|
||||
<string name="security_verification_title">אימות אבטחה</string>
|
||||
<string name="fingerprint_their">טביעת האצבע שלהם</string>
|
||||
<string name="fingerprint_yours">טביעת האצבע שלך</string>
|
||||
<string name="fingerprint_pending">לחיצת יד ממתינה</string>
|
||||
<string name="fingerprint_no_peer">פתח שיחה פרטית כדי לצפות בטביעות אצבע</string>
|
||||
<string name="fingerprint_status_verified">מוצפן ומאומת</string>
|
||||
<string name="fingerprint_status_encrypted">מוצפן</string>
|
||||
<string name="fingerprint_status_handshaking">מבצע לחיצת יד</string>
|
||||
<string name="fingerprint_status_failed">לחיצת היד נכשלה</string>
|
||||
<string name="fingerprint_status_uninitialized">לא מוצפן</string>
|
||||
<string name="fingerprint_verified_label">מאומת</string>
|
||||
<string name="fingerprint_verified_message">אימתת את זהותו של אדם זה.</string>
|
||||
<string name="fingerprint_not_verified_label">לא מאומת</string>
|
||||
<string name="fingerprint_not_verified_message_fmt">השווה טביעות אצבע אלה עם %1$s באמצעות ערוץ מאובטח.</string>
|
||||
<string name="fingerprint_mark_verified">סמן כמאומת</string>
|
||||
<string name="fingerprint_start_handshake">התחל לחיצת יד</string>
|
||||
<string name="fingerprint_copy">העתק</string>
|
||||
<string name="verify_mutual_match_title">אימות הדדי</string>
|
||||
<string name="verify_mutual_match_body">אתה ו-%1$s אימתתם זה את זה</string>
|
||||
<string name="verify_mutual_system_message">אימות הדדי עם %1$s</string>
|
||||
<string name="verify_success_title">מאומת</string>
|
||||
<string name="verify_success_body">אימתת את %1$s</string>
|
||||
<string name="verify_success_system_message">אימת את %1$s</string>
|
||||
|
||||
<string name="app_name">bitchat</string>
|
||||
<string name="permission_bluetooth_rationale">נדרשת הרשאת Bluetooth להעברת הודעות בין עמיתים ללא אינטרנט.</string>
|
||||
<string name="permission_location_rationale">נדרשת הרשאת מיקום כדי לגלות מכשירים קרובים באמצעות Bluetooth.</string>
|
||||
<string name="permission_notification_rationale">נדרשת הרשאת התראות כדי להתריע לך על הודעות חדשות.</string>
|
||||
<string name="nickname_hint">כינוי</string>
|
||||
<string name="message_hint">הקלד הודעה…</string>
|
||||
<string name="channel_password_hint">סיסמה</string>
|
||||
<string name="join_channel">הצטרף לערוץ</string>
|
||||
<string name="leave_channel">עזוב</string>
|
||||
<string name="send_message">שלח</string>
|
||||
<string name="show_commands">הצג פקודות</string>
|
||||
<string name="back">חזרה</string>
|
||||
<string name="people">אנשים</string>
|
||||
<string name="channels">ערוצים</string>
|
||||
<string name="online_users">משתמשים מחוברים</string>
|
||||
<string name="no_one_connected">אף אחד לא מחובר</string>
|
||||
<string name="emergency_clear_hint">הקש שלוש פעמים כדי למחוק את כל הנתונים</string>
|
||||
<string name="your_network">רשת</string>
|
||||
<string name="battery_optimization_detected">זוהה ייעול צריכת סוללה</string>
|
||||
<string name="battery_optimization_disabled">ייעול צריכת סוללה מבוטל</string>
|
||||
<string name="battery_optimization_not_required">ייעול צריכת סוללה אינו נדרש</string>
|
||||
<string name="battery_optimization_checking">בודק ייעול צריכת סוללה</string>
|
||||
<string name="battery_optimization_why_disable">מדוע לבטל ייעול צריכת סוללה?</string>
|
||||
<string name="battery_optimization_explanation">bitchat פועל ברקע כדי לשמור על חיבורי רשת Mesh עם מכשירים קרובים. ייעול צריכת סוללה עלול לשבש חיבורים אלה, ולגרום לעיכוב או לאובדן הודעות.\n\nביטול ייעול צריכת הסוללה מבטיח העברת הודעות אמינה בין עמיתים.</string>
|
||||
<string name="battery_optimization_disable_button">בטל ייעול צריכת סוללה</string>
|
||||
<string name="battery_optimization_note">הערה: תוכל לשנות הגדרה זו מאוחר יותר בהגדרות אנדרואיד > אפליקציות > bitchat > סוללה</string>
|
||||
<string name="battery_optimization_not_supported_explanation">המכשיר שלך אינו דורש הגדרות ייעול צריכת סוללה. bitchat יפעל כרגיל.</string>
|
||||
<string name="battery_optimization_not_supported_message">המכשיר שלך אינו דורש הגדרות ייעול צריכת סוללה. bitchat יפעל כרגיל.</string>
|
||||
<string name="battery_optimization_success_message">bitchat יכול לפעול באופן אמין ברקע</string>
|
||||
<string name="battery_optimization_benefits">• מבטיח מסירת הודעות אמינה\n• שומר על קישוריות רשת Mesh\n• מאפשר העברת הודעות ברקע\n• מונע ניתוקי חיבור</string>
|
||||
<string name="battery_optimization_check_again">בדוק שוב</string>
|
||||
<string name="battery_optimization_skip">דלג לעת עתה</string>
|
||||
<string name="battery_optimization_continue">המשך</string>
|
||||
<string name="retry">נסה שוב</string>
|
||||
<string name="notification_summary_more">ועוד %1$d</string>
|
||||
<string name="notification_messages_from_people">%1$d הודעות מ-%2$d אנשים</string>
|
||||
<string name="notification_more_conversations">ועוד %1$d שיחות</string>
|
||||
<string name="notification_active_peers_title">👥 יש בצ׳אטרים בקרבת מקום!</string>
|
||||
<string name="notification_active_peers_one">אדם אחד בסביבה</string>
|
||||
<string name="notification_active_peers_many">%1$d אנשים בסביבה</string>
|
||||
<string name="notification_new_messages">הודעות חדשות</string>
|
||||
<string name="notification_new_location_messages">הודעות מיקום חדשות</string>
|
||||
<string name="notification_mentions_in">אוזכרת ב-%1$s</string>
|
||||
<string name="notification_mentions_in_more">אוזכרת ב-%1$s (+%2$d נוספים)</string>
|
||||
<string name="notification_mentions_in_plural">%1$d אזכורים ב-%2$s</string>
|
||||
<string name="notification_new_activity_in">פעילות חדשה ב-%1$s</string>
|
||||
<string name="notification_messages_in">הודעות ב-%1$s</string>
|
||||
<string name="notification_joined_conversation">%1$s הצטרף/ה לשיחה</string>
|
||||
<string name="notification_geohash_summary_title_mentions">bitchat - %1$d אזכורים</string>
|
||||
<string name="notification_geohash_summary_title">bitchat - צ׳אטים לפי מיקום</string>
|
||||
<string name="notification_geohash_summary_text">%1$d הודעות מ-%2$d מיקומים</string>
|
||||
<string name="notification_mesh_mention_title_singular">אוזכרת בצ׳אט Mesh</string>
|
||||
<string name="notification_mesh_mention_title_plural">%1$d אזכורים בצ׳אט Mesh</string>
|
||||
<string name="notification_more_locations">ועוד %1$d מיקומים</string>
|
||||
<plurals name="notification_and_more">
|
||||
<item quantity="one">ועוד %1$d</item>
|
||||
<item quantity="other">ועוד %1$d</item>
|
||||
</plurals>
|
||||
<string name="mesh_service_channel_name">שירות רקע של רשת Mesh</string>
|
||||
<string name="mesh_service_channel_desc">שומר על פעילות רשת ה-Mesh ברקע</string>
|
||||
<string name="cd_add_favorite">הוסף למועדפים</string>
|
||||
<string name="cd_remove_favorite">הסר מהמועדפים</string>
|
||||
<string name="cd_add_bookmark">הוסף סימנייה</string>
|
||||
<string name="cd_nostr_reachable">נגיש דרך Nostr</string>
|
||||
<string name="cd_unread_private_messages">הודעות פרטיות שלא נקראו</string>
|
||||
<string name="cd_location_notes">הערות מיקום</string>
|
||||
<string name="cd_teleported">טלפורט</string>
|
||||
<string name="cd_tor_status">סטטוס Tor</string>
|
||||
<string name="cd_open_location_channels">פתיחת הגדרות מיקום וערוצים</string>
|
||||
<string name="nearby_notes_reveal">בדיקה אם הושארו כאן פתקים</string>
|
||||
<string name="nearby_notes_one">פתק אחד הושאר כאן — הקש לקריאה</string>
|
||||
<string name="nearby_notes_many">%d פתקים הושארו כאן — הקש לקריאה</string>
|
||||
<string name="cd_connected_peers">עמיתים מחוברים</string>
|
||||
<string name="cd_geohash_participants">משתתפי geohash</string>
|
||||
<string name="cd_ready_for_handshake">מוכן ללחיצת יד</string>
|
||||
<string name="cd_handshake_in_progress">לחיצת יד בתהליך</string>
|
||||
<string name="cd_encrypted">מוצפן מקצה לקצה</string>
|
||||
<string name="cd_handshake_failed">לחיצת היד נכשלה</string>
|
||||
<string name="file_viewer_title">📎 התקבל קובץ</string>
|
||||
<string name="file_viewer_name">📄 %1$s</string>
|
||||
<string name="file_viewer_size">📏 גודל: %1$s</string>
|
||||
<string name="file_viewer_type">🏷️ סוג: %1$s</string>
|
||||
<string name="file_viewer_open_save">📂 פתח / שמור</string>
|
||||
<string name="close_with_emoji">❌ סגור</string>
|
||||
<string name="pick_image">בחר תמונה</string>
|
||||
<string name="cd_save_current_image">שמור את התמונה הנוכחית</string>
|
||||
<string name="cd_close">סגור</string>
|
||||
<string name="toast_image_saved">התמונה נשמרה בהורדות</string>
|
||||
<string name="toast_failed_to_save_image">שמירת התמונה נכשלה</string>
|
||||
<string name="cd_image_index_of">תמונה %1$d מתוך %2$d</string>
|
||||
<string name="cd_pick_file">בחר קובץ</string>
|
||||
<string name="about_tagline">העברת הודעות מבוזרת ברשת Mesh עם הצפנה מקצה לקצה</string>
|
||||
<string name="about_offline_mesh_title">צ׳אט Mesh לא מקוון</string>
|
||||
<string name="about_offline_mesh_desc">תקשר ישירות באמצעות Bluetooth LE ללא אינטרנט או שרתים. הודעות מועברות דרך מכשירים קרובים כדי להאריך את הטווח.</string>
|
||||
<string name="about_online_geohash_title">ערוצי geohash מקוונים</string>
|
||||
<string name="about_online_geohash_desc">התחבר לאנשים באזורך באמצעות ערוצים מבוססי geohash. הרחב את רשת ה-Mesh באמצעות ממסרי אינטרנט ציבוריים.</string>
|
||||
<string name="about_e2e_title">הצפנה מקצה לקצה</string>
|
||||
<string name="about_e2e_desc">הודעות פרטיות מוצפנות. הודעות בערוצים הן ציבוריות.</string>
|
||||
<string name="about_system">מערכת</string>
|
||||
<string name="about_light">בהיר</string>
|
||||
<string name="about_dark">כהה</string>
|
||||
<string name="about_pow">הוכחת עבודה</string>
|
||||
<string name="about_pow_off">הוכחת עבודה כבויה</string>
|
||||
<string name="about_pow_on">הוכחת עבודה דלוקה</string>
|
||||
<string name="about_pow_tip">הוסף הוכחת עבודה להודעות geohash כדי להרתיע ספאם.</string>
|
||||
<string name="about_pow_difficulty">קושי: %1$d ביטים (~%2$s)</string>
|
||||
<string name="about_pow_difficulty_attempts">קושי %1$d דורש כ-%2$s ניסיונות גיבוב</string>
|
||||
<string name="about_pow_desc_none">לא נדרשת הוכחת עבודה</string>
|
||||
<string name="about_pow_desc_very_low">נמוך מאוד - הגנה מינימלית מפני ספאם</string>
|
||||
<string name="about_pow_desc_low">נמוך - הגנה בסיסית מפני ספאם</string>
|
||||
<string name="about_pow_desc_medium">בינוני - הגנה טובה מפני ספאם</string>
|
||||
<string name="about_pow_desc_high">גבוה - הגנה חזקה מפני ספאם</string>
|
||||
<string name="about_pow_desc_very_high">גבוה מאוד - עלול לגרום לעיכובים</string>
|
||||
<string name="about_pow_desc_extreme">קיצוני - נדרש חישוב משמעותי</string>
|
||||
<string name="about_network">רשת</string>
|
||||
<string name="about_tor_off">Tor כבוי</string>
|
||||
<string name="about_tor_on">Tor דלוק</string>
|
||||
<string name="about_tor_route">נתב תעבורת אינטרנט דרך Tor לפרטיות משופרת.</string>
|
||||
<string name="about_tor_status">סטטוס Tor: %1$s, אתחול %2$d%%</string>
|
||||
<string name="about_last">אחרון: %1$s</string>
|
||||
<string name="about_emergency_title">מחיקת נתונים חירומית</string>
|
||||
<string name="about_debug_settings">הגדרות דיבוג</string>
|
||||
<string name="about_footer">קוד פתוח • פרטיות תחילה • מבוזר</string>
|
||||
<string name="close_plain">סגור</string>
|
||||
<string name="cd_privacy_protected">פרטיות מוגנת</string>
|
||||
<string name="cancel_lower">בטל</string>
|
||||
<string name="cd_warning">אזהרה</string>
|
||||
<string name="cd_location_services">שירותי מיקום</string>
|
||||
<string name="cd_privacy">פרטיות</string>
|
||||
<string name="cd_error">שגיאה</string>
|
||||
<string name="cd_battery_optimization">ייעול צריכת סוללה</string>
|
||||
<string name="cd_benefits">יתרונות</string>
|
||||
<string name="cd_checking_battery_optimization">בודק ייעול צריכת סוללה</string>
|
||||
<string name="cd_not_supported_battery_optimization">ייעול צריכת סוללה אינו נתמך</string>
|
||||
<string name="cd_bluetooth">Bluetooth</string>
|
||||
<string name="cd_unread_message">הודעה שלא נקראה</string>
|
||||
<string name="cd_open_map">פתח מפה</string>
|
||||
<string name="cd_remove_bookmark">הסר סימנייה</string>
|
||||
<string name="cd_teleport">טלפורט</string>
|
||||
<string name="notification_action_quit_bitchat">צא מ-bitchat</string>
|
||||
<string name="about_background_title">הפעל ברקע</string>
|
||||
<string name="about_background_desc">שמור על רשת ה-Mesh פעילה כאשר האפליקציה סגורה (שירות חזית)</string>
|
||||
<string name="cd_leave_channel">עזוב ערוץ</string>
|
||||
<string name="cd_reachable_via_nostr">נגיש דרך Nostr</string>
|
||||
<string name="cd_offline_favorite">מועדף לא מקוון</string>
|
||||
<string name="cd_decrease_precision">הפחת דיוק</string>
|
||||
<string name="cd_increase_precision">הגבר דיוק</string>
|
||||
<string name="cd_select_geohash">בחר geohash</string>
|
||||
<string name="cd_scroll_to_bottom">גלול לתחתית</string>
|
||||
<string name="cd_file">קובץ</string>
|
||||
<string name="cd_image">תמונה</string>
|
||||
<string name="cd_cancel">בטל</string>
|
||||
<string name="cd_link">קישור</string>
|
||||
<string name="cd_record_voice">הקלט הודעה קולית</string>
|
||||
<string name="cd_pick_media">בחר מדיה</string>
|
||||
<string name="cd_offline_mesh_chat">צ׳אט Mesh לא מקוון</string>
|
||||
<string name="cd_online_geohash_channels">ערוצי geohash מקוונים</string>
|
||||
<string name="cd_end_to_end_encryption">הצפנה מקצה לקצה</string>
|
||||
<string name="location_bluetooth_subtitle">#bluetooth • %1$s</string>
|
||||
<string name="image_page_of">תמונה %1$d מתוך %2$d</string>
|
||||
<string name="image_unavailable">התמונה אינה זמינה</string>
|
||||
<string name="image_saved_to_downloads">התמונה נשמרה בהורדות</string>
|
||||
<string name="image_save_failed">שמירת התמונה נכשלה</string>
|
||||
<string name="pick_file">בחר קובץ</string>
|
||||
<string name="file_unavailable">[הקובץ אינו זמין]</string>
|
||||
<string name="unknown">לא ידוע</string>
|
||||
<string name="choose_action_message_or_user">בחר פעולה עבור הודעה או משתמש זה</string>
|
||||
<string name="choose_action_user">בחר פעולה עבור משתמש זה</string>
|
||||
<string name="action_copy_message_title">העתק הודעה</string>
|
||||
<string name="action_copy_message_subtitle">העתק הודעה זו ללוח</string>
|
||||
<string name="action_slap_title">סטור ל-%1$s</string>
|
||||
<string name="action_slap_subtitle">שלח הודעת סטירה שובבה</string>
|
||||
<string name="action_hug_title">חבק את %1$s</string>
|
||||
<string name="action_hug_subtitle">שלח הודעת חיבוק ידידותית</string>
|
||||
<string name="action_block_title">חסום את %1$s</string>
|
||||
<string name="action_block_subtitle">חסום את כל ההודעות ממשתמש זה</string>
|
||||
<string name="action_private_message_title">הודעה ל-%1$s</string>
|
||||
<string name="action_private_message_subtitle">שלח הודעה פרטית</string>
|
||||
<string name="location_channels_title">#ערוצי מיקום</string>
|
||||
<string name="location_channels_desc">שוחח עם אנשים בקרבתך באמצעות ערוצי geohash. משותף רק geohash גס, לעולם לא מיקום GPS מדויק. אל תצלם מסך או תשתף מסך זה כדי להגן על פרטיותך.</string>
|
||||
<string name="grant_location_permission">הענק הרשאת מיקום</string>
|
||||
<string name="location_permission_denied">הרשאת המיקום נדחתה. אפשר בהגדרות כדי להשתמש בערוצי מיקום.</string>
|
||||
<string name="location_permission_granted">✓ הרשאת מיקום ניתנה</string>
|
||||
<string name="checking_permissions">בודק הרשאות...</string>
|
||||
<string name="finding_nearby_channels">מחפש ערוצים בקרבת מקום…</string>
|
||||
<string name="bookmarked">סומן</string>
|
||||
<string name="geohash_placeholder">geohash</string>
|
||||
<string name="invalid_geohash">geohash לא תקין</string>
|
||||
<string name="teleport">טלפורט</string>
|
||||
<string name="disable_location_services">בטל שירותי מיקום</string>
|
||||
<string name="enable_location_services">אפשר שירותי מיקום</string>
|
||||
<string name="mesh_label">mesh</string>
|
||||
<string name="location_level_block">רחוב</string>
|
||||
<string name="location_level_neighborhood">שכונה</string>
|
||||
<string name="location_level_city">עיר</string>
|
||||
<string name="location_level_province">מחוז</string>
|
||||
<string name="location_level_region">אזור</string>
|
||||
<string name="debug_tools">כלי דיבוג</string>
|
||||
<string name="debug_tools_desc">כלי מפתחים לאבחון ובקרה</string>
|
||||
<string name="debug_verbose_logging">רישום מפורט</string>
|
||||
<string name="debug_verbose_hint">רושם הצטרפות/עזיבת עמיתים, כיוון חיבור, ניתוב חבילות וממסרים</string>
|
||||
<string name="debug_bluetooth_roles">תפקידי Bluetooth</string>
|
||||
<string name="debug_gatt_server">שרת GATT</string>
|
||||
<string name="debug_connections_fmt">חיבורים: %1$d / %2$d</string>
|
||||
<string name="debug_max_server">מקסימום שרת</string>
|
||||
<string name="debug_gatt_client">לקוח GATT</string>
|
||||
<string name="debug_max_client">מקסימום לקוח</string>
|
||||
<string name="debug_overall_connections_fmt">חיבורים: %1$d / %2$d</string>
|
||||
<string name="debug_max_overall">מקסימום כולל</string>
|
||||
<string name="debug_packet_relay">ממסר חבילות</string>
|
||||
<string name="debug_since_start_fmt">מאז ההתחלה: %1$d</string>
|
||||
<string name="debug_roles_hint">הפעל/כבה תפקידים וסגור את כל החיבורים כשמושבת</string>
|
||||
<string name="debug_sync_settings">הגדרות סנכרון</string>
|
||||
<string name="debug_max_packets_per_sync_fmt">מקסימום חבילות לסנכרון: %1$d</string>
|
||||
<string name="debug_max_gcs_filter_size_fmt">גודל מסנן GCS מקסימלי: %1$d בייטים (128–1024)</string>
|
||||
<string name="debug_target_fpr_fmt">FPR יעד: %1$.2f%%</string>
|
||||
<string name="debug_connected_devices">מכשירים מחוברים</string>
|
||||
<string name="debug_our_device_id_fmt">מזהה המכשיר שלנו: %1$s</string>
|
||||
<string name="debug_none">ללא</string>
|
||||
<string name="debug_disconnect">נתק</string>
|
||||
<string name="debug_recent_scan_results">תוצאות סריקה אחרונות</string>
|
||||
<string name="debug_connect">התחבר</string>
|
||||
<string name="debug_debug_console">מסוף דיבוג</string>
|
||||
<string name="debug_clear">נקה</string>
|
||||
<string name="debug_relays_window_fmt">10 שנ׳ אחרונות: %1$d • 1 דקה: %2$d • 15 דקות: %3$d</string>
|
||||
<string name="debug_derived_p_fmt">P נגזר: %1$s • הערכת מקסימום אלמנטים: %2$s</string>
|
||||
<string name="debug_direct_suffix"> • ישיר</string>
|
||||
<string name="debug_rssi_fmt">RSSI: %1$s</string>
|
||||
<string name="debug_question_mark">?</string>
|
||||
<string name="debug_role_server">כשרת (אנחנו מארחים)</string>
|
||||
<string name="debug_role_client">כלקוח (אנחנו מתחברים)</string>
|
||||
<string name="location_services_required">נדרשים שירותי מיקום</string>
|
||||
<string name="privacy_first">פרטיות תחילה</string>
|
||||
<string name="location_explanation">bitchat לא עוקב אחר המיקום שלך.\n\nשירותי מיקום נדרשים לסריקת Bluetooth ולתכונת צ׳אט ה-Geohash.</string>
|
||||
<string name="location_needs_for">bitchat זקוק לשירותי מיקום עבור:</string>
|
||||
<string name="location_needs_bullets">• סריקת מכשירי Bluetooth\n• איתור משתמשים קרובים ברשת ה-Mesh\n• תכונת צ׳אט Geohash\n• ללא מעקב או איסוף מיקום</string>
|
||||
<string name="background_location_required_title">מומלץ מיקום ברקע</string>
|
||||
<string name="background_location_required_subtitle">אופציונלי, משפר את אמינות ה-Mesh</string>
|
||||
<string name="background_location_explanation">אנדרואיד ממליץ על מיקום ברקע כדי ש-bitchat יוכל לסרוק מכשירים קרובים כשהאפליקציה אינה פתוחה. זה שומר על פעילות ה-Mesh לאחר איתחול מחדש.</string>
|
||||
<string name="background_location_settings_tip">כשההגדרות נפתחות, בחר \"אפשר תמיד\".</string>
|
||||
<string name="background_location_needs_for">bitchat משתמש במיקום ברקע עבור:</string>
|
||||
<string name="background_location_needs_bullets">- סריקת מכשירים קרובים כשהאפליקציה סגורה\n- חיבור מחדש לאחר איתחול\n- שמירה על פעילות ה-Mesh ברקע</string>
|
||||
<string name="background_location_privacy_note">אנחנו לעולם לא אוספים או שומרים את המיקום שלך. הפרטיות שלך בטוחה.</string>
|
||||
<string name="grant_background_location">אפשר מיקום ברקע</string>
|
||||
<string name="skip_background_location">המשך ללא מיקום ברקע</string>
|
||||
<string name="open_location_settings">פתח הגדרות מיקום</string>
|
||||
<string name="check_again">בדוק שוב</string>
|
||||
<string name="location_services_unavailable">שירותי מיקום אינם זמינים</string>
|
||||
<string name="location_unavailable_explanation">שירותי מיקום אינם זמינים במכשיר זה. זה נדיר, מכיוון ששירותי מיקום הם סטנדרטיים במכשירי אנדרואיד.\n\nbitchat זקוק לשירותי מיקום כדי שסריקת Bluetooth תפעל כראוי (דרישת אנדרואיד). ללא זה, האפליקציה אינה יכולה לאתר משתמשים קרובים.</string>
|
||||
<string name="checking_location_services">בודק שירותי מיקום...</string>
|
||||
<string name="bluetooth_required">נדרש Bluetooth</string>
|
||||
<string name="bluetooth_needs_for">bitchat זקוק ל-Bluetooth כדי:</string>
|
||||
<string name="bluetooth_needs_bullets">• לאתר משתמשים קרובים\n• ליצור חיבורי רשת Mesh\n• לשלוח ולקבל הודעות\n• לפעול ללא אינטרנט או שרתים</string>
|
||||
<string name="enable_bluetooth">אפשר Bluetooth</string>
|
||||
<string name="bluetooth_not_supported">Bluetooth אינו נתמך</string>
|
||||
<string name="bluetooth_unsupported_explanation">מכשיר זה אינו תומך ב-Bluetooth Low Energy (BLE), הנדרש כדי ש-bitchat יפעל.\n\nbitchat זקוק ל-BLE כדי ליצור רשתות Mesh ולתקשר עם מכשירים קרובים ללא אינטרנט.</string>
|
||||
<string name="checking_bluetooth_status">בודק סטטוס Bluetooth...</string>
|
||||
<string name="battery_optimization_detected_title">זוהה ייעול צריכת סוללה</string>
|
||||
<string name="battery_optimization_enabled_title">ייעול צריכת סוללה מופעל</string>
|
||||
<string name="battery_optimization_explanation_short">bitchat צריך לפעול ברקע כדי לשמור על חיבורי Mesh. ייעול צריכת סוללה עלול לשבש חיבורים אלה.</string>
|
||||
<string name="benefits_of_disabling">יתרונות הביטול</string>
|
||||
<string name="battery_benefits_short">• מסירת הודעות אמינה\n• שמירה על קישוריות Mesh\n• מניעת ניתוקי חיבור</string>
|
||||
<string name="disable_battery_optimization">בטל ייעול צריכת סוללה</string>
|
||||
<string name="battery_optimization_disabled_title">ייעול צריכת סוללה בוטל</string>
|
||||
<string name="continue_btn">המשך</string>
|
||||
<string name="initializing_mesh_network">מאתחל רשת Mesh</string>
|
||||
<string name="dot">.</string>
|
||||
<string name="setting_up_bluetooth">מגדיר רשת Mesh דרך Bluetooth...</string>
|
||||
<string name="should_take_seconds">זה אמור לקחת רק כמה שניות</string>
|
||||
<string name="warning_emoji">⚠️</string>
|
||||
<string name="setup_not_complete">ההגדרה לא הושלמה</string>
|
||||
<string name="try_again">נסה שוב</string>
|
||||
<string name="open_settings">פתח הגדרות</string>
|
||||
<string name="privacy_protected">הפרטיות שלך מוגנת</string>
|
||||
<string name="privacy_bullets">• ללא מעקב או איסוף נתונים\n• צ׳אטים ברשת Mesh דרך Bluetooth הם לחלוטין לא מקוונים\n• צ׳אטים לפי מיקום (Geohash) משתמשים באינטרנט</string>
|
||||
<string name="permissions_header">הרשאות</string>
|
||||
<string name="grant_permissions">הענק הרשאות</string>
|
||||
<string name="location_tracking_warning">bitchat לא עוקב אחר המיקום שלך</string>
|
||||
<string name="at_symbol">@</string>
|
||||
<string name="channel_count_prefix"> · ⧉ </string>
|
||||
<string name="nobody_around">אין אף אחד בסביבה...</string>
|
||||
<string name="you_suffix"> (אתה)</string>
|
||||
<string name="pan_zoom_instruction">הזז ושנה תצוגה כדי לבחור geohash</string>
|
||||
<string name="select">בחר</string>
|
||||
<string name="type_a_message_placeholder">הקלד הודעה...</string>
|
||||
<string name="mention_suggestion_at">@%1$s</string>
|
||||
<string name="mention">אזכור</string>
|
||||
<string name="image_counter">%1$d / %2$d</string>
|
||||
<string name="at_nickname">@%1$s</string>
|
||||
<string name="version_prefix">v%1$s</string>
|
||||
<string name="hash_symbol">#</string>
|
||||
<string name="underscore">_</string>
|
||||
<string name="progress_bar_brackets">[%1$s] %2$d%%</string>
|
||||
<string name="progress_filled">█</string>
|
||||
<string name="progress_empty">░</string>
|
||||
<string name="image_star">image/*</string>
|
||||
<string name="media_type_image">תמונה</string>
|
||||
<string name="media_type_file">קובץ</string>
|
||||
<string name="status_sending">○</string>
|
||||
<string name="status_pending">○</string>
|
||||
<string name="status_sent">✓</string>
|
||||
<string name="status_delivered">✓✓</string>
|
||||
<string name="status_failed">⚠</string>
|
||||
<string name="status_read">✓</string>
|
||||
<string name="notification_sent_image">📷 שלח/ה תמונה</string>
|
||||
<string name="notification_sent_voice">🎤 שלח/ה הודעה קולית</string>
|
||||
<string name="notification_sent_file">📎 שלח/ה קובץ</string>
|
||||
<string name="notification_file_pdf">📄</string>
|
||||
<string name="notification_file_zip">🗜️</string>
|
||||
<string name="notification_file_doc">📄</string>
|
||||
<string name="notification_file_xls">📊</string>
|
||||
<string name="notification_file_ppt">📈</string>
|
||||
<string name="notification_file_generic">📎</string>
|
||||
<string name="cd_play_voice">נגן</string>
|
||||
<string name="cd_pause_voice">השהה</string>
|
||||
<string name="perm_nearby_devices_desc">נדרש כדי לאתר משתמשי bitchat באמצעות Bluetooth</string>
|
||||
<string name="perm_nearby_devices_system">אפשר ל-bitchat להתחבר למכשירים קרובים</string>
|
||||
<string name="perm_location_desc">נדרש על ידי אנדרואיד כדי לאתר משתמשי bitchat קרובים באמצעות Bluetooth</string>
|
||||
<string name="perm_location_system">bitchat זקוק לזה כדי לסרוק מכשירים קרובים</string>
|
||||
<string name="perm_background_location_desc">מומלץ כדי לסרוק מכשירים קרובים כשהאפליקציה ברקע</string>
|
||||
<string name="perm_background_location_system">אפשר גישה למיקום ברקע</string>
|
||||
<string name="perm_notifications_desc">קבל התראות כשאתה מקבל הודעות פרטיות</string>
|
||||
<string name="perm_notifications_system">אפשר ל-bitchat לשלוח לך התראות</string>
|
||||
<string name="perm_battery_desc">בטל ייעול צריכת סוללה כדי להבטיח ש-bitchat יפעל באופן אמין ברקע וישמור על חיבורי רשת Mesh</string>
|
||||
<string name="perm_battery_system">אפשר ל-bitchat לפעול ללא הגבלות סוללה</string>
|
||||
<string name="perm_type_nearby_devices">מכשירים קרובים</string>
|
||||
<string name="perm_type_precise_location">מיקום מדויק</string>
|
||||
<string name="perm_type_background_location">מיקום ברקע</string>
|
||||
<string name="perm_type_microphone">מיקרופון</string>
|
||||
<string name="perm_type_notifications">התראות</string>
|
||||
<string name="perm_type_battery_optimization">ייעול צריכת סוללה</string>
|
||||
<string name="perm_type_other">אחר</string>
|
||||
<string name="pwd_prompt_title">הזן סיסמת ערוץ</string>
|
||||
<string name="pwd_prompt_message">הערוץ %1$s מוגן בסיסמה. הזן את הסיסמה כדי להצטרף.</string>
|
||||
<string name="pwd_label">סיסמה</string>
|
||||
<string name="join">הצטרף</string>
|
||||
<string name="cancel">בטל</string>
|
||||
<string name="tor_not_available_in_this_build">Tor אינו זמין בגרסה זו</string>
|
||||
|
||||
<plurals name="people_count">
|
||||
<item quantity="one">איש אחד</item>
|
||||
<item quantity="other">%1$d אנשים</item>
|
||||
</plurals>
|
||||
<string name="about_language">שפה</string>
|
||||
<string name="about_app_language">שפת האפליקציה</string>
|
||||
<string name="about_system_default">ברירת המחדל של המערכת</string>
|
||||
<string name="about_select_language">בחירת שפה</string>
|
||||
</resources>
|
||||
|
||||
@ -46,7 +46,7 @@
|
||||
<string name="notification_summary_more">और %1$d</string>
|
||||
<string name="notification_messages_from_people">%2$d लोगों से %1$d संदेश</string>
|
||||
<string name="notification_more_conversations">और %1$d वार्तालाप</string>
|
||||
<string name="notification_active_peers_title">👥 पास में उपयोगकर्ता!</string>
|
||||
<string name="notification_active_peers_title">पास में उपयोगकर्ता!</string>
|
||||
<string name="notification_active_peers_one">पास में 1 व्यक्ति</string>
|
||||
<string name="notification_active_peers_many">पास में %1$d लोग</string>
|
||||
<string name="notification_new_messages">नए संदेश</string>
|
||||
@ -390,4 +390,8 @@
|
||||
<string name="nearby_notes_reveal">देखें कि यहाँ नोट छोड़े गए हैं या नहीं</string>
|
||||
<string name="nearby_notes_one">यहाँ 1 नोट छोड़ा गया है — पढ़ने के लिए टैप करें</string>
|
||||
<string name="nearby_notes_many">यहाँ %d नोट छोड़े गए हैं — पढ़ने के लिए टैप करें</string>
|
||||
<string name="about_language">भाषा</string>
|
||||
<string name="about_app_language">ऐप की भाषा</string>
|
||||
<string name="about_system_default">सिस्टम डिफ़ॉल्ट</string>
|
||||
<string name="about_select_language">भाषा चुनें</string>
|
||||
</resources>
|
||||
|
||||
@ -46,7 +46,7 @@
|
||||
<string name="notification_summary_more">dan %1$d lagi</string>
|
||||
<string name="notification_messages_from_people">%1$d pesan dari %2$d orang</string>
|
||||
<string name="notification_more_conversations">dan %1$d percakapan lagi</string>
|
||||
<string name="notification_active_peers_title">👥 bitchatter terdekat!</string>
|
||||
<string name="notification_active_peers_title">bitchatter terdekat!</string>
|
||||
<string name="notification_active_peers_one">1 orang terdekat</string>
|
||||
<string name="notification_active_peers_many">%1$d orang terdekat</string>
|
||||
<string name="notification_new_messages">Pesan baru</string>
|
||||
@ -390,4 +390,8 @@
|
||||
<string name="nearby_notes_reveal">periksa catatan yang ditinggalkan di sini</string>
|
||||
<string name="nearby_notes_one">1 catatan ditinggalkan di sini — ketuk untuk membaca</string>
|
||||
<string name="nearby_notes_many">%d catatan ditinggalkan di sini — ketuk untuk membaca</string>
|
||||
<string name="about_language">Bahasa</string>
|
||||
<string name="about_app_language">Bahasa aplikasi</string>
|
||||
<string name="about_system_default">Default sistem</string>
|
||||
<string name="about_select_language">Pilih bahasa</string>
|
||||
</resources>
|
||||
|
||||
@ -46,7 +46,7 @@
|
||||
<string name="notification_summary_more">e altri %1$d</string>
|
||||
<string name="notification_messages_from_people">%1$d messaggi da %2$d persone</string>
|
||||
<string name="notification_more_conversations">e altre %1$d conversazioni</string>
|
||||
<string name="notification_active_peers_title">👥 bitchatter nelle vicinanze!</string>
|
||||
<string name="notification_active_peers_title">bitchatter nelle vicinanze!</string>
|
||||
<string name="notification_active_peers_one">1 persona nei dintorni</string>
|
||||
<string name="notification_active_peers_many">%1$d persone nei dintorni</string>
|
||||
<string name="notification_new_messages">Nuovi messaggi</string>
|
||||
@ -423,4 +423,8 @@
|
||||
<string name="nearby_notes_reveal">controlla se ci sono note lasciate qui</string>
|
||||
<string name="nearby_notes_one">1 nota lasciata qui — tocca per leggere</string>
|
||||
<string name="nearby_notes_many">%d note lasciate qui — tocca per leggere</string>
|
||||
<string name="about_language">Lingua</string>
|
||||
<string name="about_app_language">Lingua dell’app</string>
|
||||
<string name="about_system_default">Predefinita di sistema</string>
|
||||
<string name="about_select_language">Seleziona lingua</string>
|
||||
</resources>
|
||||
|
||||
@ -46,7 +46,7 @@
|
||||
<string name="notification_summary_more">ほか %1$d 件</string>
|
||||
<string name="notification_messages_from_people">%2$d 人からの %1$d 件のメッセージ</string>
|
||||
<string name="notification_more_conversations">ほか %1$d 会話</string>
|
||||
<string name="notification_active_peers_title">👥 近くに bitchatter !</string>
|
||||
<string name="notification_active_peers_title">近くに bitchatter !</string>
|
||||
<string name="notification_active_peers_one">近くに 1 人</string>
|
||||
<string name="notification_active_peers_many">近くに %1$d 人</string>
|
||||
<string name="notification_new_messages">新着メッセージ</string>
|
||||
@ -390,4 +390,8 @@
|
||||
<string name="nearby_notes_reveal">ここに残されたメモを確認</string>
|
||||
<string name="nearby_notes_one">ここに1件のメモがあります — タップして読む</string>
|
||||
<string name="nearby_notes_many">ここに%d件のメモがあります — タップして読む</string>
|
||||
<string name="about_language">言語</string>
|
||||
<string name="about_app_language">アプリの言語</string>
|
||||
<string name="about_system_default">システムのデフォルト</string>
|
||||
<string name="about_select_language">言語を選択</string>
|
||||
</resources>
|
||||
|
||||
@ -46,7 +46,7 @@
|
||||
<string name="notification_summary_more">და კიდევ %1$d</string>
|
||||
<string name="notification_messages_from_people">%2$d ადამიანიდან %1$d შეტყობინება</string>
|
||||
<string name="notification_more_conversations">და კიდევ %1$d საუბარი</string>
|
||||
<string name="notification_active_peers_title">👥 bitchatter-ები ახლოს!</string>
|
||||
<string name="notification_active_peers_title">bitchatter-ები ახლოს!</string>
|
||||
<string name="notification_active_peers_one">1 ადამიანი ახლოს</string>
|
||||
<string name="notification_active_peers_many">ახლოს %1$d ადამიანი</string>
|
||||
<string name="notification_new_messages">ახალი შეტყობინებები</string>
|
||||
@ -377,4 +377,8 @@
|
||||
<string name="nearby_notes_reveal">აქ დატოვებული ჩანაწერების შემოწმება</string>
|
||||
<string name="nearby_notes_one">აქ 1 ჩანაწერია დატოვებული — წასაკითხად შეეხეთ</string>
|
||||
<string name="nearby_notes_many">აქ %d ჩანაწერია დატოვებული — წასაკითხად შეეხეთ</string>
|
||||
<string name="about_language">ენა</string>
|
||||
<string name="about_app_language">აპის ენა</string>
|
||||
<string name="about_system_default">სისტემის ნაგულისხმევი</string>
|
||||
<string name="about_select_language">ენის არჩევა</string>
|
||||
</resources>
|
||||
|
||||
@ -46,7 +46,7 @@
|
||||
<string name="notification_summary_more">그리고 %1$d개 더</string>
|
||||
<string name="notification_messages_from_people">%2$d명에게서 %1$d개의 메시지</string>
|
||||
<string name="notification_more_conversations">그리고 %1$d개의 대화 더</string>
|
||||
<string name="notification_active_peers_title">👥 근처 bitchatter!</string>
|
||||
<string name="notification_active_peers_title">근처 bitchatter!</string>
|
||||
<string name="notification_active_peers_one">근처 1명</string>
|
||||
<string name="notification_active_peers_many">근처 %1$d명</string>
|
||||
<string name="notification_new_messages">새 메시지</string>
|
||||
@ -390,4 +390,8 @@
|
||||
<string name="nearby_notes_reveal">여기 남겨진 쪽지 확인</string>
|
||||
<string name="nearby_notes_one">여기 남겨진 쪽지 1개 — 탭하여 읽기</string>
|
||||
<string name="nearby_notes_many">여기 남겨진 쪽지 %d개 — 탭하여 읽기</string>
|
||||
<string name="about_language">언어</string>
|
||||
<string name="about_app_language">앱 언어</string>
|
||||
<string name="about_system_default">시스템 기본값</string>
|
||||
<string name="about_select_language">언어 선택</string>
|
||||
</resources>
|
||||
|
||||
@ -46,7 +46,7 @@
|
||||
<string name="notification_summary_more">ary %1$d hafa</string>
|
||||
<string name="notification_messages_from_people">%1$d hafatra avy amin\'ny olona %2$d</string>
|
||||
<string name="notification_more_conversations">ary %1$d resaka hafa</string>
|
||||
<string name="notification_active_peers_title">👥 bitchatter akaiky!</string>
|
||||
<string name="notification_active_peers_title">bitchatter akaiky!</string>
|
||||
<string name="notification_active_peers_one">Olona 1 manodidina</string>
|
||||
<string name="notification_active_peers_many">Olona %1$d manodidina</string>
|
||||
<string name="notification_new_messages">Hafatra vaovao</string>
|
||||
@ -403,4 +403,8 @@
|
||||
<string name="nearby_notes_reveal">hizaha raha misy naoty navela teto</string>
|
||||
<string name="nearby_notes_one">naoty 1 no navela teto — tsindrio raha hamaky</string>
|
||||
<string name="nearby_notes_many">naoty %d no navela teto — tsindrio raha hamaky</string>
|
||||
<string name="about_language">Fiteny</string>
|
||||
<string name="about_app_language">Fitenin’ny app</string>
|
||||
<string name="about_system_default">Fitenin’ny rafitra</string>
|
||||
<string name="about_select_language">Safidio ny fiteny</string>
|
||||
</resources>
|
||||
|
||||
@ -1,48 +1,443 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- TODO: Malay translations -->
|
||||
<string name="skip">Skip</string>
|
||||
<string name="bluetooth_recommended">Bluetooth Recommended</string>
|
||||
<!-- Machine-translated (Claude) — pending native-speaker review -->
|
||||
<string name="app_name">bitchat</string>
|
||||
<string name="permission_bluetooth_rationale">Kebenaran Bluetooth diperlukan untuk pemesejan rakan-ke-rakan tanpa internet.</string>
|
||||
<string name="permission_location_rationale">Kebenaran lokasi diperlukan untuk mengesan peranti berdekatan melalui Bluetooth.</string>
|
||||
<string name="permission_notification_rationale">Kebenaran pemberitahuan diperlukan untuk memaklumkan anda tentang mesej baharu.</string>
|
||||
<string name="nickname_hint">nama samaran</string>
|
||||
<string name="message_hint">taip mesej…</string>
|
||||
<string name="channel_password_hint">Kata Laluan</string>
|
||||
<string name="join_channel">Sertai Saluran</string>
|
||||
<string name="leave_channel">Keluar</string>
|
||||
<string name="send_message">Hantar</string>
|
||||
<string name="show_commands">Tunjukkan arahan</string>
|
||||
<string name="back">Kembali</string>
|
||||
<string name="people">Orang</string>
|
||||
<string name="channels">Saluran</string>
|
||||
<string name="online_users">Pengguna Dalam Talian</string>
|
||||
<string name="no_one_connected">Tiada sesiapa yang disambungkan</string>
|
||||
<string name="emergency_clear_hint">Ketik tiga kali untuk memadam semua data</string>
|
||||
<string name="your_network">Rangkaian</string>
|
||||
|
||||
<!-- Battery Optimization Strings -->
|
||||
<string name="battery_optimization_detected">Pengoptimuman Bateri Dikesan</string>
|
||||
<string name="battery_optimization_disabled">Pengoptimuman Bateri Dilumpuhkan</string>
|
||||
<string name="battery_optimization_not_required">Pengoptimuman Bateri Tidak Diperlukan</string>
|
||||
<string name="battery_optimization_checking">Menyemak Pengoptimuman Bateri</string>
|
||||
<string name="battery_optimization_why_disable">Kenapa lumpuhkan pengoptimuman bateri?</string>
|
||||
<string name="battery_optimization_explanation">bitchat berjalan di latar belakang untuk mengekalkan sambungan rangkaian mesh dengan peranti berdekatan. Pengoptimuman bateri boleh mengganggu sambungan ini, menyebabkan mesej lewat atau terlepas.\n\nMelumpuhkan pengoptimuman bateri memastikan pemesejan rakan-ke-rakan yang boleh dipercayai.</string>
|
||||
<string name="battery_optimization_disable_button">Lumpuhkan Pengoptimuman Bateri</string>
|
||||
<string name="battery_optimization_note">Nota: Anda boleh menukar tetapan ini kemudian dalam Tetapan Android > Apl > bitchat > Bateri</string>
|
||||
<string name="battery_optimization_not_supported_explanation">Peranti anda tidak memerlukan tetapan pengoptimuman bateri. bitchat akan berjalan seperti biasa.</string>
|
||||
<string name="battery_optimization_not_supported_message">Peranti anda tidak memerlukan tetapan pengoptimuman bateri. bitchat akan berjalan seperti biasa.</string>
|
||||
<string name="battery_optimization_success_message">bitchat boleh berjalan dengan boleh dipercayai di latar belakang</string>
|
||||
<string name="battery_optimization_benefits">• Memastikan penghantaran mesej yang boleh dipercayai\n• Mengekalkan ketersambungan rangkaian mesh\n• Membenarkan geganti mesej latar belakang\n• Mengelakkan sambungan terputus</string>
|
||||
<string name="battery_optimization_check_again">Semak Semula</string>
|
||||
<string name="battery_optimization_skip">Langkau Buat Masa Ini</string>
|
||||
<string name="battery_optimization_continue">Teruskan</string>
|
||||
<string name="retry">Cuba Lagi</string>
|
||||
<string name="skip">Skip</string>
|
||||
|
||||
<!-- Notifications -->
|
||||
<string name="notification_summary_more">dan %1$d lagi</string>
|
||||
<string name="notification_messages_from_people">%1$d mesej daripada %2$d orang</string>
|
||||
<string name="notification_more_conversations">dan %1$d perbualan lagi</string>
|
||||
<string name="notification_active_peers_title">👥 bitchatter berdekatan!</string>
|
||||
<string name="notification_active_peers_one">1 orang berdekatan</string>
|
||||
<string name="notification_active_peers_many">%1$d orang berdekatan</string>
|
||||
<string name="notification_new_messages">Mesej Baharu</string>
|
||||
<string name="notification_new_location_messages">Mesej Lokasi Baharu</string>
|
||||
<string name="notification_mentions_in">Disebut dalam %1$s</string>
|
||||
<string name="notification_mentions_in_more">Disebut dalam %1$s (+%2$d lagi)</string>
|
||||
<string name="notification_mentions_in_plural">%1$d sebutan dalam %2$s</string>
|
||||
<string name="notification_new_activity_in">Aktiviti baharu dalam %1$s</string>
|
||||
<string name="notification_messages_in">Mesej dalam %1$s</string>
|
||||
<string name="notification_joined_conversation">%1$s telah menyertai perbualan</string>
|
||||
<string name="notification_geohash_summary_title_mentions">bitchat - %1$d sebutan</string>
|
||||
<string name="notification_geohash_summary_title">bitchat - sembang lokasi</string>
|
||||
<string name="notification_geohash_summary_text">%1$d mesej daripada %2$d lokasi</string>
|
||||
<string name="notification_mesh_mention_title_singular">Disebut dalam Sembang Mesh</string>
|
||||
<string name="notification_mesh_mention_title_plural">%1$d sebutan dalam Sembang Mesh</string>
|
||||
<string name="notification_more_locations">dan %1$d lokasi lagi</string>
|
||||
|
||||
<!-- Mesh Service Foreground Notification -->
|
||||
<string name="mesh_service_channel_name">Perkhidmatan Latar Belakang Mesh</string>
|
||||
<string name="mesh_service_channel_desc">Mengekalkan mesh Bluetooth berjalan di latar belakang</string>
|
||||
<string name="mesh_service_notification_content">Mesh sedang berjalan — %1$d rakan</string>
|
||||
|
||||
<string name="verify_title">verify</string>
|
||||
<string name="verify_my_qr_title">scan to verify me</string>
|
||||
<string name="verify_scan_prompt_friend">scan someone elses qr</string>
|
||||
<string name="verify_scan_someone">scan someone elses qr</string>
|
||||
<string name="verify_show_my_qr">show my qr</string>
|
||||
<string name="verify_remove">remove verification</string>
|
||||
<string name="verify_qr_unavailable">qr unavailable</string>
|
||||
<string name="verify_camera_permission">camera permission is needed to scan qr codes</string>
|
||||
<string name="verify_request_camera">enable camera</string>
|
||||
<string name="verify_paste_label">paste verification url</string>
|
||||
<string name="verify_validate">validate</string>
|
||||
<string name="verify_scanned">verification requested</string>
|
||||
<string name="security_verification_title">security verification</string>
|
||||
<string name="fingerprint_their">their fingerprint</string>
|
||||
<string name="fingerprint_yours">your fingerprint</string>
|
||||
<string name="fingerprint_pending">handshake pending</string>
|
||||
<string name="fingerprint_no_peer">open a private chat to view fingerprints</string>
|
||||
<string name="fingerprint_status_verified">encrypted & verified</string>
|
||||
<string name="fingerprint_status_encrypted">encrypted</string>
|
||||
<string name="fingerprint_status_handshaking">handshaking</string>
|
||||
<string name="fingerprint_status_failed">handshake failed</string>
|
||||
<string name="fingerprint_status_uninitialized">not encrypted</string>
|
||||
<string name="fingerprint_verified_label">verified</string>
|
||||
<string name="fingerprint_verified_message">you have verified this persons identity.</string>
|
||||
<string name="fingerprint_not_verified_label">not verified</string>
|
||||
<string name="fingerprint_not_verified_message_fmt">compare these fingerprints with %1$s using a secure channel.</string>
|
||||
<string name="fingerprint_mark_verified">mark as verified</string>
|
||||
<string name="fingerprint_start_handshake">start handshake</string>
|
||||
<string name="fingerprint_copy">copy</string>
|
||||
<string name="verify_mutual_match_title">Mutual verification</string>
|
||||
<string name="verify_mutual_match_body">You and %1$s verified each other</string>
|
||||
<string name="verify_mutual_system_message">mutual verification with %1$s</string>
|
||||
<string name="verify_success_title">Verified</string>
|
||||
<string name="verify_success_body">You verified %1$s</string>
|
||||
<string name="verify_success_system_message">verified %1$s</string>
|
||||
<string name="cd_open_about">Buka Perihal</string>
|
||||
<!-- Favorites accessibility -->
|
||||
<string name="cd_add_favorite">Tambah ke kegemaran</string>
|
||||
<string name="cd_remove_favorite">Alih keluar daripada kegemaran</string>
|
||||
<string name="cd_add_bookmark">Tambah penanda buku</string>
|
||||
|
||||
<!-- Chat header & accessibility -->
|
||||
<string name="cd_nostr_reachable">Boleh dihubungi melalui Nostr</string>
|
||||
<string name="cd_unread_private_messages">Mesej peribadi belum dibaca</string>
|
||||
<string name="cd_location_notes">Nota lokasi</string>
|
||||
<string name="cd_teleported">Diteleport</string>
|
||||
<string name="cd_tor_status">Status Tor</string>
|
||||
<string name="cd_open_location_channels">Buka tetapan lokasi dan saluran</string>
|
||||
<string name="nearby_notes_reveal">semak nota yang ditinggalkan di sini</string>
|
||||
<string name="nearby_notes_one">1 nota ditinggalkan di sini — ketik untuk baca</string>
|
||||
<string name="nearby_notes_many">%d nota ditinggalkan di sini — ketik untuk baca</string>
|
||||
<string name="cd_connected_peers">Rakan disambungkan</string>
|
||||
<string name="cd_geohash_participants">Peserta geohash</string>
|
||||
<string name="cd_ready_for_handshake">Sedia untuk berjabat tangan</string>
|
||||
<string name="cd_handshake_in_progress">Jabat tangan sedang berlangsung</string>
|
||||
<string name="cd_encrypted">Disulitkan hujung-ke-hujung</string>
|
||||
<string name="cd_handshake_failed">Jabat tangan gagal</string>
|
||||
|
||||
<!-- File viewer dialog -->
|
||||
<string name="file_viewer_title">📎 Fail Diterima</string>
|
||||
<string name="file_viewer_name">📄 %1$s</string>
|
||||
<string name="file_viewer_size">📏 Saiz: %1$s</string>
|
||||
<string name="file_viewer_type">🏷️ Jenis: %1$s</string>
|
||||
<string name="file_viewer_open_save">📂 Buka / Simpan</string>
|
||||
<string name="close_with_emoji">❌ Tutup</string>
|
||||
<string name="pick_image">Pilih imej</string>
|
||||
<string name="cd_save_current_image">Simpan imej semasa</string>
|
||||
<string name="cd_close">Tutup</string>
|
||||
<string name="toast_image_saved">Imej disimpan ke Muat Turun</string>
|
||||
<string name="toast_failed_to_save_image">Gagal menyimpan imej</string>
|
||||
<string name="cd_image_index_of">Imej %1$d daripada %2$d</string>
|
||||
<string name="cd_pick_file">Pilih fail</string>
|
||||
|
||||
<!-- About sheet -->
|
||||
<string name="about_tagline">pemesejan mesh terdesentralisasi dengan penyulitan hujung-ke-hujung</string>
|
||||
<string name="about_offline_mesh_title">Sembang Mesh Luar Talian</string>
|
||||
<string name="about_offline_mesh_desc">Berkomunikasi terus melalui Bluetooth LE tanpa internet atau pelayan. Mesej digeganti melalui peranti berdekatan untuk melanjutkan jarak.</string>
|
||||
<string name="about_online_geohash_title">Saluran Geohash Dalam Talian</string>
|
||||
<string name="about_online_geohash_desc">Berhubung dengan orang di kawasan anda menggunakan saluran berasaskan geohash. Melanjutkan mesh menggunakan geganti internet awam.</string>
|
||||
<string name="about_e2e_title">Penyulitan Hujung-ke-Hujung</string>
|
||||
<string name="about_e2e_desc">Mesej peribadi disulitkan. Mesej saluran adalah awam.</string>
|
||||
<string name="about_system">sistem</string>
|
||||
<string name="about_light">cerah</string>
|
||||
<string name="about_dark">gelap</string>
|
||||
<string name="about_pow">bukti kerja</string>
|
||||
<string name="about_pow_off">pow dimatikan</string>
|
||||
<string name="about_pow_on">pow dihidupkan</string>
|
||||
<string name="about_pow_tip">tambah bukti kerja pada mesej geohash untuk mengelakkan spam.</string>
|
||||
<string name="about_pow_difficulty">tahap kesukaran: %1$d bit (~%2$s)</string>
|
||||
<string name="about_pow_difficulty_attempts">kesukaran %1$d memerlukan ~%2$s percubaan hash</string>
|
||||
<string name="about_pow_desc_none">tiada bukti kerja diperlukan</string>
|
||||
<string name="about_pow_desc_very_low">sangat rendah - perlindungan spam minimum</string>
|
||||
<string name="about_pow_desc_low">rendah - perlindungan spam asas</string>
|
||||
<string name="about_pow_desc_medium">sederhana - perlindungan spam yang baik</string>
|
||||
<string name="about_pow_desc_high">tinggi - perlindungan spam yang kuat</string>
|
||||
<string name="about_pow_desc_very_high">sangat tinggi - mungkin menyebabkan kelewatan</string>
|
||||
<string name="about_pow_desc_extreme">melampau - pengiraan besar diperlukan</string>
|
||||
<string name="about_network">rangkaian</string>
|
||||
<string name="about_tor_off">tor dimatikan</string>
|
||||
<string name="about_tor_on">tor dihidupkan</string>
|
||||
<string name="about_tor_route">laluan internet melalui tor untuk privasi yang lebih baik.</string>
|
||||
<string name="about_tor_status">Status tor: %1$s, but-mula %2$d%%</string>
|
||||
<string name="about_last">Terakhir: %1$s</string>
|
||||
<string name="about_emergency_title">Pemadaman Data Kecemasan</string>
|
||||
<string name="about_debug_settings">Tetapan Nyahpepijat</string>
|
||||
<string name="about_footer">Sumber Terbuka • Privasi Diutamakan • Terdesentralisasi</string>
|
||||
<string name="close_plain">Tutup</string>
|
||||
<string name="cd_privacy_protected">Privasi Dilindungi</string>
|
||||
<string name="cancel_lower">batal</string>
|
||||
|
||||
<!-- Generic content descriptions -->
|
||||
<string name="cd_warning">Amaran</string>
|
||||
<string name="cd_location_services">Perkhidmatan Lokasi</string>
|
||||
<string name="cd_privacy">Privasi</string>
|
||||
<string name="cd_error">Ralat</string>
|
||||
<string name="cd_battery_optimization">Pengoptimuman Bateri</string>
|
||||
<string name="cd_benefits">Faedah</string>
|
||||
<string name="cd_checking_battery_optimization">Menyemak Pengoptimuman Bateri</string>
|
||||
<string name="cd_not_supported_battery_optimization">Pengoptimuman Bateri Tidak Disokong</string>
|
||||
<string name="cd_bluetooth">Bluetooth</string>
|
||||
<string name="cd_unread_message">Mesej belum dibaca</string>
|
||||
<string name="cd_open_map">Buka peta</string>
|
||||
<string name="cd_remove_bookmark">Alih keluar penanda buku</string>
|
||||
<string name="cd_teleport">Teleport</string>
|
||||
<string name="notification_action_quit_bitchat">Keluar bitchat</string>
|
||||
<string name="about_background_title">jalankan di latar belakang</string>
|
||||
<string name="about_background_desc">kekalkan mesh aktif apabila apl ditutup (perkhidmatan latar depan)</string>
|
||||
<string name="cd_leave_channel">Keluar saluran</string>
|
||||
<string name="cd_reachable_via_nostr">Boleh dihubungi melalui Nostr</string>
|
||||
<string name="cd_offline_favorite">Kegemaran luar talian</string>
|
||||
<string name="cd_decrease_precision">Kurangkan ketepatan</string>
|
||||
<string name="cd_increase_precision">Tingkatkan ketepatan</string>
|
||||
<string name="cd_select_geohash">Pilih geohash</string>
|
||||
<string name="cd_scroll_to_bottom">Tatal ke bawah</string>
|
||||
<string name="cd_file">Fail</string>
|
||||
<string name="cd_image">Imej</string>
|
||||
<string name="cd_cancel">Batal</string>
|
||||
<string name="cd_link">Pautan</string>
|
||||
<string name="cd_record_voice">Rakam nota suara</string>
|
||||
<string name="cd_pick_media">Pilih media</string>
|
||||
<string name="cd_offline_mesh_chat">Sembang Mesh Luar Talian</string>
|
||||
<string name="cd_online_geohash_channels">Saluran Geohash Dalam Talian</string>
|
||||
<string name="cd_end_to_end_encryption">Penyulitan Hujung-ke-Hujung</string>
|
||||
<string name="location_bluetooth_subtitle">#bluetooth • %1$s</string>
|
||||
|
||||
<string name="image_page_of">Imej %1$d daripada %2$d</string>
|
||||
<string name="image_unavailable">Imej tidak tersedia</string>
|
||||
<string name="image_saved_to_downloads">Imej disimpan ke Muat Turun</string>
|
||||
<string name="image_save_failed">Gagal menyimpan imej</string>
|
||||
<string name="pick_file">Pilih fail</string>
|
||||
<string name="file_unavailable">[fail tidak tersedia]</string>
|
||||
<string name="unknown">tidak diketahui</string>
|
||||
|
||||
<!-- Chat user sheet actions -->
|
||||
<string name="choose_action_message_or_user">pilih tindakan untuk mesej atau pengguna ini</string>
|
||||
<string name="choose_action_user">pilih tindakan untuk pengguna ini</string>
|
||||
<string name="action_copy_message_title">salin mesej</string>
|
||||
<string name="action_copy_message_subtitle">salin mesej ini ke papan keratan</string>
|
||||
<string name="action_slap_title">tampar %1$s</string>
|
||||
<string name="action_slap_subtitle">hantar mesej tamparan bergurau</string>
|
||||
<string name="action_hug_title">peluk %1$s</string>
|
||||
<string name="action_hug_subtitle">hantar mesej pelukan mesra</string>
|
||||
<string name="action_block_title">sekat %1$s</string>
|
||||
<string name="action_block_subtitle">sekat semua mesej daripada pengguna ini</string>
|
||||
<string name="action_private_message_title">mesej %1$s</string>
|
||||
<string name="action_private_message_subtitle">hantar mesej peribadi</string>
|
||||
|
||||
|
||||
<!-- Location channels sheet -->
|
||||
<string name="location_channels_title">#saluran lokasi</string>
|
||||
<string name="location_channels_desc">berbual dengan orang berdekatan anda menggunakan saluran geohash. hanya geohash kasar dikongsi, tidak sekali-kali gps tepat. jangan ambil tangkapan skrin atau kongsi skrin ini untuk melindungi privasi anda.</string>
|
||||
<string name="grant_location_permission">berikan kebenaran lokasi</string>
|
||||
<string name="location_permission_denied">kebenaran lokasi ditolak. aktifkan dalam tetapan untuk menggunakan saluran lokasi.</string>
|
||||
|
||||
<string name="location_permission_granted">✓ kebenaran lokasi diberikan</string>
|
||||
<string name="checking_permissions">menyemak kebenaran...</string>
|
||||
<string name="finding_nearby_channels">mencari saluran berdekatan…</string>
|
||||
<string name="bookmarked">ditanda buku</string>
|
||||
<string name="geohash_placeholder">geohash</string>
|
||||
<string name="invalid_geohash">geohash tidak sah</string>
|
||||
<string name="teleport">teleport</string>
|
||||
<string name="disable_location_services">lumpuhkan perkhidmatan lokasi</string>
|
||||
<string name="enable_location_services">aktifkan perkhidmatan lokasi</string>
|
||||
<string name="mesh_label">mesh</string>
|
||||
<string name="location_level_block">blok</string>
|
||||
<string name="location_level_neighborhood">kejiranan</string>
|
||||
<string name="location_level_city">bandar</string>
|
||||
<string name="location_level_province">wilayah</string>
|
||||
<string name="location_level_region">rantau</string>
|
||||
|
||||
<!-- Location notes sheet -->
|
||||
<plurals name="location_notes_title">
|
||||
<item quantity="other">#%1$s ± 1 • %2$d nota</item>
|
||||
</plurals>
|
||||
<string name="location_notes_description">tambah nota kekal ringkas pada lokasi ini untuk pelawat lain temui.</string>
|
||||
<string name="location_notes_relays_unavailable">geganti geo tidak tersedia; nota dijeda</string>
|
||||
<string name="location_notes_no_relays_title">tiada geganti geo berdekatan</string>
|
||||
<string name="location_notes_no_relays_desc">nota bergantung pada geganti geo. semak sambungan dan cuba lagi.</string>
|
||||
<string name="loading_location_notes">memuatkan nota…</string>
|
||||
<string name="location_notes_empty_title">belum ada nota</string>
|
||||
<string name="location_notes_empty_desc">jadilah yang pertama menambah satu untuk tempat ini.</string>
|
||||
<string name="dismiss">tolak</string>
|
||||
<string name="location_notes_input_placeholder">tambah nota untuk tempat ini</string>
|
||||
|
||||
<!-- Debug / Diagnostics -->
|
||||
<string name="debug_tools">alat nyahpepijat</string>
|
||||
<string name="debug_tools_desc">utiliti pembangun untuk diagnostik dan kawalan</string>
|
||||
<string name="debug_verbose_logging">log terperinci</string>
|
||||
<string name="debug_verbose_hint">merekod penyertaan/pemergian rakan, arah sambungan, penghalaan paket dan geganti</string>
|
||||
<string name="debug_bluetooth_roles">peranan bluetooth</string>
|
||||
<string name="debug_gatt_server">pelayan gatt</string>
|
||||
<string name="debug_connections_fmt">sambungan: %1$d / %2$d</string>
|
||||
<string name="debug_max_server">maksimum pelayan</string>
|
||||
<string name="debug_gatt_client">klien gatt</string>
|
||||
<string name="debug_max_client">maksimum klien</string>
|
||||
<string name="debug_overall_connections_fmt">sambungan: %1$d / %2$d</string>
|
||||
<string name="debug_max_overall">maksimum keseluruhan</string>
|
||||
<string name="debug_packet_relay">geganti paket</string>
|
||||
<string name="debug_since_start_fmt">sejak permulaan: %1$d</string>
|
||||
<string name="debug_roles_hint">hidup/matikan peranan dan tutup semua sambungan apabila dilumpuhkan</string>
|
||||
<string name="debug_sync_settings">tetapan penyegerakan</string>
|
||||
<string name="debug_max_packets_per_sync_fmt">maksimum paket setiap penyegerakan: %1$d</string>
|
||||
<string name="debug_max_gcs_filter_size_fmt">saiz penapis GCS maksimum: %1$d bait (128–1024)</string>
|
||||
<string name="debug_target_fpr_fmt">FPR sasaran: %1$.2f%%</string>
|
||||
<string name="debug_connected_devices">peranti disambungkan</string>
|
||||
<string name="debug_our_device_id_fmt">id peranti kami: %1$s</string>
|
||||
<string name="debug_none">tiada</string>
|
||||
<string name="debug_disconnect">putuskan sambungan</string>
|
||||
<string name="debug_recent_scan_results">keputusan imbasan terkini</string>
|
||||
|
||||
<string name="verify_title">sahkan</string>
|
||||
<string name="verify_my_qr_title">imbas untuk sahkan saya</string>
|
||||
<string name="verify_scan_prompt_friend">imbas kod qr orang lain</string>
|
||||
<string name="verify_scan_someone">imbas kod qr orang lain</string>
|
||||
<string name="verify_show_my_qr">tunjukkan kod qr saya</string>
|
||||
<string name="verify_remove">alih keluar pengesahan</string>
|
||||
<string name="verify_qr_unavailable">kod qr tidak tersedia</string>
|
||||
<string name="verify_camera_permission">kebenaran kamera diperlukan untuk mengimbas kod qr</string>
|
||||
<string name="verify_request_camera">dayakan kamera</string>
|
||||
<string name="verify_paste_label">tampal pautan pengesahan</string>
|
||||
<string name="verify_validate">sahkan</string>
|
||||
<string name="verify_scanned">pengesahan diminta</string>
|
||||
<string name="security_verification_title">pengesahan keselamatan</string>
|
||||
<string name="fingerprint_their">cap jari mereka</string>
|
||||
<string name="fingerprint_yours">cap jari anda</string>
|
||||
<string name="fingerprint_pending">jabat tangan tertangguh</string>
|
||||
<string name="fingerprint_no_peer">buka sembang peribadi untuk melihat cap jari</string>
|
||||
<string name="fingerprint_status_verified">disulitkan & disahkan</string>
|
||||
<string name="fingerprint_status_encrypted">disulitkan</string>
|
||||
<string name="fingerprint_status_handshaking">berjabat tangan</string>
|
||||
<string name="fingerprint_status_failed">jabat tangan gagal</string>
|
||||
<string name="fingerprint_status_uninitialized">tidak disulitkan</string>
|
||||
<string name="fingerprint_verified_label">disahkan</string>
|
||||
<string name="fingerprint_verified_message">anda telah mengesahkan identiti orang ini.</string>
|
||||
<string name="fingerprint_not_verified_label">tidak disahkan</string>
|
||||
<string name="fingerprint_not_verified_message_fmt">bandingkan cap jari ini dengan %1$s menggunakan saluran selamat.</string>
|
||||
<string name="fingerprint_mark_verified">tanda sebagai disahkan</string>
|
||||
<string name="fingerprint_start_handshake">mulakan jabat tangan</string>
|
||||
<string name="fingerprint_copy">salin</string>
|
||||
|
||||
<string name="verify_mutual_match_title">Pengesahan bersama</string>
|
||||
<string name="verify_mutual_match_body">Anda dan %1$s telah saling mengesahkan</string>
|
||||
<string name="verify_mutual_system_message">pengesahan bersama dengan %1$s</string>
|
||||
<string name="verify_success_title">Disahkan</string>
|
||||
<string name="verify_success_body">Anda telah mengesahkan %1$s</string>
|
||||
<string name="verify_success_system_message">telah mengesahkan %1$s</string>
|
||||
|
||||
<string name="debug_connect">sambung</string>
|
||||
<string name="debug_debug_console">konsol nyahpepijat</string>
|
||||
<string name="debug_clear">kosongkan</string>
|
||||
<string name="debug_relays_window_fmt">10s lepas: %1$d • 1m: %2$d • 15m: %3$d</string>
|
||||
<string name="debug_derived_p_fmt">P terbitan: %1$s • anggaran unsur maks: %2$s</string>
|
||||
<string name="debug_direct_suffix"> • terus</string>
|
||||
<string name="debug_rssi_fmt">RSSI: %1$s</string>
|
||||
<string name="debug_question_mark">?</string>
|
||||
<string name="debug_role_server">sebagai pelayan (kami hos)</string>
|
||||
<string name="debug_role_client">sebagai klien (kami sambung)</string>
|
||||
|
||||
<!-- Onboarding screens -->
|
||||
<string name="location_services_required">Perkhidmatan Lokasi Diperlukan</string>
|
||||
<string name="privacy_first">Privasi Diutamakan</string>
|
||||
<string name="location_explanation">bitchat TIDAK menjejaki lokasi anda.\n\nPerkhidmatan lokasi diperlukan untuk imbasan Bluetooth dan untuk ciri sembang Geohash.</string>
|
||||
<string name="location_needs_for">bitchat memerlukan perkhidmatan lokasi untuk:</string>
|
||||
<string name="location_needs_bullets">• Imbasan peranti Bluetooth\n• Mengesan pengguna berdekatan pada rangkaian mesh\n• Ciri sembang Geohash\n• Tiada penjejakan atau pengumpulan lokasi</string>
|
||||
<string name="background_location_required_title">Lokasi Latar Belakang Disyorkan</string>
|
||||
<string name="background_location_required_subtitle">pilihan, meningkatkan kebolehpercayaan mesh</string>
|
||||
<string name="background_location_explanation">Android mengesyorkan lokasi latar belakang supaya bitchat boleh mengimbas peranti berdekatan semasa apl tidak dibuka. Ini mengekalkan mesh aktif selepas but semula.</string>
|
||||
<string name="background_location_settings_tip">Apabila tetapan dibuka, pilih "Benarkan sepanjang masa".</string>
|
||||
<string name="background_location_needs_for">bitchat menggunakan lokasi latar belakang untuk:</string>
|
||||
<string name="background_location_needs_bullets">- mengimbas peranti berdekatan semasa apl ditutup\n- menyambung semula selepas but semula\n- mengekalkan mesh berjalan di latar belakang</string>
|
||||
<string name="background_location_privacy_note">Kami TIDAK PERNAH mengumpul atau menyimpan lokasi anda. Privasi anda selamat.</string>
|
||||
<string name="grant_background_location">Benarkan Lokasi Latar Belakang</string>
|
||||
<string name="skip_background_location">Teruskan tanpa lokasi latar belakang</string>
|
||||
<string name="open_location_settings">Buka Tetapan Lokasi</string>
|
||||
<string name="check_again">Semak Semula</string>
|
||||
<string name="location_services_unavailable">Perkhidmatan Lokasi Tidak Tersedia</string>
|
||||
<string name="location_unavailable_explanation">Perkhidmatan lokasi tidak tersedia pada peranti ini. Ini luar biasa kerana perkhidmatan lokasi adalah standard pada peranti Android.\n\nbitchat memerlukan perkhidmatan lokasi untuk imbasan Bluetooth berfungsi dengan betul (keperluan Android). Tanpa ini, apl tidak dapat mengesan pengguna berdekatan.</string>
|
||||
<string name="checking_location_services">Menyemak perkhidmatan lokasi...</string>
|
||||
<string name="bluetooth_required">Bluetooth Diperlukan</string>
|
||||
<string name="bluetooth_needs_for">bitchat memerlukan Bluetooth untuk:</string>
|
||||
<string name="bluetooth_needs_bullets">• Mengesan pengguna berdekatan\n• Mewujudkan sambungan rangkaian mesh\n• Menghantar dan menerima mesej\n• Berfungsi tanpa internet atau pelayan</string>
|
||||
<string name="enable_bluetooth">Aktifkan Bluetooth</string>
|
||||
<string name="bluetooth_not_supported">Bluetooth Tidak Disokong</string>
|
||||
<string name="bluetooth_unsupported_explanation">Peranti ini tidak menyokong Bluetooth Low Energy (BLE), yang diperlukan untuk bitchat berfungsi.\n\nbitchat memerlukan BLE untuk mewujudkan rangkaian mesh dan berkomunikasi dengan peranti berdekatan tanpa internet.</string>
|
||||
<string name="checking_bluetooth_status">Menyemak status Bluetooth...</string>
|
||||
<string name="battery_optimization_detected_title">pengoptimuman bateri dikesan</string>
|
||||
<string name="battery_optimization_enabled_title">Pengoptimuman Bateri Diaktifkan</string>
|
||||
<string name="battery_optimization_explanation_short">bitchat perlu berjalan di latar belakang untuk mengekalkan sambungan mesh. pengoptimuman bateri boleh mengganggu sambungan ini.</string>
|
||||
<string name="benefits_of_disabling">Faedah Melumpuhkan</string>
|
||||
<string name="battery_benefits_short">• penghantaran mesej yang boleh dipercayai\n• mengekalkan ketersambungan mesh\n• mengelakkan sambungan terputus</string>
|
||||
<string name="disable_battery_optimization">Lumpuhkan Pengoptimuman Bateri</string>
|
||||
<string name="battery_optimization_disabled_title">pengoptimuman bateri dilumpuhkan</string>
|
||||
<string name="continue_btn">Teruskan</string>
|
||||
<string name="initializing_mesh_network">Memulakan rangkaian mesh</string>
|
||||
<string name="dot">.</string>
|
||||
<string name="setting_up_bluetooth">Menyediakan rangkaian mesh Bluetooth...</string>
|
||||
<string name="should_take_seconds">Ini sepatutnya hanya mengambil masa beberapa saat</string>
|
||||
<string name="warning_emoji">⚠️</string>
|
||||
<string name="setup_not_complete">Persediaan Tidak Lengkap</string>
|
||||
<string name="try_again">Cuba Lagi</string>
|
||||
<string name="open_settings">Buka Tetapan</string>
|
||||
<string name="privacy_protected">Privasi Anda Dilindungi</string>
|
||||
<string name="privacy_bullets">• tiada penjejakan atau pengumpulan data\n• sembang mesh Bluetooth sepenuhnya luar talian\n• sembang Geohash menggunakan internet</string>
|
||||
<string name="permissions_header">kebenaran</string>
|
||||
<string name="grant_permissions">Berikan Kebenaran</string>
|
||||
<string name="location_tracking_warning">bitchat TIDAK menjejaki lokasi anda</string>
|
||||
<string name="at_symbol">@</string>
|
||||
<string name="channel_count_prefix"> · ⧉ </string>
|
||||
<string name="nobody_around">tiada sesiapa berdekatan...</string>
|
||||
<string name="you_suffix"> (anda)</string>
|
||||
<string name="pan_zoom_instruction">gerak dan zum untuk memilih geohash</string>
|
||||
<string name="select">pilih</string>
|
||||
<string name="type_a_message_placeholder">taip mesej...</string>
|
||||
<string name="mention_suggestion_at">@%1$s</string>
|
||||
<string name="mention">sebutan</string>
|
||||
<string name="image_counter">%1$d / %2$d</string>
|
||||
<string name="at_nickname">@%1$s</string>
|
||||
<string name="version_prefix">v%1$s</string>
|
||||
<string name="hash_symbol">#</string>
|
||||
<string name="underscore">_</string>
|
||||
<string name="progress_bar_brackets">[%1$s] %2$d%%</string>
|
||||
<string name="progress_filled">█</string>
|
||||
<string name="progress_empty">░</string>
|
||||
<string name="image_star">image/*</string>
|
||||
<string name="media_type_image">Imej</string>
|
||||
<string name="media_type_file">Fail</string>
|
||||
|
||||
<!-- Message status icons -->
|
||||
<string name="status_sending">○</string>
|
||||
<string name="status_pending">○</string>
|
||||
<string name="status_sent">✓</string>
|
||||
<string name="status_delivered">✓✓</string>
|
||||
<string name="status_failed">⚠</string>
|
||||
<string name="status_read">✓</string>
|
||||
|
||||
<!-- Notification text helpers -->
|
||||
<string name="notification_sent_image">📷 menghantar imej</string>
|
||||
<string name="notification_sent_voice">🎤 menghantar mesej suara</string>
|
||||
<string name="notification_sent_file">📎 menghantar fail</string>
|
||||
<string name="notification_file_pdf">📄</string>
|
||||
<string name="notification_file_zip">🗜️</string>
|
||||
<string name="notification_file_doc">📄</string>
|
||||
<string name="notification_file_xls">📊</string>
|
||||
<string name="notification_file_ppt">📈</string>
|
||||
<string name="notification_file_generic">📎</string>
|
||||
|
||||
<!-- Voice player accessibility -->
|
||||
<string name="cd_play_voice">Main</string>
|
||||
<string name="cd_pause_voice">Jeda</string>
|
||||
|
||||
<!-- Permission descriptions -->
|
||||
<string name="perm_nearby_devices_desc">Diperlukan untuk mengesan pengguna bitchat melalui Bluetooth</string>
|
||||
<string name="perm_nearby_devices_system">Benarkan bitchat menyambung kepada peranti berdekatan</string>
|
||||
<string name="perm_location_desc">Diperlukan oleh Android untuk mengesan pengguna bitchat berdekatan melalui Bluetooth</string>
|
||||
<string name="perm_location_system">bitchat memerlukan ini untuk mengimbas peranti berdekatan</string>
|
||||
<string name="perm_background_location_desc">Disyorkan untuk mengimbas peranti berdekatan semasa apl di latar belakang</string>
|
||||
<string name="perm_background_location_system">Benarkan akses lokasi latar belakang</string>
|
||||
<string name="perm_notifications_desc">Terima pemberitahuan apabila anda menerima mesej peribadi</string>
|
||||
<string name="perm_notifications_system">Benarkan bitchat menghantar pemberitahuan kepada anda</string>
|
||||
<string name="perm_battery_desc">Lumpuhkan pengoptimuman bateri untuk memastikan bitchat berjalan dengan boleh dipercayai di latar belakang dan mengekalkan sambungan rangkaian mesh</string>
|
||||
<string name="perm_battery_system">Benarkan bitchat berjalan tanpa sekatan bateri</string>
|
||||
|
||||
<!-- Permission types -->
|
||||
<string name="perm_type_nearby_devices">Peranti Berdekatan</string>
|
||||
<string name="perm_type_precise_location">Lokasi Tepat</string>
|
||||
<string name="perm_type_background_location">Lokasi Latar Belakang</string>
|
||||
<string name="perm_type_microphone">Mikrofon</string>
|
||||
<string name="perm_type_notifications">Pemberitahuan</string>
|
||||
<string name="perm_type_battery_optimization">Pengoptimuman Bateri</string>
|
||||
<string name="perm_type_other">Lain-lain</string>
|
||||
|
||||
<!-- Password prompt dialog -->
|
||||
<string name="pwd_prompt_title">Masukkan Kata Laluan Saluran</string>
|
||||
<string name="pwd_prompt_message">Saluran %1$s dilindungi kata laluan. Masukkan kata laluan untuk menyertai.</string>
|
||||
<string name="pwd_label">Kata Laluan</string>
|
||||
<string name="join">Sertai</string>
|
||||
<string name="cancel">Batal</string>
|
||||
<string name="tor_not_available_in_this_build">Tor tidak tersedia dalam binaan ini</string>
|
||||
|
||||
<!-- Plurals -->
|
||||
<plurals name="notification_and_more">
|
||||
<item quantity="other">dan %d lagi</item>
|
||||
</plurals>
|
||||
|
||||
<plurals name="people_count">
|
||||
<item quantity="other">%d orang</item>
|
||||
</plurals>
|
||||
|
||||
<string name="bluetooth_recommended">Bluetooth Disyorkan</string>
|
||||
<string name="about_language">Bahasa</string>
|
||||
<string name="about_app_language">Bahasa aplikasi</string>
|
||||
<string name="about_system_default">Lalai sistem</string>
|
||||
<string name="about_select_language">Pilih bahasa</string>
|
||||
</resources>
|
||||
|
||||
@ -46,7 +46,7 @@
|
||||
<string name="notification_summary_more">र %1$d थप</string>
|
||||
<string name="notification_messages_from_people">%1$d सन्देश %2$d जनाबाट</string>
|
||||
<string name="notification_more_conversations">र %1$d अरू कुराकानी</string>
|
||||
<string name="notification_active_peers_title">👥 नजिकै bitchatter हरू!</string>
|
||||
<string name="notification_active_peers_title">नजिकै bitchatter हरू!</string>
|
||||
<string name="notification_active_peers_one">१ जना नजिकै</string>
|
||||
<string name="notification_active_peers_many">%1$d जना नजिकै</string>
|
||||
<string name="notification_new_messages">नयाँ सन्देश</string>
|
||||
@ -394,4 +394,8 @@
|
||||
<string name="nearby_notes_reveal">यहाँ छोडिएका नोटहरू छन् कि हेर्नुहोस्</string>
|
||||
<string name="nearby_notes_one">यहाँ 1 नोट छोडिएको छ — पढ्न ट्याप गर्नुहोस्</string>
|
||||
<string name="nearby_notes_many">यहाँ %d नोटहरू छोडिएका छन् — पढ्न ट्याप गर्नुहोस्</string>
|
||||
<string name="about_language">भाषा</string>
|
||||
<string name="about_app_language">एपको भाषा</string>
|
||||
<string name="about_system_default">प्रणालीको पूर्वनिर्धारित</string>
|
||||
<string name="about_select_language">भाषा छान्नुहोस्</string>
|
||||
</resources>
|
||||
|
||||
@ -46,7 +46,7 @@
|
||||
<string name="notification_summary_more">en %1$d meer</string>
|
||||
<string name="notification_messages_from_people">%1$d berichten van %2$d personen</string>
|
||||
<string name="notification_more_conversations">en %1$d meer conversaties</string>
|
||||
<string name="notification_active_peers_title">👥 bitchatters in de buurt!</string>
|
||||
<string name="notification_active_peers_title">bitchatters in de buurt!</string>
|
||||
<string name="notification_active_peers_one">1 persoon in de buurt</string>
|
||||
<string name="notification_active_peers_many">%1$d personen in de buurt</string>
|
||||
<string name="notification_new_messages">Nieuwe berichten</string>
|
||||
@ -421,4 +421,8 @@
|
||||
<string name="nearby_notes_reveal">kijk of hier notities zijn achtergelaten</string>
|
||||
<string name="nearby_notes_one">1 notitie hier achtergelaten — tik om te lezen</string>
|
||||
<string name="nearby_notes_many">%d notities hier achtergelaten — tik om te lezen</string>
|
||||
<string name="about_language">Taal</string>
|
||||
<string name="about_app_language">App-taal</string>
|
||||
<string name="about_system_default">Systeemstandaard</string>
|
||||
<string name="about_select_language">Taal selecteren</string>
|
||||
</resources>
|
||||
|
||||
@ -46,7 +46,7 @@
|
||||
<string name="notification_summary_more">تے %1$d ہور</string>
|
||||
<string name="notification_messages_from_people">%2$d لوکان کولوں %1$d پیغام</string>
|
||||
<string name="notification_more_conversations">تے %1$d ہور گلاں</string>
|
||||
<string name="notification_active_peers_title">👥 نیڑے bitchatter!</string>
|
||||
<string name="notification_active_peers_title">نیڑے bitchatter!</string>
|
||||
<string name="notification_active_peers_one">نیڑے 1 بندہ</string>
|
||||
<string name="notification_active_peers_many">نیڑے %1$d بندے</string>
|
||||
<string name="notification_new_messages">نویں پیغام</string>
|
||||
@ -377,4 +377,8 @@
|
||||
<string name="nearby_notes_reveal">ایتھے چھڈے نوٹس ویکھو</string>
|
||||
<string name="nearby_notes_one">ایتھے 1 نوٹ چھڈیا گیا — پڑھݨ لئی ٹیپ کرو</string>
|
||||
<string name="nearby_notes_many">ایتھے %d نوٹس چھڈے گئے — پڑھݨ لئی ٹیپ کرو</string>
|
||||
<string name="about_language">بولی</string>
|
||||
<string name="about_app_language">ایپ دی بولی</string>
|
||||
<string name="about_system_default">سسٹم دی ڈیفالٹ</string>
|
||||
<string name="about_select_language">بولی چݨو</string>
|
||||
</resources>
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- TODO: Polish translations -->
|
||||
<!-- Machine-translated (Claude) — pending native-speaker review -->
|
||||
|
||||
<plurals name="location_notes_title">
|
||||
<item quantity="one">#%1$s ± 1 • %2$d notatka</item>
|
||||
<item quantity="other">#%1$s ± 1 • %2$d notatek</item>
|
||||
@ -18,44 +20,389 @@
|
||||
<string name="bluetooth_recommended">Bluetooth zalecany</string>
|
||||
<string name="mesh_service_notification_content">Mesh działa — %1$d peerów</string>
|
||||
|
||||
<string name="verify_title">verify</string>
|
||||
<string name="verify_my_qr_title">scan to verify me</string>
|
||||
<string name="verify_scan_prompt_friend">scan someone elses qr</string>
|
||||
<string name="verify_scan_someone">scan someone elses qr</string>
|
||||
<string name="verify_show_my_qr">show my qr</string>
|
||||
<string name="verify_remove">remove verification</string>
|
||||
<string name="verify_qr_unavailable">qr unavailable</string>
|
||||
<string name="verify_camera_permission">camera permission is needed to scan qr codes</string>
|
||||
<string name="verify_request_camera">enable camera</string>
|
||||
<string name="verify_paste_label">paste verification url</string>
|
||||
<string name="verify_validate">validate</string>
|
||||
<string name="verify_scanned">verification requested</string>
|
||||
<string name="security_verification_title">security verification</string>
|
||||
<string name="fingerprint_their">their fingerprint</string>
|
||||
<string name="fingerprint_yours">your fingerprint</string>
|
||||
<string name="fingerprint_pending">handshake pending</string>
|
||||
<string name="fingerprint_no_peer">open a private chat to view fingerprints</string>
|
||||
<string name="fingerprint_status_verified">encrypted & verified</string>
|
||||
<string name="fingerprint_status_encrypted">encrypted</string>
|
||||
<string name="fingerprint_status_handshaking">handshaking</string>
|
||||
<string name="fingerprint_status_failed">handshake failed</string>
|
||||
<string name="fingerprint_status_uninitialized">not encrypted</string>
|
||||
<string name="fingerprint_verified_label">verified</string>
|
||||
<string name="fingerprint_verified_message">you have verified this persons identity.</string>
|
||||
<string name="fingerprint_not_verified_label">not verified</string>
|
||||
<string name="fingerprint_not_verified_message_fmt">compare these fingerprints with %1$s using a secure channel.</string>
|
||||
<string name="fingerprint_mark_verified">mark as verified</string>
|
||||
<string name="fingerprint_start_handshake">start handshake</string>
|
||||
<string name="fingerprint_copy">copy</string>
|
||||
<string name="verify_mutual_match_title">Mutual verification</string>
|
||||
<string name="verify_mutual_match_body">You and %1$s verified each other</string>
|
||||
<string name="verify_mutual_system_message">mutual verification with %1$s</string>
|
||||
<string name="verify_success_title">Verified</string>
|
||||
<string name="verify_success_body">You verified %1$s</string>
|
||||
<string name="verify_success_system_message">verified %1$s</string>
|
||||
<string name="cd_open_about">Otwórz informacje</string>
|
||||
<string name="cd_open_location_channels">Otwórz ustawienia lokalizacji i kanałów</string>
|
||||
<string name="nearby_notes_reveal">sprawdź, czy zostawiono tutaj notatki</string>
|
||||
<string name="nearby_notes_one">1 notatka zostawiona tutaj — stuknij, aby przeczytać</string>
|
||||
<string name="nearby_notes_many">%d notatek zostawionych tutaj — stuknij, aby przeczytać</string>
|
||||
<string name="verify_title">weryfikuj</string>
|
||||
<string name="verify_my_qr_title">zeskanuj, aby mnie zweryfikować</string>
|
||||
<string name="verify_scan_prompt_friend">zeskanuj kod QR innej osoby</string>
|
||||
<string name="verify_scan_someone">zeskanuj kod QR innej osoby</string>
|
||||
<string name="verify_show_my_qr">pokaż mój kod QR</string>
|
||||
<string name="verify_remove">usuń weryfikację</string>
|
||||
<string name="verify_qr_unavailable">kod QR niedostępny</string>
|
||||
<string name="verify_camera_permission">wymagane jest uprawnienie do aparatu, aby zeskanować kody QR</string>
|
||||
<string name="verify_request_camera">włącz aparat</string>
|
||||
<string name="verify_paste_label">wklej link weryfikacyjny</string>
|
||||
<string name="verify_validate">zweryfikuj</string>
|
||||
<string name="verify_scanned">poproszono o weryfikację</string>
|
||||
<string name="security_verification_title">weryfikacja bezpieczeństwa</string>
|
||||
<string name="fingerprint_their">odcisk drugiej osoby</string>
|
||||
<string name="fingerprint_yours">twój odcisk</string>
|
||||
<string name="fingerprint_pending">oczekiwanie na uzgadnianie</string>
|
||||
<string name="fingerprint_no_peer">otwórz czat prywatny, aby zobaczyć odciski</string>
|
||||
<string name="fingerprint_status_verified">zaszyfrowane i zweryfikowane</string>
|
||||
<string name="fingerprint_status_encrypted">zaszyfrowane</string>
|
||||
<string name="fingerprint_status_handshaking">uzgadnianie</string>
|
||||
<string name="fingerprint_status_failed">uzgadnianie nie powiodło się</string>
|
||||
<string name="fingerprint_status_uninitialized">niezaszyfrowane</string>
|
||||
<string name="fingerprint_verified_label">zweryfikowano</string>
|
||||
<string name="fingerprint_verified_message">zweryfikowałeś tożsamość tej osoby.</string>
|
||||
<string name="fingerprint_not_verified_label">niezweryfikowano</string>
|
||||
<string name="fingerprint_not_verified_message_fmt">porównaj te odciski z %1$s za pomocą bezpiecznego kanału.</string>
|
||||
<string name="fingerprint_mark_verified">oznacz jako zweryfikowane</string>
|
||||
<string name="fingerprint_start_handshake">rozpocznij uzgadnianie</string>
|
||||
<string name="fingerprint_copy">kopiuj</string>
|
||||
<string name="verify_mutual_match_title">Weryfikacja wzajemna</string>
|
||||
<string name="verify_mutual_match_body">Ty i %1$s zweryfikowaliście się nawzajem</string>
|
||||
<string name="verify_mutual_system_message">wzajemna weryfikacja z %1$s</string>
|
||||
<string name="verify_success_title">Zweryfikowano</string>
|
||||
<string name="verify_success_body">Zweryfikowałeś %1$s</string>
|
||||
<string name="verify_success_system_message">zweryfikowano %1$s</string>
|
||||
|
||||
<!-- Newly translated strings -->
|
||||
<string name="app_name">bitchat</string>
|
||||
<string name="permission_bluetooth_rationale">Uprawnienie Bluetooth jest wymagane do komunikacji peer-to-peer bez internetu.</string>
|
||||
<string name="permission_location_rationale">Uprawnienie lokalizacji jest wymagane do wykrywania pobliskich urządzeń przez Bluetooth.</string>
|
||||
<string name="permission_notification_rationale">Uprawnienie powiadomień jest wymagane, aby informować Cię o nowych wiadomościach.</string>
|
||||
<string name="nickname_hint">pseudonim</string>
|
||||
<string name="message_hint">napisz wiadomość…</string>
|
||||
<string name="channel_password_hint">Hasło</string>
|
||||
<string name="join_channel">Dołącz do kanału</string>
|
||||
<string name="leave_channel">Opuść</string>
|
||||
<string name="send_message">Wyślij</string>
|
||||
<string name="show_commands">Pokaż komendy</string>
|
||||
<string name="back">Wstecz</string>
|
||||
<string name="people">Osoby</string>
|
||||
<string name="channels">Kanały</string>
|
||||
<string name="online_users">Użytkownicy online</string>
|
||||
<string name="no_one_connected">Nikt nie jest połączony</string>
|
||||
<string name="emergency_clear_hint">Stuknij trzykrotnie, aby wyczyścić wszystkie dane</string>
|
||||
<string name="your_network">Sieć</string>
|
||||
<string name="battery_optimization_detected">Wykryto optymalizację baterii</string>
|
||||
<string name="battery_optimization_disabled">Optymalizacja baterii wyłączona</string>
|
||||
<string name="battery_optimization_not_required">Optymalizacja baterii niewymagana</string>
|
||||
<string name="battery_optimization_checking">Sprawdzanie optymalizacji baterii</string>
|
||||
<string name="battery_optimization_why_disable">Dlaczego wyłączyć optymalizację baterii?</string>
|
||||
<string name="battery_optimization_explanation">bitchat działa w tle, aby utrzymać połączenia z siecią mesh z pobliskimi urządzeniami. Optymalizacja baterii może przerywać te połączenia, powodując opóźnienia lub utratę wiadomości.\n\nWyłączenie optymalizacji baterii zapewnia niezawodną komunikację peer-to-peer.</string>
|
||||
<string name="battery_optimization_disable_button">Wyłącz optymalizację baterii</string>
|
||||
<string name="battery_optimization_note">Uwaga: możesz zmienić to ustawienie później w Ustawieniach Androida > Aplikacje > bitchat > Bateria</string>
|
||||
<string name="battery_optimization_not_supported_explanation">Twoje urządzenie nie wymaga ustawień optymalizacji baterii. bitchat będzie działać normalnie.</string>
|
||||
<string name="battery_optimization_not_supported_message">Twoje urządzenie nie wymaga ustawień optymalizacji baterii. bitchat będzie działać normalnie.</string>
|
||||
<string name="battery_optimization_success_message">bitchat może działać niezawodnie w tle</string>
|
||||
<string name="battery_optimization_benefits">• Zapewnia niezawodne dostarczanie wiadomości\n• Utrzymuje łączność sieci mesh\n• Umożliwia przekazywanie wiadomości w tle\n• Zapobiega zrywaniu połączeń</string>
|
||||
<string name="battery_optimization_check_again">Sprawdź ponownie</string>
|
||||
<string name="battery_optimization_skip">Pomiń na razie</string>
|
||||
<string name="battery_optimization_continue">Kontynuuj</string>
|
||||
<string name="retry">Ponów</string>
|
||||
<string name="notification_summary_more">i jeszcze %1$d</string>
|
||||
<string name="notification_messages_from_people">%1$d wiadomości od %2$d osób</string>
|
||||
<string name="notification_more_conversations">i jeszcze %1$d rozmów</string>
|
||||
<string name="notification_active_peers_title">👥 bitchatowicze w pobliżu!</string>
|
||||
<string name="notification_active_peers_one">1 osoba w pobliżu</string>
|
||||
<string name="notification_active_peers_many">%1$d osób w pobliżu</string>
|
||||
<string name="notification_new_messages">Nowe wiadomości</string>
|
||||
<string name="notification_new_location_messages">Nowe wiadomości lokalizacyjne</string>
|
||||
<string name="notification_mentions_in">Wzmianka w %1$s</string>
|
||||
<string name="notification_mentions_in_more">Wzmianka w %1$s (+%2$d więcej)</string>
|
||||
<string name="notification_mentions_in_plural">%1$d wzmianek w %2$s</string>
|
||||
<string name="notification_new_activity_in">Nowa aktywność w %1$s</string>
|
||||
<string name="notification_messages_in">Wiadomości w %1$s</string>
|
||||
<string name="notification_joined_conversation">%1$s dołączył(a) do rozmowy</string>
|
||||
<string name="notification_geohash_summary_title_mentions">bitchat - %1$d wzmianek</string>
|
||||
<string name="notification_geohash_summary_title">bitchat - czaty lokalizacyjne</string>
|
||||
<string name="notification_geohash_summary_text">%1$d wiadomości z %2$d lokalizacji</string>
|
||||
<string name="notification_mesh_mention_title_singular">Wzmianka w czacie Mesh</string>
|
||||
<string name="notification_mesh_mention_title_plural">%1$d wzmianek w czacie Mesh</string>
|
||||
<string name="notification_more_locations">i jeszcze %1$d lokalizacji</string>
|
||||
<string name="mesh_service_channel_name">Usługa mesh w tle</string>
|
||||
<string name="mesh_service_channel_desc">Utrzymuje działanie sieci mesh Bluetooth w tle</string>
|
||||
<string name="cd_add_favorite">Dodaj do ulubionych</string>
|
||||
<string name="cd_remove_favorite">Usuń z ulubionych</string>
|
||||
<string name="cd_add_bookmark">Dodaj zakładkę</string>
|
||||
<string name="cd_nostr_reachable">Dostępny przez Nostr</string>
|
||||
<string name="cd_unread_private_messages">Nieprzeczytane wiadomości prywatne</string>
|
||||
<string name="cd_location_notes">Notatki lokalizacji</string>
|
||||
<string name="cd_teleported">Teleportowany</string>
|
||||
<string name="cd_tor_status">Status Tor</string>
|
||||
<string name="cd_open_location_channels">Otwórz ustawienia lokalizacji i kanałów</string>
|
||||
<string name="cd_connected_peers">Połączeni użytkownicy</string>
|
||||
<string name="cd_geohash_participants">Uczestnicy geohash</string>
|
||||
<string name="cd_ready_for_handshake">Gotowy do uzgadniania</string>
|
||||
<string name="cd_handshake_in_progress">Uzgadnianie w toku</string>
|
||||
<string name="cd_encrypted">Szyfrowane end-to-end</string>
|
||||
<string name="cd_handshake_failed">Uzgadnianie nie powiodło się</string>
|
||||
<string name="file_viewer_title">📎 Otrzymano plik</string>
|
||||
<string name="file_viewer_name">📄 %1$s</string>
|
||||
<string name="file_viewer_size">📏 Rozmiar: %1$s</string>
|
||||
<string name="file_viewer_type">🏷️ Typ: %1$s</string>
|
||||
<string name="file_viewer_open_save">📂 Otwórz / Zapisz</string>
|
||||
<string name="close_with_emoji">❌ Zamknij</string>
|
||||
<string name="pick_image">Wybierz obraz</string>
|
||||
<string name="cd_save_current_image">Zapisz bieżący obraz</string>
|
||||
<string name="cd_close">Zamknij</string>
|
||||
<string name="toast_image_saved">Obraz zapisany w Pobranych</string>
|
||||
<string name="toast_failed_to_save_image">Nie udało się zapisać obrazu</string>
|
||||
<string name="cd_image_index_of">Obraz %1$d z %2$d</string>
|
||||
<string name="cd_pick_file">Wybierz plik</string>
|
||||
<string name="about_tagline">zdecentralizowana komunikacja mesh z szyfrowaniem end-to-end</string>
|
||||
<string name="about_offline_mesh_title">Czat mesh offline</string>
|
||||
<string name="about_offline_mesh_desc">Komunikuj się bezpośrednio przez Bluetooth LE bez internetu i serwerów. Wiadomości są przekazywane przez pobliskie urządzenia, aby zwiększyć zasięg.</string>
|
||||
<string name="about_online_geohash_title">Kanały geohash online</string>
|
||||
<string name="about_online_geohash_desc">Łącz się z ludźmi w Twojej okolicy za pomocą kanałów opartych na geohash. Rozszerz sieć mesh, korzystając z publicznych przekaźników internetowych.</string>
|
||||
<string name="about_e2e_title">Szyfrowanie end-to-end</string>
|
||||
<string name="about_e2e_desc">Wiadomości prywatne są szyfrowane. Wiadomości na kanałach są publiczne.</string>
|
||||
<string name="about_system">systemowy</string>
|
||||
<string name="about_light">jasny</string>
|
||||
<string name="about_dark">ciemny</string>
|
||||
<string name="about_pow">dowód pracy</string>
|
||||
<string name="about_pow_off">pow wyłączony</string>
|
||||
<string name="about_pow_on">pow włączony</string>
|
||||
<string name="about_pow_tip">dodaj dowód pracy do wiadomości geohash, aby zniechęcić do spamu.</string>
|
||||
<string name="about_pow_difficulty">trudność: %1$d bitów (~%2$s)</string>
|
||||
<string name="about_pow_difficulty_attempts">trudność %1$d wymaga ~%2$s prób hashowania</string>
|
||||
<string name="about_pow_desc_none">dowód pracy niewymagany</string>
|
||||
<string name="about_pow_desc_very_low">bardzo niska - minimalna ochrona przed spamem</string>
|
||||
<string name="about_pow_desc_low">niska - podstawowa ochrona przed spamem</string>
|
||||
<string name="about_pow_desc_medium">średnia - dobra ochrona przed spamem</string>
|
||||
<string name="about_pow_desc_high">wysoka - silna ochrona przed spamem</string>
|
||||
<string name="about_pow_desc_very_high">bardzo wysoka - może powodować opóźnienia</string>
|
||||
<string name="about_pow_desc_extreme">ekstremalna - wymaga znacznych obliczeń</string>
|
||||
<string name="about_network">sieć</string>
|
||||
<string name="about_tor_off">tor wyłączony</string>
|
||||
<string name="about_tor_on">tor włączony</string>
|
||||
<string name="about_tor_route">kieruj ruch internetowy przez Tor dla większej prywatności.</string>
|
||||
<string name="about_tor_status">Status tor: %1$s, bootstrap %2$d%%</string>
|
||||
<string name="about_last">Ostatnio: %1$s</string>
|
||||
<string name="about_emergency_title">Awaryjne usuwanie danych</string>
|
||||
<string name="about_debug_settings">Ustawienia debugowania</string>
|
||||
<string name="about_footer">Open Source • Prywatność przede wszystkim • Zdecentralizowane</string>
|
||||
<string name="close_plain">Zamknij</string>
|
||||
<string name="cd_privacy_protected">Prywatność chroniona</string>
|
||||
<string name="cancel_lower">anuluj</string>
|
||||
<string name="cd_warning">Ostrzeżenie</string>
|
||||
<string name="cd_location_services">Usługi lokalizacji</string>
|
||||
<string name="cd_privacy">Prywatność</string>
|
||||
<string name="cd_error">Błąd</string>
|
||||
<string name="cd_battery_optimization">Optymalizacja baterii</string>
|
||||
<string name="cd_benefits">Korzyści</string>
|
||||
<string name="cd_checking_battery_optimization">Sprawdzanie optymalizacji baterii</string>
|
||||
<string name="cd_not_supported_battery_optimization">Optymalizacja baterii nieobsługiwana</string>
|
||||
<string name="cd_bluetooth">Bluetooth</string>
|
||||
<string name="cd_unread_message">Nieprzeczytana wiadomość</string>
|
||||
<string name="cd_open_map">Otwórz mapę</string>
|
||||
<string name="cd_remove_bookmark">Usuń zakładkę</string>
|
||||
<string name="cd_teleport">Teleportuj</string>
|
||||
<string name="notification_action_quit_bitchat">Zamknij bitchat</string>
|
||||
<string name="about_background_title">działaj w tle</string>
|
||||
<string name="about_background_desc">utrzymuj sieć mesh aktywną po zamknięciu aplikacji (usługa pierwszoplanowa)</string>
|
||||
<string name="cd_leave_channel">Opuść kanał</string>
|
||||
<string name="cd_reachable_via_nostr">Dostępny przez Nostr</string>
|
||||
<string name="cd_offline_favorite">Ulubiony offline</string>
|
||||
<string name="cd_decrease_precision">Zmniejsz precyzję</string>
|
||||
<string name="cd_increase_precision">Zwiększ precyzję</string>
|
||||
<string name="cd_select_geohash">Wybierz geohash</string>
|
||||
<string name="cd_scroll_to_bottom">Przewiń na dół</string>
|
||||
<string name="cd_file">Plik</string>
|
||||
<string name="cd_image">Obraz</string>
|
||||
<string name="cd_cancel">Anuluj</string>
|
||||
<string name="cd_link">Link</string>
|
||||
<string name="cd_record_voice">Nagraj notatkę głosową</string>
|
||||
<string name="cd_pick_media">Wybierz multimedia</string>
|
||||
<string name="cd_offline_mesh_chat">Czat mesh offline</string>
|
||||
<string name="cd_online_geohash_channels">Kanały geohash online</string>
|
||||
<string name="cd_end_to_end_encryption">Szyfrowanie end-to-end</string>
|
||||
<string name="location_bluetooth_subtitle">#bluetooth • %1$s</string>
|
||||
<string name="image_page_of">Obraz %1$d z %2$d</string>
|
||||
<string name="image_unavailable">Obraz niedostępny</string>
|
||||
<string name="image_saved_to_downloads">Obraz zapisany w Pobranych</string>
|
||||
<string name="image_save_failed">Nie udało się zapisać obrazu</string>
|
||||
<string name="pick_file">Wybierz plik</string>
|
||||
<string name="file_unavailable">[plik niedostępny]</string>
|
||||
<string name="unknown">nieznany</string>
|
||||
<string name="choose_action_message_or_user">wybierz działanie dla tej wiadomości lub użytkownika</string>
|
||||
<string name="choose_action_user">wybierz działanie dla tego użytkownika</string>
|
||||
<string name="action_copy_message_title">kopiuj wiadomość</string>
|
||||
<string name="action_copy_message_subtitle">skopiuj tę wiadomość do schowka</string>
|
||||
<string name="action_slap_title">spoliczkuj %1$s</string>
|
||||
<string name="action_slap_subtitle">wyślij żartobliwą wiadomość o spoliczkowaniu</string>
|
||||
<string name="action_hug_title">przytul %1$s</string>
|
||||
<string name="action_hug_subtitle">wyślij przyjazną wiadomość z przytuleniem</string>
|
||||
<string name="action_block_title">zablokuj %1$s</string>
|
||||
<string name="action_block_subtitle">zablokuj wszystkie wiadomości od tego użytkownika</string>
|
||||
<string name="action_private_message_title">napisz do %1$s</string>
|
||||
<string name="action_private_message_subtitle">wyślij prywatną wiadomość</string>
|
||||
<string name="location_channels_title">#kanały lokalizacyjne</string>
|
||||
<string name="location_channels_desc">rozmawiaj z osobami w pobliżu za pomocą kanałów geohash. udostępniany jest tylko przybliżony geohash, nigdy dokładne GPS. nie rób zrzutów ekranu ani nie udostępniaj tego ekranu, aby chronić swoją prywatność.</string>
|
||||
<string name="grant_location_permission">przyznaj uprawnienie lokalizacji</string>
|
||||
<string name="location_permission_denied">odmówiono uprawnienia lokalizacji. włącz je w ustawieniach, aby korzystać z kanałów lokalizacyjnych.</string>
|
||||
<string name="location_permission_granted">\u2713 przyznano uprawnienie lokalizacji</string>
|
||||
<string name="checking_permissions">sprawdzanie uprawnień...</string>
|
||||
<string name="finding_nearby_channels">wyszukiwanie pobliskich kanałów…</string>
|
||||
<string name="bookmarked">dodano do zakładek</string>
|
||||
<string name="geohash_placeholder">geohash</string>
|
||||
<string name="invalid_geohash">nieprawidłowy geohash</string>
|
||||
<string name="teleport">teleportuj</string>
|
||||
<string name="disable_location_services">wyłącz usługi lokalizacji</string>
|
||||
<string name="enable_location_services">włącz usługi lokalizacji</string>
|
||||
<string name="mesh_label">mesh</string>
|
||||
<string name="location_level_block">blok</string>
|
||||
<string name="location_level_neighborhood">dzielnica</string>
|
||||
<string name="location_level_city">miasto</string>
|
||||
<string name="location_level_province">województwo</string>
|
||||
<string name="location_level_region">region</string>
|
||||
<string name="debug_tools">narzędzia debugowania</string>
|
||||
<string name="debug_tools_desc">narzędzia deweloperskie do diagnostyki i kontroli</string>
|
||||
<string name="debug_verbose_logging">szczegółowe logowanie</string>
|
||||
<string name="debug_verbose_hint">loguje dołączenia/odejścia peerów, kierunek połączenia, trasowanie pakietów i przekaźniki</string>
|
||||
<string name="debug_bluetooth_roles">role bluetooth</string>
|
||||
<string name="debug_gatt_server">serwer gatt</string>
|
||||
<string name="debug_connections_fmt">połączenia: %1$d / %2$d</string>
|
||||
<string name="debug_max_server">maks. serwer</string>
|
||||
<string name="debug_gatt_client">klient gatt</string>
|
||||
<string name="debug_max_client">maks. klient</string>
|
||||
<string name="debug_overall_connections_fmt">połączenia: %1$d / %2$d</string>
|
||||
<string name="debug_max_overall">maks. ogółem</string>
|
||||
<string name="debug_packet_relay">przekazywanie pakietów</string>
|
||||
<string name="debug_since_start_fmt">od startu: %1$d</string>
|
||||
<string name="debug_roles_hint">włączaj/wyłączaj role i zamykaj wszystkie połączenia po wyłączeniu</string>
|
||||
<string name="debug_sync_settings">ustawienia synchronizacji</string>
|
||||
<string name="debug_max_packets_per_sync_fmt">maks. pakietów na synchronizację: %1$d</string>
|
||||
<string name="debug_max_gcs_filter_size_fmt">maks. rozmiar filtra GCS: %1$d bajtów (128–1024)</string>
|
||||
<string name="debug_target_fpr_fmt">docelowy FPR: %1$.2f%%</string>
|
||||
<string name="debug_connected_devices">połączone urządzenia</string>
|
||||
<string name="debug_our_device_id_fmt">id naszego urządzenia: %1$s</string>
|
||||
<string name="debug_none">brak</string>
|
||||
<string name="debug_disconnect">rozłącz</string>
|
||||
<string name="debug_recent_scan_results">ostatnie wyniki skanowania</string>
|
||||
<string name="debug_connect">połącz</string>
|
||||
<string name="debug_debug_console">konsola debugowania</string>
|
||||
<string name="debug_clear">wyczyść</string>
|
||||
<string name="debug_relays_window_fmt">ostatnie 10s: %1$d • 1min: %2$d • 15min: %3$d</string>
|
||||
<string name="debug_derived_p_fmt">wyliczone P: %1$s • szac. maks. elementów: %2$s</string>
|
||||
<string name="debug_direct_suffix"> • bezpośrednio</string>
|
||||
<string name="debug_rssi_fmt">RSSI: %1$s</string>
|
||||
<string name="debug_question_mark">?</string>
|
||||
<string name="debug_role_server">jako serwer (hostujemy)</string>
|
||||
<string name="debug_role_client">jako klient (łączymy się)</string>
|
||||
<string name="location_services_required">Wymagane usługi lokalizacji</string>
|
||||
<string name="privacy_first">Prywatność przede wszystkim</string>
|
||||
<string name="location_explanation">bitchat NIE śledzi Twojej lokalizacji.\n\nUsługi lokalizacji są wymagane do skanowania Bluetooth oraz do funkcji czatu Geohash.</string>
|
||||
<string name="location_needs_for">bitchat potrzebuje usług lokalizacji do:</string>
|
||||
<string name="location_needs_bullets">• Skanowania urządzeń Bluetooth\n• Wykrywania pobliskich użytkowników w sieci mesh\n• Funkcji czatu geohash\n• Brak śledzenia lub gromadzenia lokalizacji</string>
|
||||
<string name="background_location_required_title">Zalecana lokalizacja w tle</string>
|
||||
<string name="background_location_required_subtitle">opcjonalne, poprawia niezawodność sieci mesh</string>
|
||||
<string name="background_location_explanation">Android zaleca lokalizację w tle, aby bitchat mógł skanować pobliskie urządzenia, gdy aplikacja nie jest otwarta. Utrzymuje to sieć mesh aktywną po ponownym uruchomieniu.</string>
|
||||
<string name="background_location_settings_tip">Gdy otworzą się ustawienia, wybierz „Zawsze zezwalaj”.</string>
|
||||
<string name="background_location_needs_for">bitchat używa lokalizacji w tle do:</string>
|
||||
<string name="background_location_needs_bullets">- skanowania pobliskich urządzeń, gdy aplikacja jest zamknięta\n- ponownego łączenia po restarcie\n- utrzymywania działania sieci mesh w tle</string>
|
||||
<string name="background_location_privacy_note">NIGDY nie gromadzimy ani nie zapisujemy Twojej lokalizacji. Twoja prywatność jest bezpieczna.</string>
|
||||
<string name="grant_background_location">Zezwól na lokalizację w tle</string>
|
||||
<string name="skip_background_location">Kontynuuj bez lokalizacji w tle</string>
|
||||
<string name="open_location_settings">Otwórz ustawienia lokalizacji</string>
|
||||
<string name="check_again">Sprawdź ponownie</string>
|
||||
<string name="location_services_unavailable">Usługi lokalizacji niedostępne</string>
|
||||
<string name="location_unavailable_explanation">Usługi lokalizacji są niedostępne na tym urządzeniu. Jest to nietypowe, ponieważ usługi lokalizacji są standardem w urządzeniach z Androidem.\n\nbitchat potrzebuje usług lokalizacji, aby skanowanie Bluetooth działało poprawnie (wymóg Androida). Bez tego aplikacja nie może wykrywać pobliskich użytkowników.</string>
|
||||
<string name="checking_location_services">Sprawdzanie usług lokalizacji...</string>
|
||||
<string name="bluetooth_required">Wymagany Bluetooth</string>
|
||||
<string name="bluetooth_needs_for">bitchat potrzebuje Bluetooth, aby:</string>
|
||||
<string name="bluetooth_needs_bullets">• Wykrywać pobliskich użytkowników\n• Tworzyć połączenia sieci mesh\n• Wysyłać i odbierać wiadomości\n• Działać bez internetu i serwerów</string>
|
||||
<string name="enable_bluetooth">Włącz Bluetooth</string>
|
||||
<string name="bluetooth_not_supported">Bluetooth nieobsługiwany</string>
|
||||
<string name="bluetooth_unsupported_explanation">To urządzenie nie obsługuje Bluetooth Low Energy (BLE), który jest wymagany do działania bitchat.\n\nbitchat potrzebuje BLE, aby tworzyć sieci mesh i komunikować się z pobliskimi urządzeniami bez internetu.</string>
|
||||
<string name="checking_bluetooth_status">Sprawdzanie stanu Bluetooth...</string>
|
||||
<string name="battery_optimization_detected_title">wykryto optymalizację baterii</string>
|
||||
<string name="battery_optimization_enabled_title">Optymalizacja baterii włączona</string>
|
||||
<string name="battery_optimization_explanation_short">bitchat musi działać w tle, aby utrzymać połączenia z siecią mesh. optymalizacja baterii może przerywać te połączenia.</string>
|
||||
<string name="benefits_of_disabling">Korzyści z wyłączenia</string>
|
||||
<string name="battery_benefits_short">• niezawodne dostarczanie wiadomości\n• utrzymuje łączność sieci mesh\n• zapobiega zrywaniu połączeń</string>
|
||||
<string name="disable_battery_optimization">Wyłącz optymalizację baterii</string>
|
||||
<string name="battery_optimization_disabled_title">optymalizacja baterii wyłączona</string>
|
||||
<string name="continue_btn">Kontynuuj</string>
|
||||
<string name="initializing_mesh_network">Inicjalizowanie sieci mesh</string>
|
||||
<string name="dot">.</string>
|
||||
<string name="setting_up_bluetooth">Konfigurowanie sieci mesh Bluetooth...</string>
|
||||
<string name="should_take_seconds">To powinno zająć tylko kilka sekund</string>
|
||||
<string name="warning_emoji">⚠️</string>
|
||||
<string name="setup_not_complete">Konfiguracja niekompletna</string>
|
||||
<string name="try_again">Spróbuj ponownie</string>
|
||||
<string name="open_settings">Otwórz ustawienia</string>
|
||||
<string name="privacy_protected">Twoja prywatność jest chroniona</string>
|
||||
<string name="privacy_bullets">• brak śledzenia ani gromadzenia danych\n• czaty sieci mesh Bluetooth są całkowicie offline\n• czaty geohash korzystają z internetu</string>
|
||||
<string name="permissions_header">uprawnienia</string>
|
||||
<string name="grant_permissions">Przyznaj uprawnienia</string>
|
||||
<string name="location_tracking_warning">bitchat NIE śledzi Twojej lokalizacji</string>
|
||||
<string name="at_symbol">@</string>
|
||||
<string name="channel_count_prefix"> · ⧉ </string>
|
||||
<string name="nobody_around">nikogo w pobliżu...</string>
|
||||
<string name="you_suffix"> (ty)</string>
|
||||
<string name="pan_zoom_instruction">przesuwaj i powiększaj, aby wybrać geohash</string>
|
||||
<string name="select">wybierz</string>
|
||||
<string name="type_a_message_placeholder">napisz wiadomość...</string>
|
||||
<string name="mention_suggestion_at">@%1$s</string>
|
||||
<string name="mention">wzmianka</string>
|
||||
<string name="image_counter">%1$d / %2$d</string>
|
||||
<string name="at_nickname">@%1$s</string>
|
||||
<string name="version_prefix">v%1$s</string>
|
||||
<string name="hash_symbol">#</string>
|
||||
<string name="underscore">_</string>
|
||||
<string name="progress_bar_brackets">[%1$s] %2$d%%</string>
|
||||
<string name="progress_filled">█</string>
|
||||
<string name="progress_empty">░</string>
|
||||
<string name="image_star">image/*</string>
|
||||
<string name="media_type_image">Obraz</string>
|
||||
<string name="media_type_file">Plik</string>
|
||||
<string name="status_sending">○</string>
|
||||
<string name="status_pending">○</string>
|
||||
<string name="status_sent">✓</string>
|
||||
<string name="status_delivered">✓✓</string>
|
||||
<string name="status_failed">⚠</string>
|
||||
<string name="status_read">✓</string>
|
||||
<string name="notification_sent_image">📷 wysłał(a) obraz</string>
|
||||
<string name="notification_sent_voice">🎤 wysłał(a) wiadomość głosową</string>
|
||||
<string name="notification_sent_file">📎 wysłał(a) plik</string>
|
||||
<string name="notification_file_pdf">📄</string>
|
||||
<string name="notification_file_zip">🗜️</string>
|
||||
<string name="notification_file_doc">📄</string>
|
||||
<string name="notification_file_xls">📊</string>
|
||||
<string name="notification_file_ppt">📈</string>
|
||||
<string name="notification_file_generic">📎</string>
|
||||
<string name="cd_play_voice">Odtwórz</string>
|
||||
<string name="cd_pause_voice">Wstrzymaj</string>
|
||||
<string name="perm_nearby_devices_desc">Wymagane do wykrywania użytkowników bitchat przez Bluetooth</string>
|
||||
<string name="perm_nearby_devices_system">Zezwól bitchat na łączenie się z pobliskimi urządzeniami</string>
|
||||
<string name="perm_location_desc">Wymagane przez Android do wykrywania pobliskich użytkowników bitchat przez Bluetooth</string>
|
||||
<string name="perm_location_system">bitchat potrzebuje tego, aby skanować pobliskie urządzenia</string>
|
||||
<string name="perm_background_location_desc">Zalecane do skanowania pobliskich urządzeń, gdy aplikacja działa w tle</string>
|
||||
<string name="perm_background_location_system">Zezwól na dostęp do lokalizacji w tle</string>
|
||||
<string name="perm_notifications_desc">Otrzymuj powiadomienia, gdy dostaniesz prywatne wiadomości</string>
|
||||
<string name="perm_notifications_system">Zezwól bitchat na wysyłanie Ci powiadomień</string>
|
||||
<string name="perm_battery_desc">Wyłącz optymalizację baterii, aby zapewnić niezawodne działanie bitchat w tle i utrzymanie połączeń z siecią mesh</string>
|
||||
<string name="perm_battery_system">Zezwól bitchat na działanie bez ograniczeń baterii</string>
|
||||
<string name="perm_type_nearby_devices">Pobliskie urządzenia</string>
|
||||
<string name="perm_type_precise_location">Precyzyjna lokalizacja</string>
|
||||
<string name="perm_type_background_location">Lokalizacja w tle</string>
|
||||
<string name="perm_type_microphone">Mikrofon</string>
|
||||
<string name="perm_type_notifications">Powiadomienia</string>
|
||||
<string name="perm_type_battery_optimization">Optymalizacja baterii</string>
|
||||
<string name="perm_type_other">Inne</string>
|
||||
<string name="pwd_prompt_title">Wprowadź hasło kanału</string>
|
||||
<string name="pwd_prompt_message">Kanał %1$s jest chroniony hasłem. Wprowadź hasło, aby dołączyć.</string>
|
||||
<string name="pwd_label">Hasło</string>
|
||||
<string name="join">Dołącz</string>
|
||||
<string name="cancel">Anuluj</string>
|
||||
<string name="tor_not_available_in_this_build">Tor niedostępny w tej wersji</string>
|
||||
|
||||
<plurals name="notification_and_more">
|
||||
<item quantity="one">i jeszcze %1$d</item>
|
||||
<item quantity="few">i jeszcze %1$d</item>
|
||||
<item quantity="many">i jeszcze %1$d</item>
|
||||
<item quantity="other">i jeszcze %1$d</item>
|
||||
</plurals>
|
||||
<plurals name="people_count">
|
||||
<item quantity="one">%1$d osoba</item>
|
||||
<item quantity="few">%1$d osoby</item>
|
||||
<item quantity="many">%1$d osób</item>
|
||||
<item quantity="other">%1$d osoby</item>
|
||||
</plurals>
|
||||
<string name="about_language">Język</string>
|
||||
<string name="about_app_language">Język aplikacji</string>
|
||||
<string name="about_system_default">Domyślny systemu</string>
|
||||
<string name="about_select_language">Wybierz język</string>
|
||||
</resources>
|
||||
|
||||
@ -46,7 +46,7 @@
|
||||
<string name="notification_summary_more">e mais %1$d</string>
|
||||
<string name="notification_messages_from_people">%1$d mensagens de %2$d pessoas</string>
|
||||
<string name="notification_more_conversations">e mais %1$d conversas</string>
|
||||
<string name="notification_active_peers_title">👥 pessoas do bitchat por perto!</string>
|
||||
<string name="notification_active_peers_title">pessoas do bitchat por perto!</string>
|
||||
<string name="notification_active_peers_one">1 pessoa por perto</string>
|
||||
<string name="notification_active_peers_many">%1$d pessoas por perto</string>
|
||||
<string name="notification_new_messages">Novas mensagens</string>
|
||||
@ -389,4 +389,8 @@
|
||||
<string name="nearby_notes_reveal">ver se há notas deixadas aqui</string>
|
||||
<string name="nearby_notes_one">1 nota deixada aqui — toque para ler</string>
|
||||
<string name="nearby_notes_many">%d notas deixadas aqui — toque para ler</string>
|
||||
<string name="about_language">Idioma</string>
|
||||
<string name="about_app_language">Idioma do app</string>
|
||||
<string name="about_system_default">Padrão do sistema</string>
|
||||
<string name="about_select_language">Selecionar idioma</string>
|
||||
</resources>
|
||||
|
||||
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