Merge branch 'main' into wifi-aware-refactor-mesh-core

# Conflicts:
#	app/src/main/AndroidManifest.xml
#	app/src/main/java/com/bitchat/android/BitchatApplication.kt
#	app/src/main/java/com/bitchat/android/MainActivity.kt
#	app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionTracker.kt
#	app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt
#	app/src/main/java/com/bitchat/android/noise/NoiseEncryptionService.kt
#	app/src/main/java/com/bitchat/android/onboarding/PermissionManager.kt
#	app/src/main/java/com/bitchat/android/ui/SidebarComponents.kt
This commit is contained in:
CC
2026-06-08 22:56:40 +02:00
146 changed files with 12976 additions and 2773 deletions
+1
View File
@@ -1,6 +1,7 @@
name: Android CI
on:
workflow_dispatch:
push:
branches: [ "main", "develop" ]
pull_request:
+19 -18
View File
@@ -38,17 +38,20 @@ jobs:
- name: Grant execute permission for Gradlew
run: chmod +x ./gradlew
- name: Build Release APK (with Tor)
- name: Build Release APKs (with architecture splits)
run: ./gradlew assembleRelease --no-daemon --stacktrace
- name: List APK files
run: |
echo "APK files built:"
find app/build/outputs/apk -name "*.apk" -type f
find app/build/outputs/apk/release -name "*.apk" -type f -exec ls -lh {} \;
- name: Rename APK
- name: Rename APKs for GitHub Release
run: |
mv app/build/outputs/apk/release/app-release-unsigned.apk app/build/outputs/apk/release/bitchat-android.apk
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: |
@@ -59,8 +62,8 @@ jobs:
ls -all
tree || ls -R
# Optional: Sign APK (requires secrets)
# - name: Sign APK
# 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
@@ -69,10 +72,10 @@ jobs:
# keyStorePassword: ${{ secrets.KEY_STORE_PASSWORD }}
# keyPassword: ${{ secrets.KEY_PASSWORD }}
- name: Upload APK as artifact
- name: Upload APKs as artifacts
uses: actions/upload-artifact@v4
with:
name: bitchat-android-apk-${{ github.ref_name }}
name: bitchat-android-release-${{ github.ref_name }}
path: app/build/outputs/apk/release/*.apk
retention-days: 30
if-no-files-found: error
@@ -82,25 +85,23 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Download APK artifact
- name: Download release artifacts
uses: actions/download-artifact@v4
with:
name: bitchat-android-apk-${{ github.ref_name }}
name: bitchat-android-release-${{ github.ref_name }}
path: release
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
files: |
release/bitchat-android.apk
release/bitchat-android-arm64.apk
release/bitchat-android-x86_64.apk
release/bitchat-android-universal.apk
name: Release ${{ github.ref_name }}
body: |
## bitchat Android Release
**bitchat-android.apk** (~15MB)
- Secure P2P messaging over Bluetooth mesh and Nostr
- Built-in Tor support (custom Arti build with 16KB page size)
- Compatible with Google Play requirements (Nov 2025+)
- Cross-platform compatible with iOS version
**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 }}
+74
View File
@@ -0,0 +1,74 @@
# Bitchat Android - Agent Guide
This document provides context, architectural insights, and development standards for AI agents working on the Bitchat Android codebase.
## 1. Project Overview
**Bitchat** is a decentralized, off-grid communication application focused on privacy and censorship resistance. It utilizes mesh networking (primarily Bluetooth LE and Tor/Arti) to enable peer-to-peer messaging without centralized servers.
**Key Technologies:**
- **Language:** Kotlin (JVM Target 1.8)
- **UI Framework:** Jetpack Compose (Material 3)
- **Asynchronous:** Kotlin Coroutines & Flow
- **Networking:** Bluetooth Low Energy (BLE), Tor (Arti Rust bridge), OkHttp
- **Architecture:** MVVM with Clean Architecture principles
- **Build System:** Gradle (Kotlin DSL)
## 2. Architecture & Directory Structure
The application follows a clean architecture pattern, heavily modularized by feature within the `app` module.
**Root Package:** `com.bitchat.android`
| Directory | Purpose |
|-----------|---------|
| `ui/` | **Presentation Layer**: Jetpack Compose screens, themes, and ViewModels. |
| `service/` | **Core Service**: Contains `MeshForegroundService`, managing persistent background connectivity. |
| `mesh/` | **Mesh Networking**: Logic for peer discovery, advertising, and message routing. |
| `protocol/` | **Wire Protocol**: Definitions of messages exchanged between peers. |
| `crypto/` | **Security**: Cryptographic primitives and key management. |
| `noise/` | **Encryption**: Implementation of the Noise Protocol Framework for secure channels. |
| `identity/` | **User Identity**: Management of user profiles and public/private keys. |
| `features/` | **App Features**: Sub-modules for `voice`, `file`, and `media` handling. |
| `nostr/` | **Relay Integration**: Logic for Nostr protocol integration and relay management. |
| `geohash/` | **Location**: Utilities for location-based features and geohashing. |
| `net/` | **Networking**: General network utilities and abstractions. |
## 3. Key Components
### UI Layer (Jetpack Compose)
- **Activity**: Single-Activity architecture (`MainActivity.kt`).
- **Navigation**: Jetpack Compose Navigation.
- **State Management**: `ViewModel` exposing `StateFlow` to Composables.
- **Theme**: Custom theme definitions in `ui/theme`.
### Networking & Connectivity
- **MeshForegroundService**: The critical component that keeps the mesh network alive. It manages the lifecycle of BLE scanning/advertising and other transport layers.
- **BLE Stack**: Located in `mesh/` and `net/`, handles the intricacies of Android Bluetooth interactions.
- **Tor/Arti**: Integrated via JNI (`jniLibs`) to provide anonymous internet routing where available.
## 4. Development Standards
### Code Style
- **Kotlin**: Adhere to official Kotlin coding conventions.
- **Compose**: Use functional components. Hoist state to ViewModels where possible.
- **Coroutines**: Use `suspend` functions for all I/O operations. strictly avoid blocking the main thread.
- **Naming**: Clear, descriptive names. Follow standard Android naming patterns (e.g., `*ViewModel`, `*Repository`, `*Screen`).
### Testing
- **Unit Tests**: Located in `app/src/test/`. Use for business logic, protocols, and utility testing.
- **Instrumented Tests**: Located in `app/src/androidTest/`. Use for UI and permission integration testing.
- **Execution**:
- Unit: `./gradlew test`
- Instrumented: `./gradlew connectedAndroidTest`
## 5. Critical Constraints & Gotchas
1. **Permissions**: The app relies heavily on dangerous runtime permissions (Location, Bluetooth Scan/Connect/Advertise, Audio Recording). Always verify permission handling patterns in `MainActivity` or permission wrappers before adding new hardware features.
2. **Hardware Dependency**: Features like BLE are difficult to emulate. When writing code for these, focus on robust error handling and defensive programming as hardware behavior can be flaky.
3. **Background Limits**: Android enforces strict background execution limits. Network operations intended to persist must be tied to the `MeshForegroundService`.
## 6. Common Tasks
- **Build Debug APK**: `./gradlew assembleDebug`
- **Lint Check**: `./gradlew lint`
- **Clean Build**: `./gradlew clean`
---
*Note: This file is intended to assist AI agents in navigating and modifying the codebase efficiently. Always verify context by reading the actual files before making changes.*
+670 -17
View File
@@ -1,21 +1,674 @@
MIT License
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (c) 2025
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
Preamble
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
The GNU General Public License is a free, copyleft license for
software and other kinds of works.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
the GNU General Public License is intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users. We, the Free Software Foundation, use the
GNU General Public License for most of our software; it applies also to
any other work released this way by its authors. You can apply it to
your programs, too.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
To protect your rights, we need to prevent others from denying you
these rights or asking you to surrender the rights. Therefore, you have
certain responsibilities if you distribute copies of the software, or if
you modify it: responsibilities to respect the freedom of others.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must pass on to the recipients the same
freedoms that you received. You must make sure that they, too, receive
or can get the source code. And you must show them these terms so they
know their rights.
Developers that use the GNU GPL protect your rights with two steps:
(1) assert copyright on the software, and (2) offer you this License
giving you legal permission to copy, distribute and/or modify it.
For the developers' and authors' protection, the GPL clearly explains
that there is no warranty for this free software. For both users' and
authors' sake, the GPL requires that modified versions be marked as
changed, so that their problems will not be attributed erroneously to
authors of previous versions.
Some devices are designed to deny users access to install or run
modified versions of the software inside them, although the manufacturer
can do so. This is fundamentally incompatible with the aim of
protecting users' freedom to change the software. The systematic
pattern of such abuse occurs in the area of products for individuals to
use, which is precisely where it is most unacceptable. Therefore, we
have designed this version of the GPL to prohibit the practice for those
products. If such problems arise substantially in other domains, we
stand ready to extend this provision to those domains in future versions
of the GPL, as needed to protect the freedom of users.
Finally, every program is threatened constantly by software patents.
States should not allow patents to restrict development and use of
software on general-purpose computers, but in those that do, we wish to
avoid the special danger that patents applied to a free program could
make it effectively proprietary. To prevent this, the GPL assures that
patents cannot be used to render the program non-free.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Use with the GNU Affero General Public License.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU Affero General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the special requirements of the GNU Affero General Public License,
section 13, concerning interaction through a network will apply to the
combination as such.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If the program does terminal interaction, make it output a short
notice like this when it starts in an interactive mode:
<program> Copyright (C) <year> <name of author>
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, your program's commands
might be different; for a GUI interface, you would use an "about box".
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU GPL, see
<https://www.gnu.org/licenses/>.
The GNU General Public License does not permit incorporating your program
into proprietary programs. If your program is a subroutine library, you
may consider it more useful to permit linking proprietary applications with
the library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License. But first, please read
<https://www.gnu.org/licenses/why-not-lgpl.html>.
+22 -7
View File
@@ -8,7 +8,10 @@ bitchat is designed with privacy as its foundation. We believe private communica
## Summary
**WE DO NOT COLLECT ANY INFORMATION.**
- **No personal data collection** - We don't collect names, emails, or phone numbers
- **No location data collection** - Location is accessed only for local processing (BLE/Geohash) and is never collected or sent to us
- **Hybrid Functionality** - bitchat offers two modes of communication:
- **Bluetooth Mesh Chat**: This mode is completely offline, using peer-to-peer Bluetooth connections. It does not use any servers or internet connection.
- **Geohash Chat**: This mode uses an internet connection to communicate with others in a specific geographic area. It relies on Nostr relays for message transport.
@@ -68,7 +71,8 @@ When you join a password-protected room:
bitchat **never**:
- Collects personal information
- Tracks your location
- Collects location history
- Transmits any data to us (the developers)
- Stores data on servers
- Shares data with third parties
- Uses analytics or telemetry
@@ -91,13 +95,24 @@ You have complete control:
- **No Account**: Nothing to delete from servers because there are none
- **Portability**: Your data never leaves your device unless you export it
## Bluetooth & Permissions
## Location Data & Permissions
bitchat requires Bluetooth permission to function:
- Used only for peer-to-peer communication
- No location data is accessed or stored
- Bluetooth is not used for tracking
- You can revoke this permission at any time in system settings
To provide the core functionality of bitchat, we access your device's location data. This access is necessary for the following specific purposes:
### 1. Bluetooth Low Energy (BLE) Scanning
- **Why we need it:** The Android operating system requires Location permission to scan for nearby Bluetooth LE devices (especially on Android 11 and lower). This is a system-level requirement because Bluetooth scans can theoretically be used to derive location.
- **How we use it:** We use this permission strictly to discover other bitchat peers nearby for the "Bluetooth Mesh Chat" mode.
- **Privacy protection:** We do not record or store your location during this process. The data is processed instantaneously by the Android system to facilitate the connection.
### 2. Geohash Chat Functionality
- **Why we need it:** The "Geohash Chat" mode allows you to communicate with others in your approximate geographic area.
- **How we use it:** If you enable this mode, we access your location to calculate a "geohash" (a short alphanumeric string representing a geographic region). This geohash is used to find and subscribe to relevant channels on decentralized Nostr relays.
- **Privacy protection:**
- Your precise GPS coordinates are **never** sent to any server or peer.
- Only the coarse geohash (representing an area, not a pinpoint) is shared with the Nostr network.
- You can use the "Bluetooth Mesh Chat" mode without this feature if you prefer.
**We do not collect, store, or share your location history.** Location data is processed locally on your device to enable these specific features.
## Children's Privacy
+32 -8
View File
@@ -13,8 +13,8 @@ android {
applicationId = "com.bitchat.droid"
minSdk = libs.versions.minSdk.get().toInt()
targetSdk = libs.versions.targetSdk.get().toInt()
versionCode = 27
versionName = "1.6.0"
versionCode = 33
versionName = "1.7.2"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
@@ -33,7 +33,7 @@ android {
debug {
ndk {
// Include x86_64 for emulator support during development
abiFilters += listOf("arm64-v8a", "x86_64")
abiFilters += listOf("arm64-v8a", "x86_64", "armeabi-v7a", "x86")
}
}
release {
@@ -43,13 +43,27 @@ android {
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
ndk {
// ARM64-only to minimize APK size (~5.8MB savings)
// Excludes x86_64 as emulator not needed for production builds
abiFilters += listOf("arm64-v8a")
}
}
}
// APK splits for GitHub releases - creates arm64, x86_64, and universal APKs
// AAB for Play Store handles architecture distribution automatically
// Auto-detects: splits enabled for assemble tasks, disabled for bundle tasks
// Works in Android Studio GUI and CLI without needing extra properties
val enableSplits = gradle.startParameter.taskNames.any { taskName ->
taskName.contains("assemble", ignoreCase = true) &&
!taskName.contains("bundle", ignoreCase = true)
}
splits {
abi {
isEnable = enableSplits
reset()
include("arm64-v8a", "x86_64", "armeabi-v7a", "x86")
isUniversalApk = true // For F-Droid and fallback
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
@@ -84,6 +98,7 @@ dependencies {
// Lifecycle
implementation(libs.bundles.lifecycle)
implementation(libs.androidx.lifecycle.process)
// Navigation
implementation(libs.androidx.navigation.compose)
@@ -91,6 +106,15 @@ dependencies {
// Permissions
implementation(libs.accompanist.permissions)
// QR
implementation(libs.zxing.core)
implementation(libs.mlkit.barcode.scanning)
// CameraX
implementation(libs.androidx.camera.camera2)
implementation(libs.androidx.camera.lifecycle)
implementation(libs.androidx.camera.compose)
// Cryptography
implementation(libs.bundles.cryptography)
+5
View File
@@ -26,3 +26,8 @@
-keepnames class org.torproject.arti.**
-dontwarn info.guardianproject.arti.**
-dontwarn org.torproject.arti.**
# Fix for AbstractMethodError on API < 29 where LocationListener methods are abstract
-keepclassmembers class * implements android.location.LocationListener {
public <methods>;
}
+13 -1
View File
@@ -17,6 +17,7 @@
<!-- Location permission required for BLE scanning -->
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />
<!-- Notification permissions -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
@@ -34,10 +35,14 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE" />
<!-- Data sync foreground service type (required when declaring dataSync) -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<!-- Location foreground service type required for BLE scanning in background -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<!-- Microphone for voice notes -->
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<!-- Camera for QR verification -->
<uses-permission android:name="android.permission.CAMERA" />
<!-- Storage permissions for file sharing -->
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="28" />
@@ -56,6 +61,7 @@
<uses-feature android:name="android.hardware.bluetooth" android:required="true" />
<!-- Device support hint for WiFi Aware (optional) -->
<uses-feature android:name="android.hardware.wifi.aware" android:required="false" />
<uses-feature android:name="android.hardware.camera" android:required="false" />
<permission
android:name="com.bitchat.android.permission.FORCE_FINISH"
@@ -98,13 +104,19 @@
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="bitchat" android:host="verify" />
</intent-filter>
</activity>
<!-- Persistent foreground service to run the mesh in background -->
<service
android:name=".service.MeshForegroundService"
android:exported="false"
android:foregroundServiceType="connectedDevice|dataSync"
android:foregroundServiceType="connectedDevice|dataSync|location"
tools:ignore="DataExtractionRules">
</service>
+433 -287
View File
@@ -1,295 +1,441 @@
Relay URL,Latitude,Longitude
strfry.shock.network,39.0438,-77.4874
wot.nostr.party,36.1627,-86.7816
nostr.blankfors.se,60.1699,24.9384
nostr.now,36.55,139.733
nostr.simplex.icu,51.5121,-0.0005238
relay.threenine.services,51.5524,-0.29686
relay.barine.co,43.6532,-79.3832
theoutpost.life,64.1476,-21.9392
purplerelay.com,50.1109,8.68213
nostrue.com,40.8054,-74.0241
relay-arg.zombi.cloudrodion.com,1.35208,103.82
shu02.shugur.net,21.4902,39.2246
relayone.geektank.ai,18.2148,-63.0574
relay03.lnfi.network,39.0997,-94.5786
soloco.nl,43.6532,-79.3832
relay-testnet.k8s.layer3.news,37.3387,-121.885
nostr.azzamo.net,52.2633,21.0283
nostr.red5d.dev,43.6532,-79.3832
nostr.thebiglake.org,32.71,-96.6745
nostr.tadryanom.me,43.6532,-79.3832
schnorr.me,43.6532,-79.3832
ithurtswhenip.ee,51.223,6.78245
orly.ft.hn,50.4754,12.3683
relay02.lnfi.network,39.0997,-94.5786
relay.nosto.re,51.1792,5.89444
strfry.openhoofd.nl,51.9229,4.40833
relays.diggoo.com,43.6532,-79.3832
relay.nostrhub.tech,49.0291,8.35696
relay.artx.market,43.652,-79.3633
relay.sigit.io,50.4754,12.3683
nostr-relay.psfoundation.info,39.0438,-77.4874
wot.brightbolt.net,47.6735,-116.781
relay.bitcoinartclock.com,50.4754,12.3683
khatru.nostrver.se,51.1792,5.89444
relay.cosmicbolt.net,37.3986,-121.964
adre.su,59.9311,30.3609
nostrelay.memory-art.xyz,43.6532,-79.3832
temp.iris.to,43.6532,-79.3832
nproxy.kristapsk.lv,60.1699,24.9384
relay.ngengine.org,43.6532,-79.3832
relay.puresignal.news,43.6532,-79.3832
nostr.stakey.net,52.3676,4.90414
nostr.commonshub.brussels,49.4543,11.0746
wot.shaving.kiwi,43.6532,-79.3832
espelho.girino.org,43.6532,-79.3832
relay.thibautduchene.fr,43.6532,-79.3832
nostr-relay.corb.net,38.8353,-104.822
wot.soundhsa.com,33.1384,-95.6011
relay.jabato.space,52.52,13.405
relay.credenso.cafe,43.3601,-80.3127
relayone.soundhsa.com,33.1384,-95.6011
relay.bitcoinveneto.org,64.1466,-21.9426
relay.samt.st,40.8302,-74.1299
relay.vrtmrz.net,43.6532,-79.3832
nostr.data.haus,50.4754,12.3683
relay.npubhaus.com,43.6532,-79.3832
strfry.elswa-dev.online,50.1109,8.68213
relay.wavefunc.live,34.0362,-118.443
wot.sovbit.host,64.1466,-21.9426
relay.smies.me,33.7501,-84.3885
nostr.bilthon.dev,25.8128,-80.2377
relay-fra.zombi.cloudrodion.com,48.8566,2.35222
santo.iguanatech.net,40.8302,-74.1299
prl.plus,42.6978,23.3246
relay-freeharmonypeople.space,38.7223,-9.13934
nostream.breadslice.com,1.35208,103.82
nostr.notribe.net,40.8302,-74.1299
relay.fountain.fm,39.0997,-94.5786
orangepiller.org,60.1699,24.9384
nostr.21crypto.ch,47.5356,8.73209
nostr.camalolo.com,24.1469,120.684
okn.czas.top,51.267,6.81738
relay.wolfcoil.com,35.6092,139.73
nostr.quali.chat,60.1699,24.9384
nostr-relay.online,43.6532,-79.3832
alienos.libretechsystems.xyz,55.4724,9.87335
ribo.eu.nostria.app,52.3676,4.90414
wot.yesnostr.net,50.9871,2.12554
nostr.overmind.lol,43.6532,-79.3832
relay.chakany.systems,43.6532,-79.3832
relay.hasenpfeffr.com,39.0438,-77.4874
nostrelites.org,41.8781,-87.6298
strfry.bonsai.com,37.8715,-122.273
relay.agora.social,50.7383,15.0648
alien.macneilmediagroup.com,43.6532,-79.3832
relay.getsafebox.app,43.6532,-79.3832
relay.nuts.cash,34.0362,-118.443
notemine.io,52.2026,20.9397
relay.nostr.place,32.7767,-96.797
relay.21e6.cz,50.7383,15.0648
nostr.casa21.space,43.6532,-79.3832
premium.primal.net,43.6532,-79.3832
relay5.bitransfer.org,43.6532,-79.3832
nostr.rblb.it,43.7094,10.6582
relay.goodmorningbitcoin.com,43.6532,-79.3832
relay.camelus.app,45.5201,-122.99
relay.origin.land,35.6673,139.751
relay.fr13nd5.com,52.5233,13.3426
nos.lol,50.4754,12.3683
wot.nostr.net,43.6532,-79.3832
relay.javi.space,43.4633,11.8796
relay2.ngengine.org,43.6532,-79.3832
relay.angor.io,48.1046,11.6002
pyramid.treegaze.com,43.6532,-79.3832
relay.letsfo.com,52.2633,21.0283
relay.nostrzh.org,43.6532,-79.3832
nostr.hekster.org,37.3986,-121.964
relay.0xchat.com,1.35208,103.82
nostr.czas.top,50.1109,8.68213
nostr.superfriends.online,43.6532,-79.3832
hsuite-nostr-relay.hbarsuite.workers.dev,43.6532,-79.3832
relay.mccormick.cx,52.3563,4.95714
nostr.88mph.life,51.5072,-0.127586
strfry.ymir.cloud,34.0965,-117.585
nostr.lkjsxc.com,43.6532,-79.3832
yabu.me,35.6092,139.73
relay.chorus.community,50.1109,8.68213
nostr-02.yakihonne.com,1.32123,103.695
neuromancer.nettek.io,39.1429,-94.573
relay04.lnfi.network,39.0997,-94.5786
nostr-relay.amethyst.name,39.0438,-77.4874
nostrcheck.tnsor.network,43.6532,-79.3832
wot.nostr.place,32.7767,-96.797
cyberspace.nostr1.com,40.7057,-74.0136
relay.guggero.org,47.3769,8.54169
nostr.calitabby.net,39.9268,-75.0246
x.kojira.io,43.6532,-79.3832
nostr.agentcampfire.com,52.3676,4.90414
relay.bullishbounty.com,43.6532,-79.3832
relay.nostrverse.net,43.6532,-79.3832
nostr.robosats.org,64.1476,-21.9392
nostr-verified.wellorder.net,45.5201,-122.99
nostr.huszonegy.world,47.4979,19.0402
relay.cypherflow.ai,48.8566,2.35222
nostr.bitcoiner.social:443,47.6743,-117.112
relay-dev.satlantis.io:443,40.8302,-74.1299
relay.lightning.pub:443,39.0438,-77.4874
ribo.us.nostria.app:443,43.6532,-79.3832
relay.notoshi.win,13.3622,100.983
openrelay.ziomc.com,50.0755,14.4378
relay.agentry.com:443,42.8864,-78.8784
nostr-relay.xbytez.io,50.6924,3.20113
nostr.faultables.net,43.6532,-79.3832
relay.libernet.app,43.6532,-79.3832
relay.magiccity.live,25.8128,-80.2377
relay.wellorder.net,45.5201,-122.99
black.nostrcity.club,48.8575,2.35138
relay.jeffg.fyi,43.6532,-79.3832
freeben666.fr,43.7221,7.15296
relay.nostr.wirednet.jp,34.706,135.493
nostrelay.circum.space,52.3676,4.90414
relay.islandbitcoin.com,12.8498,77.6545
nostr-rs-relay.dev.fedibtc.com,39.0438,-77.4874
relay.nostriot.com,41.5695,-83.9786
relayrs.notoshi.win,43.6532,-79.3832
relay.gulugulu.moe,43.6532,-79.3832
nostr.thebiglake.org:443,32.71,-96.6745
relay.fountain.fm,43.6532,-79.3832
freelay.sovbit.host,60.1699,24.9384
nostr.girino.org:443,43.6532,-79.3832
relay.openresist.com,43.6532,-79.3832
nas01xanthosnet.synology.me:7778,47.1285,8.74735
relay.ohstr.com:443,43.6532,-79.3832
nostr-pub.wellorder.net,45.5201,-122.99
nostr.vulpem.com,49.4543,11.0746
nostr.sathoarder.com,48.5734,7.75211
wheat.happytavern.co,43.6532,-79.3832
relay.nostr.net,43.6532,-79.3832
bcast.girino.org,43.6532,-79.3832
nostr-02.czas.top,51.2277,6.77346
vault.iris.to,43.6532,-79.3832
ynostr.yael.at,60.1699,24.9384
nostr.nodesmap.com,59.3327,18.0656
nostr.n7ekb.net,47.4941,-122.294
relayb.uid.ovh,43.6532,-79.3832
shu05.shugur.net,48.8566,2.35222
dev-relay.lnfi.network,39.0997,-94.5786
relay.bitcoindistrict.org,43.6532,-79.3832
nostr.spicyz.io,43.6532,-79.3832
nostr.0x7e.xyz,47.4988,8.72369
relay.dwadziesciajeden.pl,52.2297,21.0122
relay.zone667.com,60.1699,24.9384
nostr-dev.wellorder.net,45.5201,-122.99
nos.xmark.cc,50.6924,3.20113
relay.etch.social,41.2619,-95.8608
nostr.na.social,43.6532,-79.3832
relay.orangepill.ovh,49.1689,-0.358841
relay.olas.app,50.4754,12.3683
relay.holzeis.me,43.6532,-79.3832
relay2.angor.io,48.1046,11.6002
relay.degmods.com,50.4754,12.3683
vitor.nostr1.com,40.7128,-74.006
relay.btcforplebs.com,43.6532,-79.3832
nostr.luisschwab.net,43.6532,-79.3832
relay.moinsen.com,50.4754,12.3683
czas.xyz,48.8566,2.35222
nostr.bitcoiner.social,39.1585,-94.5728
nostr.mehdibekhtaoui.com,49.4939,-1.54813
inbox.azzamo.net,52.2633,21.0283
nostr.ovia.to,43.6532,-79.3832
nostr.myshosholoza.co.za,52.3676,4.90414
fenrir-s.notoshi.win,43.6532,-79.3832
relay-rpi.edufeed.org,49.4521,11.0767
chat-relay.zap-work.com,43.6532,-79.3832
nostr.bond,50.1109,8.68213
relay01.lnfi.network,39.0997,-94.5786
relay.seq1.net,43.6532,-79.3832
nostr.rikmeijer.nl,50.4754,12.3683
offchain.pub,47.6743,-117.112
nostr.spaceshell.xyz,43.6532,-79.3832
nos4smartnkind.tech,40.1872,44.5152
kotukonostr.onrender.com,37.7775,-122.397
nostr.ps1829.com,33.8851,130.883
relay.lightning.pub,39.0438,-77.4874
nostr-relay.gateway.in.th,15.2634,100.344
relay.thebluepulse.com,49.4521,11.0767
relay.malxte.de,52.52,13.405
relay.usefusion.ai,38.7134,-78.1591
nostr-relay.cbrx.io,43.6532,-79.3832
shu01.shugur.net,21.4902,39.2246
nostr.jerrynya.fun,31.2304,121.474
dev-nostr.bityacht.io,25.0797,121.234
bitsat.molonlabe.holdings,51.4012,-1.3147
relay.primal.net,43.6532,-79.3832
relay.wavlake.com,41.2619,-95.8608
nostr-relay.nextblockvending.com,47.2343,-119.853
v-relay.d02.vrtmrz.net,34.6937,135.502
nostr.mikoshi.de,47.74,12.0917
nostr.coincards.com,53.5501,-113.469
relay.snort.social,53.3498,-6.26031
bitcoiner.social,39.1585,-94.5728
nostr.noones.com,50.1109,8.68213
wot.dergigi.com,64.1476,-21.9392
relay.lumina.rocks,49.0291,8.35695
nostr-rs-relay-ishosta.phamthanh.me,43.6532,-79.3832
nostr.snowbla.de,60.1699,24.9384
strfry.felixzieger.de,50.1013,8.62643
relay.satlantis.io,32.8769,-80.0114
relay-dev.satlantis.io,40.8302,-74.1299
nostr.davidebtc.me,51.5072,-0.127586
relay.mattybs.lol,43.6532,-79.3832
relay.fundstr.me,42.3601,-71.0589
relay.tagayasu.xyz,43.6715,-79.38
nostr-relay.zimage.com,34.0549,-118.243
nostrcheck.me,43.6532,-79.3832
bucket.coracle.social,37.7775,-122.397
relay.siamdev.cc,13.9178,100.424
r.bitcoinhold.net,43.6532,-79.3832
skeme.vanderwarker.family,40.8218,-74.45
articles.layer3.news,37.3387,-121.885
no.str.cr,9.92857,-84.0528
srtrelay.c-stellar.net,43.6532,-79.3832
relay.comcomponent.com,34.7062,135.493
relay.illuminodes.com,47.6061,-122.333
nostr.openhoofd.nl,51.9229,4.40833
nostr.plantroon.com,50.1013,8.62643
slick.mjex.me,39.048,-77.4817
relay.minibolt.info,43.6532,-79.3832
nostr-relay-1.trustlessenterprise.com,43.6532,-79.3832
relay.nostrdice.com,-33.8688,151.209
wot.dtonon.com,43.6532,-79.3832
shu04.shugur.net,25.2604,55.2989
nostr.tavux.tech,48.8575,2.35138
nostr-02.uid.ovh,43.6532,-79.3832
relay.nostrcheck.me,43.6532,-79.3832
relay.nsnip.io,60.1699,24.9384
relay.toastr.net,40.8054,-74.0241
relay.divine.video,43.6532,-79.3832
nostr.4rs.nl,49.0291,8.35696
wot.sebastix.social,51.1792,5.89444
relay.openfarmtools.org,60.1699,24.9384
relay.damus.io,43.6532,-79.3832
purpura.cloud,43.6532,-79.3832
wot.sudocarlos.com,51.5072,-0.127586
relay.arx-ccn.com,50.4754,12.3683
nostr.zoracle.org,45.6018,-121.185
nostr.tac.lol,47.4748,-122.273
relay.coinos.io,43.6532,-79.3832
nostr.rtvslawenia.com,49.4543,11.0746
relay.davidebtc.me,51.5072,-0.127586
ribo.us.nostria.app,41.5868,-93.625
relay.upleb.uk,51.9194,19.1451
librerelay.aaroniumii.com,43.6532,-79.3832
relay.endfiat.money,43.6532,-79.3832
bbw-nostr.xyz,41.5284,-87.4237
cs-relay.nostrdev.com:443,50.4754,12.3683
top.testrelay.top,43.6532,-79.3832
relay.trotters.cc,43.6532,-79.3832
blossom.gnostr.cloud,43.6532,-79.3832
ribo.eu.nostria.app:443,43.6532,-79.3832
public.crostr.com,43.6532,-79.3832
relay.nostar.org,43.6532,-79.3832
relay.electriclifestyle.com,26.2897,-80.1293
relay.nostrhub.fr,48.1045,11.6004
relay.agorist.space,52.3734,4.89406
freelay.sovbit.host,64.1476,-21.9392
nostr.hifish.org,47.4043,8.57398
nostr-01.yakihonne.com,1.32123,103.695
relay.routstr.com,43.6532,-79.3832
nr.yay.so,46.2126,6.1154
bcast.seutoba.com.br,43.6532,-79.3832
fanfares.nostr1.com,40.7128,-74.006
nostr.girino.org,43.6532,-79.3832
strfry.bonsai.com:443,39.0438,-77.4874
nostr.carroarmato0.be,50.914,3.21378
relay.endfiat.money,59.3327,18.0656
nostr.overmind.lol:443,43.6532,-79.3832
nostr.myshosholoza.co.za:443,52.3913,4.66545
relay.wellorder.net,45.5201,-122.99
nostr.tagomago.me,42.3601,-71.0589
relay.internationalright-wing.org,-22.5022,-48.7114
relay.fckstate.net,59.3293,18.0686
nostr.azzamo.net,52.2633,21.0283
relay.0xchat.com,43.6532,-79.3832
relayone.geektank.ai,39.1008,-94.5811
relay.homeinhk.xyz,35.694,139.754
nostr.nodesmap.com,59.3327,18.0656
relay.islandbitcoin.com:443,12.8498,77.6545
relay.mrmave.work,43.6532,-79.3832
relay.arx-ccn.com,50.4754,12.3683
fanfares.nostr1.com:443,40.7057,-74.0136
wot.shaving.kiwi,43.6532,-79.3832
relay.nearhood.co.uk,51.5072,-0.127586
relay.fundstr.me,42.3601,-71.0589
syb.lol:443,43.6532,-79.3832
dm-test-strfry-discovery.samt.st:443,43.6532,-79.3832
relay.nostx.io,43.6532,-79.3832
no.str.cr,10.6352,-85.4378
relay.jbnco.co,43.6532,-79.3832
ribo.nostria.app:443,43.6532,-79.3832
us-east.nostr.pikachat.org,39.0438,-77.4874
nexus.libernet.app,43.6532,-79.3832
nostr.dlcdevkit.com,40.0992,-83.1141
nostr.debate.report,50.1109,8.68213
str-define-contributing-jackets.trycloudflare.com,43.6532,-79.3832
nostr2.girino.org,43.6532,-79.3832
relay.notoshi.win,13.311,101.112
nostrja-kari.heguro.com,43.6532,-79.3832
relay.mostro.network,40.8302,-74.1299
satsage.xyz,37.3986,-121.964
relay.evanverma.com,40.8302,-74.1299
nostr-2.21crypto.ch,47.5356,8.73209
relay.mitchelltribe.com,39.0438,-77.4874
relaynostr.breadslice.com,43.6532,-79.3832
nostr.chaima.info,51.223,6.78245
nostr.unkn0wn.world,46.8499,9.53287
nostr.spicyz.io:443,43.6532,-79.3832
relay.layer.systems:443,49.0291,8.35695
nostr-relay.amethyst.name,39.0067,-77.4291
slick.mjex.me,39.0418,-77.4744
nostr.girino.org,43.6532,-79.3832
nostr.quali.chat:443,60.1699,24.9384
purplerelay.com:443,43.6532,-79.3832
nostr.notribe.net,40.8302,-74.1299
relay.plebeian.market:443,50.1109,8.68213
relay.samt.st,40.8302,-74.1299
relay.mitchelltribe.com:443,39.0438,-77.4874
nostr.0x7e.xyz,47.4949,8.71954
relayone.soundhsa.com:443,39.1008,-94.5811
nostr.thalheim.io:443,60.1699,24.9384
relay.getsafebox.app:443,43.6532,-79.3832
nostr-relay.psfoundation.info,39.0438,-77.4874
bucket.coracle.social,37.7775,-122.397
nostr.carroarmato0.be:443,50.914,3.21378
relay.staging.commonshub.brussels,49.4543,11.0746
relay-dev.satlantis.io,40.8302,-74.1299
relay.lacompagniemaximus.com:443,45.3147,-73.8785
relay-rpi.edufeed.org,49.4521,11.0767
nostr.computingcache.com:443,34.0356,-118.442
relay.lanavault.space:443,60.1699,24.9384
relay.getsafebox.app,43.6532,-79.3832
nrs-02.darkcloudarcade.com:443,39.9526,-75.1652
nostr.hekster.org,37.3986,-121.964
node.kommonzenze.de,49.4521,11.0767
0x-nostr-relay.fly.dev,37.7648,-122.432
speakeasy.cellar.social,49.4543,11.0746
nostr.0x7e.xyz:443,47.4949,8.71954
nostr.vulpem.com,49.4543,11.0746
relay5.bitransfer.org,43.6532,-79.3832
relay.inforsupports.com,43.6532,-79.3832
relay.plebeian.market,50.1109,8.68213
shu02.shugur.net,21.4902,39.2246
nostr.easycryptosend.it,43.6532,-79.3832
nostr.spaceshell.xyz,43.6532,-79.3832
relay.minibolt.info,43.6532,-79.3832
nostr.pbfs.io:443,50.4754,12.3683
nos.lol:443,50.4754,12.3683
nostr.thebiglake.org,32.71,-96.6745
relay.nostriot.com,41.5695,-83.9786
strfry.shock.network:443,39.0438,-77.4874
relay01.lnfi.network,35.6764,139.65
r.0kb.io:443,32.789,-96.7989
bcast.girino.org,43.6532,-79.3832
nostr-relay.psfoundation.info:443,39.0438,-77.4874
dm-test-strfry-discovery.samt.st,43.6532,-79.3832
testnet.samt.st,43.6532,-79.3832
relay.bornheimer.app,51.5072,-0.127586
nrs-02.darkcloudarcade.com,39.9526,-75.1652
relay.binaryrobot.com:443,43.6532,-79.3832
nostr.computingcache.com,34.0356,-118.442
nostr-rs-relay-qj1h.onrender.com,37.7775,-122.397
schnorr.me,43.6532,-79.3832
nostr.twinkle.lol,51.902,7.6657
nostr.overmind.lol,43.6532,-79.3832
relay.mmwaves.de:443,48.8575,2.35138
relay2.veganostr.com,60.1699,24.9384
nostr.mom,50.4754,12.3683
relay.nostr-check.me,43.6532,-79.3832
nostr2.girino.org:443,43.6532,-79.3832
wot.sudocarlos.com,43.6532,-79.3832
dev.relay.stream,43.6532,-79.3832
nostr.thalheim.io,60.1699,24.9384
relay-dev.gulugulu.moe:443,43.6532,-79.3832
conduitl2.fly.dev,37.7648,-122.432
relay.bullishbounty.com,43.6532,-79.3832
myvoiceourstory.org,37.3598,-121.981
relay.angor.io,48.1046,11.6002
r.0kb.io,32.789,-96.7989
nexus.libernet.app:443,43.6532,-79.3832
us-east.nostr.pikachat.org:443,39.0438,-77.4874
nostr.bitcoiner.social,47.6743,-117.112
nostrelay.circum.space:443,52.6907,4.8181
relayone.soundhsa.com,39.1008,-94.5811
nostr.snowbla.de,60.1699,24.9384
relay1.orangesync.tech,44.7839,-106.941
nostr-2.21crypto.ch:443,47.5356,8.73209
espelho.girino.org,43.6532,-79.3832
nostr.blankfors.se,60.1699,24.9384
relay.sigit.io:443,50.4754,12.3683
relay.vrtmrz.net:443,43.6532,-79.3832
nostr.wecsats.io:443,43.6532,-79.3832
nostrcity-club.fly.dev,37.7648,-122.432
nostrelay.circum.space,52.6907,4.8181
nostr-relay-1.trustlessenterprise.com,43.6532,-79.3832
relay.edufeed.org,49.4521,11.0767
nostr.dlcdevkit.com:443,40.0992,-83.1141
x.kojira.io,43.6532,-79.3832
relay.binaryrobot.com,43.6532,-79.3832
relay.nostrhub.fr,48.1045,11.6004
nostr.sathoarder.com,48.5734,7.75211
prl.plus,55.7628,37.5983
relay.cosmicbolt.net:443,37.3986,-121.964
relay.nostu.be:443,40.4167,-3.70329
cs-relay.nostrdev.com,50.4754,12.3683
satsage.xyz,37.3986,-121.964
relay.lab.rytswd.com:443,49.4543,11.0746
rilo.nostria.app:443,43.6532,-79.3832
portal-relay.pareto.space,49.0291,8.35696
relay.wavlake.com:443,41.2619,-95.8608
relay.beginningend.com,35.2227,-97.4786
relay.openresist.com:443,43.6532,-79.3832
bitcoiner.social:443,47.6743,-117.112
relay.notoshi.win:443,13.3622,100.983
dev.relay.edufeed.org:443,49.4521,11.0767
relay.cypherflow.ai:443,48.8575,2.35138
relay.ru.ac.th,13.7607,100.627
shu03.shugur.net,25.2048,55.2708
nostr.rtvslawenia.com:443,49.4543,11.0746
relay.agorist.space:443,52.3734,4.89406
relay02.lnfi.network,35.6764,139.65
relay-testnet.k8s.layer3.news:443,37.3387,-121.885
nostr.notribe.net:443,40.8302,-74.1299
relay.ditto.pub,43.6532,-79.3832
nostr.rikmeijer.nl,51.7111,5.36809
blossom.gnostr.cloud:443,43.6532,-79.3832
nostr.oxtr.dev,50.4754,12.3683
cache.trustr.ing,43.6548,-79.3885
nostr.bitczat.pl,60.1699,24.9384
relay.nostrverse.net,43.6532,-79.3832
shu04.shugur.net,25.2048,55.2708
eu.nostr.pikachat.org:443,49.4543,11.0746
ribo.us.nostria.app,43.6532,-79.3832
nostr-relay.cbrx.io,43.6532,-79.3832
nostr.bitczat.pl:443,60.1699,24.9384
nostr-relay.corb.net:443,38.8353,-104.822
relay.libernet.app,43.6532,-79.3832
nostr-verified.wellorder.net,45.5201,-122.99
relay.nostu.be,40.4167,-3.70329
rele.speyhard.fi,51.5072,-0.127586
relay.staging.plebeian.market,51.5072,-0.127586
nostr.spicyz.io,43.6532,-79.3832
nostrride.io,37.3986,-121.964
x.kojira.io:443,43.6532,-79.3832
relay.staging.plebeian.market:443,51.5072,-0.127586
relay.sigit.io,50.4754,12.3683
nostr.stakey.net:443,52.3676,4.90414
relay.nostr.blockhenge.com,39.0438,-77.4874
relay.comcomponent.com,43.6532,-79.3832
wot.nostr.place,43.6532,-79.3832
relay.mostr.pub:443,43.6532,-79.3832
nostr-rs-relay-ishosta.phamthanh.me,43.6532,-79.3832
no.str.cr:443,10.6352,-85.4378
relay.chorus.community:443,48.5333,10.7
relay.aarpia.com,37.3986,-121.964
relay.nostrmap.net,60.1699,24.9384
relay.snotr.nl:49999,52.0195,4.42946
relay.bebond.net,43.6532,-79.3832
relay.illuminodes.com,43.6532,-79.3832
chat-relay.zap-work.com:443,43.6532,-79.3832
testr.nymble.world,40.8054,-74.0241
relay.underorion.se,50.1109,8.68213
fanfares.nostr1.com,40.7057,-74.0136
relay.damus.io,43.6532,-79.3832
relay.nostriches.club,43.6532,-79.3832
nostr-dev.wellorder.net,45.5201,-122.99
relay2.angor.io,48.1046,11.6002
relay.openfarmtools.org,60.1699,24.9384
wot.makenomistakes.ca,43.7064,-79.3986
relay.thecryptosquid.com,50.4754,12.3683
test.thedude.cloud,50.1109,8.68213
nostr.rtvslawenia.com,49.4543,11.0746
nostr-rs-relay.dev.fedibtc.com,39.0438,-77.4874
relay.olas.app:443,60.1699,24.9384
strfry.bonsai.com,39.0438,-77.4874
relayrs.notoshi.win,43.6532,-79.3832
articles.layer3.news,37.3387,-121.885
wot.utxo.one,43.6532,-79.3832
relay.angor.io:443,48.1046,11.6002
relay.dwadziesciajeden.pl,52.2297,21.0122
soloco.nl,43.6532,-79.3832
armada.sharegap.net,43.6532,-79.3832
dm-test-strfry-generic.samt.st,43.6532,-79.3832
nostr.4rs.nl,49.0291,8.35696
nrs-01.darkcloudarcade.com,39.1008,-94.5811
relay.beginningend.com:443,35.2227,-97.4786
relay.nostr.place,43.6532,-79.3832
relay.layer.systems,49.0291,8.35695
adre.su,59.9311,30.3609
relay.0xchat.com:443,43.6532,-79.3832
nostr.infero.net,35.6764,139.65
relay.typedcypher.com:443,51.5072,-0.127586
relay.mostro.network:443,40.8302,-74.1299
relay-fra.zombi.cloudrodion.com,48.8566,2.35222
relay.mitchelltribe.com,39.0438,-77.4874
relay.mostr.pub,43.6532,-79.3832
bitcoiner.social,47.6743,-117.112
nostr.tac.lol:443,47.4748,-122.273
nostr.hekster.org:443,37.3986,-121.964
relay.satmaxt.xyz:443,43.6532,-79.3832
relay.cypherflow.ai,48.8575,2.35138
relay.zone667.com:443,60.1699,24.9384
relay.jeffg.fyi:443,43.6532,-79.3832
relay.agorist.space,52.3734,4.89406
nostr.spaceshell.xyz:443,43.6532,-79.3832
top.testrelay.top:443,43.6532,-79.3832
insta-relay.apps3.slidestr.net,40.4167,-3.70329
relay.nostrian-conquest.com:443,41.223,-111.974
relay.laantungir.net:443,-19.4692,-42.5315
relay.islandbitcoin.com,12.8498,77.6545
purplerelay.com,43.6532,-79.3832
nostr.tac.lol,47.4748,-122.273
nostr.wecsats.io,43.6532,-79.3832
nostr.2b9t.xyz,34.0549,-118.243
nostr.quali.chat,60.1699,24.9384
relay.dreamith.to,43.6532,-79.3832
nostr.islandarea.net:443,35.4669,-97.6473
relay.primal.net,43.6532,-79.3832
eu.nostr.pikachat.org,49.4543,11.0746
relay.nostr.net,43.6532,-79.3832
nostr.n7ekb.net,47.4941,-122.294
relay.mulatta.io,37.5665,126.978
nittom.nostr1.com,40.7057,-74.0136
relay2.orangesync.tech,40.7128,-74.006
relay.artx.market,43.6548,-79.3885
relay.wavefunc.live,41.8781,-87.6298
bitchat.nostr1.com,40.7057,-74.0136
relay.ditto.pub:443,43.6532,-79.3832
relay.wisp.talk,49.4543,11.0746
nostrelites.org,41.8781,-87.6298
relay.goodmorningbitcoin.com,43.6532,-79.3832
dm-test-nostr-rs-42-disabled.samt.st,43.6532,-79.3832
relay.chorus.community,48.5333,10.7
relay.plebchain.club,43.6532,-79.3832
nostr-relay.amethyst.name:443,39.0067,-77.4291
relay.lanacoin-eternity.com,40.8302,-74.1299
relay.edufeed.org:443,49.4521,11.0767
relay.lacompagniemaximus.com,45.3147,-73.8785
relay.dreamith.to:443,43.6532,-79.3832
nostr.stakey.net,52.3676,4.90414
nostr.n7ekb.net:443,47.4941,-122.294
relay.dyne.org,49.0291,8.35705
nostr.janx.com,43.6532,-79.3832
relay.trustr.ing,43.6548,-79.3885
relay.paulstephenborile.com:443,49.4543,11.0746
relay.wisp.talk:443,49.4543,11.0746
yabu.me,35.6092,139.73
bcast.seutoba.com.br,43.6532,-79.3832
nos.xmark.cc,50.6924,3.20113
nos.lol,50.4754,12.3683
relay.satmaxt.xyz,43.6532,-79.3832
relay.endfiat.money:443,59.3327,18.0656
relay.tapestry.ninja,40.8054,-74.0241
relay.lab.rytswd.com,49.4543,11.0746
nostr-kyomu-haskell.onrender.com,37.7775,-122.397
relay.damus.io:443,43.6532,-79.3832
treuzkas.branruz.com,48.8575,2.35138
nostr-01.yakihonne.com,1.32123,103.695
relay.nostriot.com:443,41.5695,-83.9786
nostr-relay.nextblockvending.com,47.2343,-119.853
nostr.aruku.ovh,1.27994,103.849
nostr.chaima.info,50.1109,8.68213
ynostr.yael.at,60.1699,24.9384
ynostr.yael.at:443,60.1699,24.9384
nostr-01.yakihonne.com:443,1.32123,103.695
spookstr2.nostr1.com,40.7057,-74.0136
relay.trustr.ing:443,43.6548,-79.3885
dev.relay.edufeed.org,49.4521,11.0767
relay2.angor.io:443,48.1046,11.6002
nostr.azzamo.net:443,52.2633,21.0283
offchain.bostr.online,43.6532,-79.3832
relay.zone667.com,60.1699,24.9384
relay.veganostr.com,60.1699,24.9384
vault.iris.to:443,43.6532,-79.3832
relay.artx.market:443,43.6548,-79.3885
wot.rejecttheframe.xyz,43.6532,-79.3832
nostr.pbfs.io,50.4754,12.3683
relay.mostro.network,40.8302,-74.1299
strfry.shock.network,39.0438,-77.4874
relay.klabo.world,47.2343,-119.853
relay.fountain.fm:443,43.6532,-79.3832
nostrja-kari.heguro.com,43.6532,-79.3832
relaisnostr.trivaco.fr,48.5734,7.75211
offchain.pub,39.1585,-94.5728
relay.degmods.com,50.4754,12.3683
nostr-relay.xbytez.io:443,50.6924,3.20113
relay.paulstephenborile.com,49.4543,11.0746
nittom.nostr1.com:443,40.7057,-74.0136
dev-relay.nostreon.com,60.1699,24.9384
nostr-rs-relay.dev.fedibtc.com:443,39.0438,-77.4874
relay.jeffg.fyi,43.6532,-79.3832
relay.laantungir.net,-19.4692,-42.5315
nostr.data.haus:443,50.4754,12.3683
wot.codingarena.top,50.4754,12.3683
nostr.2b9t.xyz:443,34.0549,-118.243
relay.libernet.app:443,43.6532,-79.3832
nostr.ps1829.com,33.8851,130.883
wot.dergigi.com,64.1476,-21.9392
relay.olas.app,60.1699,24.9384
relay.lanacoin-eternity.com:443,40.8302,-74.1299
nostr.data.haus,50.4754,12.3683
relay-dev.gulugulu.moe,43.6532,-79.3832
relay.ohstr.com,43.6532,-79.3832
relay.lightning.pub,39.0438,-77.4874
relay.guggero.org,46.5971,9.59652
testnet-relay.samt.st:443,40.8302,-74.1299
thecitadel.nostr1.com,40.7057,-74.0136
nostr.ps1829.com:443,33.8851,130.883
nostr-relay.corb.net,38.8353,-104.822
relay.npubhaus.com,43.6532,-79.3832
nostr.21crypto.ch,47.5356,8.73209
nostr.tadryanom.me,43.6532,-79.3832
nostr.myshosholoza.co.za,52.3913,4.66545
relay.bitmacro.cloud,43.6532,-79.3832
ribo.nostria.app,43.6532,-79.3832
relay.vrtmrz.net,43.6532,-79.3832
syb.lol,43.6532,-79.3832
relay.bebond.net:443,43.6532,-79.3832
relay.agentry.com,42.8864,-78.8784
nostr-2.21crypto.ch,47.5356,8.73209
nostrcity-club.fly.dev:443,37.7648,-122.432
articles.layer3.news:443,37.3387,-121.885
relay.internationalright-wing.org:443,-22.5022,-48.7114
nostr-relay.zimage.com,34.0549,-118.243
chat-relay.zap-work.com,43.6532,-79.3832
relay.mwaters.net,50.9871,2.12554
nostr.liberty.fans,36.9104,-89.5875
spookstr2.nostr1.com:443,40.7057,-74.0136
nostr.chaima.info:443,50.1109,8.68213
schnorr.me:443,43.6532,-79.3832
ribo.eu.nostria.app,43.6532,-79.3832
nostr.bond,50.1109,8.68213
wot.nostr.party,36.1659,-86.7844
temp.iris.to,43.6532,-79.3832
relay.lotek-distro.com,43.6532,-79.3832
relay.minibolt.info:443,43.6532,-79.3832
relay.lanavault.space,60.1699,24.9384
relay.mmwaves.de,48.8575,2.35138
social.amanah.eblessing.co,48.1046,11.6002
nostr2.thalheim.io,49.4543,11.0746
relay.typedcypher.com,51.5072,-0.127586
relay.cosmicbolt.net,37.3986,-121.964
relayrs.notoshi.win:443,43.6532,-79.3832
nostr-relay-1.trustlessenterprise.com:443,43.6532,-79.3832
nostr.islandarea.net,35.4669,-97.6473
relay.nostrmap.net:443,60.1699,24.9384
relay.bullishbounty.com:443,43.6532,-79.3832
nostr.oxtr.dev:443,50.4754,12.3683
srtrelay.c-stellar.net,43.6532,-79.3832
relay.mccormick.cx,52.3563,4.95714
vault.iris.to,43.6532,-79.3832
relay.mccormick.cx:443,52.3563,4.95714
relay.toastr.net,40.8054,-74.0241
nostr.hifish.org,47.4244,8.57658
speakeasy.cellar.social:443,49.4543,11.0746
relay.wavlake.com,41.2619,-95.8608
testnet-relay.samt.st,40.8302,-74.1299
aeon.libretechsystems.xyz,55.486,9.86577
mostro-p2p.tech,50.1109,8.68213
nostr.wild-vibes.ts.net,48.8566,2.35222
nostr.88mph.life,52.1941,-2.21905
relay.nostrcheck.me,43.6532,-79.3832
rilo.nostria.app,43.6532,-79.3832
relay.bowlafterbowl.com,32.9483,-96.7299
relay.nostr.place:443,43.6532,-79.3832
nostr.mom:443,50.4754,12.3683
relay.nostrian-conquest.com,41.223,-111.974
herbstmeister.com,34.0549,-118.243
nrs-01.darkcloudarcade.com:443,39.1008,-94.5811
nostr.red5d.dev,43.6532,-79.3832
nostrbtc.com,43.6532,-79.3832
strfry.apps3.slidestr.net,40.4167,-3.70329
relay.sharegap.net,43.6532,-79.3832
reraw.pbla2fish.cc,43.6532,-79.3832
relay-testnet.k8s.layer3.news,37.3387,-121.885
nostr.sathoarder.com:443,48.5734,7.75211
relay.veganostr.com:443,60.1699,24.9384
relay-fra.zombi.cloudrodion.com:443,48.8566,2.35222
premium.primal.net,43.6532,-79.3832
relay.wavefunc.live:443,41.8781,-87.6298
relay-rpi.edufeed.org:443,49.4521,11.0767
kotukonostr.onrender.com,37.7775,-122.397
bridge.tagomago.me,42.3601,-71.0589
nostr.tadryanom.me:443,43.6532,-79.3832
relay.gulugulu.moe:443,43.6532,-79.3832
offchain.pub:443,39.1585,-94.5728
relay.directsponsor.net,42.8864,-78.8784
nostr.snowbla.de:443,60.1699,24.9384
1 Relay URL Latitude Longitude
2 strfry.shock.network nostr.bitcoiner.social:443 39.0438 47.6743 -77.4874 -117.112
3 wot.nostr.party relay-dev.satlantis.io:443 36.1627 40.8302 -86.7816 -74.1299
4 nostr.blankfors.se relay.lightning.pub:443 60.1699 39.0438 24.9384 -77.4874
5 nostr.now ribo.us.nostria.app:443 36.55 43.6532 139.733 -79.3832
6 nostr.simplex.icu relay.notoshi.win 51.5121 13.3622 -0.0005238 100.983
7 relay.threenine.services openrelay.ziomc.com 51.5524 50.0755 -0.29686 14.4378
8 relay.barine.co relay.agentry.com:443 43.6532 42.8864 -79.3832 -78.8784
theoutpost.life 64.1476 -21.9392
purplerelay.com 50.1109 8.68213
nostrue.com 40.8054 -74.0241
relay-arg.zombi.cloudrodion.com 1.35208 103.82
shu02.shugur.net 21.4902 39.2246
relayone.geektank.ai 18.2148 -63.0574
relay03.lnfi.network 39.0997 -94.5786
soloco.nl 43.6532 -79.3832
relay-testnet.k8s.layer3.news 37.3387 -121.885
nostr.azzamo.net 52.2633 21.0283
nostr.red5d.dev 43.6532 -79.3832
nostr.thebiglake.org 32.71 -96.6745
nostr.tadryanom.me 43.6532 -79.3832
schnorr.me 43.6532 -79.3832
ithurtswhenip.ee 51.223 6.78245
orly.ft.hn 50.4754 12.3683
relay02.lnfi.network 39.0997 -94.5786
relay.nosto.re 51.1792 5.89444
strfry.openhoofd.nl 51.9229 4.40833
relays.diggoo.com 43.6532 -79.3832
relay.nostrhub.tech 49.0291 8.35696
relay.artx.market 43.652 -79.3633
relay.sigit.io 50.4754 12.3683
nostr-relay.psfoundation.info 39.0438 -77.4874
wot.brightbolt.net 47.6735 -116.781
relay.bitcoinartclock.com 50.4754 12.3683
khatru.nostrver.se 51.1792 5.89444
relay.cosmicbolt.net 37.3986 -121.964
adre.su 59.9311 30.3609
nostrelay.memory-art.xyz 43.6532 -79.3832
temp.iris.to 43.6532 -79.3832
nproxy.kristapsk.lv 60.1699 24.9384
relay.ngengine.org 43.6532 -79.3832
relay.puresignal.news 43.6532 -79.3832
nostr.stakey.net 52.3676 4.90414
nostr.commonshub.brussels 49.4543 11.0746
wot.shaving.kiwi 43.6532 -79.3832
espelho.girino.org 43.6532 -79.3832
relay.thibautduchene.fr 43.6532 -79.3832
nostr-relay.corb.net 38.8353 -104.822
wot.soundhsa.com 33.1384 -95.6011
relay.jabato.space 52.52 13.405
relay.credenso.cafe 43.3601 -80.3127
relayone.soundhsa.com 33.1384 -95.6011
relay.bitcoinveneto.org 64.1466 -21.9426
relay.samt.st 40.8302 -74.1299
relay.vrtmrz.net 43.6532 -79.3832
nostr.data.haus 50.4754 12.3683
relay.npubhaus.com 43.6532 -79.3832
strfry.elswa-dev.online 50.1109 8.68213
relay.wavefunc.live 34.0362 -118.443
wot.sovbit.host 64.1466 -21.9426
relay.smies.me 33.7501 -84.3885
nostr.bilthon.dev 25.8128 -80.2377
relay-fra.zombi.cloudrodion.com 48.8566 2.35222
santo.iguanatech.net 40.8302 -74.1299
prl.plus 42.6978 23.3246
relay-freeharmonypeople.space 38.7223 -9.13934
nostream.breadslice.com 1.35208 103.82
nostr.notribe.net 40.8302 -74.1299
relay.fountain.fm 39.0997 -94.5786
orangepiller.org 60.1699 24.9384
nostr.21crypto.ch 47.5356 8.73209
nostr.camalolo.com 24.1469 120.684
okn.czas.top 51.267 6.81738
relay.wolfcoil.com 35.6092 139.73
nostr.quali.chat 60.1699 24.9384
nostr-relay.online 43.6532 -79.3832
alienos.libretechsystems.xyz 55.4724 9.87335
ribo.eu.nostria.app 52.3676 4.90414
wot.yesnostr.net 50.9871 2.12554
nostr.overmind.lol 43.6532 -79.3832
relay.chakany.systems 43.6532 -79.3832
relay.hasenpfeffr.com 39.0438 -77.4874
nostrelites.org 41.8781 -87.6298
strfry.bonsai.com 37.8715 -122.273
relay.agora.social 50.7383 15.0648
alien.macneilmediagroup.com 43.6532 -79.3832
relay.getsafebox.app 43.6532 -79.3832
relay.nuts.cash 34.0362 -118.443
notemine.io 52.2026 20.9397
relay.nostr.place 32.7767 -96.797
relay.21e6.cz 50.7383 15.0648
nostr.casa21.space 43.6532 -79.3832
premium.primal.net 43.6532 -79.3832
relay5.bitransfer.org 43.6532 -79.3832
nostr.rblb.it 43.7094 10.6582
relay.goodmorningbitcoin.com 43.6532 -79.3832
relay.camelus.app 45.5201 -122.99
relay.origin.land 35.6673 139.751
relay.fr13nd5.com 52.5233 13.3426
nos.lol 50.4754 12.3683
wot.nostr.net 43.6532 -79.3832
relay.javi.space 43.4633 11.8796
relay2.ngengine.org 43.6532 -79.3832
relay.angor.io 48.1046 11.6002
pyramid.treegaze.com 43.6532 -79.3832
relay.letsfo.com 52.2633 21.0283
relay.nostrzh.org 43.6532 -79.3832
nostr.hekster.org 37.3986 -121.964
relay.0xchat.com 1.35208 103.82
nostr.czas.top 50.1109 8.68213
nostr.superfriends.online 43.6532 -79.3832
hsuite-nostr-relay.hbarsuite.workers.dev 43.6532 -79.3832
relay.mccormick.cx 52.3563 4.95714
nostr.88mph.life 51.5072 -0.127586
strfry.ymir.cloud 34.0965 -117.585
nostr.lkjsxc.com 43.6532 -79.3832
yabu.me 35.6092 139.73
relay.chorus.community 50.1109 8.68213
nostr-02.yakihonne.com 1.32123 103.695
neuromancer.nettek.io 39.1429 -94.573
relay04.lnfi.network 39.0997 -94.5786
nostr-relay.amethyst.name 39.0438 -77.4874
nostrcheck.tnsor.network 43.6532 -79.3832
wot.nostr.place 32.7767 -96.797
cyberspace.nostr1.com 40.7057 -74.0136
relay.guggero.org 47.3769 8.54169
nostr.calitabby.net 39.9268 -75.0246
x.kojira.io 43.6532 -79.3832
nostr.agentcampfire.com 52.3676 4.90414
relay.bullishbounty.com 43.6532 -79.3832
relay.nostrverse.net 43.6532 -79.3832
nostr.robosats.org 64.1476 -21.9392
nostr-verified.wellorder.net 45.5201 -122.99
nostr.huszonegy.world 47.4979 19.0402
relay.cypherflow.ai 48.8566 2.35222
9 nostr-relay.xbytez.io 50.6924 3.20113
10 nostr.faultables.net relay.gulugulu.moe 43.6532 -79.3832
11 relay.libernet.app nostr.thebiglake.org:443 43.6532 32.71 -79.3832 -96.6745
12 relay.magiccity.live relay.fountain.fm 25.8128 43.6532 -80.2377 -79.3832
13 relay.wellorder.net freelay.sovbit.host 45.5201 60.1699 -122.99 24.9384
14 black.nostrcity.club nostr.girino.org:443 48.8575 43.6532 2.35138 -79.3832
15 relay.jeffg.fyi relay.openresist.com 43.6532 -79.3832
16 freeben666.fr nas01xanthosnet.synology.me:7778 43.7221 47.1285 7.15296 8.74735
17 relay.nostr.wirednet.jp relay.ohstr.com:443 34.706 43.6532 135.493 -79.3832
nostrelay.circum.space 52.3676 4.90414
relay.islandbitcoin.com 12.8498 77.6545
nostr-rs-relay.dev.fedibtc.com 39.0438 -77.4874
relay.nostriot.com 41.5695 -83.9786
relayrs.notoshi.win 43.6532 -79.3832
18 nostr-pub.wellorder.net 45.5201 -122.99
nostr.vulpem.com 49.4543 11.0746
nostr.sathoarder.com 48.5734 7.75211
wheat.happytavern.co 43.6532 -79.3832
relay.nostr.net 43.6532 -79.3832
bcast.girino.org 43.6532 -79.3832
nostr-02.czas.top 51.2277 6.77346
vault.iris.to 43.6532 -79.3832
ynostr.yael.at 60.1699 24.9384
nostr.nodesmap.com 59.3327 18.0656
nostr.n7ekb.net 47.4941 -122.294
relayb.uid.ovh 43.6532 -79.3832
shu05.shugur.net 48.8566 2.35222
dev-relay.lnfi.network 39.0997 -94.5786
relay.bitcoindistrict.org 43.6532 -79.3832
nostr.spicyz.io 43.6532 -79.3832
nostr.0x7e.xyz 47.4988 8.72369
relay.dwadziesciajeden.pl 52.2297 21.0122
relay.zone667.com 60.1699 24.9384
nostr-dev.wellorder.net 45.5201 -122.99
nos.xmark.cc 50.6924 3.20113
relay.etch.social 41.2619 -95.8608
nostr.na.social 43.6532 -79.3832
relay.orangepill.ovh 49.1689 -0.358841
relay.olas.app 50.4754 12.3683
relay.holzeis.me 43.6532 -79.3832
relay2.angor.io 48.1046 11.6002
relay.degmods.com 50.4754 12.3683
vitor.nostr1.com 40.7128 -74.006
relay.btcforplebs.com 43.6532 -79.3832
nostr.luisschwab.net 43.6532 -79.3832
relay.moinsen.com 50.4754 12.3683
czas.xyz 48.8566 2.35222
nostr.bitcoiner.social 39.1585 -94.5728
nostr.mehdibekhtaoui.com 49.4939 -1.54813
inbox.azzamo.net 52.2633 21.0283
nostr.ovia.to 43.6532 -79.3832
nostr.myshosholoza.co.za 52.3676 4.90414
fenrir-s.notoshi.win 43.6532 -79.3832
relay-rpi.edufeed.org 49.4521 11.0767
chat-relay.zap-work.com 43.6532 -79.3832
nostr.bond 50.1109 8.68213
relay01.lnfi.network 39.0997 -94.5786
relay.seq1.net 43.6532 -79.3832
nostr.rikmeijer.nl 50.4754 12.3683
offchain.pub 47.6743 -117.112
nostr.spaceshell.xyz 43.6532 -79.3832
nos4smartnkind.tech 40.1872 44.5152
kotukonostr.onrender.com 37.7775 -122.397
nostr.ps1829.com 33.8851 130.883
relay.lightning.pub 39.0438 -77.4874
nostr-relay.gateway.in.th 15.2634 100.344
relay.thebluepulse.com 49.4521 11.0767
relay.malxte.de 52.52 13.405
relay.usefusion.ai 38.7134 -78.1591
nostr-relay.cbrx.io 43.6532 -79.3832
shu01.shugur.net 21.4902 39.2246
nostr.jerrynya.fun 31.2304 121.474
dev-nostr.bityacht.io 25.0797 121.234
bitsat.molonlabe.holdings 51.4012 -1.3147
relay.primal.net 43.6532 -79.3832
relay.wavlake.com 41.2619 -95.8608
nostr-relay.nextblockvending.com 47.2343 -119.853
v-relay.d02.vrtmrz.net 34.6937 135.502
nostr.mikoshi.de 47.74 12.0917
nostr.coincards.com 53.5501 -113.469
relay.snort.social 53.3498 -6.26031
bitcoiner.social 39.1585 -94.5728
nostr.noones.com 50.1109 8.68213
wot.dergigi.com 64.1476 -21.9392
relay.lumina.rocks 49.0291 8.35695
nostr-rs-relay-ishosta.phamthanh.me 43.6532 -79.3832
nostr.snowbla.de 60.1699 24.9384
strfry.felixzieger.de 50.1013 8.62643
relay.satlantis.io 32.8769 -80.0114
relay-dev.satlantis.io 40.8302 -74.1299
nostr.davidebtc.me 51.5072 -0.127586
relay.mattybs.lol 43.6532 -79.3832
relay.fundstr.me 42.3601 -71.0589
relay.tagayasu.xyz 43.6715 -79.38
nostr-relay.zimage.com 34.0549 -118.243
nostrcheck.me 43.6532 -79.3832
bucket.coracle.social 37.7775 -122.397
relay.siamdev.cc 13.9178 100.424
r.bitcoinhold.net 43.6532 -79.3832
skeme.vanderwarker.family 40.8218 -74.45
articles.layer3.news 37.3387 -121.885
no.str.cr 9.92857 -84.0528
srtrelay.c-stellar.net 43.6532 -79.3832
relay.comcomponent.com 34.7062 135.493
relay.illuminodes.com 47.6061 -122.333
nostr.openhoofd.nl 51.9229 4.40833
nostr.plantroon.com 50.1013 8.62643
slick.mjex.me 39.048 -77.4817
relay.minibolt.info 43.6532 -79.3832
nostr-relay-1.trustlessenterprise.com 43.6532 -79.3832
19 relay.nostrdice.com -33.8688 151.209
20 wot.dtonon.com bbw-nostr.xyz 43.6532 41.5284 -79.3832 -87.4237
21 shu04.shugur.net cs-relay.nostrdev.com:443 25.2604 50.4754 55.2989 12.3683
22 nostr.tavux.tech top.testrelay.top 48.8575 43.6532 2.35138 -79.3832
23 nostr-02.uid.ovh relay.trotters.cc 43.6532 -79.3832
24 relay.nostrcheck.me blossom.gnostr.cloud 43.6532 -79.3832
25 relay.nsnip.io ribo.eu.nostria.app:443 60.1699 43.6532 24.9384 -79.3832
26 relay.toastr.net public.crostr.com 40.8054 43.6532 -74.0241 -79.3832
relay.divine.video 43.6532 -79.3832
nostr.4rs.nl 49.0291 8.35696
wot.sebastix.social 51.1792 5.89444
relay.openfarmtools.org 60.1699 24.9384
relay.damus.io 43.6532 -79.3832
purpura.cloud 43.6532 -79.3832
wot.sudocarlos.com 51.5072 -0.127586
relay.arx-ccn.com 50.4754 12.3683
nostr.zoracle.org 45.6018 -121.185
nostr.tac.lol 47.4748 -122.273
relay.coinos.io 43.6532 -79.3832
nostr.rtvslawenia.com 49.4543 11.0746
relay.davidebtc.me 51.5072 -0.127586
ribo.us.nostria.app 41.5868 -93.625
relay.upleb.uk 51.9194 19.1451
librerelay.aaroniumii.com 43.6532 -79.3832
relay.endfiat.money 43.6532 -79.3832
27 relay.nostar.org 43.6532 -79.3832
28 relay.electriclifestyle.com strfry.bonsai.com:443 26.2897 39.0438 -80.1293 -77.4874
29 relay.nostrhub.fr nostr.carroarmato0.be 48.1045 50.914 11.6004 3.21378
30 relay.agorist.space relay.endfiat.money 52.3734 59.3327 4.89406 18.0656
31 freelay.sovbit.host nostr.overmind.lol:443 64.1476 43.6532 -21.9392 -79.3832
32 nostr.hifish.org nostr.myshosholoza.co.za:443 47.4043 52.3913 8.57398 4.66545
33 nostr-01.yakihonne.com relay.wellorder.net 1.32123 45.5201 103.695 -122.99
34 relay.routstr.com nostr.tagomago.me 43.6532 42.3601 -79.3832 -71.0589
35 nr.yay.so relay.internationalright-wing.org 46.2126 -22.5022 6.1154 -48.7114
36 bcast.seutoba.com.br relay.fckstate.net 43.6532 59.3293 -79.3832 18.0686
37 fanfares.nostr1.com nostr.azzamo.net 40.7128 52.2633 -74.006 21.0283
38 nostr.girino.org relay.0xchat.com 43.6532 -79.3832
39 relayone.geektank.ai 39.1008 -94.5811
40 relay.homeinhk.xyz 35.694 139.754
41 nostr.nodesmap.com 59.3327 18.0656
42 relay.islandbitcoin.com:443 12.8498 77.6545
43 relay.mrmave.work 43.6532 -79.3832
44 relay.arx-ccn.com 50.4754 12.3683
45 fanfares.nostr1.com:443 40.7057 -74.0136
46 wot.shaving.kiwi 43.6532 -79.3832
47 relay.nearhood.co.uk 51.5072 -0.127586
48 relay.fundstr.me 42.3601 -71.0589
49 syb.lol:443 43.6532 -79.3832
50 dm-test-strfry-discovery.samt.st:443 43.6532 -79.3832
51 relay.nostx.io 43.6532 -79.3832
52 no.str.cr 10.6352 -85.4378
53 relay.jbnco.co 43.6532 -79.3832
54 ribo.nostria.app:443 43.6532 -79.3832
55 us-east.nostr.pikachat.org 39.0438 -77.4874
56 nexus.libernet.app 43.6532 -79.3832
57 nostr.dlcdevkit.com 40.0992 -83.1141
58 nostr.debate.report 50.1109 8.68213
59 str-define-contributing-jackets.trycloudflare.com 43.6532 -79.3832
60 nostr2.girino.org 43.6532 -79.3832
61 relay.notoshi.win nostr.unkn0wn.world 13.311 46.8499 101.112 9.53287
62 nostrja-kari.heguro.com nostr.spicyz.io:443 43.6532 -79.3832
63 relay.mostro.network relay.layer.systems:443 40.8302 49.0291 -74.1299 8.35695
64 satsage.xyz nostr-relay.amethyst.name 37.3986 39.0067 -121.964 -77.4291
65 relay.evanverma.com slick.mjex.me 40.8302 39.0418 -74.1299 -77.4744
66 nostr-2.21crypto.ch nostr.girino.org 47.5356 43.6532 8.73209 -79.3832
67 relay.mitchelltribe.com nostr.quali.chat:443 39.0438 60.1699 -77.4874 24.9384
68 relaynostr.breadslice.com purplerelay.com:443 43.6532 -79.3832
69 nostr.chaima.info nostr.notribe.net 51.223 40.8302 6.78245 -74.1299
70 relay.plebeian.market:443 50.1109 8.68213
71 relay.samt.st 40.8302 -74.1299
72 relay.mitchelltribe.com:443 39.0438 -77.4874
73 nostr.0x7e.xyz 47.4949 8.71954
74 relayone.soundhsa.com:443 39.1008 -94.5811
75 nostr.thalheim.io:443 60.1699 24.9384
76 relay.getsafebox.app:443 43.6532 -79.3832
77 nostr-relay.psfoundation.info 39.0438 -77.4874
78 bucket.coracle.social 37.7775 -122.397
79 nostr.carroarmato0.be:443 50.914 3.21378
80 relay.staging.commonshub.brussels 49.4543 11.0746
81 relay-dev.satlantis.io 40.8302 -74.1299
82 relay.lacompagniemaximus.com:443 45.3147 -73.8785
83 relay-rpi.edufeed.org 49.4521 11.0767
84 nostr.computingcache.com:443 34.0356 -118.442
85 relay.lanavault.space:443 60.1699 24.9384
86 relay.getsafebox.app 43.6532 -79.3832
87 nrs-02.darkcloudarcade.com:443 39.9526 -75.1652
88 nostr.hekster.org 37.3986 -121.964
89 node.kommonzenze.de 49.4521 11.0767
90 0x-nostr-relay.fly.dev 37.7648 -122.432
91 speakeasy.cellar.social 49.4543 11.0746
92 nostr.0x7e.xyz:443 47.4949 8.71954
93 nostr.vulpem.com 49.4543 11.0746
94 relay5.bitransfer.org 43.6532 -79.3832
95 relay.inforsupports.com 43.6532 -79.3832
96 relay.plebeian.market 50.1109 8.68213
97 shu02.shugur.net 21.4902 39.2246
98 nostr.easycryptosend.it 43.6532 -79.3832
99 nostr.spaceshell.xyz 43.6532 -79.3832
100 relay.minibolt.info 43.6532 -79.3832
101 nostr.pbfs.io:443 50.4754 12.3683
102 nos.lol:443 50.4754 12.3683
103 nostr.thebiglake.org 32.71 -96.6745
104 relay.nostriot.com 41.5695 -83.9786
105 strfry.shock.network:443 39.0438 -77.4874
106 relay01.lnfi.network 35.6764 139.65
107 r.0kb.io:443 32.789 -96.7989
108 bcast.girino.org 43.6532 -79.3832
109 nostr-relay.psfoundation.info:443 39.0438 -77.4874
110 dm-test-strfry-discovery.samt.st 43.6532 -79.3832
111 testnet.samt.st 43.6532 -79.3832
112 relay.bornheimer.app 51.5072 -0.127586
113 nrs-02.darkcloudarcade.com 39.9526 -75.1652
114 relay.binaryrobot.com:443 43.6532 -79.3832
115 nostr.computingcache.com 34.0356 -118.442
116 nostr-rs-relay-qj1h.onrender.com 37.7775 -122.397
117 schnorr.me 43.6532 -79.3832
118 nostr.twinkle.lol 51.902 7.6657
119 nostr.overmind.lol 43.6532 -79.3832
120 relay.mmwaves.de:443 48.8575 2.35138
121 relay2.veganostr.com 60.1699 24.9384
122 nostr.mom 50.4754 12.3683
123 relay.nostr-check.me nostr2.girino.org:443 43.6532 -79.3832
124 wot.sudocarlos.com 43.6532 -79.3832
125 dev.relay.stream 43.6532 -79.3832
126 nostr.thalheim.io 60.1699 24.9384
127 relay-dev.gulugulu.moe:443 43.6532 -79.3832
128 conduitl2.fly.dev 37.7648 -122.432
129 relay.bullishbounty.com 43.6532 -79.3832
130 myvoiceourstory.org 37.3598 -121.981
131 relay.angor.io 48.1046 11.6002
132 r.0kb.io 32.789 -96.7989
133 nexus.libernet.app:443 43.6532 -79.3832
134 us-east.nostr.pikachat.org:443 39.0438 -77.4874
135 nostr.bitcoiner.social 47.6743 -117.112
136 nostrelay.circum.space:443 52.6907 4.8181
137 relayone.soundhsa.com 39.1008 -94.5811
138 nostr.snowbla.de 60.1699 24.9384
139 relay1.orangesync.tech 44.7839 -106.941
140 nostr-2.21crypto.ch:443 47.5356 8.73209
141 espelho.girino.org 43.6532 -79.3832
142 nostr.blankfors.se 60.1699 24.9384
143 relay.sigit.io:443 50.4754 12.3683
144 relay.vrtmrz.net:443 43.6532 -79.3832
145 nostr.wecsats.io:443 43.6532 -79.3832
146 nostrcity-club.fly.dev 37.7648 -122.432
147 nostrelay.circum.space 52.6907 4.8181
148 nostr-relay-1.trustlessenterprise.com 43.6532 -79.3832
149 relay.edufeed.org 49.4521 11.0767
150 nostr.dlcdevkit.com:443 40.0992 -83.1141
151 x.kojira.io 43.6532 -79.3832
152 relay.binaryrobot.com 43.6532 -79.3832
153 relay.nostrhub.fr 48.1045 11.6004
154 nostr.sathoarder.com 48.5734 7.75211
155 prl.plus 55.7628 37.5983
156 relay.cosmicbolt.net:443 37.3986 -121.964
157 relay.nostu.be:443 40.4167 -3.70329
158 cs-relay.nostrdev.com 50.4754 12.3683
159 satsage.xyz 37.3986 -121.964
160 relay.lab.rytswd.com:443 49.4543 11.0746
161 rilo.nostria.app:443 43.6532 -79.3832
162 portal-relay.pareto.space 49.0291 8.35696
163 relay.wavlake.com:443 41.2619 -95.8608
164 relay.beginningend.com 35.2227 -97.4786
165 relay.openresist.com:443 43.6532 -79.3832
166 bitcoiner.social:443 47.6743 -117.112
167 relay.notoshi.win:443 13.3622 100.983
168 dev.relay.edufeed.org:443 49.4521 11.0767
169 relay.cypherflow.ai:443 48.8575 2.35138
170 relay.ru.ac.th 13.7607 100.627
171 shu03.shugur.net 25.2048 55.2708
172 nostr.rtvslawenia.com:443 49.4543 11.0746
173 relay.agorist.space:443 52.3734 4.89406
174 relay02.lnfi.network 35.6764 139.65
175 relay-testnet.k8s.layer3.news:443 37.3387 -121.885
176 nostr.notribe.net:443 40.8302 -74.1299
177 relay.ditto.pub 43.6532 -79.3832
178 nostr.rikmeijer.nl 51.7111 5.36809
179 blossom.gnostr.cloud:443 43.6532 -79.3832
180 nostr.oxtr.dev 50.4754 12.3683
181 cache.trustr.ing 43.6548 -79.3885
182 nostr.bitczat.pl 60.1699 24.9384
183 relay.nostrverse.net 43.6532 -79.3832
184 shu04.shugur.net 25.2048 55.2708
185 eu.nostr.pikachat.org:443 49.4543 11.0746
186 ribo.us.nostria.app 43.6532 -79.3832
187 nostr-relay.cbrx.io 43.6532 -79.3832
188 nostr.bitczat.pl:443 60.1699 24.9384
189 nostr-relay.corb.net:443 38.8353 -104.822
190 relay.libernet.app 43.6532 -79.3832
191 nostr-verified.wellorder.net 45.5201 -122.99
192 relay.nostu.be 40.4167 -3.70329
193 rele.speyhard.fi 51.5072 -0.127586
194 relay.staging.plebeian.market 51.5072 -0.127586
195 nostr.spicyz.io 43.6532 -79.3832
196 nostrride.io 37.3986 -121.964
197 x.kojira.io:443 43.6532 -79.3832
198 relay.staging.plebeian.market:443 51.5072 -0.127586
199 relay.sigit.io 50.4754 12.3683
200 nostr.stakey.net:443 52.3676 4.90414
201 relay.nostr.blockhenge.com 39.0438 -77.4874
202 relay.comcomponent.com 43.6532 -79.3832
203 wot.nostr.place 43.6532 -79.3832
204 relay.mostr.pub:443 43.6532 -79.3832
205 nostr-rs-relay-ishosta.phamthanh.me 43.6532 -79.3832
206 no.str.cr:443 10.6352 -85.4378
207 relay.chorus.community:443 48.5333 10.7
208 relay.aarpia.com 37.3986 -121.964
209 relay.nostrmap.net 60.1699 24.9384
210 relay.snotr.nl:49999 52.0195 4.42946
211 relay.bebond.net 43.6532 -79.3832
212 relay.illuminodes.com 43.6532 -79.3832
213 chat-relay.zap-work.com:443 43.6532 -79.3832
214 testr.nymble.world 40.8054 -74.0241
215 relay.underorion.se 50.1109 8.68213
216 fanfares.nostr1.com 40.7057 -74.0136
217 relay.damus.io 43.6532 -79.3832
218 relay.nostriches.club 43.6532 -79.3832
219 nostr-dev.wellorder.net 45.5201 -122.99
220 relay2.angor.io 48.1046 11.6002
221 relay.openfarmtools.org 60.1699 24.9384
222 wot.makenomistakes.ca 43.7064 -79.3986
223 relay.thecryptosquid.com 50.4754 12.3683
224 test.thedude.cloud 50.1109 8.68213
225 nostr.rtvslawenia.com 49.4543 11.0746
226 nostr-rs-relay.dev.fedibtc.com 39.0438 -77.4874
227 relay.olas.app:443 60.1699 24.9384
228 strfry.bonsai.com 39.0438 -77.4874
229 relayrs.notoshi.win 43.6532 -79.3832
230 articles.layer3.news 37.3387 -121.885
231 wot.utxo.one 43.6532 -79.3832
232 relay.angor.io:443 48.1046 11.6002
233 relay.dwadziesciajeden.pl 52.2297 21.0122
234 soloco.nl 43.6532 -79.3832
235 armada.sharegap.net 43.6532 -79.3832
236 dm-test-strfry-generic.samt.st 43.6532 -79.3832
237 nostr.4rs.nl 49.0291 8.35696
238 nrs-01.darkcloudarcade.com 39.1008 -94.5811
239 relay.beginningend.com:443 35.2227 -97.4786
240 relay.nostr.place 43.6532 -79.3832
241 relay.layer.systems 49.0291 8.35695
242 adre.su 59.9311 30.3609
243 relay.0xchat.com:443 43.6532 -79.3832
244 nostr.infero.net 35.6764 139.65
245 relay.typedcypher.com:443 51.5072 -0.127586
246 relay.mostro.network:443 40.8302 -74.1299
247 relay-fra.zombi.cloudrodion.com 48.8566 2.35222
248 relay.mitchelltribe.com 39.0438 -77.4874
249 relay.mostr.pub 43.6532 -79.3832
250 bitcoiner.social 47.6743 -117.112
251 nostr.tac.lol:443 47.4748 -122.273
252 nostr.hekster.org:443 37.3986 -121.964
253 relay.satmaxt.xyz:443 43.6532 -79.3832
254 relay.cypherflow.ai 48.8575 2.35138
255 relay.zone667.com:443 60.1699 24.9384
256 relay.jeffg.fyi:443 43.6532 -79.3832
257 relay.agorist.space 52.3734 4.89406
258 nostr.spaceshell.xyz:443 43.6532 -79.3832
259 top.testrelay.top:443 43.6532 -79.3832
260 insta-relay.apps3.slidestr.net 40.4167 -3.70329
261 relay.nostrian-conquest.com:443 41.223 -111.974
262 relay.laantungir.net:443 -19.4692 -42.5315
263 relay.islandbitcoin.com 12.8498 77.6545
264 purplerelay.com 43.6532 -79.3832
265 nostr.tac.lol 47.4748 -122.273
266 nostr.wecsats.io 43.6532 -79.3832
267 nostr.2b9t.xyz 34.0549 -118.243
268 nostr.quali.chat 60.1699 24.9384
269 relay.dreamith.to 43.6532 -79.3832
270 nostr.islandarea.net:443 35.4669 -97.6473
271 relay.primal.net 43.6532 -79.3832
272 eu.nostr.pikachat.org 49.4543 11.0746
273 relay.nostr.net 43.6532 -79.3832
274 nostr.n7ekb.net 47.4941 -122.294
275 relay.mulatta.io 37.5665 126.978
276 nittom.nostr1.com 40.7057 -74.0136
277 relay2.orangesync.tech 40.7128 -74.006
278 relay.artx.market 43.6548 -79.3885
279 relay.wavefunc.live 41.8781 -87.6298
280 bitchat.nostr1.com 40.7057 -74.0136
281 relay.ditto.pub:443 43.6532 -79.3832
282 relay.wisp.talk 49.4543 11.0746
283 nostrelites.org 41.8781 -87.6298
284 relay.goodmorningbitcoin.com 43.6532 -79.3832
285 dm-test-nostr-rs-42-disabled.samt.st 43.6532 -79.3832
286 relay.chorus.community 48.5333 10.7
287 relay.plebchain.club 43.6532 -79.3832
288 nostr-relay.amethyst.name:443 39.0067 -77.4291
289 relay.lanacoin-eternity.com 40.8302 -74.1299
290 relay.edufeed.org:443 49.4521 11.0767
291 relay.lacompagniemaximus.com 45.3147 -73.8785
292 relay.dreamith.to:443 43.6532 -79.3832
293 nostr.stakey.net 52.3676 4.90414
294 nostr.n7ekb.net:443 47.4941 -122.294
295 relay.dyne.org 49.0291 8.35705
296 nostr.janx.com 43.6532 -79.3832
297 relay.trustr.ing 43.6548 -79.3885
298 relay.paulstephenborile.com:443 49.4543 11.0746
299 relay.wisp.talk:443 49.4543 11.0746
300 yabu.me 35.6092 139.73
301 bcast.seutoba.com.br 43.6532 -79.3832
302 nos.xmark.cc 50.6924 3.20113
303 nos.lol 50.4754 12.3683
304 relay.satmaxt.xyz 43.6532 -79.3832
305 relay.endfiat.money:443 59.3327 18.0656
306 relay.tapestry.ninja 40.8054 -74.0241
307 relay.lab.rytswd.com 49.4543 11.0746
308 nostr-kyomu-haskell.onrender.com 37.7775 -122.397
309 relay.damus.io:443 43.6532 -79.3832
310 treuzkas.branruz.com 48.8575 2.35138
311 nostr-01.yakihonne.com 1.32123 103.695
312 relay.nostriot.com:443 41.5695 -83.9786
313 nostr-relay.nextblockvending.com 47.2343 -119.853
314 nostr.aruku.ovh 1.27994 103.849
315 nostr.chaima.info 50.1109 8.68213
316 ynostr.yael.at 60.1699 24.9384
317 ynostr.yael.at:443 60.1699 24.9384
318 nostr-01.yakihonne.com:443 1.32123 103.695
319 spookstr2.nostr1.com 40.7057 -74.0136
320 relay.trustr.ing:443 43.6548 -79.3885
321 dev.relay.edufeed.org 49.4521 11.0767
322 relay2.angor.io:443 48.1046 11.6002
323 nostr.azzamo.net:443 52.2633 21.0283
324 offchain.bostr.online 43.6532 -79.3832
325 relay.zone667.com 60.1699 24.9384
326 relay.veganostr.com 60.1699 24.9384
327 vault.iris.to:443 43.6532 -79.3832
328 relay.artx.market:443 43.6548 -79.3885
329 wot.rejecttheframe.xyz 43.6532 -79.3832
330 nostr.pbfs.io 50.4754 12.3683
331 relay.mostro.network 40.8302 -74.1299
332 strfry.shock.network 39.0438 -77.4874
333 relay.klabo.world 47.2343 -119.853
334 relay.fountain.fm:443 43.6532 -79.3832
335 nostrja-kari.heguro.com 43.6532 -79.3832
336 relaisnostr.trivaco.fr 48.5734 7.75211
337 offchain.pub 39.1585 -94.5728
338 relay.degmods.com 50.4754 12.3683
339 nostr-relay.xbytez.io:443 50.6924 3.20113
340 relay.paulstephenborile.com 49.4543 11.0746
341 nittom.nostr1.com:443 40.7057 -74.0136
342 dev-relay.nostreon.com 60.1699 24.9384
343 nostr-rs-relay.dev.fedibtc.com:443 39.0438 -77.4874
344 relay.jeffg.fyi 43.6532 -79.3832
345 relay.laantungir.net -19.4692 -42.5315
346 nostr.data.haus:443 50.4754 12.3683
347 wot.codingarena.top 50.4754 12.3683
348 nostr.2b9t.xyz:443 34.0549 -118.243
349 relay.libernet.app:443 43.6532 -79.3832
350 nostr.ps1829.com 33.8851 130.883
351 wot.dergigi.com 64.1476 -21.9392
352 relay.olas.app 60.1699 24.9384
353 relay.lanacoin-eternity.com:443 40.8302 -74.1299
354 nostr.data.haus 50.4754 12.3683
355 relay-dev.gulugulu.moe 43.6532 -79.3832
356 relay.ohstr.com 43.6532 -79.3832
357 relay.lightning.pub 39.0438 -77.4874
358 relay.guggero.org 46.5971 9.59652
359 testnet-relay.samt.st:443 40.8302 -74.1299
360 thecitadel.nostr1.com 40.7057 -74.0136
361 nostr.ps1829.com:443 33.8851 130.883
362 nostr-relay.corb.net 38.8353 -104.822
363 relay.npubhaus.com 43.6532 -79.3832
364 nostr.21crypto.ch 47.5356 8.73209
365 nostr.tadryanom.me 43.6532 -79.3832
366 nostr.myshosholoza.co.za 52.3913 4.66545
367 relay.bitmacro.cloud 43.6532 -79.3832
368 ribo.nostria.app 43.6532 -79.3832
369 relay.vrtmrz.net 43.6532 -79.3832
370 syb.lol 43.6532 -79.3832
371 relay.bebond.net:443 43.6532 -79.3832
372 relay.agentry.com 42.8864 -78.8784
373 nostr-2.21crypto.ch 47.5356 8.73209
374 nostrcity-club.fly.dev:443 37.7648 -122.432
375 articles.layer3.news:443 37.3387 -121.885
376 relay.internationalright-wing.org:443 -22.5022 -48.7114
377 nostr-relay.zimage.com 34.0549 -118.243
378 chat-relay.zap-work.com 43.6532 -79.3832
379 relay.mwaters.net 50.9871 2.12554
380 nostr.liberty.fans 36.9104 -89.5875
381 spookstr2.nostr1.com:443 40.7057 -74.0136
382 nostr.chaima.info:443 50.1109 8.68213
383 schnorr.me:443 43.6532 -79.3832
384 ribo.eu.nostria.app 43.6532 -79.3832
385 nostr.bond 50.1109 8.68213
386 wot.nostr.party 36.1659 -86.7844
387 temp.iris.to 43.6532 -79.3832
388 relay.lotek-distro.com 43.6532 -79.3832
389 relay.minibolt.info:443 43.6532 -79.3832
390 relay.lanavault.space 60.1699 24.9384
391 relay.mmwaves.de 48.8575 2.35138
392 social.amanah.eblessing.co 48.1046 11.6002
393 nostr2.thalheim.io 49.4543 11.0746
394 relay.typedcypher.com 51.5072 -0.127586
395 relay.cosmicbolt.net 37.3986 -121.964
396 relayrs.notoshi.win:443 43.6532 -79.3832
397 nostr-relay-1.trustlessenterprise.com:443 43.6532 -79.3832
398 nostr.islandarea.net 35.4669 -97.6473
399 relay.nostrmap.net:443 60.1699 24.9384
400 relay.bullishbounty.com:443 43.6532 -79.3832
401 nostr.oxtr.dev:443 50.4754 12.3683
402 srtrelay.c-stellar.net 43.6532 -79.3832
403 relay.mccormick.cx 52.3563 4.95714
404 vault.iris.to 43.6532 -79.3832
405 relay.mccormick.cx:443 52.3563 4.95714
406 relay.toastr.net 40.8054 -74.0241
407 nostr.hifish.org 47.4244 8.57658
408 speakeasy.cellar.social:443 49.4543 11.0746
409 relay.wavlake.com 41.2619 -95.8608
410 testnet-relay.samt.st 40.8302 -74.1299
411 aeon.libretechsystems.xyz 55.486 9.86577
412 mostro-p2p.tech 50.1109 8.68213
413 nostr.wild-vibes.ts.net 48.8566 2.35222
414 nostr.88mph.life 52.1941 -2.21905
415 relay.nostrcheck.me 43.6532 -79.3832
416 rilo.nostria.app 43.6532 -79.3832
417 relay.bowlafterbowl.com 32.9483 -96.7299
418 relay.nostr.place:443 43.6532 -79.3832
419 nostr.mom:443 50.4754 12.3683
420 relay.nostrian-conquest.com 41.223 -111.974
421 herbstmeister.com 34.0549 -118.243
422 nrs-01.darkcloudarcade.com:443 39.1008 -94.5811
423 nostr.red5d.dev 43.6532 -79.3832
424 nostrbtc.com 43.6532 -79.3832
425 strfry.apps3.slidestr.net 40.4167 -3.70329
426 relay.sharegap.net 43.6532 -79.3832
427 reraw.pbla2fish.cc 43.6532 -79.3832
428 relay-testnet.k8s.layer3.news 37.3387 -121.885
429 nostr.sathoarder.com:443 48.5734 7.75211
430 relay.veganostr.com:443 60.1699 24.9384
431 relay-fra.zombi.cloudrodion.com:443 48.8566 2.35222
432 premium.primal.net 43.6532 -79.3832
433 relay.wavefunc.live:443 41.8781 -87.6298
434 relay-rpi.edufeed.org:443 49.4521 11.0767
435 kotukonostr.onrender.com 37.7775 -122.397
436 bridge.tagomago.me 42.3601 -71.0589
437 nostr.tadryanom.me:443 43.6532 -79.3832
438 relay.gulugulu.moe:443 43.6532 -79.3832
439 offchain.pub:443 39.1585 -94.5728
440 relay.directsponsor.net 42.8864 -78.8784
441 nostr.snowbla.de:443 60.1699 24.9384
@@ -46,6 +46,13 @@ class BitchatApplication : Application() {
val enabled = com.bitchat.android.ui.debug.DebugPreferenceManager.getWifiAwareEnabled(false)
com.bitchat.android.wifiaware.WifiAwareController.initialize(this, enabled)
} catch (_: Exception) { }
// Initialize Geohash Registries for persistence
try {
com.bitchat.android.nostr.GeohashAliasRegistry.initialize(this)
com.bitchat.android.nostr.GeohashConversationRegistry.initialize(this)
} catch (_: Exception) { }
// Initialize mesh service preferences
try { com.bitchat.android.service.MeshServicePreferences.init(this) } catch (_: Exception) { }
@@ -26,6 +26,7 @@ import com.bitchat.android.onboarding.BatteryOptimizationManager
import com.bitchat.android.onboarding.BatteryOptimizationPreferenceManager
import com.bitchat.android.onboarding.BatteryOptimizationScreen
import com.bitchat.android.onboarding.BatteryOptimizationStatus
import com.bitchat.android.onboarding.BackgroundLocationPermissionScreen
import com.bitchat.android.onboarding.InitializationErrorScreen
import com.bitchat.android.onboarding.InitializingScreen
import com.bitchat.android.onboarding.LocationCheckScreen
@@ -42,6 +43,7 @@ import com.bitchat.android.ui.theme.BitchatTheme
import com.bitchat.android.wifiaware.WifiAwareController
import com.bitchat.android.wifiaware.WifiAwareMeshDelegate
import com.bitchat.android.nostr.PoWPreferenceManager
import com.bitchat.android.services.VerificationService
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@@ -170,6 +172,9 @@ class MainActivity : OrientationAwareActivity() {
activity = this,
permissionManager = permissionManager,
onOnboardingComplete = ::handleOnboardingComplete,
onBackgroundLocationRequired = {
mainViewModel.updateOnboardingState(OnboardingState.BACKGROUND_LOCATION_EXPLANATION)
},
onOnboardingFailed = ::handleOnboardingFailed
)
@@ -318,6 +323,21 @@ class MainActivity : OrientationAwareActivity() {
)
}
OnboardingState.BACKGROUND_LOCATION_EXPLANATION -> {
BackgroundLocationPermissionScreen(
modifier = modifier,
onContinue = {
onboardingCoordinator.requestBackgroundLocation()
},
onRetry = {
onboardingCoordinator.checkBackgroundLocationAndProceed()
},
onSkip = {
onboardingCoordinator.skipBackgroundLocation()
}
)
}
OnboardingState.CHECKING, OnboardingState.INITIALIZING, OnboardingState.COMPLETE -> {
// Set up back navigation handling for the chat screen
val backCallback = object : OnBackPressedCallback(true) {
@@ -444,10 +464,17 @@ class MainActivity : OrientationAwareActivity() {
if (permissionManager.isFirstTimeLaunch()) {
Log.d("MainActivity", "First time launch, showing permission explanation")
mainViewModel.updateOnboardingState(OnboardingState.PERMISSION_EXPLANATION)
} else if (permissionManager.areAllPermissionsGranted()) {
Log.d("MainActivity", "Existing user with permissions, initializing app")
mainViewModel.updateOnboardingState(OnboardingState.INITIALIZING)
initializeApp()
} else if (permissionManager.areRequiredPermissionsGranted()) {
Log.d("MainActivity", "Existing user with required permissions")
if (permissionManager.needsBackgroundLocationPermission() &&
!permissionManager.isBackgroundLocationGranted() &&
!com.bitchat.android.onboarding.BackgroundLocationPreferenceManager.isSkipped(this@MainActivity)
) {
mainViewModel.updateOnboardingState(OnboardingState.BACKGROUND_LOCATION_EXPLANATION)
} else {
mainViewModel.updateOnboardingState(OnboardingState.INITIALIZING)
initializeApp()
}
} else {
Log.d("MainActivity", "Existing user missing permissions, showing explanation")
mainViewModel.updateOnboardingState(OnboardingState.PERMISSION_EXPLANATION)
@@ -723,6 +750,7 @@ class MainActivity : OrientationAwareActivity() {
// Handle any notification intent
handleNotificationIntent(intent)
handleVerificationIntent(intent)
// Small delay to ensure mesh service is fully initialized
delay(500)
@@ -751,6 +779,7 @@ class MainActivity : OrientationAwareActivity() {
// Handle notification intents when app is already running
if (mainViewModel.onboardingState.value == OnboardingState.COMPLETE) {
handleNotificationIntent(intent)
handleVerificationIntent(intent)
}
}
@@ -761,9 +790,6 @@ class MainActivity : OrientationAwareActivity() {
// Reattach mesh delegate to new ChatViewModel instance after Activity recreation
try { meshService.delegate = chatViewModel } catch (_: Exception) { }
try { WifiAwareController.getService()?.delegate = wifiAwareDelegate } catch (_: Exception) { }
// Set app foreground state
meshService.connectionManager.setAppBackgroundState(false)
chatViewModel.setAppBackgroundState(false)
// Check if Bluetooth was disabled while app was backgrounded
val currentBluetoothStatus = bluetoothStatusManager.checkBluetoothStatus()
@@ -793,9 +819,6 @@ class MainActivity : OrientationAwareActivity() {
super.onPause()
// Only set background state if app is fully initialized
if (mainViewModel.onboardingState.value == OnboardingState.COMPLETE) {
// Set app background state
meshService.connectionManager.setAppBackgroundState(true)
chatViewModel.setAppBackgroundState(true)
// Detach UI delegate so the foreground service can own DM notifications while UI is closed
try { meshService.delegate = null } catch (_: Exception) { }
try { WifiAwareController.getService()?.delegate = null } catch (_: Exception) { }
@@ -824,8 +847,9 @@ class MainActivity : OrientationAwareActivity() {
if (peerID != null) {
Log.d("MainActivity", "Opening private chat with $senderNickname (peerID: $peerID) from notification")
// Open the private chat with this peer
chatViewModel.startPrivateChat(peerID)
// Open the private chat sheet with this peer
chatViewModel.showMeshPeerList()
chatViewModel.showPrivateChatSheet(peerID)
// Clear notifications for this sender since user is now viewing the chat
chatViewModel.clearNotificationsForSender(peerID)
@@ -861,6 +885,17 @@ class MainActivity : OrientationAwareActivity() {
}
}
private fun handleVerificationIntent(intent: Intent) {
val uri = intent.data ?: return
if (uri.scheme != "bitchat" || uri.host != "verify") return
chatViewModel.showVerificationSheet()
val qr = VerificationService.verifyScannedQR(uri.toString())
if (qr != null) {
chatViewModel.beginQRVerification(qr)
}
}
override fun onDestroy() {
super.onDestroy()
@@ -0,0 +1,32 @@
package com.bitchat.android.core.ui.component.sheet
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.SheetState
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun BitchatBottomSheet(
modifier: Modifier = Modifier,
sheetState: SheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
onDismissRequest: () -> Unit,
content: @Composable (ColumnScope.() -> Unit),
) {
ModalBottomSheet(
modifier = modifier.statusBarsPadding(),
onDismissRequest = onDismissRequest,
sheetState = sheetState,
dragHandle = null,
shape = RoundedCornerShape(topStart = 28.dp, topEnd = 28.dp),
containerColor = MaterialTheme.colorScheme.background,
content = content,
)
}
@@ -0,0 +1,86 @@
package com.bitchat.android.core.ui.component.sheet
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.CenterAlignedTopAppBar
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.bitchat.android.core.ui.component.button.CloseButton
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun BitchatSheetTopBar(
onClose: () -> Unit,
modifier: Modifier = Modifier,
backgroundAlpha: Float = 0.98f,
title: @Composable () -> Unit,
navigationIcon: (@Composable () -> Unit)? = null,
actions: @Composable RowScope.() -> Unit = {}
) {
TopAppBar(
title = title,
navigationIcon = { navigationIcon?.invoke() },
actions = {
actions()
CloseButton(
onClick = onClose,
modifier = Modifier.padding(horizontal = 16.dp)
)
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.background.copy(alpha = backgroundAlpha),
titleContentColor = MaterialTheme.colorScheme.onSurface,
navigationIconContentColor = MaterialTheme.colorScheme.onSurface,
actionIconContentColor = MaterialTheme.colorScheme.onSurface
),
modifier = modifier
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun BitchatSheetCenterTopBar(
onClose: () -> Unit,
modifier: Modifier = Modifier,
backgroundAlpha: Float = 0.98f,
title: @Composable () -> Unit,
navigationIcon: (@Composable () -> Unit)? = null,
actions: @Composable RowScope.() -> Unit = {}
) {
CenterAlignedTopAppBar(
title = title,
navigationIcon = { navigationIcon?.invoke() },
actions = {
actions()
CloseButton(
onClick = onClose,
modifier = Modifier.padding(horizontal = 16.dp)
)
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.background.copy(alpha = backgroundAlpha),
titleContentColor = MaterialTheme.colorScheme.onSurface,
navigationIconContentColor = MaterialTheme.colorScheme.onSurface,
actionIconContentColor = MaterialTheme.colorScheme.onSurface
),
modifier = modifier
)
}
@Composable
fun BitchatSheetTitle(text: String) {
Text(
text = text,
style = MaterialTheme.typography.titleMedium.copy(
fontWeight = FontWeight.Bold,
fontFamily = FontFamily.Monospace
)
)
}
@@ -1,7 +1,11 @@
package com.bitchat.android.crypto
import android.content.Context
import android.content.SharedPreferences
import android.util.Base64
import android.util.Log
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
import com.bitchat.android.noise.NoiseEncryptionService
import org.bouncycastle.crypto.AsymmetricCipherKeyPair
import org.bouncycastle.crypto.generators.Ed25519KeyPairGenerator
@@ -11,6 +15,7 @@ import org.bouncycastle.crypto.params.Ed25519PublicKeyParameters
import org.bouncycastle.crypto.signers.Ed25519Signer
import java.security.SecureRandom
import java.util.concurrent.ConcurrentHashMap
import androidx.core.content.edit
/**
* Encryption service that now uses NoiseEncryptionService internally
@@ -19,29 +24,55 @@ import java.util.concurrent.ConcurrentHashMap
* This is the main interface for all encryption/decryption operations in bitchat.
* It now uses the Noise protocol for secure transport encryption with proper session management.
*/
class EncryptionService(private val context: Context) {
open class EncryptionService(private val context: Context) {
companion object {
private const val TAG = "EncryptionService"
private const val ED25519_PRIVATE_KEY_PREF = "ed25519_signing_private_key"
private const val OLD_PREFS_NAME = "bitchat_crypto"
private const val SECURE_PREFS_NAME = "bitchat_crypto_secure"
}
// Core Noise encryption service
private val noiseService: NoiseEncryptionService = NoiseEncryptionService(context)
private val noiseService: NoiseEncryptionService by lazy { NoiseEncryptionService(context) }
// Session tracking for established connections
private val establishedSessions = ConcurrentHashMap<String, String>() // peerID -> fingerprint
// Ed25519 signing keys (separate from Noise static keys)
private val ed25519PrivateKey: Ed25519PrivateKeyParameters
private val ed25519PublicKey: Ed25519PublicKeyParameters
private lateinit var ed25519PrivateKey: Ed25519PrivateKeyParameters
private lateinit var ed25519PublicKey: Ed25519PublicKeyParameters
// Callbacks for UI state updates
var onSessionEstablished: ((String) -> Unit)? = null // peerID
var onSessionLost: ((String) -> Unit)? = null // peerID
var onHandshakeRequired: ((String) -> Unit)? = null // peerID
private lateinit var prefs: SharedPreferences
init {
initialize()
}
private fun setUpEncryptedPrefs() {
val masterKey = MasterKey.Builder(context, MasterKey.DEFAULT_MASTER_KEY_ALIAS)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
// Create encrypted shared preferences
prefs = EncryptedSharedPreferences.create(
context,
SECURE_PREFS_NAME,
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
)
}
/**
* Initialization logic moved to method to allow overriding in tests
*/
protected open fun initialize() {
setUpEncryptedPrefs()
// Initialize or load Ed25519 signing keys
val keyPair = loadOrCreateEd25519KeyPair()
ed25519PrivateKey = keyPair.private as Ed25519PrivateKeyParameters
@@ -135,9 +166,14 @@ class EncryptionService(private val context: Context) {
// Clear Ed25519 signing key from preferences
try {
val prefs = context.getSharedPreferences("bitchat_crypto", Context.MODE_PRIVATE)
prefs.edit().remove(ED25519_PRIVATE_KEY_PREF).apply()
prefs.edit { remove(ED25519_PRIVATE_KEY_PREF) }
Log.d(TAG, "🗑️ Cleared Ed25519 signing keys from preferences")
// Generate new keys immediately
val keyPair = loadOrCreateEd25519KeyPair()
ed25519PrivateKey = keyPair.private as Ed25519PrivateKeyParameters
ed25519PublicKey = keyPair.public as Ed25519PublicKeyParameters
Log.d(TAG, "✅ Rotated Ed25519 signing keys in memory")
} catch (e: Exception) {
Log.e(TAG, "❌ Failed to clear Ed25519 keys: ${e.message}")
}
@@ -356,7 +392,7 @@ class EncryptionService(private val context: Context) {
/**
* Verify Ed25519 signature against data using a public key
*/
fun verifyEd25519Signature(signature: ByteArray, data: ByteArray, publicKeyBytes: ByteArray): Boolean {
open fun verifyEd25519Signature(signature: ByteArray, data: ByteArray, publicKeyBytes: ByteArray): Boolean {
return try {
val publicKey = Ed25519PublicKeyParameters(publicKeyBytes, 0)
val verifier = Ed25519Signer()
@@ -377,13 +413,14 @@ class EncryptionService(private val context: Context) {
* Load existing Ed25519 key pair from preferences or create a new one
*/
private fun loadOrCreateEd25519KeyPair(): AsymmetricCipherKeyPair {
// Migrate legacy plaintext Ed25519 key to encrypted storage if present
migrateOldEd25519KeyIfNeeded()
try {
val prefs = context.getSharedPreferences("bitchat_crypto", Context.MODE_PRIVATE)
val storedKey = prefs.getString(ED25519_PRIVATE_KEY_PREF, null)
if (storedKey != null) {
// Load existing key
val privateKeyBytes = android.util.Base64.decode(storedKey, android.util.Base64.DEFAULT)
val privateKeyBytes = Base64.decode(storedKey, Base64.DEFAULT)
val privateKey = Ed25519PrivateKeyParameters(privateKeyBytes, 0)
val publicKey = privateKey.generatePublicKey()
Log.d(TAG, "✅ Loaded existing Ed25519 signing key pair")
@@ -394,6 +431,10 @@ class EncryptionService(private val context: Context) {
}
// Create new key pair
return generateAndSaveEd25519KeyPair()
}
fun generateAndSaveEd25519KeyPair(): AsymmetricCipherKeyPair {
val keyGen = Ed25519KeyPairGenerator()
keyGen.init(Ed25519KeyGenerationParameters(SecureRandom()))
val keyPair = keyGen.generateKeyPair()
@@ -402,10 +443,9 @@ class EncryptionService(private val context: Context) {
try {
val privateKey = keyPair.private as Ed25519PrivateKeyParameters
val privateKeyBytes = privateKey.encoded
val encodedKey = android.util.Base64.encodeToString(privateKeyBytes, android.util.Base64.DEFAULT)
val encodedKey = Base64.encodeToString(privateKeyBytes, Base64.DEFAULT)
val prefs = context.getSharedPreferences("bitchat_crypto", Context.MODE_PRIVATE)
prefs.edit().putString(ED25519_PRIVATE_KEY_PREF, encodedKey).apply()
prefs.edit { putString(ED25519_PRIVATE_KEY_PREF, encodedKey) }
Log.d(TAG, "✅ Created and stored new Ed25519 signing key pair")
} catch (e: Exception) {
Log.e(TAG, "❌ Failed to store Ed25519 private key: ${e.message}")
@@ -413,4 +453,25 @@ class EncryptionService(private val context: Context) {
return keyPair
}
private fun migrateOldEd25519KeyIfNeeded() {
try {
// old existing plain text preference
val oldPrefs = context.getSharedPreferences(OLD_PREFS_NAME, Context.MODE_PRIVATE)
val oldKey = oldPrefs.getString(ED25519_PRIVATE_KEY_PREF, null)
if (oldKey != null && !prefs.contains(ED25519_PRIVATE_KEY_PREF)) {
prefs.edit {
putString(ED25519_PRIVATE_KEY_PREF, oldKey)
}
oldPrefs.edit {
remove(ED25519_PRIVATE_KEY_PREF)
}
Log.d(TAG, "🔁 Migrated Ed25519 key to EncryptedSharedPreferences")
}
} catch (e: Exception) {
Log.w(TAG, "⚠️ Failed to migrate Ed25519 key; generating new identity: ${e.message}")
}
}
}
@@ -197,7 +197,9 @@ object FileUtils {
): String {
val lowerMime = file.mimeType.lowercase()
val isImage = lowerMime.startsWith("image/")
val baseDir = context.filesDir
// FIX: Use cacheDir instead of filesDir to prevent storage exhaustion attacks (Issue #592)
// Files in cacheDir are eligible for automatic system cleanup when space is low
val baseDir = context.cacheDir
val subdir = if (isImage) "images/incoming" else "files/incoming"
val dir = java.io.File(baseDir, subdir).apply { mkdirs() }
@@ -271,4 +273,54 @@ object FileUtils {
else -> com.bitchat.android.model.BitchatMessageType.File
}
}
/**
* Recursively delete all media files (incoming and outgoing)
* Used for Panic Mode cleanup
*/
fun clearAllMedia(context: Context) {
try {
// Clear files dir subdirectories (legacy storage and outgoing)
val filesDir = context.filesDir
val dirsToClear = listOf(
"files/incoming",
"files/outgoing",
"images/incoming",
"images/outgoing",
"voicenotes"
)
dirsToClear.forEach { subDir ->
val dir = File(filesDir, subDir)
if (dir.exists()) {
dir.deleteRecursively()
Log.d(TAG, "Deleted media directory from filesDir: $subDir")
}
}
// Clear cache dir subdirectories (new incoming storage)
// Note: cacheDir.deleteRecursively() below would handle this, but being explicit ensures these
// specific media folders are targeted even if full cache clear fails or is modified later.
val cacheDir = context.cacheDir
val cacheDirsToClear = listOf(
"files/incoming",
"images/incoming"
)
cacheDirsToClear.forEach { subDir ->
val dir = File(cacheDir, subDir)
if (dir.exists()) {
dir.deleteRecursively()
Log.d(TAG, "Deleted media directory from cacheDir: $subDir")
}
}
// Also clear entire cache dir as a catch-all
context.cacheDir.deleteRecursively()
Log.d(TAG, "Cleared entire cache directory")
} catch (e: Exception) {
Log.e(TAG, "Failed to clear media files", e)
}
}
}
@@ -0,0 +1,52 @@
package com.bitchat.android.geohash
import android.content.Context
import android.location.Address
import android.location.Geocoder
import android.os.Build
import android.util.Log
import kotlinx.coroutines.suspendCancellableCoroutine
import java.util.Locale
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
class AndroidGeocoderProvider(context: Context) : GeocoderProvider {
private val geocoder = Geocoder(context, Locale.getDefault())
private val TAG = "AndroidGeocoderProvider"
override suspend fun getFromLocation(latitude: Double, longitude: Double, maxResults: Int): List<Address> {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
suspendCancellableCoroutine { cont ->
try {
geocoder.getFromLocation(
latitude,
longitude,
maxResults,
object : Geocoder.GeocodeListener {
override fun onGeocode(addresses: MutableList<Address>) {
if (cont.isActive) cont.resume(addresses)
}
override fun onError(errorMessage: String?) {
if (cont.isActive) {
Log.e(TAG, "Geocode error: $errorMessage")
cont.resume(emptyList())
}
}
}
)
} catch (e: Exception) {
if (cont.isActive) cont.resumeWithException(e)
}
}
} else {
@Suppress("DEPRECATION")
try {
geocoder.getFromLocation(latitude, longitude, maxResults) ?: emptyList()
} catch (e: Exception) {
Log.e(TAG, "Geocode failed", e)
emptyList()
}
}
}
}
@@ -0,0 +1,142 @@
package com.bitchat.android.geohash
import android.Manifest
import android.annotation.SuppressLint
import android.content.Context
import android.content.pm.PackageManager
import android.location.Location
import android.os.Looper
import android.util.Log
import androidx.core.app.ActivityCompat
import com.google.android.gms.location.*
class FusedLocationProvider(private val context: Context) : LocationProvider {
companion object {
private const val TAG = "FusedLocationProvider"
}
private val fusedLocationClient: FusedLocationProviderClient = LocationServices.getFusedLocationProviderClient(context)
// Map to keep track of callbacks to remove them later
private val activeCallbacks = mutableMapOf<(Location) -> Unit, LocationCallback>()
private fun hasLocationPermission(): Boolean {
return ActivityCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED ||
ActivityCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED
}
@SuppressLint("MissingPermission")
override fun getLastKnownLocation(callback: (Location?) -> Unit) {
if (!hasLocationPermission()) {
callback(null)
return
}
try {
fusedLocationClient.lastLocation
.addOnSuccessListener { location ->
callback(location)
}
.addOnFailureListener { e ->
Log.e(TAG, "Error getting last known fused location: ${e.message}")
callback(null)
}
} catch (e: Exception) {
Log.e(TAG, "Exception getting last known fused location: ${e.message}")
callback(null)
}
}
@SuppressLint("MissingPermission")
override fun requestFreshLocation(callback: (Location?) -> Unit) {
if (!hasLocationPermission()) {
callback(null)
return
}
try {
val request = CurrentLocationRequest.Builder()
.setPriority(Priority.PRIORITY_HIGH_ACCURACY)
.setDurationMillis(30000)
.build()
fusedLocationClient.getCurrentLocation(request, null)
.addOnSuccessListener { location ->
callback(location)
}
.addOnFailureListener { e ->
Log.e(TAG, "Error getting fresh fused location: ${e.message}")
callback(null)
}
} catch (e: Exception) {
Log.e(TAG, "Exception getting fresh fused location: ${e.message}")
callback(null)
}
}
@SuppressLint("MissingPermission")
override fun requestLocationUpdates(
intervalMs: Long,
minDistanceMeters: Float,
callback: (Location) -> Unit
) {
if (!hasLocationPermission()) return
try {
val request = LocationRequest.Builder(intervalMs)
.setMinUpdateDistanceMeters(minDistanceMeters)
.setPriority(Priority.PRIORITY_HIGH_ACCURACY)
.build()
val locationCallback = object : LocationCallback() {
override fun onLocationResult(result: LocationResult) {
result.lastLocation?.let { callback(it) }
}
}
synchronized(activeCallbacks) {
activeCallbacks[callback] = locationCallback
}
fusedLocationClient.requestLocationUpdates(
request,
locationCallback,
Looper.getMainLooper()
)
Log.d(TAG, "Registered fused updates")
} catch (e: Exception) {
Log.e(TAG, "Error requesting fused updates: ${e.message}")
}
}
override fun removeLocationUpdates(callback: (Location) -> Unit) {
try {
val locationCallback = synchronized(activeCallbacks) {
activeCallbacks.remove(callback)
}
if (locationCallback != null) {
fusedLocationClient.removeLocationUpdates(locationCallback)
Log.d(TAG, "Removed fused updates")
}
} catch (e: Exception) {
Log.e(TAG, "Error removing fused updates: ${e.message}")
}
}
override fun cancel() {
try {
synchronized(activeCallbacks) {
for ((callback, locationCallback) in activeCallbacks) {
fusedLocationClient.removeLocationUpdates(locationCallback)
}
activeCallbacks.clear()
}
Log.d(TAG, "Cancelled all fused updates")
} catch (e: Exception) {
Log.e(TAG, "Error cancelling fused provider: ${e.message}")
}
}
}
@@ -0,0 +1,19 @@
package com.bitchat.android.geohash
import android.content.Context
import android.location.Geocoder
/**
* Factory to provide the best available geocoder.
*/
object GeocoderFactory {
fun get(context: Context): GeocoderProvider {
// If Google Play Services Geocoder is present, use it.
// Otherwise, fall back to OpenStreetMap.
return if (Geocoder.isPresent()) {
AndroidGeocoderProvider(context)
} else {
OpenStreetMapGeocoderProvider()
}
}
}
@@ -0,0 +1,13 @@
package com.bitchat.android.geohash
import android.location.Address
/**
* Interface for reverse geocoding providers.
*/
interface GeocoderProvider {
/**
* Get a list of Address objects from latitude and longitude.
*/
suspend fun getFromLocation(latitude: Double, longitude: Double, maxResults: Int): List<Address>
}
@@ -167,12 +167,11 @@ class GeohashBookmarksStore private constructor(private val context: Context) {
if (gh.isEmpty()) return
if (_bookmarkNames.value?.containsKey(gh) == true) return
if (resolving.contains(gh)) return
if (!Geocoder.isPresent()) return
resolving.add(gh)
CoroutineScope(Dispatchers.IO).launch {
try {
val geocoder = Geocoder(context, Locale.getDefault())
val geocoderProvider = GeocoderFactory.get(context)
val name: String? = if (gh.length <= 2) {
// Composite admin name from multiple points
val b = Geohash.decodeToBounds(gh)
@@ -186,9 +185,8 @@ class GeohashBookmarksStore private constructor(private val context: Context) {
val admins = linkedSetOf<String>()
for (loc in points) {
try {
@Suppress("DEPRECATION")
val list = geocoder.getFromLocation(loc.latitude, loc.longitude, 1)
val a = list?.firstOrNull()
val list = geocoderProvider.getFromLocation(loc.latitude, loc.longitude, 1)
val a = list.firstOrNull()
val admin = a?.adminArea?.takeIf { !it.isNullOrEmpty() }
val country = a?.countryName?.takeIf { !it.isNullOrEmpty() }
if (admin != null) admins.add(admin)
@@ -203,9 +201,8 @@ class GeohashBookmarksStore private constructor(private val context: Context) {
}
} else {
val center = Geohash.decodeToCenter(gh)
@Suppress("DEPRECATION")
val list = geocoder.getFromLocation(center.first, center.second, 1)
val a = list?.firstOrNull()
val list = geocoderProvider.getFromLocation(center.first, center.second, 1)
val a = list.firstOrNull()
pickNameForLength(gh.length, a)
}
@@ -36,7 +36,18 @@ data class GeohashChannel(
*/
sealed class ChannelID {
object Mesh : ChannelID()
data class Location(val channel: GeohashChannel) : ChannelID()
data class Location(val channel: GeohashChannel) : ChannelID() {
companion object {
fun fromPersisted(levelName: String, geohash: String): Location? {
return try {
val level = GeohashChannelLevel.valueOf(levelName)
Location(GeohashChannel(level, geohash))
} catch (_: IllegalArgumentException) {
null
}
}
}
}
/**
* Human readable name for UI.
@@ -2,20 +2,25 @@ package com.bitchat.android.geohash
import android.Manifest
import android.content.Context
import android.content.IntentFilter
import android.content.pm.PackageManager
import android.location.Geocoder
import android.location.Location
import android.location.LocationListener
import android.location.LocationManager
import android.os.Bundle
import android.util.Log
import androidx.core.app.ActivityCompat
import com.google.android.gms.common.ConnectionResult
import com.google.android.gms.common.GoogleApiAvailability
import kotlinx.coroutines.*
import java.util.*
import com.google.gson.Gson
import com.google.gson.JsonSyntaxException
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
/**
* Manages location permissions, one-shot location retrieval, and computing geohash channels.
@@ -38,22 +43,45 @@ class LocationChannelManager private constructor(private val context: Context) {
// State enum matching iOS
enum class PermissionState {
NOT_DETERMINED,
DENIED,
RESTRICTED,
AUTHORIZED
}
private val locationManager: LocationManager = context.getSystemService(Context.LOCATION_SERVICE) as LocationManager
private val geocoder: Geocoder = Geocoder(context, Locale.getDefault())
private val locationProvider: LocationProvider
private val geocoderProvider: GeocoderProvider = GeocoderFactory.get(context)
private var lastLocation: Location? = null
private var refreshTimer: Job? = null
private var isGeocoding: Boolean = false
private var geocodingJob: Job? = null
private val gson = Gson()
private var dataManager: com.bitchat.android.ui.DataManager? = null
private fun checkSystemLocationEnabled(): Boolean {
return try {
locationManager.isProviderEnabled(LocationManager.GPS_PROVIDER) ||
locationManager.isProviderEnabled(LocationManager.NETWORK_PROVIDER)
} catch (_: Exception) {
false
}
}
private val locationStateReceiver = object : android.content.BroadcastReceiver() {
override fun onReceive(context: Context?, intent: android.content.Intent?) {
if (intent?.action == LocationManager.PROVIDERS_CHANGED_ACTION) {
val isEnabled = checkSystemLocationEnabled()
Log.d(TAG, "System location state changed: $isEnabled")
_systemLocationEnabled.value = isEnabled
}
}
}
private val locationUpdateCallback: (Location) -> Unit = { location ->
onLocationUpdated(location)
}
// Published state for UI bindings (matching iOS @Published properties)
private val _permissionState = MutableStateFlow(PermissionState.NOT_DETERMINED)
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
private val _permissionState = MutableStateFlow(PermissionState.DENIED)
val permissionState: StateFlow<PermissionState> = _permissionState
private val _availableChannels = MutableStateFlow<List<GeohashChannel>>(emptyList())
@@ -74,12 +102,40 @@ class LocationChannelManager private constructor(private val context: Context) {
private val _locationServicesEnabled = MutableStateFlow(false)
val locationServicesEnabled: StateFlow<Boolean> = _locationServicesEnabled
private val _systemLocationEnabled = MutableStateFlow(checkSystemLocationEnabled())
val systemLocationEnabled: StateFlow<Boolean> = _systemLocationEnabled
val effectiveLocationEnabled: StateFlow<Boolean> = combine(
locationServicesEnabled,
systemLocationEnabled
) { appToggle, systemToggle ->
appToggle && systemToggle
}.stateIn(
scope,
SharingStarted.Eagerly,
false
)
init {
updatePermissionState()
// Choose the best location provider
val availability = GoogleApiAvailability.getInstance().isGooglePlayServicesAvailable(context)
locationProvider = if (availability == ConnectionResult.SUCCESS) {
Log.i(TAG, "Using FusedLocationProvider (Google Play Services)")
FusedLocationProvider(context)
} else {
Log.i(TAG, "Using SystemLocationProvider (Native LocationManager)")
SystemLocationProvider(context)
}
checkAndSyncPermission()
// Initialize DataManager and load persisted settings
dataManager = com.bitchat.android.ui.DataManager(context)
loadPersistedChannelSelection()
loadLocationServicesState()
// Register for system location changes
context.registerReceiver(locationStateReceiver, IntentFilter(LocationManager.PROVIDERS_CHANGED_ACTION))
}
// MARK: - Public API (matching iOS interface)
@@ -91,26 +147,19 @@ class LocationChannelManager private constructor(private val context: Context) {
fun enableLocationChannels() {
Log.d(TAG, "enableLocationChannels() called")
// UNIFIED FIX: Check if location services are enabled by user
if (!isLocationServicesEnabled()) {
Log.w(TAG, "Location services disabled by user - not requesting location")
if (!_locationServicesEnabled.value || !_systemLocationEnabled.value) {
Log.w(TAG, "Location services disabled (app or system) - not requesting location")
return
}
when (getCurrentPermissionStatus()) {
PermissionState.NOT_DETERMINED -> {
Log.d(TAG, "Permission not determined - user needs to grant in app settings")
_permissionState.value = PermissionState.NOT_DETERMINED
}
PermissionState.DENIED, PermissionState.RESTRICTED -> {
Log.d(TAG, "Permission denied or restricted")
_permissionState.value = PermissionState.DENIED
}
PermissionState.AUTHORIZED -> {
Log.d(TAG, "Permission authorized - requesting location")
_permissionState.value = PermissionState.AUTHORIZED
requestOneShotLocation()
}
if (getCurrentPermissionStatus() == PermissionState.AUTHORIZED) {
Log.d(TAG, "Permission authorized - requesting location")
_permissionState.value = PermissionState.AUTHORIZED
requestOneShotLocation()
} else {
Log.d(TAG, "Permission not granted")
_permissionState.value = PermissionState.DENIED
}
}
@@ -124,10 +173,11 @@ class LocationChannelManager private constructor(private val context: Context) {
}
/**
* Begin periodic one-shot location refreshes while a selector UI is visible
* Begin real-time location updates while a selector UI is visible
* Uses requestLocationUpdates for continuous updates, plus a one-shot to prime state immediately
*/
fun beginLiveRefresh(interval: Long = 5000L) {
Log.d(TAG, "Beginning live refresh with interval ${interval}ms")
Log.d(TAG, "Beginning live refresh (continuous updates)")
if (_permissionState.value != PermissionState.AUTHORIZED) {
Log.w(TAG, "Cannot start live refresh - permission not authorized")
@@ -135,24 +185,18 @@ class LocationChannelManager private constructor(private val context: Context) {
}
if (!isLocationServicesEnabled()) {
Log.w(TAG, "Cannot start live refresh - location services disabled by user")
Log.w(TAG, "Cannot start live refresh - location services disabled")
return
}
// Cancel existing timer
refreshTimer?.cancel()
// Register for continuous updates from available provider
locationProvider.requestLocationUpdates(
intervalMs = interval,
minDistanceMeters = 5f,
callback = locationUpdateCallback
)
// Start new timer with coroutines
refreshTimer = CoroutineScope(Dispatchers.IO).launch {
while (isActive) {
if (isLocationServicesEnabled()) {
requestOneShotLocation()
}
delay(interval)
}
}
// Kick off immediately
// Prime state immediately with last known / current location
requestOneShotLocation()
}
@@ -161,8 +205,7 @@ class LocationChannelManager private constructor(private val context: Context) {
*/
fun endLiveRefresh() {
Log.d(TAG, "Ending live refresh")
refreshTimer?.cancel()
refreshTimer = null
locationProvider.removeLocationUpdates(locationUpdateCallback)
}
/**
@@ -210,8 +253,8 @@ class LocationChannelManager private constructor(private val context: Context) {
_locationServicesEnabled.value = true
saveLocationServicesState(true)
// If we have permission, start location operations
if (_permissionState.value == PermissionState.AUTHORIZED) {
// If we have permission and system location is on, start location operations
if (_permissionState.value == PermissionState.AUTHORIZED && systemLocationEnabled.value) {
requestOneShotLocation()
}
}
@@ -240,178 +283,76 @@ class LocationChannelManager private constructor(private val context: Context) {
/**
* Check if location services are enabled by the user
*/
/**
* Check if both the app toggle and system location are enabled
*/
fun isLocationServicesEnabled(): Boolean {
return _locationServicesEnabled.value
return _locationServicesEnabled.value && _systemLocationEnabled.value
}
// MARK: - Location Operations
private fun requestOneShotLocation() {
if (!hasLocationPermission()) {
if (!checkAndSyncPermission()) {
Log.w(TAG, "No location permission for one-shot request")
return
}
Log.d(TAG, "Requesting one-shot location")
// Set loading state initially
_isLoadingLocation.value = true
try {
// Try to get last known location from all available providers
var lastKnownLocation: Location? = null
// Get all available providers and try each one
val providers = locationManager.getProviders(true)
for (provider in providers) {
val location = locationManager.getLastKnownLocation(provider)
if (location != null) {
// If we find a location, check if it's more recent than what we have
if (lastKnownLocation == null || location.time > lastKnownLocation.time) {
lastKnownLocation = location
}
}
}
if (lastKnownLocation == null) {
lastKnownLocation = lastLocation;
}
// Use last known location if we have one
if (lastKnownLocation != null) {
Log.d(TAG, "Using last known location: ${lastKnownLocation.latitude}, ${lastKnownLocation.longitude}")
lastLocation = lastKnownLocation
_isLoadingLocation.value = false // Make sure loading state is off
computeChannels(lastKnownLocation)
reverseGeocodeIfNeeded(lastKnownLocation)
locationProvider.getLastKnownLocation { cached ->
// If we have a cached location and it's reasonably recent (e.g. < 5 mins), use it
// For now, we just use it if it exists, similar to previous logic
if (cached != null) {
Log.d(TAG, "Using last known location: ${cached.latitude}, ${cached.longitude}")
onLocationUpdated(cached)
} else {
Log.d(TAG, "No last known location available")
// Set loading state to true so UI can show a spinner
_isLoadingLocation.value = true
// Request a fresh location only when we don't have a last known location
Log.d(TAG, "Requesting fresh location...")
requestFreshLocation()
}
} catch (e: SecurityException) {
Log.e(TAG, "Security exception requesting location: ${e.message}")
_isLoadingLocation.value = false // Turn off loading state on error
updatePermissionState()
}
}
// One-time location listener to get a fresh location update
private val oneShotLocationListener = object : LocationListener {
override fun onLocationChanged(location: Location) {
Log.d(TAG, "Fresh location received: ${location.latitude}, ${location.longitude}")
lastLocation = location
computeChannels(location)
reverseGeocodeIfNeeded(location)
// Update loading state to indicate we have a location now
_isLoadingLocation.value = false
// Remove this listener after getting the update
try {
locationManager.removeUpdates(this)
} catch (e: SecurityException) {
Log.e(TAG, "Error removing location listener: ${e.message}")
}
}
}
// Request a fresh location update using getCurrentLocation instead of continuous updates
private fun requestFreshLocation() {
if (!hasLocationPermission()) {
_isLoadingLocation.value = false // Turn off loading state if no permission
return
}
try {
// Set loading state to true to indicate we're actively trying to get a location
_isLoadingLocation.value = true
// Try common providers in order of preference
val providers = listOf(
LocationManager.GPS_PROVIDER,
LocationManager.NETWORK_PROVIDER,
LocationManager.PASSIVE_PROVIDER
)
var providerFound = false
for (provider in providers) {
if (locationManager.isProviderEnabled(provider)) {
Log.d(TAG, "Getting current location from $provider")
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.R) {
// For Android 11+ (API 30+), use getCurrentLocation
locationManager.getCurrentLocation(
provider,
null, // No cancellation signal
context.mainExecutor,
{ location ->
if (location != null) {
Log.d(TAG, "Fresh location received: ${location.latitude}, ${location.longitude}")
lastLocation = location
computeChannels(location)
reverseGeocodeIfNeeded(location)
} else {
Log.w(TAG, "Received null location from getCurrentLocation")
}
// Update loading state to indicate we have a location now
_isLoadingLocation.value = false
}
)
Log.d(TAG, "No last known location available, requesting fresh...")
locationProvider.requestFreshLocation { fresh ->
if (fresh != null) {
Log.d(TAG, "Fresh location received: ${fresh.latitude}, ${fresh.longitude}")
onLocationUpdated(fresh)
} else {
// For older versions, fall back to one-shot requestSingleUpdate
locationManager.requestSingleUpdate(
provider,
oneShotLocationListener,
null // Looper - null uses the main thread
)
Log.w(TAG, "Failed to get fresh location")
_isLoadingLocation.value = false
}
providerFound = true
break
}
}
// If no provider was available, turn off loading state
if (!providerFound) {
Log.w(TAG, "No location providers available")
_isLoadingLocation.value = false
}
} catch (e: SecurityException) {
Log.e(TAG, "Security exception requesting location: ${e.message}")
_isLoadingLocation.value = false // Turn off loading state on error
} catch (e: Exception) {
Log.e(TAG, "Error requesting location: ${e.message}")
_isLoadingLocation.value = false // Turn off loading state on error
}
}
private fun onLocationUpdated(location: Location) {
lastLocation = location
_isLoadingLocation.value = false
computeChannels(location)
reverseGeocodeIfNeeded(location)
}
// MARK: - Helpers
private fun getCurrentPermissionStatus(): PermissionState {
return when {
ActivityCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED -> {
PermissionState.AUTHORIZED
}
ActivityCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED -> {
PermissionState.AUTHORIZED
}
else -> {
PermissionState.DENIED // In Android, we can't distinguish between denied and not determined after first ask
}
return if (checkAndSyncPermission()) {
PermissionState.AUTHORIZED
} else {
PermissionState.DENIED
}
}
private fun updatePermissionState() {
val newState = getCurrentPermissionStatus()
Log.d(TAG, "Permission state updated to: $newState")
_permissionState.value = newState
}
private fun hasLocationPermission(): Boolean {
return ActivityCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED ||
private fun checkAndSyncPermission(): Boolean {
val hasPermission = ActivityCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED ||
ActivityCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED
val newState = if (hasPermission) PermissionState.AUTHORIZED else PermissionState.DENIED
if (_permissionState.value != newState) {
Log.d(TAG, "Permission state updated to: $newState")
_permissionState.value = newState
}
return hasPermission
}
private fun computeChannels(location: Location) {
@@ -453,38 +394,29 @@ class LocationChannelManager private constructor(private val context: Context) {
}
private fun reverseGeocodeIfNeeded(location: Location) {
if (!Geocoder.isPresent()) {
Log.w(TAG, "Geocoder not present on this device")
return
}
// Cancel any pending geocoding job to avoid race conditions
geocodingJob?.cancel()
if (isGeocoding) {
Log.d(TAG, "Already geocoding, skipping")
return
}
isGeocoding = true
CoroutineScope(Dispatchers.IO).launch {
geocodingJob = scope.launch(Dispatchers.IO) {
try {
Log.d(TAG, "Starting reverse geocoding")
@Suppress("DEPRECATION")
val addresses = geocoder.getFromLocation(location.latitude, location.longitude, 1)
val addresses = geocoderProvider.getFromLocation(location.latitude, location.longitude, 1)
if (!addresses.isNullOrEmpty()) {
if (!isActive) return@launch
if (addresses.isNotEmpty()) {
val address = addresses[0]
val names = namesByLevel(address)
Log.d(TAG, "Reverse geocoding result: $names")
_locationNames.value = names
} else {
Log.w(TAG, "No reverse geocoding results")
}
} catch (e: Exception) {
Log.e(TAG, "Reverse geocoding failed: ${e.message}")
} finally {
isGeocoding = false
if (e !is CancellationException) {
Log.e(TAG, "Reverse geocoding failed: ${e.message}")
}
}
}
}
@@ -497,11 +429,13 @@ class LocationChannelManager private constructor(private val context: Context) {
dict[GeohashChannelLevel.REGION] = it
}
// Province (state/province or county)
// Province (state/province or county or city)
address.adminArea?.takeIf { it.isNotEmpty() }?.let {
dict[GeohashChannelLevel.PROVINCE] = it
} ?: address.subAdminArea?.takeIf { it.isNotEmpty() }?.let {
dict[GeohashChannelLevel.PROVINCE] = it
} ?: address.locality?.takeIf { it.isNotEmpty() }?.let {
dict[GeohashChannelLevel.PROVINCE] = it
}
// City (locality)
@@ -538,18 +472,14 @@ class LocationChannelManager private constructor(private val context: Context) {
private fun saveChannelSelection(channel: ChannelID) {
try {
val channelData = when (channel) {
is ChannelID.Mesh -> {
gson.toJson(mapOf("type" to "mesh"))
}
is ChannelID.Location -> {
gson.toJson(mapOf(
"type" to "location",
"level" to channel.channel.level.name,
"precision" to channel.channel.level.precision,
"geohash" to channel.channel.geohash,
"displayName" to channel.channel.level.displayName
))
}
is ChannelID.Mesh -> gson.toJson(PersistedChannel(mesh = true))
is ChannelID.Location -> gson.toJson(
PersistedChannel(
mesh = false,
level = channel.channel.level.name,
geohash = channel.channel.geohash
)
)
}
dataManager?.saveLastGeohashChannel(channelData)
Log.d(TAG, "Saved channel selection: ${channel.displayName}")
@@ -564,46 +494,14 @@ class LocationChannelManager private constructor(private val context: Context) {
private fun loadPersistedChannelSelection() {
try {
val channelData = dataManager?.loadLastGeohashChannel()
if (channelData != null) {
val channelMap = gson.fromJson(channelData, Map::class.java) as? Map<String, Any>
if (channelMap != null) {
val channel = when (channelMap["type"] as? String) {
"mesh" -> ChannelID.Mesh
"location" -> {
val levelName = channelMap["level"] as? String
val precision = (channelMap["precision"] as? Double)?.toInt()
val geohash = channelMap["geohash"] as? String
val displayName = channelMap["displayName"] as? String
if (levelName != null && precision != null && geohash != null && displayName != null) {
try {
val level = GeohashChannelLevel.valueOf(levelName)
val geohashChannel = GeohashChannel(level, geohash)
ChannelID.Location(geohashChannel)
} catch (e: IllegalArgumentException) {
Log.w(TAG, "Invalid geohash level in persisted data: $levelName")
null
}
} else {
Log.w(TAG, "Incomplete location channel data in persistence")
null
}
}
else -> {
Log.w(TAG, "Unknown channel type in persisted data: ${channelMap["type"]}")
null
}
}
if (channel != null) {
_selectedChannel.value = channel
Log.d(TAG, "Restored persisted channel: ${channel.displayName}")
} else {
Log.d(TAG, "Could not restore persisted channel, defaulting to Mesh")
_selectedChannel.value = ChannelID.Mesh
}
if (!channelData.isNullOrBlank()) {
val persisted = gson.fromJson(channelData, PersistedChannel::class.java)
val channel = persisted?.toChannel()
if (channel != null) {
_selectedChannel.value = channel
Log.d(TAG, "Restored persisted channel: ${channel.displayName}")
} else {
Log.w(TAG, "Invalid channel data format in persistence")
Log.d(TAG, "Could not restore persisted channel, defaulting to Mesh")
_selectedChannel.value = ChannelID.Mesh
}
} else {
@@ -619,6 +517,22 @@ class LocationChannelManager private constructor(private val context: Context) {
}
}
data class PersistedChannel(
val mesh: Boolean,
val level: String? = null,
val geohash: String? = null
) {
fun toChannel(): ChannelID? {
return if (mesh) {
ChannelID.Mesh
} else {
val levelName = level ?: return null
val gh = geohash ?: return null
ChannelID.Location.fromPersisted(levelName, gh)
}
}
}
/**
* Clear persisted channel selection (useful for testing or reset)
*/
@@ -662,17 +576,12 @@ class LocationChannelManager private constructor(private val context: Context) {
fun cleanup() {
Log.d(TAG, "Cleaning up LocationChannelManager")
endLiveRefresh()
locationProvider.cancel()
// For older Android versions, remove any remaining location listener to prevent memory leaks
if (android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.R) {
try {
locationManager.removeUpdates(oneShotLocationListener)
} catch (e: SecurityException) {
Log.e(TAG, "Error removing location listener during cleanup: ${e.message}")
} catch (e: Exception) {
Log.e(TAG, "Error during cleanup: ${e.message}")
}
}
// For Android 11+, getCurrentLocation doesn't need explicit cleanup as it's a one-time operation
geocodingJob?.cancel()
geocodingJob = null
// Unregister receiver
try { context.unregisterReceiver(locationStateReceiver) } catch (_: Exception) {}
}
}
@@ -0,0 +1,40 @@
package com.bitchat.android.geohash
import android.location.Location
/**
* Abstraction for location providers to support both
* System (LocationManager) and Google Play Services (FusedLocationProvider).
*/
interface LocationProvider {
/**
* Get the last known location from cache.
* @param callback Called with the location or null if not found/error.
*/
fun getLastKnownLocation(callback: (Location?) -> Unit)
/**
* Request a single, fresh location update.
* @param callback Called with the location or null if failed.
*/
fun requestFreshLocation(callback: (Location?) -> Unit)
/**
* Request continuous location updates.
* @param intervalMs Desired interval in milliseconds.
* @param minDistanceMeters Minimum distance in meters.
* @param callback Called when location updates.
*/
fun requestLocationUpdates(intervalMs: Long, minDistanceMeters: Float, callback: (Location) -> Unit)
/**
* Stop location updates.
* @param callback The same callback instance passed to requestLocationUpdates.
*/
fun removeLocationUpdates(callback: (Location) -> Unit)
/**
* Cancel any pending one-shot location requests and cleanup resources.
*/
fun cancel()
}
@@ -0,0 +1,106 @@
package com.bitchat.android.geohash
import android.location.Address
import android.util.Log
import com.bitchat.android.net.OkHttpProvider
import com.google.gson.Gson
import okhttp3.Request
import java.util.Locale
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
class OpenStreetMapGeocoderProvider : GeocoderProvider {
private val TAG = "OSMGeocoderProvider"
private val gson = Gson()
private val userAgent = "Bitchat-Android/1.0"
override suspend fun getFromLocation(latitude: Double, longitude: Double, maxResults: Int): List<Address> {
return withContext(Dispatchers.IO) {
val lang = Locale.getDefault().toLanguageTag()
// Using format=jsonv2 for structured address breakdown
val url = "https://nominatim.openstreetmap.org/reverse?format=jsonv2&lat=$latitude&lon=$longitude&zoom=18&addressdetails=1&accept-language=$lang"
try {
val request = Request.Builder()
.url(url)
.header("User-Agent", userAgent)
.build()
val response = OkHttpProvider.httpClient().newCall(request).execute()
if (!response.isSuccessful) {
Log.e(TAG, "OSM Request failed: ${response.code}")
response.close()
return@withContext emptyList<Address>()
}
val body = response.body?.string()
response.close()
if (body.isNullOrEmpty()) return@withContext emptyList<Address>()
try {
val osmResponse = gson.fromJson(body, OsmResponse::class.java)
if (osmResponse?.address == null) return@withContext emptyList<Address>()
val address = mapToAddress(osmResponse, latitude, longitude)
listOf(address)
} catch (e: Exception) {
Log.e(TAG, "OSM Parse failed: ${e.message}")
emptyList<Address>()
}
} catch (e: Exception) {
Log.e(TAG, "OSM Geocoding failed", e)
emptyList<Address>()
}
}
}
private fun mapToAddress(res: OsmResponse, lat: Double, lon: Double): Address {
val address = Address(Locale.getDefault())
address.latitude = lat
address.longitude = lon
val a = res.address ?: return address
address.countryName = a.country
address.adminArea = a.state
address.subAdminArea = a.county
// City logic similar to Google's mapping
address.locality = a.city ?: a.town ?: a.village ?: a.hamlet
// Neighborhood logic
address.subLocality = a.suburb ?: a.neighbourhood ?: a.residential ?: a.quarter
address.postalCode = a.postcode
address.thoroughfare = a.road
// Feature name
address.featureName = res.name
return address
}
// Data classes for JSON parsing
private data class OsmResponse(
val name: String?,
val display_name: String?,
val address: OsmAddress?
)
private data class OsmAddress(
val country: String?,
val state: String?,
val county: String?,
val city: String?,
val town: String?,
val village: String?,
val hamlet: String?,
val suburb: String?,
val neighbourhood: String?,
val residential: String?,
val quarter: String?,
val postcode: String?,
val road: String?
)
}
@@ -0,0 +1,238 @@
package com.bitchat.android.geohash
import android.Manifest
import android.annotation.SuppressLint
import android.content.Context
import android.content.pm.PackageManager
import android.location.Location
import android.location.LocationListener
import android.location.LocationManager
import android.os.Build
import android.os.Bundle
import android.util.Log
import androidx.core.app.ActivityCompat
class SystemLocationProvider(private val context: Context) : LocationProvider {
companion object {
private const val TAG = "SystemLocationProvider"
}
private val locationManager = context.getSystemService(Context.LOCATION_SERVICE) as LocationManager
private val handler = android.os.Handler(android.os.Looper.getMainLooper())
// Map to keep track of listeners to unregister them later
private val activeListeners = mutableMapOf<(Location) -> Unit, LocationListener>()
private val activeOneShotListeners = mutableMapOf<(Location?) -> Unit, LocationListener>()
private val activeOneShotRunnables = mutableMapOf<(Location?) -> Unit, Runnable>()
private fun hasLocationPermission(): Boolean {
return ActivityCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED ||
ActivityCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED
}
@SuppressLint("MissingPermission")
override fun getLastKnownLocation(callback: (Location?) -> Unit) {
if (!hasLocationPermission()) {
callback(null)
return
}
try {
var bestLocation: Location? = null
val providers = locationManager.getProviders(true)
for (provider in providers) {
val location = locationManager.getLastKnownLocation(provider)
if (location != null) {
if (bestLocation == null || location.time > bestLocation.time) {
bestLocation = location
}
}
}
callback(bestLocation)
} catch (e: Exception) {
Log.e(TAG, "Error getting last known location: ${e.message}")
callback(null)
}
}
@SuppressLint("MissingPermission")
override fun requestFreshLocation(callback: (Location?) -> Unit) {
if (!hasLocationPermission()) {
callback(null)
return
}
try {
val providers = listOf(
LocationManager.GPS_PROVIDER,
LocationManager.NETWORK_PROVIDER,
LocationManager.PASSIVE_PROVIDER
)
var providerFound = false
for (provider in providers) {
if (locationManager.isProviderEnabled(provider)) {
Log.d(TAG, "Requesting fresh location from $provider")
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
locationManager.getCurrentLocation(
provider,
null,
context.mainExecutor
) { location ->
callback(location)
}
} else {
// For older versions, use requestSingleUpdate with timeout mechanism
val timeoutRunnable = Runnable {
Log.w(TAG, "Location request timed out")
synchronized(activeOneShotListeners) {
val listener = activeOneShotListeners.remove(callback)
activeOneShotRunnables.remove(callback)
if (listener != null) {
try {
locationManager.removeUpdates(listener)
} catch (e: Exception) {
Log.e(TAG, "Error removing timed out listener: ${e.message}")
}
}
}
callback(null)
}
val listener = object : LocationListener {
override fun onLocationChanged(location: Location) {
synchronized(activeOneShotListeners) {
activeOneShotListeners.remove(callback)
val runnable = activeOneShotRunnables.remove(callback)
if (runnable != null) {
handler.removeCallbacks(runnable)
}
}
try {
locationManager.removeUpdates(this)
} catch (e: Exception) {
Log.e(TAG, "Error removing updates in callback: ${e.message}")
}
callback(location)
}
override fun onStatusChanged(provider: String?, status: Int, extras: Bundle?) {}
override fun onProviderEnabled(provider: String) {}
override fun onProviderDisabled(provider: String) {}
}
synchronized(activeOneShotListeners) {
activeOneShotListeners[callback] = listener
activeOneShotRunnables[callback] = timeoutRunnable
}
locationManager.requestSingleUpdate(provider, listener, null)
handler.postDelayed(timeoutRunnable, 30000L) // 30s timeout
}
providerFound = true
break
}
}
if (!providerFound) {
Log.w(TAG, "No location providers available for fresh location")
callback(null)
}
} catch (e: Exception) {
Log.e(TAG, "Error requesting fresh location: ${e.message}")
callback(null)
}
}
@SuppressLint("MissingPermission")
override fun requestLocationUpdates(
intervalMs: Long,
minDistanceMeters: Float,
callback: (Location) -> Unit
) {
if (!hasLocationPermission()) return
try {
val listener = object : LocationListener {
override fun onLocationChanged(location: Location) {
callback(location)
}
override fun onStatusChanged(provider: String?, status: Int, extras: Bundle?) {}
override fun onProviderEnabled(provider: String) {}
override fun onProviderDisabled(provider: String) {}
}
// Store the listener so we can remove it later
synchronized(activeListeners) {
activeListeners[callback] = listener
}
val providers = listOf(LocationManager.GPS_PROVIDER, LocationManager.NETWORK_PROVIDER)
var registered = false
for (provider in providers) {
if (locationManager.isProviderEnabled(provider)) {
locationManager.requestLocationUpdates(
provider,
intervalMs,
minDistanceMeters,
listener
)
registered = true
Log.d(TAG, "Registered updates for $provider")
}
}
if (!registered) {
Log.w(TAG, "No providers enabled for continuous updates")
}
} catch (e: Exception) {
Log.e(TAG, "Error requesting location updates: ${e.message}")
}
}
override fun removeLocationUpdates(callback: (Location) -> Unit) {
try {
val listener = synchronized(activeListeners) {
activeListeners.remove(callback)
}
if (listener != null) {
locationManager.removeUpdates(listener)
Log.d(TAG, "Removed location updates")
}
} catch (e: Exception) {
Log.e(TAG, "Error removing updates: ${e.message}")
}
}
override fun cancel() {
try {
// Cancel continuous updates
synchronized(activeListeners) {
for ((_, listener) in activeListeners) {
try { locationManager.removeUpdates(listener) } catch (_: Exception) {}
}
activeListeners.clear()
}
// Cancel one-shot requests
synchronized(activeOneShotListeners) {
for ((_, listener) in activeOneShotListeners) {
try { locationManager.removeUpdates(listener) } catch (_: Exception) {}
}
activeOneShotListeners.clear()
for ((_, runnable) in activeOneShotRunnables) {
handler.removeCallbacks(runnable)
}
activeOneShotRunnables.clear()
}
Log.d(TAG, "Cancelled all system location requests")
} catch (e: Exception) {
Log.e(TAG, "Error cancelling system provider: ${e.message}")
}
}
}
@@ -5,7 +5,10 @@ import android.content.SharedPreferences
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
import java.security.MessageDigest
import android.util.Base64
import android.util.Log
import com.bitchat.android.util.hexEncodedString
import androidx.core.content.edit
/**
* Manages persistent identity storage and peer ID rotation - 100% compatible with iOS implementation
@@ -24,9 +27,15 @@ class SecureIdentityStateManager(private val context: Context) {
private const val KEY_STATIC_PUBLIC_KEY = "static_public_key"
private const val KEY_SIGNING_PRIVATE_KEY = "signing_private_key"
private const val KEY_SIGNING_PUBLIC_KEY = "signing_public_key"
private const val KEY_VERIFIED_FINGERPRINTS = "verified_fingerprints"
private const val KEY_CACHED_PEER_FINGERPRINTS = "cached_peer_fingerprints"
private const val KEY_CACHED_PEER_NOISE_KEYS = "cached_peer_noise_keys"
private const val KEY_CACHED_NOISE_FINGERPRINTS = "cached_noise_fingerprints"
private const val KEY_CACHED_FINGERPRINT_NICKNAMES = "cached_fingerprint_nicknames"
}
private val prefs: SharedPreferences
private val lock = Any()
init {
// Create master key for encryption
@@ -168,7 +177,7 @@ class SecureIdentityStateManager(private val context: Context) {
fun generateFingerprint(publicKeyData: ByteArray): String {
val digest = MessageDigest.getInstance("SHA-256")
val hash = digest.digest(publicKeyData)
return hash.joinToString("") { "%02x".format(it) }
return hash.hexEncodedString()
}
/**
@@ -179,6 +188,112 @@ class SecureIdentityStateManager(private val context: Context) {
return fingerprint.matches(Regex("^[a-fA-F0-9]{64}$"))
}
// MARK: - Verified Fingerprints
fun getVerifiedFingerprints(): Set<String> {
return prefs.getStringSet(KEY_VERIFIED_FINGERPRINTS, emptySet())?.toSet() ?: emptySet()
}
fun isVerifiedFingerprint(fingerprint: String): Boolean {
return getVerifiedFingerprints().contains(fingerprint)
}
fun setVerifiedFingerprint(fingerprint: String, verified: Boolean) {
if (!isValidFingerprint(fingerprint)) return
synchronized(lock) {
val current = prefs.getStringSet(KEY_VERIFIED_FINGERPRINTS, emptySet())?.toMutableSet() ?: mutableSetOf()
if (verified) {
current.add(fingerprint)
} else {
current.remove(fingerprint)
}
prefs.edit { putStringSet(KEY_VERIFIED_FINGERPRINTS, current) }
}
}
fun getCachedPeerFingerprint(peerID: String): String? {
val pid = peerID.lowercase()
// Reading is safe without lock for SharedPreferences, but synchronizing ensures memory visibility
// if we are paranoid, but SharedPreferences is generally thread-safe for reads.
// However, to ensure we don't read a partial update (unlikely with SP), we can leave it.
// The critical part is the write.
val entries = prefs.getStringSet(KEY_CACHED_PEER_FINGERPRINTS, emptySet()) ?: return null
val entry = entries.firstOrNull { it.startsWith("$pid:") } ?: return null
return entry.substringAfter(':').takeIf { isValidFingerprint(it) }
}
fun cachePeerFingerprint(peerID: String, fingerprint: String) {
if (!isValidFingerprint(fingerprint)) return
val pid = peerID.lowercase()
synchronized(lock) {
val current = prefs.getStringSet(KEY_CACHED_PEER_FINGERPRINTS, emptySet())?.toMutableSet() ?: mutableSetOf()
current.removeAll { it.startsWith("$pid:") }
current.add("$pid:$fingerprint")
prefs.edit { putStringSet(KEY_CACHED_PEER_FINGERPRINTS, current) }
}
}
fun getCachedNoiseKey(peerID: String): String? {
val pid = peerID.lowercase()
val entries = prefs.getStringSet(KEY_CACHED_PEER_NOISE_KEYS, emptySet()) ?: return null
val entry = entries.firstOrNull { it.startsWith("$pid=") } ?: return null
return entry.substringAfter('=').takeIf { it.matches(Regex("^[a-fA-F0-9]{64}$")) }
}
fun cachePeerNoiseKey(peerID: String, noiseKeyHex: String) {
if (!noiseKeyHex.matches(Regex("^[a-fA-F0-9]{64}$"))) return
val pid = peerID.lowercase()
synchronized(lock) {
val current = prefs.getStringSet(KEY_CACHED_PEER_NOISE_KEYS, emptySet())?.toMutableSet() ?: mutableSetOf()
current.removeAll { it.startsWith("$pid=") }
current.add("$pid=${noiseKeyHex.lowercase()}")
prefs.edit { putStringSet(KEY_CACHED_PEER_NOISE_KEYS, current) }
}
}
fun getCachedNoiseFingerprint(noiseKeyHex: String): String? {
val key = noiseKeyHex.lowercase()
val entries = prefs.getStringSet(KEY_CACHED_NOISE_FINGERPRINTS, emptySet()) ?: return null
val entry = entries.firstOrNull { it.startsWith("$key=") } ?: return null
return entry.substringAfter('=').takeIf { isValidFingerprint(it) }
}
fun cacheNoiseFingerprint(noiseKeyHex: String, fingerprint: String) {
if (!isValidFingerprint(fingerprint)) return
if (!noiseKeyHex.matches(Regex("^[a-fA-F0-9]{64}$"))) return
val key = noiseKeyHex.lowercase()
synchronized(lock) {
val current = prefs.getStringSet(KEY_CACHED_NOISE_FINGERPRINTS, emptySet())?.toMutableSet() ?: mutableSetOf()
current.removeAll { it.startsWith("$key=") }
current.add("$key=$fingerprint")
prefs.edit { putStringSet(KEY_CACHED_NOISE_FINGERPRINTS, current) }
}
}
fun getCachedFingerprintNickname(fingerprint: String): String? {
if (!isValidFingerprint(fingerprint)) return null
val key = fingerprint.lowercase()
val entries = prefs.getStringSet(KEY_CACHED_FINGERPRINT_NICKNAMES, emptySet()) ?: return null
val entry = entries.firstOrNull { it.startsWith("$key=") } ?: return null
val encoded = entry.substringAfter('=')
return runCatching {
val bytes = Base64.decode(encoded, Base64.NO_WRAP)
String(bytes, Charsets.UTF_8)
}.getOrNull()
}
fun cacheFingerprintNickname(fingerprint: String, nickname: String) {
if (!isValidFingerprint(fingerprint)) return
val key = fingerprint.lowercase()
val encoded = Base64.encodeToString(nickname.toByteArray(Charsets.UTF_8), Base64.NO_WRAP)
synchronized(lock) {
val current = prefs.getStringSet(KEY_CACHED_FINGERPRINT_NICKNAMES, emptySet())?.toMutableSet() ?: mutableSetOf()
current.removeAll { it.startsWith("$key=") }
current.add("$key=$encoded")
prefs.edit { putStringSet(KEY_CACHED_FINGERPRINT_NICKNAMES, current) }
}
}
// MARK: - Peer ID Rotation Management (removed)
// Android now derives peer ID from the persisted Noise identity fingerprint.
// No timed peer ID rotation is performed here.
@@ -7,6 +7,7 @@ import com.bitchat.android.model.RoutedPacket
import com.bitchat.android.protocol.BitchatPacket
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.combine
/**
* Power-optimized Bluetooth connection manager with comprehensive memory management
@@ -37,7 +38,7 @@ class BluetoothConnectionManager(
// Component managers
private val permissionManager = BluetoothPermissionManager(context)
private val connectionTracker = BluetoothConnectionTracker(connectionScope, powerManager)
private val packetBroadcaster = BluetoothPacketBroadcaster(connectionScope, connectionTracker, fragmentManager)
private val packetBroadcaster = BluetoothPacketBroadcaster(connectionScope, connectionTracker, fragmentManager, myPeerID)
// Delegate for component managers to call back to main manager
private val componentDelegate = object : BluetoothConnectionManagerDelegate {
@@ -57,6 +58,8 @@ class BluetoothConnectionManager(
}
override fun onDeviceConnected(device: BluetoothDevice) {
// Trigger limit enforcement immediately upon any new connection
enforceStrictLimits()
delegate?.onDeviceConnected(device)
}
@@ -70,7 +73,7 @@ class BluetoothConnectionManager(
}
private val serverManager = BluetoothGattServerManager(
context, connectionScope, connectionTracker, permissionManager, powerManager, componentDelegate
context, connectionScope, connectionTracker, permissionManager, powerManager, componentDelegate, myPeerID
)
private val clientManager = BluetoothGattClientManager(
context, connectionScope, connectionTracker, permissionManager, powerManager, componentDelegate
@@ -85,10 +88,6 @@ class BluetoothConnectionManager(
// Public property for address-peer mapping
val addressPeerMap get() = connectionTracker.addressPeerMap
// Expose first-announce helpers to higher layers
fun noteAnnounceReceived(address: String) { connectionTracker.noteAnnounceReceived(address) }
fun hasSeenFirstAnnounce(address: String): Boolean = connectionTracker.hasSeenFirstAnnounce(address)
init {
powerManager.delegate = this
// Observe debug settings to enforce role state while active
@@ -107,30 +106,58 @@ class BluetoothConnectionManager(
if (enabled) startClient() else stopClient()
}
}
// Connection caps: enforce on change
// Centralized limit enforcement on any setting change
connectionScope.launch {
dbg.maxConnectionsOverall.collect {
if (!isActive) return@collect
connectionTracker.enforceConnectionLimits()
// Also enforce server side best-effort
serverManager.enforceServerLimit(dbg.maxServerConnections.value)
}
}
connectionScope.launch {
dbg.maxClientConnections.collect {
if (!isActive) return@collect
connectionTracker.enforceConnectionLimits()
}
}
connectionScope.launch {
dbg.maxServerConnections.collect {
if (!isActive) return@collect
serverManager.enforceServerLimit(dbg.maxServerConnections.value)
combine(
dbg.maxConnectionsOverall,
dbg.maxServerConnections,
dbg.maxClientConnections
) { _, _, _ ->
// We don't need the values here, we just need to trigger enforcement
Unit
}.collect {
if (isActive) {
enforceStrictLimits()
}
}
}
} catch (_: Exception) { }
}
/**
* Centralized connection limit enforcement
*/
private fun enforceStrictLimits() {
if (!isActive) return
try {
val dbg = com.bitchat.android.ui.debug.DebugSettingsManager.getInstance()
val maxOverall = dbg.maxConnectionsOverall.value
val maxServer = dbg.maxServerConnections.value
val maxClient = dbg.maxClientConnections.value
// Get list of connections to evict to satisfy all constraints
val toEvict = connectionTracker.getConnectionsToEvict(maxOverall, maxServer, maxClient)
if (toEvict.isNotEmpty()) {
Log.i(TAG, "Enforcing limits (max: $maxOverall, s: $maxServer, c: $maxClient) - evicting ${toEvict.size} connections")
toEvict.forEach { conn ->
if (conn.isClient) {
Log.d(TAG, "Evicting client ${conn.device.address}")
try { conn.gatt?.disconnect() } catch (_: Exception) { }
} else {
Log.d(TAG, "Evicting server ${conn.device.address}")
serverManager.disconnectDevice(conn.device)
}
}
}
} catch (e: Exception) {
Log.e(TAG, "Error enforcing limits: ${e.message}")
}
}
/**
* Start all Bluetooth services with power optimization
*/
@@ -247,13 +274,6 @@ class BluetoothConnectionManager(
return active
}
/**
* Set app background state for power optimization
*/
fun setAppBackgroundState(inBackground: Boolean) {
powerManager.setAppBackgroundState(inBackground)
}
/**
* Broadcast packet to connected devices with connection limit enforcement
* Automatically fragments large packets to fit within BLE MTU limits
@@ -268,6 +288,16 @@ class BluetoothConnectionManager(
)
}
fun sendToPeer(peerID: String, routed: RoutedPacket): Boolean {
if (!isActive) return false
return packetBroadcaster.sendToPeer(
peerID,
routed,
serverManager.getGattServer(),
serverManager.getCharacteristic()
)
}
fun cancelTransfer(transferId: String): Boolean {
return packetBroadcaster.cancelTransfer(transferId)
}
@@ -392,12 +422,7 @@ class BluetoothConnectionManager(
}
// Enforce connection limits
connectionTracker.enforceConnectionLimits()
// Best-effort server cap
try {
val maxServer = com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().maxServerConnections.value
serverManager.enforceServerLimit(maxServer)
} catch (_: Exception) { }
enforceStrictLimits()
}
}
@@ -29,7 +29,6 @@ class BluetoothConnectionTracker(
val addressPeerMap = ConcurrentHashMap<String, String>()
// Track whether we have seen the first ANNOUNCE on a given device connection
private val firstAnnounceSeen = ConcurrentHashMap<String, Boolean>()
// RSSI tracking from scan results (for devices we discover but may connect as servers)
private val scanRSSI = ConcurrentHashMap<String, Int>()
@@ -42,7 +41,8 @@ class BluetoothConnectionTracker(
val characteristic: BluetoothGattCharacteristic? = null,
val rssi: Int = Int.MIN_VALUE,
val isClient: Boolean = false,
val connectedAt: Long = System.currentTimeMillis()
val connectedAt: Long = System.currentTimeMillis(),
val peerID: String? = null
)
override fun start() {
@@ -151,6 +151,14 @@ class BluetoothConnectionTracker(
*/
fun isDeviceConnected(deviceAddress: String): Boolean = isConnected(deviceAddress)
/**
* Check if a peer is already connected (by PeerID)
*/
fun isPeerConnected(peerID: String): Boolean {
// Only consider actual connected devices that have identified themselves
return connectedDevices.values.any { it.peerID == peerID }
}
/**
* Disconnect a specific device (by MAC address)
*/
@@ -164,48 +172,61 @@ class BluetoothConnectionTracker(
/**
* Check if connection limit is reached
*/
fun isConnectionLimitReached(): Boolean {
return connectedDevices.size >= powerManager.getMaxConnections()
/**
* Check if a new client connection is allowed based on limits
*/
fun canConnectAsClient(maxOverall: Int, maxClient: Int): Boolean {
val total = connectedDevices.size
val clients = connectedDevices.values.count { it.isClient }
return total < maxOverall && clients < maxClient
}
/**
* Enforce connection limits by disconnecting oldest connections
* Calculate which connections should be evicted to satisfy limits.
* Logic:
* 1. Enforce strict role limits (maxClient, maxServer) - evict oldest excess.
* 2. Enforce overall limit (maxOverall) - evict oldest remaining, preferring clients.
*/
fun enforceConnectionLimits() {
// Read debug overrides if available
val dbg = try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance() } catch (_: Exception) { null }
val maxOverall = dbg?.maxConnectionsOverall?.value ?: powerManager.getMaxConnections()
val maxClient = dbg?.maxClientConnections?.value ?: maxOverall
val maxServer = dbg?.maxServerConnections?.value ?: maxOverall
fun getConnectionsToEvict(maxOverall: Int, maxServer: Int, maxClient: Int): List<DeviceConnection> {
val toEvict = mutableSetOf<DeviceConnection>()
val currentDevices = connectedDevices.values.toList()
val clients = connectedDevices.values.filter { it.isClient }
val servers = connectedDevices.values.filter { !it.isClient }
// Enforce client cap first (we can actively disconnect)
// 1. Enforce Role Limits
val clients = currentDevices.filter { it.isClient }.sortedBy { it.connectedAt }
if (clients.size > maxClient) {
Log.i(TAG, "Enforcing client cap: ${clients.size} > $maxClient")
val toDisconnect = clients.sortedBy { it.connectedAt }.take(clients.size - maxClient)
toDisconnect.forEach { dc ->
Log.d(TAG, "Disconnecting client ${dc.device.address} due to client cap")
dc.gatt?.disconnect()
toEvict.addAll(clients.take(clients.size - maxClient))
}
val servers = currentDevices.filter { !it.isClient }.sortedBy { it.connectedAt }
if (servers.size > maxServer) {
toEvict.addAll(servers.take(servers.size - maxServer))
}
// 2. Enforce Overall Limit
// Count how many would remain after the above evictions
val remaining = currentDevices.filter { !toEvict.contains(it) }
if (remaining.size > maxOverall) {
val excessCount = remaining.size - maxOverall
// Explicitly prefer evicting clients first
val clientCandidates = remaining.filter { it.isClient }.sortedBy { it.connectedAt }
val serverCandidates = remaining.filter { !it.isClient }.sortedBy { it.connectedAt }
var needed = excessCount
// Take from clients first
val fromClients = clientCandidates.take(needed)
toEvict.addAll(fromClients)
needed -= fromClients.size
// If still need more, take from servers
if (needed > 0) {
val fromServers = serverCandidates.take(needed)
toEvict.addAll(fromServers)
}
}
// Note: server cap enforced in GattServerManager (we don't have server handle here)
// Enforce overall cap by disconnecting oldest client connections
if (connectedDevices.size > maxOverall) {
Log.i(TAG, "Enforcing overall cap: ${connectedDevices.size} > $maxOverall")
val excess = connectedDevices.size - maxOverall
val toDisconnect = connectedDevices.values
.filter { it.isClient } // only clients from here
.sortedBy { it.connectedAt }
.take(excess)
toDisconnect.forEach { dc ->
Log.d(TAG, "Disconnecting client ${dc.device.address} due to overall cap")
dc.gatt?.disconnect()
}
}
return toEvict.toList()
}
/**
@@ -320,6 +320,22 @@ class BluetoothGattClientManager(
return
}
// Try to extract peerID from Service Data (if available) for stable identity
val serviceData = scanRecord?.getServiceData(ParcelUuid(AppConstants.Mesh.Gatt.SERVICE_UUID))
val peerID = if (serviceData != null && serviceData.size >= 8) {
serviceData.joinToString("") { "%02x".format(it) }
} else {
null
}
if (peerID != null) {
// Log.v(TAG, "Found peerID $peerID in scan record for $deviceAddress")
if (connectionTracker.isPeerConnected(peerID)) {
Log.d(TAG, "Deduplication: Peer $peerID is already connected (ignoring $deviceAddress)")
return
}
}
// Log.d(TAG, "Received scan result from $deviceAddress - already connected: ${connectionTracker.isDeviceConnected(deviceAddress)}")
// Store RSSI from scan results for later use (especially for server connections)
@@ -332,7 +348,7 @@ class BluetoothGattClientManager(
deviceName = device.name,
deviceAddress = deviceAddress,
rssi = rssi,
peerID = null // peerID unknown at scan time
peerID = peerID // Use the discovered peerID if available
)
)
} catch (_: Exception) { }
@@ -342,13 +358,12 @@ class BluetoothGattClientManager(
Log.d(TAG, "Skipping device $deviceAddress due to weak signal: $rssi < ${powerManager.getRSSIThreshold()}")
// Even if we skip connecting, still publish scan result to debug UI
try {
val pid: String? = null // We don't know peerID until packet exchange
DebugSettingsManager.getInstance().addScanResult(
DebugScanResult(
deviceName = device.name,
deviceAddress = deviceAddress,
rssi = rssi,
peerID = pid
peerID = peerID
)
)
} catch (_: Exception) { }
@@ -366,14 +381,19 @@ class BluetoothGattClientManager(
return
}
if (connectionTracker.isConnectionLimitReached()) {
Log.d(TAG, "Connection limit reached (${powerManager.getMaxConnections()})")
// Check if connection limit is reached
val dbg = try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance() } catch (_: Exception) { null }
val maxOverall = dbg?.maxConnectionsOverall?.value ?: powerManager.getMaxConnections()
val maxClient = dbg?.maxClientConnections?.value ?: maxOverall
if (!connectionTracker.canConnectAsClient(maxOverall, maxClient)) {
Log.d(TAG, "Client connection limit reached (overall: $maxOverall, client: $maxClient)")
return
}
// Add pending connection and start connection
if (connectionTracker.addPendingConnection(deviceAddress)) {
connectToDevice(device, rssi)
connectToDevice(device, rssi, peerID)
}
}
@@ -381,11 +401,11 @@ class BluetoothGattClientManager(
* Connect to a device as GATT client
*/
@Suppress("DEPRECATION")
private fun connectToDevice(device: BluetoothDevice, rssi: Int) {
private fun connectToDevice(device: BluetoothDevice, rssi: Int, peerID: String? = null) {
if (!permissionManager.hasBluetoothPermissions()) return
val deviceAddress = device.address
Log.i(TAG, "Connecting to bitchat device: $deviceAddress")
Log.i(TAG, "Connecting to bitchat device: $deviceAddress (peerID: $peerID)")
val gattCallback = object : BluetoothGattCallback() {
override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) {
@@ -435,7 +455,8 @@ class BluetoothGattClientManager(
device = gatt.device,
gatt = gatt,
rssi = rssi,
isClient = true
isClient = true,
peerID = peerID // Store the peerID discovered during scan
)
connectionTracker.addDeviceConnection(deviceAddress, deviceConn)
@@ -24,7 +24,8 @@ class BluetoothGattServerManager(
private val connectionTracker: BluetoothConnectionTracker,
private val permissionManager: BluetoothPermissionManager,
private val powerManager: PowerManager,
private val delegate: BluetoothConnectionManagerDelegate?
private val delegate: BluetoothConnectionManagerDelegate?,
private val myPeerID: String
) {
companion object {
@@ -45,18 +46,15 @@ class BluetoothGattServerManager(
// State management
private var isActive = false
// Enforce a server connection limit by canceling the oldest connections (best-effort)
fun enforceServerLimit(maxServer: Int) {
if (maxServer <= 0) return
/**
* Disconnect a specific device (used by ConnectionManager to enforce overall limits)
*/
fun disconnectDevice(device: BluetoothDevice) {
try {
val subs = connectionTracker.getSubscribedDevices()
if (subs.size > maxServer) {
val excess = subs.size - maxServer
subs.take(excess).forEach { d ->
try { gattServer?.cancelConnection(d) } catch (_: Exception) { }
}
}
} catch (_: Exception) { }
gattServer?.cancelConnection(device)
} catch (e: Exception) {
Log.w(TAG, "Error disconnecting device ${device.address}: ${e.message}")
}
}
/**
@@ -122,9 +120,10 @@ class BluetoothGattServerManager(
// Try to cancel any active connections explicitly before closing
try {
val devices = connectionTracker.getSubscribedDevices()
devices.forEach { d ->
try { gattServer?.cancelConnection(d) } catch (_: Exception) { }
// Disconnect ALL server connections
val servers = connectionTracker.getConnectedDevices().values.filter { !it.isClient }
servers.forEach { d ->
try { gattServer?.cancelConnection(d.device) } catch (_: Exception) { }
}
} catch (_: Exception) { }
@@ -359,12 +358,26 @@ class BluetoothGattServerManager(
.setIncludeDeviceName(false)
.build()
// Add stable identity (first 8 bytes of peerID) to Scan Response
// This allows scanners to deduplicate devices even if MAC address rotates
val peerIDBytes = try {
myPeerID.chunked(2).map { it.toInt(16).toByte() }.toByteArray().take(8).toByteArray()
} catch (e: Exception) {
ByteArray(0)
}
val scanResponse = AdvertiseData.Builder()
.addServiceData(ParcelUuid(AppConstants.Mesh.Gatt.SERVICE_UUID), peerIDBytes)
.setIncludeTxPowerLevel(false)
.setIncludeDeviceName(false)
.build()
advertiseCallback = object : AdvertiseCallback() {
override fun onStartSuccess(settingsInEffect: AdvertiseSettings) {
val mode = try {
powerManager.getPowerInfo().split("Current Mode: ")[1].split("\n")[0]
} catch (_: Exception) { "unknown" }
Log.i(TAG, "Advertising started (power mode: $mode)")
Log.i(TAG, "Advertising started (power mode: $mode) with stable ID: ${peerIDBytes.joinToString("") { "%02x".format(it) }}")
}
override fun onStartFailure(errorCode: Int) {
@@ -373,7 +386,7 @@ class BluetoothGattServerManager(
}
try {
bleAdvertiser.startAdvertising(settings, data, advertiseCallback)
bleAdvertiser.startAdvertising(settings, data, scanResponse, advertiseCallback)
} catch (se: SecurityException) {
Log.e(TAG, "SecurityException starting advertising (missing permission?): ${se.message}")
} catch (e: Exception) {
File diff suppressed because it is too large Load Diff
@@ -42,7 +42,8 @@ import kotlinx.coroutines.channels.actor
class BluetoothPacketBroadcaster(
private val connectionScope: CoroutineScope,
private val connectionTracker: BluetoothConnectionTracker,
private val fragmentManager: FragmentManager?
private val fragmentManager: FragmentManager?,
private val myPeerID: String
) {
companion object {
@@ -68,7 +69,9 @@ class BluetoothPacketBroadcaster(
incomingAddr: String?,
toPeer: String?,
toDeviceAddress: String,
ttl: UByte
ttl: UByte,
packetVersion: UByte = 1u,
routeInfo: String? = null
) {
try {
val fromNick = incomingPeer?.let { nicknameResolver?.invoke(it) }
@@ -80,7 +83,9 @@ class BluetoothPacketBroadcaster(
toPeerID = toPeer,
toNickname = toNick,
toDeviceAddress = toDeviceAddress,
previousHopPeerID = incomingPeer
previousHopPeerID = incomingPeer,
packetVersion = packetVersion,
routeInfo = routeInfo
)
// Keep the verbose relay message for human readability
manager.logPacketRelayDetailed(
@@ -94,7 +99,9 @@ class BluetoothPacketBroadcaster(
toNickname = toNick,
toDeviceAddress = toDeviceAddress,
ttl = ttl,
isRelay = true
isRelay = true,
packetVersion = packetVersion,
routeInfo = routeInfo
)
} catch (_: Exception) {
// Silently ignore debug logging failures
@@ -221,17 +228,19 @@ class BluetoothPacketBroadcaster(
TransferProgressManager.start(transferId, 1)
}
val typeName = MessageType.fromValue(packet.type)?.name ?: packet.type.toString()
val senderPeerID = routed.peerID ?: packet.senderID.toHexString()
val incomingAddr = routed.relayAddress
val incomingPeer = incomingAddr?.let { connectionTracker.addressPeerMap[it] }
val senderPeerID = routed.peerID ?: packet.senderID.toHexString()
val senderNick = senderPeerID.let { pid -> nicknameResolver?.invoke(pid) }
val route = packet.route
val routeInfo = if (!route.isNullOrEmpty()) "routed: ${route.size} hops" else null
// Prefer server-side subscriptions
val serverTarget = connectionTracker.getSubscribedDevices()
.firstOrNull { connectionTracker.addressPeerMap[it.address] == targetPeerID }
if (serverTarget != null) {
if (notifyDevice(serverTarget, data, gattServer, characteristic)) {
logPacketRelay(typeName, senderPeerID, senderNick, incomingPeer, incomingAddr, targetPeerID, serverTarget.address, packet.ttl)
logPacketRelay(typeName, senderPeerID, senderNick, incomingPeer, incomingAddr, targetPeerID, serverTarget.address, packet.ttl, packet.version, routeInfo)
if (transferId != null) {
TransferProgressManager.progress(transferId, 1, 1)
TransferProgressManager.complete(transferId, 1)
@@ -245,7 +254,7 @@ class BluetoothPacketBroadcaster(
.firstOrNull { connectionTracker.addressPeerMap[it.device.address] == targetPeerID }
if (clientTarget != null) {
if (writeToDeviceConn(clientTarget, data)) {
logPacketRelay(typeName, senderPeerID, senderNick, incomingPeer, incomingAddr, targetPeerID, clientTarget.device.address, packet.ttl)
logPacketRelay(typeName, senderPeerID, senderNick, incomingPeer, incomingAddr, targetPeerID, clientTarget.device.address, packet.ttl, packet.version, routeInfo)
if (transferId != null) {
TransferProgressManager.progress(transferId, 1, 1)
TransferProgressManager.complete(transferId, 1)
@@ -284,6 +293,46 @@ class BluetoothPacketBroadcaster(
}
}
/**
* Targeted send to a specific peer (by peerID) if directly connected.
* Returns true if sent to at least one matching connection.
*/
fun sendToPeer(
targetPeerID: String,
routed: RoutedPacket,
gattServer: BluetoothGattServer?,
characteristic: BluetoothGattCharacteristic?
): Boolean {
val packet = routed.packet
val data = packet.toBinaryData() ?: return false
val typeName = MessageType.fromValue(packet.type)?.name ?: packet.type.toString()
val senderPeerID = routed.peerID ?: packet.senderID.toHexString()
val incomingAddr = routed.relayAddress
val incomingPeer = incomingAddr?.let { connectionTracker.addressPeerMap[it] }
val senderNick = senderPeerID.let { pid -> nicknameResolver?.invoke(pid) }
// Try server-side connections first
val targetDevice = connectionTracker.getSubscribedDevices()
.firstOrNull { connectionTracker.addressPeerMap[it.address] == targetPeerID }
if (targetDevice != null) {
if (notifyDevice(targetDevice, data, gattServer, characteristic)) {
logPacketRelay(typeName, senderPeerID, senderNick, incomingPeer, incomingAddr, targetPeerID, targetDevice.address, packet.ttl)
return true
}
}
// Try client-side connections next
val targetConn = connectionTracker.getConnectedDevices().values
.firstOrNull { connectionTracker.addressPeerMap[it.device.address] == targetPeerID }
if (targetConn != null) {
if (writeToDeviceConn(targetConn, data)) {
logPacketRelay(typeName, senderPeerID, senderNick, incomingPeer, incomingAddr, targetPeerID, targetConn.device.address, packet.ttl)
return true
}
}
return false
}
/**
* Internal broadcast implementation - runs in serialized actor context
*/
@@ -299,11 +348,52 @@ class BluetoothPacketBroadcaster(
val incomingAddr = routed.relayAddress
val incomingPeer = incomingAddr?.let { connectionTracker.addressPeerMap[it] }
val senderNick = senderPeerID.let { pid -> nicknameResolver?.invoke(pid) }
val route = packet.route
val routeInfo = if (!route.isNullOrEmpty()) "routed: ${route.size} hops" else null
// Source Routing for Originating Packets
// If we are the sender and a source route is defined, we must send ONLY to the first hop.
if (packet.senderID.toHexString() == myPeerID && !packet.route.isNullOrEmpty()) {
val firstHop = packet.route!![0].toHexString()
Log.d(TAG, "Source Routing: Packet has explicit route, attempting to send to first hop: $firstHop")
var sent = false
// Try to find first hop in server connections (subscribedDevices)
val serverTarget = connectionTracker.getSubscribedDevices()
.firstOrNull { connectionTracker.addressPeerMap[it.address] == firstHop }
if (serverTarget != null) {
Log.d(TAG, "Source Routing: sending directly to first hop (server conn) $firstHop: ${serverTarget.address}")
if (notifyDevice(serverTarget, data, gattServer, characteristic)) {
val toPeer = connectionTracker.addressPeerMap[serverTarget.address]
logPacketRelay(typeName, senderPeerID, senderNick, incomingPeer, incomingAddr, toPeer, serverTarget.address, packet.ttl, packet.version, routeInfo)
sent = true
}
}
// Try to find first hop in client connections if not sent yet
if (!sent) {
val clientTarget = connectionTracker.getConnectedDevices().values
.firstOrNull { connectionTracker.addressPeerMap[it.device.address] == firstHop }
if (clientTarget != null) {
Log.d(TAG, "Source Routing: sending directly to first hop (client conn) $firstHop: ${clientTarget.device.address}")
if (writeToDeviceConn(clientTarget, data)) {
val toPeer = connectionTracker.addressPeerMap[clientTarget.device.address]
logPacketRelay(typeName, senderPeerID, senderNick, incomingPeer, incomingAddr, toPeer, clientTarget.device.address, packet.ttl, packet.version, routeInfo)
sent = true
}
}
}
if (sent) return
Log.w(TAG, "Source Routing: First hop $firstHop not connected. Falling back to standard broadcast logic.")
}
if (packet.recipientID != SpecialRecipients.BROADCAST) {
val recipientID = packet.recipientID?.let {
String(it).replace("\u0000", "").trim()
} ?: ""
val recipientID = packet.recipientID?.toHexString() ?: ""
// Try to find the recipient in server connections (subscribedDevices)
val targetDevice = connectionTracker.getSubscribedDevices()
@@ -314,7 +404,7 @@ class BluetoothPacketBroadcaster(
Log.d(TAG, "Send packet type ${packet.type} directly to target device for recipient $recipientID: ${targetDevice.address}")
if (notifyDevice(targetDevice, data, gattServer, characteristic)) {
val toPeer = connectionTracker.addressPeerMap[targetDevice.address]
logPacketRelay(typeName, senderPeerID, senderNick, incomingPeer, incomingAddr, toPeer, targetDevice.address, packet.ttl)
logPacketRelay(typeName, senderPeerID, senderNick, incomingPeer, incomingAddr, toPeer, targetDevice.address, packet.ttl, packet.version, routeInfo)
return // Sent, no need to continue
}
}
@@ -328,7 +418,7 @@ class BluetoothPacketBroadcaster(
Log.d(TAG, "Send packet type ${packet.type} directly to target client connection for recipient $recipientID: ${targetDeviceConn.device.address}")
if (writeToDeviceConn(targetDeviceConn, data)) {
val toPeer = connectionTracker.addressPeerMap[targetDeviceConn.device.address]
logPacketRelay(typeName, senderPeerID, senderNick, incomingPeer, incomingAddr, toPeer, targetDeviceConn.device.address, packet.ttl)
logPacketRelay(typeName, senderPeerID, senderNick, incomingPeer, incomingAddr, toPeer, targetDeviceConn.device.address, packet.ttl, packet.version, routeInfo)
return // Sent, no need to continue
}
}
@@ -338,9 +428,9 @@ class BluetoothPacketBroadcaster(
val subscribedDevices = connectionTracker.getSubscribedDevices()
val connectedDevices = connectionTracker.getConnectedDevices()
Log.i(TAG, "Broadcasting packet type ${packet.type} to ${subscribedDevices.size} server + ${connectedDevices.size} client connections")
Log.i(TAG, "Broadcasting packet v${packet.version} type ${packet.type} to ${subscribedDevices.size} server + ${connectedDevices.size} client connections")
val senderID = String(packet.senderID).replace("\u0000", "")
val senderID = packet.senderID.toHexString()
// Send to server connections (devices connected to our GATT server)
subscribedDevices.forEach { device ->
@@ -355,7 +445,7 @@ class BluetoothPacketBroadcaster(
val sent = notifyDevice(device, data, gattServer, characteristic)
if (sent) {
val toPeer = connectionTracker.addressPeerMap[device.address]
logPacketRelay(typeName, senderPeerID, senderNick, incomingPeer, incomingAddr, toPeer, device.address, packet.ttl)
logPacketRelay(typeName, senderPeerID, senderNick, incomingPeer, incomingAddr, toPeer, device.address, packet.ttl, packet.version, routeInfo)
}
}
@@ -373,7 +463,7 @@ class BluetoothPacketBroadcaster(
val sent = writeToDeviceConn(deviceConn, data)
if (sent) {
val toPeer = connectionTracker.addressPeerMap[deviceConn.device.address]
logPacketRelay(typeName, senderPeerID, senderNick, incomingPeer, incomingAddr, toPeer, deviceConn.device.address, packet.ttl)
logPacketRelay(typeName, senderPeerID, senderNick, incomingPeer, incomingAddr, toPeer, deviceConn.device.address, packet.ttl, packet.version, routeInfo)
}
}
}
@@ -32,6 +32,10 @@ class FragmentManager {
private val incomingFragments = ConcurrentHashMap<String, MutableMap<Int, ByteArray>>()
// iOS equivalent: fragmentMetadata: [String: (type: UInt8, total: Int, timestamp: Date)]
private val fragmentMetadata = ConcurrentHashMap<String, Triple<UByte, Int, Long>>() // originalType, totalFragments, timestamp
private val fragmentCumulativeSize = ConcurrentHashMap<String, Int>()
private val fragmentStateLock = Any()
private var globalBufferedBytes: Long = 0L
// Delegate for callbacks
var delegate: FragmentManagerDelegate? = null
@@ -77,8 +81,31 @@ class FragmentManager {
val fragmentID = FragmentPayload.generateFragmentID()
// iOS: stride(from: 0, to: fullData.count, by: maxFragmentSize)
val fragmentChunks = stride(0, fullData.size, MAX_FRAGMENT_SIZE) { offset ->
val endOffset = minOf(offset + MAX_FRAGMENT_SIZE, fullData.size)
// Calculate dynamic fragment size to fit in MTU (512)
// Packet = Header + Sender + Recipient + Route + FragmentHeader + Payload + PaddingBuffer
val hasRoute = packet.route != null
val version = if (hasRoute) 2 else 1
val headerSize = if (version == 2) 15 else 13
val senderSize = 8
val recipientSize = if (packet.recipientID != null) 8 else 0
// Route: 1 byte count + 8 bytes per hop
val routeSize = if (hasRoute) (1 + (packet.route?.size ?: 0) * 8) else 0
val fragmentHeaderSize = 13 // FragmentPayload header
val paddingBuffer = 16 // MessagePadding.optimalBlockSize adds 16 bytes overhead
// 512 - Overhead
val packetOverhead = headerSize + senderSize + recipientSize + routeSize + fragmentHeaderSize + paddingBuffer
val maxDataSize = (512 - packetOverhead).coerceAtMost(MAX_FRAGMENT_SIZE)
if (maxDataSize <= 0) {
Log.e(TAG, "❌ Calculated maxDataSize is non-positive ($maxDataSize). Route too large?")
return emptyList()
}
Log.d(TAG, "📏 Dynamic fragment size: $maxDataSize (MAX: $MAX_FRAGMENT_SIZE, Overhead: $packetOverhead)")
val fragmentChunks = stride(0, fullData.size, maxDataSize) { offset ->
val endOffset = minOf(offset + maxDataSize, fullData.size)
fullData.sliceArray(offset..<endOffset)
}
@@ -98,13 +125,16 @@ class FragmentManager {
)
// iOS: MessageType.fragment.rawValue (single fragment type)
// Fix: Fragments must inherit source route and use v2 if routed
val fragmentPacket = BitchatPacket(
version = if (packet.route != null) 2u else 1u,
type = MessageType.FRAGMENT.value,
ttl = packet.ttl,
senderID = packet.senderID,
recipientID = packet.recipientID,
timestamp = packet.timestamp,
payload = fragmentPayload.encode(),
route = packet.route,
signature = null // iOS: signature: nil
)
@@ -147,52 +177,109 @@ class FragmentManager {
Log.d(TAG, "Received fragment ${fragmentPayload.index}/${fragmentPayload.total} for fragmentID: $fragmentIDString, originalType: ${fragmentPayload.originalType}")
// iOS: if incomingFragments[fragmentID] == nil
if (!incomingFragments.containsKey(fragmentIDString)) {
incomingFragments[fragmentIDString] = mutableMapOf()
fragmentMetadata[fragmentIDString] = Triple(
fragmentPayload.originalType,
fragmentPayload.total,
System.currentTimeMillis()
)
val maxFragments = com.bitchat.android.util.AppConstants.Fragmentation.MAX_FRAGMENTS_PER_ID
if (fragmentPayload.total > maxFragments) {
Log.w(TAG, "Rejecting fragment with excessive total count: ${fragmentPayload.total} > $maxFragments")
return null
}
// iOS: incomingFragments[fragmentID]?[index] = Data(fragmentData)
incomingFragments[fragmentIDString]?.put(fragmentPayload.index, fragmentPayload.data)
// iOS: if let fragments = incomingFragments[fragmentID], fragments.count == total
val fragmentMap = incomingFragments[fragmentIDString]
if (fragmentMap != null && fragmentMap.size == fragmentPayload.total) {
Log.d(TAG, "All fragments received for $fragmentIDString, reassembling...")
// iOS reassembly logic: for i in 0..<total { if let fragment = fragments[i] { reassembled.append(fragment) } }
val reassembledData = mutableListOf<Byte>()
for (i in 0 until fragmentPayload.total) {
fragmentMap[i]?.let { data ->
reassembledData.addAll(data.asIterable())
synchronized(fragmentStateLock) {
fragmentMetadata[fragmentIDString]?.let { (expectedType, expectedTotal, _) ->
if (expectedTotal != fragmentPayload.total || expectedType != fragmentPayload.originalType) {
Log.w(
TAG,
"Rejecting fragment for $fragmentIDString: inconsistent metadata " +
"(expected type=$expectedType total=$expectedTotal, got type=${fragmentPayload.originalType} total=${fragmentPayload.total})"
)
removeFragmentSetLocked(fragmentIDString)
return null
}
}
// Decode the original packet bytes we reassembled, so flags/compression are preserved - iOS fix
val originalPacket = BitchatPacket.fromBinaryData(reassembledData.toByteArray())
if (originalPacket != null) {
// iOS cleanup: incomingFragments.removeValue(forKey: fragmentID)
incomingFragments.remove(fragmentIDString)
fragmentMetadata.remove(fragmentIDString)
val isNewSet = !incomingFragments.containsKey(fragmentIDString)
if (isNewSet) {
val maxActive = com.bitchat.android.util.AppConstants.Fragmentation.MAX_ACTIVE_FRAGMENT_SETS
if (incomingFragments.size >= maxActive) {
Log.w(TAG, "Rejecting new fragment set $fragmentIDString: active fragment sets ${incomingFragments.size} >= $maxActive")
return null
}
// Suppress re-broadcast of the reassembled packet by zeroing TTL.
// We already relayed the incoming fragments; setting TTL=0 ensures
// PacketRelayManager will skip relaying this reconstructed packet.
val suppressedTtlPacket = originalPacket.copy(ttl = 0u.toUByte())
Log.d(TAG, "Successfully reassembled original (${reassembledData.size} bytes); set TTL=0 to suppress relay")
return suppressedTtlPacket
incomingFragments[fragmentIDString] = mutableMapOf()
fragmentMetadata[fragmentIDString] = Triple(
fragmentPayload.originalType,
fragmentPayload.total,
System.currentTimeMillis()
)
fragmentCumulativeSize[fragmentIDString] = 0
}
val fragmentMap = incomingFragments[fragmentIDString]
if (fragmentMap == null) {
Log.w(TAG, "Dropping fragment set $fragmentIDString due to missing fragment map")
removeFragmentSetLocked(fragmentIDString)
return null
}
val currentSize = fragmentCumulativeSize[fragmentIDString]
if (currentSize == null) {
Log.w(TAG, "Dropping fragment set $fragmentIDString due to missing size tracker")
removeFragmentSetLocked(fragmentIDString)
return null
}
val oldEntrySize = fragmentMap[fragmentPayload.index]?.size ?: 0
val newSize = currentSize - oldEntrySize + fragmentPayload.data.size
val maxTotalBytes = com.bitchat.android.util.AppConstants.Fragmentation.MAX_FRAGMENT_TOTAL_BYTES
if (newSize > maxTotalBytes) {
Log.w(TAG, "Rejecting fragment for $fragmentIDString: cumulative size $newSize exceeds cap $maxTotalBytes")
removeFragmentSetLocked(fragmentIDString)
return null
}
val delta = (fragmentPayload.data.size - oldEntrySize).toLong()
val maxGlobalBytes = com.bitchat.android.util.AppConstants.Fragmentation.MAX_GLOBAL_FRAGMENT_TOTAL_BYTES
if (globalBufferedBytes + delta > maxGlobalBytes) {
Log.w(
TAG,
"Rejecting fragment for $fragmentIDString: global buffered bytes ${(globalBufferedBytes + delta)} exceeds cap $maxGlobalBytes"
)
if (isNewSet) {
removeFragmentSetLocked(fragmentIDString)
}
return null
}
fragmentMap[fragmentPayload.index] = fragmentPayload.data
fragmentCumulativeSize[fragmentIDString] = newSize
globalBufferedBytes += delta
val expectedTotal = fragmentMetadata[fragmentIDString]?.second ?: fragmentPayload.total
if (fragmentMap.size == expectedTotal) {
Log.d(TAG, "All fragments received for $fragmentIDString, reassembling...")
// iOS reassembly logic: for i in 0..<total { if let fragment = fragments[i] { reassembled.append(fragment) } }
val reassembledData = mutableListOf<Byte>()
for (i in 0 until expectedTotal) {
fragmentMap[i]?.let { data ->
reassembledData.addAll(data.asIterable())
}
}
val originalPacket = BitchatPacket.fromBinaryData(reassembledData.toByteArray())
if (originalPacket != null) {
removeFragmentSetLocked(fragmentIDString)
val suppressedTtlPacket = originalPacket.copy(ttl = 0u.toUByte())
Log.d(TAG, "Successfully reassembled original (${reassembledData.size} bytes); set TTL=0 to suppress relay")
return suppressedTtlPacket
} else {
val metadata = fragmentMetadata[fragmentIDString]
Log.e(TAG, "Failed to decode reassembled packet (type=${metadata?.first}, total=${metadata?.second})")
}
} else {
val metadata = fragmentMetadata[fragmentIDString]
Log.e(TAG, "Failed to decode reassembled packet (type=${metadata?.first}, total=${metadata?.second})")
val received = fragmentMap.size
Log.d(TAG, "Fragment ${fragmentPayload.index} stored, have $received/$expectedTotal fragments for $fragmentIDString")
}
} else {
val received = fragmentMap?.size ?: 0
Log.d(TAG, "Fragment ${fragmentPayload.index} stored, have $received/${fragmentPayload.total} fragments for $fragmentIDString")
}
} catch (e: Exception) {
@@ -202,6 +289,15 @@ class FragmentManager {
return null
}
private fun removeFragmentSetLocked(fragmentIDString: String) {
incomingFragments.remove(fragmentIDString)
fragmentMetadata.remove(fragmentIDString)
val bytes = fragmentCumulativeSize.remove(fragmentIDString)?.toLong() ?: 0L
if (bytes != 0L) {
globalBufferedBytes = (globalBufferedBytes - bytes).coerceAtLeast(0L)
}
}
/**
* Helper function to match iOS stride functionality
* stride(from: 0, to: fullData.count, by: maxFragmentSize)
@@ -221,20 +317,20 @@ class FragmentManager {
* Clean old fragments (> 30 seconds old)
*/
private fun cleanupOldFragments() {
val now = System.currentTimeMillis()
val cutoff = now - FRAGMENT_TIMEOUT
synchronized(fragmentStateLock) {
val now = System.currentTimeMillis()
val cutoff = now - FRAGMENT_TIMEOUT
// iOS: let oldFragments = fragmentMetadata.filter { $0.value.timestamp < cutoff }.map { $0.key }
val oldFragments = fragmentMetadata.filter { it.value.third < cutoff }.map { it.key }
// iOS: let oldFragments = fragmentMetadata.filter { $0.value.timestamp < cutoff }.map { $0.key }
val oldFragments = fragmentMetadata.filter { it.value.third < cutoff }.map { it.key }
// iOS: for fragmentID in oldFragments { incomingFragments.removeValue(forKey: fragmentID) }
for (fragmentID in oldFragments) {
incomingFragments.remove(fragmentID)
fragmentMetadata.remove(fragmentID)
}
for (fragmentID in oldFragments) {
removeFragmentSetLocked(fragmentID)
}
if (oldFragments.isNotEmpty()) {
Log.d(TAG, "Cleaned up ${oldFragments.size} old fragment sets (iOS compatible)")
if (oldFragments.isNotEmpty()) {
Log.d(TAG, "Cleaned up ${oldFragments.size} old fragment sets (iOS compatible)")
}
}
}
@@ -242,17 +338,21 @@ class FragmentManager {
* Get debug information - matches iOS debugging
*/
fun getDebugInfo(): String {
return buildString {
appendLine("=== Fragment Manager Debug Info (iOS Compatible) ===")
appendLine("Active Fragment Sets: ${incomingFragments.size}")
appendLine("Fragment Size Threshold: $FRAGMENT_SIZE_THRESHOLD bytes")
appendLine("Max Fragment Size: $MAX_FRAGMENT_SIZE bytes")
synchronized(fragmentStateLock) {
return buildString {
appendLine("=== Fragment Manager Debug Info (iOS Compatible) ===")
appendLine("Active Fragment Sets: ${incomingFragments.size}")
appendLine("Fragment Size Threshold: $FRAGMENT_SIZE_THRESHOLD bytes")
appendLine("Max Fragment Size: $MAX_FRAGMENT_SIZE bytes")
appendLine("Global Buffered Bytes: $globalBufferedBytes")
fragmentMetadata.forEach { (fragmentID, metadata) ->
val (originalType, totalFragments, timestamp) = metadata
val received = incomingFragments[fragmentID]?.size ?: 0
val ageSeconds = (System.currentTimeMillis() - timestamp) / 1000
appendLine(" - $fragmentID: $received/$totalFragments fragments, type: $originalType, age: ${ageSeconds}s")
fragmentMetadata.forEach { (fragmentID, metadata) ->
val (originalType, totalFragments, timestamp) = metadata
val received = incomingFragments[fragmentID]?.size ?: 0
val ageSeconds = (System.currentTimeMillis() - timestamp) / 1000
val bytes = fragmentCumulativeSize[fragmentID] ?: 0
appendLine(" - $fragmentID: $received/$totalFragments fragments, bytes=$bytes, type: $originalType, age: ${ageSeconds}s")
}
}
}
}
@@ -273,8 +373,12 @@ class FragmentManager {
* Clear all fragments
*/
fun clearAllFragments() {
incomingFragments.clear()
fragmentMetadata.clear()
synchronized(fragmentStateLock) {
incomingFragments.clear()
fragmentMetadata.clear()
fragmentCumulativeSize.clear()
globalBufferedBytes = 0L
}
}
/**
@@ -21,6 +21,7 @@ import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import java.util.concurrent.ConcurrentHashMap
/**
* Shared mesh coordinator that wires all mesh-layer components and provides common APIs
@@ -53,6 +54,7 @@ class MeshCore(
private val storeForwardManager = StoreForwardManager()
private val messageHandler = MessageHandler(myPeerID, context.applicationContext)
private val packetProcessor = PacketProcessor(myPeerID)
private val directPeers = ConcurrentHashMap.newKeySet<String>()
val gossipSyncManager: GossipSyncManager =
sharedGossipManager ?: GossipSyncManager(myPeerID = myPeerID, scope = scope, configProvider = gossipConfigProvider)
@@ -65,6 +67,7 @@ class MeshCore(
init {
messageHandler.packetProcessor = packetProcessor
peerManager.isPeerDirectlyConnected = { peerID -> directPeers.contains(peerID) }
setupDelegates()
if (sharedGossipManager == null) {
@@ -307,6 +310,14 @@ class MeshCore(
override fun onReadReceiptReceived(messageID: String, peerID: String) {
delegate?.didReceiveReadReceipt(messageID, peerID)
}
override fun onVerifyChallengeReceived(peerID: String, payload: ByteArray, timestampMs: Long) {
// MeshDelegate intentionally does not expose QR verification yet.
}
override fun onVerifyResponseReceived(peerID: String, payload: ByteArray, timestampMs: Long) {
// MeshDelegate intentionally does not expose QR verification yet.
}
}
packetProcessor.delegate = object : PacketProcessorDelegate {
@@ -383,6 +394,12 @@ class MeshCore(
dispatchGlobal(routed)
}
override fun sendToPeer(peerID: String, routed: RoutedPacket): Boolean {
transport.sendPacketToPeer(peerID, routed.packet)
TransportBridgeService.sendToPeer(transport.id, peerID, routed.packet)
return true
}
override fun handleRequestSync(routed: RoutedPacket) {
val fromPeer = routed.peerID ?: return
val req = RequestSyncPacket.decode(routed.packet.payload) ?: return
@@ -604,7 +621,12 @@ class MeshCore(
}
fun setDirectConnection(peerID: String, isDirect: Boolean) {
peerManager.setDirectConnection(peerID, isDirect)
if (isDirect) {
directPeers.add(peerID)
} else {
directPeers.remove(peerID)
}
peerManager.refreshPeerList()
}
fun updatePeerRSSI(peerID: String, rssi: Int) {
@@ -15,5 +15,3 @@ interface MeshDelegate {
fun getNickname(): String?
fun isFavorite(peerID: String): Boolean
}
typealias BluetoothMeshDelegate = MeshDelegate
@@ -7,6 +7,7 @@ import com.bitchat.android.model.IdentityAnnouncement
import com.bitchat.android.model.RoutedPacket
import com.bitchat.android.protocol.BitchatPacket
import com.bitchat.android.protocol.MessageType
import com.bitchat.android.sync.PacketIdUtil
import com.bitchat.android.util.toHexString
import kotlinx.coroutines.*
import java.util.*
@@ -157,6 +158,14 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro
// Simplified: Call delegate with messageID and peerID directly
delegate?.onReadReceiptReceived(messageID, peerID)
}
com.bitchat.android.model.NoisePayloadType.VERIFY_CHALLENGE -> {
Log.d(TAG, "🔐 Verify challenge received from $peerID (${noisePayload.data.size} bytes)")
delegate?.onVerifyChallengeReceived(peerID, noisePayload.data, packet.timestamp.toLong())
}
com.bitchat.android.model.NoisePayloadType.VERIFY_RESPONSE -> {
Log.d(TAG, "🔐 Verify response received from $peerID (${noisePayload.data.size} bytes)")
delegate?.onVerifyResponseReceived(peerID, noisePayload.data, packet.timestamp.toLong())
}
}
} catch (e: Exception) {
@@ -278,6 +287,13 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro
previousPeerID = null
)
// Update mesh graph from gossip neighbors (only if TLV present)
try {
val neighborsOrNull = com.bitchat.android.services.meshgraph.GossipTLV.decodeNeighborsFromAnnouncementPayload(packet.payload)
com.bitchat.android.services.meshgraph.MeshGraphService.getInstance()
.updateFromAnnouncement(peerID, nickname, neighborsOrNull, packet.timestamp)
} catch (_: Exception) { }
Log.d(TAG, "✅ Processed verified TLV announce: stored identity for $peerID")
return isFirstAnnounce
}
@@ -385,7 +401,7 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro
}
val savedPath = com.bitchat.android.features.file.FileUtils.saveIncomingFile(appContext, file)
val message = BitchatMessage(
id = java.util.UUID.randomUUID().toString().uppercase(),
id = PacketIdUtil.computeIdHex(packet).uppercase(),
sender = delegate?.getPeerNickname(peerID) ?: "unknown",
content = savedPath,
type = com.bitchat.android.features.file.FileUtils.messageTypeForMime(file.mimeType),
@@ -401,6 +417,7 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro
// Fallback: plain text
val message = BitchatMessage(
id = PacketIdUtil.computeIdHex(packet).uppercase(),
sender = delegate?.getPeerNickname(peerID) ?: "unknown",
content = String(packet.payload, Charsets.UTF_8),
senderPeerID = peerID,
@@ -611,4 +628,6 @@ interface MessageHandlerDelegate {
fun onChannelLeave(channel: String, fromPeer: String)
fun onDeliveryAckReceived(messageID: String, peerID: String)
fun onReadReceiptReceived(messageID: String, peerID: String)
fun onVerifyChallengeReceived(peerID: String, payload: ByteArray, timestampMs: Long)
fun onVerifyResponseReceived(peerID: String, payload: ByteArray, timestampMs: Long)
}
@@ -79,8 +79,6 @@ class PacketProcessor(private val myPeerID: String) {
return
}
// Get or create actor for this peer
val actor = actors.getOrPut(peerID) { getOrCreateActorForPeer(peerID) }
@@ -112,6 +110,9 @@ class PacketProcessor(private val myPeerID: String) {
override fun broadcastPacket(routed: RoutedPacket) {
delegate?.relayPacket(routed)
}
override fun sendToPeer(peerID: String, routed: RoutedPacket): Boolean {
return delegate?.sendToPeer(peerID, routed) ?: false
}
}
}
@@ -323,4 +324,5 @@ interface PacketProcessorDelegate {
fun sendAnnouncementToPeer(peerID: String)
fun sendCachedMessages(peerID: String)
fun relayPacket(routed: RoutedPacket)
fun sendToPeer(peerID: String, routed: RoutedPacket): Boolean
}
@@ -65,9 +65,40 @@ class PacketRelayManager(private val myPeerID: String) {
val relayPacket = packet.copy(ttl = (packet.ttl - 1u).toUByte())
Log.d(TAG, "Decremented TTL from ${packet.ttl} to ${relayPacket.ttl}")
// Source-based routing: if route is set and includes us, try targeted next-hop forwarding
val route = relayPacket.route
if (!route.isNullOrEmpty()) {
// Check for duplicate hops to prevent routing loops
if (route.map { it.toHexString() }.toSet().size < route.size) {
Log.w(TAG, "Packet with duplicate hops dropped")
return
}
val myIdBytes = hexStringToPeerBytes(myPeerID)
val index = route.indexOfFirst { it.contentEquals(myIdBytes) }
if (index >= 0) {
val nextHopIdHex: String? = run {
val nextIndex = index + 1
if (nextIndex < route.size) {
route[nextIndex].toHexString()
} else {
// We are the last intermediate; try final recipient as next hop
relayPacket.recipientID?.toHexString()
}
}
if (nextHopIdHex != null) {
val success = try { delegate?.sendToPeer(nextHopIdHex, RoutedPacket(relayPacket, peerID, routed.relayAddress)) } catch (_: Exception) { false } ?: false
if (success) {
Log.i(TAG, "📦 Source-route relay: ${peerID.take(8)} -> ${nextHopIdHex.take(8)} (type ${'$'}{packet.type}, TTL ${'$'}{relayPacket.ttl})")
return
} else {
Log.w(TAG, "Source-route next hop ${nextHopIdHex.take(8)} not directly connected; falling back to broadcast")
}
}
}
}
// Apply relay logic based on packet type and debug switch
val shouldRelay = isRelayEnabled() && shouldRelayPacket(relayPacket, peerID)
if (shouldRelay) {
relayPacket(RoutedPacket(relayPacket, peerID, routed.relayAddress))
} else {
@@ -170,4 +201,17 @@ interface PacketRelayManagerDelegate {
// Packet operations
fun broadcastPacket(routed: RoutedPacket)
fun sendToPeer(peerID: String, routed: RoutedPacket): Boolean
}
private fun hexStringToPeerBytes(hex: String): ByteArray {
val result = ByteArray(8)
var idx = 0
var out = 0
while (idx + 1 < hex.length && out < 8) {
val b = hex.substring(idx, idx + 2).toIntOrNull(16)?.toByte() ?: 0
result[out++] = b
idx += 2
}
return result
}
@@ -86,6 +86,9 @@ class PeerManager {
// Delegate for callbacks
var delegate: PeerManagerDelegate? = null
// Callback to check if a peer is directly connected (injected by BluetoothMeshService)
var isPeerDirectlyConnected: ((String) -> Boolean)? = null
// Coroutines
private val managerScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
@@ -165,10 +168,18 @@ class PeerManager {
}
/**
* Get peer info
* Get peer info with dynamic direct connection status
*/
fun getPeerInfo(peerID: String): PeerInfo? {
return peers[peerID]
return peers[peerID]?.let { info ->
// Dynamically check direct connection status from ConnectionManager
val isDirect = isPeerDirectlyConnected?.invoke(peerID) ?: false
if (info.isDirectConnection != isDirect) {
info.copy(isDirectConnection = isDirect)
} else {
info
}
}
}
/**
@@ -179,27 +190,12 @@ class PeerManager {
}
/**
* Get all verified peers
* Get all verified peers with dynamic direct connection status
*/
fun getVerifiedPeers(): Map<String, PeerInfo> {
return peers.filterValues { it.isVerifiedNickname }
}
/**
* Set whether a peer is directly connected over Bluetooth.
* Triggers a peer list update to refresh UI badges.
*/
fun setDirectConnection(peerID: String, isDirect: Boolean) {
peers[peerID]?.let { existing ->
if (existing.isDirectConnection != isDirect) {
peers[peerID] = existing.copy(isDirectConnection = isDirect)
notifyPeerListUpdate()
// NEW: notify UI state (if available via delegate path) about directness change
try {
// Best-effort: delegate path flows up to ChatViewModel via didUpdatePeerList
// No direct reference to UI layer here by design.
} catch (_: Exception) { }
}
return peers.filterValues { it.isVerifiedNickname }.mapValues { (_, info) ->
val isDirect = isPeerDirectlyConnected?.invoke(info.id) ?: false
if (info.isDirectConnection != isDirect) info.copy(isDirectConnection = isDirect) else info
}
}
@@ -319,8 +315,7 @@ class PeerManager {
*/
fun isPeerActive(peerID: String): Boolean {
val info = peers[peerID] ?: return false
val now = System.currentTimeMillis()
return (now - info.lastSeen) <= stalePeerTimeoutMs && info.isConnected
return info.isConnected
}
/**
@@ -348,8 +343,7 @@ class PeerManager {
* Get list of active peer IDs
*/
fun getActivePeerIDs(): List<String> {
val now = System.currentTimeMillis()
return peers.filterValues { (now - it.lastSeen) <= stalePeerTimeoutMs && it.isConnected }
return peers.filterValues { it.isConnected }
.keys
.toList()
.sorted()
@@ -386,7 +380,11 @@ class PeerManager {
return buildString {
appendLine("=== Peer Manager Debug Info ===")
appendLine("Active Peers: ${activeIds.size}")
peers.forEach { (peerID, info) ->
peers.forEach { (peerID, storedInfo) ->
// Use dynamic direct status for debug log accuracy
val isDirect = isPeerDirectlyConnected?.invoke(peerID) ?: false
val info = if (storedInfo.isDirectConnection != isDirect) storedInfo.copy(isDirectConnection = isDirect) else storedInfo
val timeSince = (now - info.lastSeen) / 1000
val rssi = peerRSSI[peerID]?.let { "${it} dBm" } ?: "No RSSI"
val deviceAddress = addressPeerMap?.entries?.find { it.value == peerID }?.key
@@ -7,7 +7,13 @@ import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.os.BatteryManager
import android.os.Handler
import android.os.Looper
import android.util.Log
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.ProcessLifecycleOwner
import kotlinx.coroutines.*
import kotlin.math.max
@@ -15,7 +21,7 @@ import kotlin.math.max
* Power-aware Bluetooth management for bitchat
* Adjusts scanning, advertising, and connection behavior based on battery state
*/
class PowerManager(private val context: Context) {
class PowerManager(private val context: Context) : LifecycleEventObserver {
companion object {
private const val TAG = "PowerManager"
@@ -49,7 +55,7 @@ class PowerManager(private val context: Context) {
private var currentMode = PowerMode.BALANCED
private var isCharging = false
private var batteryLevel = 100
private var isAppInBackground = false
private var isAppInBackground = true
private val powerScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private var dutyCycleJob: Job? = null
@@ -87,6 +93,16 @@ class PowerManager(private val context: Context) {
init {
registerBatteryReceiver()
// Register for process lifecycle events on the main thread
Handler(Looper.getMainLooper()).post {
try {
ProcessLifecycleOwner.get().lifecycle.addObserver(this)
} catch (e: Exception) {
Log.e(TAG, "Failed to register lifecycle observer: ${e.message}")
}
}
updatePowerMode()
}
@@ -99,13 +115,30 @@ class PowerManager(private val context: Context) {
Log.i(TAG, "Stopping power management")
powerScope.cancel()
unregisterBatteryReceiver()
// Unregister lifecycle observer
Handler(Looper.getMainLooper()).post {
try {
ProcessLifecycleOwner.get().lifecycle.removeObserver(this)
} catch (e: Exception) {
Log.e(TAG, "Failed to remove lifecycle observer: ${e.message}")
}
}
}
fun setAppBackgroundState(inBackground: Boolean) {
if (isAppInBackground != inBackground) {
isAppInBackground = inBackground
Log.d(TAG, "App background state changed: $inBackground")
updatePowerMode()
override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) {
when (event) {
Lifecycle.Event.ON_START -> {
Log.d(TAG, "Process lifecycle: ON_START (App coming to foreground)")
isAppInBackground = false
updatePowerMode()
}
Lifecycle.Event.ON_STOP -> {
Log.d(TAG, "Process lifecycle: ON_STOP (App going to background)")
isAppInBackground = true
updatePowerMode()
}
else -> {}
}
}
@@ -133,7 +166,7 @@ class PowerManager(private val context: Context) {
.setNumOfMatches(ScanSettings.MATCH_NUM_ONE_ADVERTISEMENT)
PowerMode.ULTRA_LOW_POWER -> builder
.setScanMode(ScanSettings.SCAN_MODE_OPPORTUNISTIC)
.setScanMode(ScanSettings.SCAN_MODE_LOW_POWER)
.setMatchMode(ScanSettings.MATCH_MODE_STICKY)
.setNumOfMatches(ScanSettings.MATCH_NUM_ONE_ADVERTISEMENT)
}
@@ -56,21 +56,30 @@ class SecurityManager(private val encryptionService: EncryptionService, private
// Duplicate detection
val messageID = generateMessageID(packet, peerID)
if (messageType != MessageType.ANNOUNCE) {
if (processedMessages.contains(messageID)) {
if (processedMessages.contains(messageID)) {
// Check for ANNOUNCE exception: allow if it looks like a direct neighbor (max TTL)
// This ensures we catch the "first announce" on a new connection for binding,
// while still dropping looped/relayed duplicates.
val isFreshAnnounce = messageType == MessageType.ANNOUNCE &&
packet.ttl >= com.bitchat.android.util.AppConstants.MESSAGE_TTL_HOPS
if (!isFreshAnnounce) {
Log.d(TAG, "Dropping duplicate packet: $messageID")
return false
}
// Add to processed messages
processedMessages.add(messageID)
messageTimestamps[messageID] = currentTime
} else {
// Do not deduplicate ANNOUNCE at the security layer.
// They are signed/idempotent and we need to ensure first-announce per-connection can bind.
Log.d(TAG, "Allowing duplicate ANNOUNCE from direct neighbor: $messageID")
}
// NEW: Signature verification logging (not rejecting yet)
verifyPacketSignatureWithLogging(packet, peerID)
// Add to processed messages
processedMessages.add(messageID)
messageTimestamps[messageID] = currentTime
// Enforce mandatory signature verification
if (!verifyPacketSignature(packet, peerID)) {
Log.w(TAG, "Dropping packet from $peerID due to signature verification failure")
return false
}
Log.d(TAG, "Packet validation passed for $peerID, messageID: $messageID")
return true
@@ -223,34 +232,58 @@ class SecurityManager(private val encryptionService: EncryptionService, private
}
/**
* Verify packet signature using peer's signing public key and log the result
* Verify packet signature using peer's signing public key
* Returns true only if signature is present and valid
*/
private fun verifyPacketSignatureWithLogging(packet: BitchatPacket, peerID: String) {
private fun verifyPacketSignature(packet: BitchatPacket, peerID: String): Boolean {
try {
// Check if packet has a signature
// only verify ANNOUNCE, MESSAGE, and FILE_TRANSFER
if (MessageType.fromValue(packet.type) !in setOf(
MessageType.ANNOUNCE,
MessageType.MESSAGE,
MessageType.FILE_TRANSFER
)) {
return true
}
// 1. Mandatory Signature Check
if (packet.signature == null) {
Log.d(TAG, "📝 Signature check for $peerID: NO_SIGNATURE (packet type ${packet.type})")
return
Log.w(TAG, " Signature check for $peerID: NO_SIGNATURE (packet type ${packet.type})")
return false
}
// Try to get peer's signing public key from peer info
val peerInfo = delegate?.getPeerInfo(peerID)
val signingPublicKey = peerInfo?.signingPublicKey
// 2. Get Signing Public Key
var signingPublicKey: ByteArray? = null
if (MessageType.fromValue(packet.type) == MessageType.ANNOUNCE) {
// Special Case: ANNOUNCE packets carry their own signing key
try {
val announcement = com.bitchat.android.model.IdentityAnnouncement.decode(packet.payload)
signingPublicKey = announcement?.signingPublicKey
} catch (e: Exception) {
Log.w(TAG, "Failed to decode announcement for key extraction: ${e.message}")
}
} else {
// Standard Case: Get key from known peer info
val peerInfo = delegate?.getPeerInfo(peerID)
signingPublicKey = peerInfo?.signingPublicKey
}
if (signingPublicKey == null) {
Log.d(TAG, "📝 Signature check for $peerID: NO_SIGNING_KEY (packet type ${packet.type})")
return
// If we don't have a key (and it's not an announce), we can't verify.
// For security, we must reject packets from unknown peers unless it's an announce.
Log.w(TAG, "❌ Signature check for $peerID: NO_SIGNING_KEY_AVAILABLE (packet type ${packet.type})")
return false
}
// Get the canonical packet data for signature verification (without signature)
// 3. Get Canonical Data
val packetDataForSigning = packet.toBinaryDataForSigning()
if (packetDataForSigning == null) {
Log.w(TAG, "📝 Signature check for $peerID: ENCODING_ERROR (packet type ${packet.type})")
return
Log.w(TAG, " Signature check for $peerID: ENCODING_ERROR (packet type ${packet.type})")
return false
}
// Verify the signature using the peer's signing public key
val signature = packet.signature!! // We already checked for null above
// 4. Verify Signature
val signature = packet.signature!!
val isSignatureValid = encryptionService.verifyEd25519Signature(
signature,
packetDataForSigning,
@@ -258,13 +291,16 @@ class SecurityManager(private val encryptionService: EncryptionService, private
)
if (isSignatureValid) {
Log.d(TAG, "📝 Signature check for $peerID: ✅ VALID (packet type ${packet.type})")
// Log.v(TAG, "✅ Signature verified for $peerID (type ${packet.type})")
return true
} else {
Log.w(TAG, "📝 Signature check for $peerID: ❌ INVALID (packet type ${packet.type})")
Log.w(TAG, " Signature INVALID for $peerID (type ${packet.type})")
return false
}
} catch (e: Exception) {
Log.w(TAG, "📝 Signature check for $peerID: ERROR - ${e.message} (packet type ${packet.type})")
Log.e(TAG, " Signature verification error for $peerID: ${e.message}")
return false
}
}
@@ -21,6 +21,8 @@ enum class NoisePayloadType(val value: UByte) {
PRIVATE_MESSAGE(0x01u), // Private chat message with TLV encoding
READ_RECEIPT(0x02u), // Message was read
DELIVERED(0x03u), // Message was delivered
VERIFY_CHALLENGE(0x10u), // Verification challenge
VERIFY_RESPONSE(0x11u), // Verification response
FILE_TRANSFER(0x20u);
@@ -29,15 +29,15 @@ class NoiseEncryptionService(private val context: Context) {
}
// Static identity key (persistent across app restarts) - loaded from secure storage
private val staticIdentityPrivateKey: ByteArray
private val staticIdentityPublicKey: ByteArray
private var staticIdentityPrivateKey: ByteArray
private var staticIdentityPublicKey: ByteArray
// Ed25519 signing key (persistent across app restarts) - loaded from secure storage
private val signingPrivateKey: ByteArray
private val signingPublicKey: ByteArray
private var signingPrivateKey: ByteArray
private var signingPublicKey: ByteArray
// Session management
private val sessionManager: NoiseSessionManager
private lateinit var sessionManager: NoiseSessionManager
// Channel encryption for password-protected channels
private val channelEncryption = NoiseChannelEncryption()
@@ -56,6 +56,33 @@ class NoiseEncryptionService(private val context: Context) {
// Initialize identity state manager for persistent storage
identityStateManager = SecureIdentityStateManager(context)
// Load or create keys - temporary placeholders
staticIdentityPrivateKey = ByteArray(32)
staticIdentityPublicKey = ByteArray(32)
signingPrivateKey = ByteArray(32)
signingPublicKey = ByteArray(32)
loadOrGenerateKeys()
// Initialize session manager
initializeSessionManager()
}
private fun initializeSessionManager() {
// Create new session manager with current keys
val localPeerID = calculateFingerprint(staticIdentityPublicKey).take(16)
sessionManager = NoiseSessionManager(staticIdentityPrivateKey, staticIdentityPublicKey, localPeerID)
// Set up session callbacks
sessionManager.onSessionEstablished = { peerID, remoteStaticKey ->
handleSessionEstablished(peerID, remoteStaticKey)
}
// Ensure any other callbacks are wired if needed
// sessionManager.onSessionFailed could be wired if we exposed it
}
private fun loadOrGenerateKeys() {
// Load or create static identity key (persistent across sessions)
val loadedKeyPair = identityStateManager.loadStaticKey()
if (loadedKeyPair != null) {
@@ -89,15 +116,6 @@ class NoiseEncryptionService(private val context: Context) {
identityStateManager.saveSigningKey(signingPrivateKey, signingPublicKey)
Log.d(TAG, "Generated and saved new Ed25519 signing key")
}
// Initialize session manager
val localPeerID = calculateFingerprint(staticIdentityPublicKey).take(16)
sessionManager = NoiseSessionManager(staticIdentityPrivateKey, staticIdentityPublicKey, localPeerID)
// Set up session callbacks
sessionManager.onSessionEstablished = { peerID, remoteStaticKey ->
handleSessionEstablished(peerID, remoteStaticKey)
}
}
// MARK: - Public Interface
@@ -136,7 +154,23 @@ class NoiseEncryptionService(private val context: Context) {
* Clear persistent identity (for panic mode)
*/
fun clearPersistentIdentity() {
Log.w(TAG, "🚨 Panic Mode: Clearing persistent identity and rotating in-memory keys")
// 1. Clear storage
identityStateManager.clearIdentityData()
// 2. Clear all sessions immediately
if (::sessionManager.isInitialized) {
sessionManager.shutdown()
}
// 3. Regenerate keys immediately (in-memory rotation)
loadOrGenerateKeys()
// 4. Re-initialize SessionManager with new keys
initializeSessionManager()
Log.d(TAG, "✅ Identity cleared and keys rotated")
}
// MARK: - Handshake Management
@@ -479,7 +513,9 @@ class NoiseEncryptionService(private val context: Context) {
* Clean shutdown
*/
fun shutdown() {
sessionManager.shutdown()
if (::sessionManager.isInitialized) {
sessionManager.shutdown()
}
channelEncryption.clear()
// No need to clear fingerprints here - they are managed centrally
}
@@ -1,17 +1,40 @@
package com.bitchat.android.nostr
import android.content.Context
import android.content.SharedPreferences
import java.util.concurrent.ConcurrentHashMap
/**
* GeohashAliasRegistry
* - Global, thread-safe registry for alias->Nostr pubkey mappings (e.g., nostr_<pub16> -> pubkeyHex)
* - Allows non-UI components (e.g., MessageRouter) to resolve geohash DM aliases without depending on UI ViewModels
* - Persisted to SharedPreferences to survive app restarts.
*/
object GeohashAliasRegistry {
private val map: MutableMap<String, String> = ConcurrentHashMap()
private const val PREFS_NAME = "geohash_alias_registry"
private var prefs: SharedPreferences? = null
fun initialize(context: Context) {
if (prefs == null) {
prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
loadFromPrefs()
}
}
private fun loadFromPrefs() {
prefs?.let { p ->
val allEntries = p.all
for ((key, value) in allEntries) {
if (key is String && value is String) {
map[key] = value
}
}
}
}
fun put(alias: String, pubkeyHex: String) {
map[alias] = pubkeyHex
prefs?.edit()?.putString(alias, pubkeyHex)?.apply()
}
fun get(alias: String): String? = map[alias]
@@ -20,5 +43,8 @@ object GeohashAliasRegistry {
fun snapshot(): Map<String, String> = HashMap(map)
fun clear() { map.clear() }
fun clear() {
map.clear()
prefs?.edit()?.clear()?.apply()
}
}
@@ -1,22 +1,51 @@
package com.bitchat.android.nostr
import android.content.Context
import android.content.SharedPreferences
import java.util.concurrent.ConcurrentHashMap
/**
* GeohashConversationRegistry
* - Global, thread-safe registry of conversationKey (e.g., "nostr_<pub16>") -> source geohash
* - Enables routing geohash DMs from anywhere by providing the correct geohash identity
* - Persisted to SharedPreferences to survive app restarts.
*/
object GeohashConversationRegistry {
private val map = ConcurrentHashMap<String, String>()
private const val PREFS_NAME = "geohash_conversation_registry"
private var prefs: SharedPreferences? = null
fun initialize(context: Context) {
if (prefs == null) {
prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
loadFromPrefs()
}
}
private fun loadFromPrefs() {
prefs?.let { p ->
val allEntries = p.all
for ((key, value) in allEntries) {
if (key is String && value is String) {
map[key] = value
}
}
}
}
fun set(convKey: String, geohash: String) {
if (geohash.isNotEmpty()) map[convKey] = geohash
if (geohash.isNotEmpty()) {
map[convKey] = geohash
prefs?.edit()?.putString(convKey, geohash)?.apply()
}
}
fun get(convKey: String): String? = map[convKey]
fun snapshot(): Map<String, String> = map.toMap()
fun clear() = map.clear()
fun clear() {
map.clear()
prefs?.edit()?.clear()?.apply()
}
}
@@ -46,15 +46,17 @@ class GeohashMessageHandler(
fun onEvent(event: NostrEvent, subscribedGeohash: String) {
scope.launch(Dispatchers.Default) {
try {
if (event.kind != 20000) return@launch
if (event.kind != NostrKind.EPHEMERAL_EVENT && event.kind != NostrKind.GEOHASH_PRESENCE) return@launch
val tagGeo = event.tags.firstOrNull { it.size >= 2 && it[0] == "g" }?.getOrNull(1)
if (tagGeo == null || !tagGeo.equals(subscribedGeohash, true)) return@launch
if (dedupe(event.id)) return@launch
// PoW validation (if enabled)
val pow = PoWPreferenceManager.getCurrentSettings()
if (pow.enabled && pow.difficulty > 0) {
if (!NostrProofOfWork.validateDifficulty(event, pow.difficulty)) return@launch
// PoW validation (if enabled) - apply to chat messages primarily
if (event.kind == NostrKind.EPHEMERAL_EVENT) {
val pow = PoWPreferenceManager.getCurrentSettings()
if (pow.enabled && pow.difficulty > 0) {
if (!NostrProofOfWork.validateDifficulty(event, pow.difficulty)) return@launch
}
}
// Blocked users check (use injected DataManager which has loaded state)
@@ -62,7 +64,12 @@ class GeohashMessageHandler(
// Update repository (participants, nickname, teleport)
// Update repository on a background-safe path; repository will post updates to LiveData
repo.updateParticipant(subscribedGeohash, event.pubkey, Date(event.createdAt * 1000L))
// Update participant count (last seen) on BOTH Presence (20001) and Chat (20000) events
if (event.kind == NostrKind.GEOHASH_PRESENCE || event.kind == NostrKind.EPHEMERAL_EVENT) {
repo.updateParticipant(subscribedGeohash, event.pubkey, Date(event.createdAt * 1000L))
}
event.tags.find { it.size >= 2 && it[0] == "n" }?.let { repo.cacheNickname(event.pubkey, it[1]) }
event.tags.find { it.size >= 2 && it[0] == "t" && it[1] == "teleport" }?.let { repo.markTeleported(event.pubkey) }
// Register a geohash DM alias for this participant so MessageRouter can route DMs via Nostr
@@ -70,6 +77,9 @@ class GeohashMessageHandler(
com.bitchat.android.nostr.GeohashAliasRegistry.put("nostr_${event.pubkey.take(16)}", event.pubkey)
} catch (_: Exception) { }
// Stop here for presence events - they don't produce chat messages
if (event.kind == NostrKind.GEOHASH_PRESENCE) return@launch
// Skip our own events for message emission
val my = NostrIdentityBridge.deriveIdentity(subscribedGeohash, application)
if (my.publicKeyHex.equals(event.pubkey, true)) return@launch
@@ -43,6 +43,20 @@ class GeohashRepository(
}?.key
}
fun findPubkeyByShortId(shortId: String): String? {
// First check cached nicknames (fastest)
var found = geoNicknames.keys.firstOrNull { it.startsWith(shortId, ignoreCase = true) }
if (found != null) return found
// If not found in nicknames (e.g. anon user), check all known participants across all geohashes
for (participants in geohashParticipants.values) {
found = participants.keys.firstOrNull { it.startsWith(shortId, ignoreCase = true) }
if (found != null) return found
}
return null
}
// peerID alias -> nostr pubkey mapping for geohash DMs and temp aliases
private val nostrKeyMapping: MutableMap<String, String> = mutableMapOf()
@@ -2,8 +2,12 @@ package com.bitchat.android.nostr
import android.app.Application
import android.util.Log
import com.bitchat.android.model.BitchatFilePacket
import com.bitchat.android.model.BitchatMessage
import com.bitchat.android.model.DeliveryStatus
import com.bitchat.android.model.NoisePayload
import com.bitchat.android.model.NoisePayloadType
import com.bitchat.android.model.PrivateMessagePacket
import com.bitchat.android.protocol.BitchatPacket
import com.bitchat.android.services.SeenMessageStore
import com.bitchat.android.ui.ChatState
@@ -71,7 +75,7 @@ class NostrDirectMessageHandler(
if (packet.type != com.bitchat.android.protocol.MessageType.NOISE_ENCRYPTED.value) return@launch
val noisePayload = com.bitchat.android.model.NoisePayload.decode(packet.payload) ?: return@launch
val noisePayload = NoisePayload.decode(packet.payload) ?: return@launch
val messageTimestamp = Date(giftWrap.createdAt * 1000L)
val convKey = "nostr_${senderPubkey.take(16)}"
repo.putNostrKeyMapping(convKey, senderPubkey)
@@ -104,7 +108,7 @@ class NostrDirectMessageHandler(
}
private suspend fun processNoisePayload(
payload: com.bitchat.android.model.NoisePayload,
payload: NoisePayload,
convKey: String,
senderNickname: String,
timestamp: Date,
@@ -112,8 +116,8 @@ class NostrDirectMessageHandler(
recipientIdentity: NostrIdentity
) {
when (payload.type) {
com.bitchat.android.model.NoisePayloadType.PRIVATE_MESSAGE -> {
val pm = com.bitchat.android.model.PrivateMessagePacket.decode(payload.data) ?: return
NoisePayloadType.PRIVATE_MESSAGE -> {
val pm = PrivateMessagePacket.decode(payload.data) ?: return
val existingMessages = state.getPrivateChatsValue()[convKey] ?: emptyList()
if (existingMessages.any { it.id == pm.messageID }) return
@@ -148,21 +152,21 @@ class NostrDirectMessageHandler(
seenStore.markRead(pm.messageID)
}
}
com.bitchat.android.model.NoisePayloadType.DELIVERED -> {
NoisePayloadType.DELIVERED -> {
val messageId = String(payload.data, Charsets.UTF_8)
withContext(Dispatchers.Main) {
meshDelegateHandler.didReceiveDeliveryAck(messageId, convKey)
}
}
com.bitchat.android.model.NoisePayloadType.READ_RECEIPT -> {
NoisePayloadType.READ_RECEIPT -> {
val messageId = String(payload.data, Charsets.UTF_8)
withContext(Dispatchers.Main) {
meshDelegateHandler.didReceiveReadReceipt(messageId, convKey)
}
}
com.bitchat.android.model.NoisePayloadType.FILE_TRANSFER -> {
NoisePayloadType.FILE_TRANSFER -> {
// Properly handle encrypted file transfer
val file = com.bitchat.android.model.BitchatFilePacket.decode(payload.data)
val file = BitchatFilePacket.decode(payload.data)
if (file != null) {
val uniqueMsgId = java.util.UUID.randomUUID().toString().uppercase()
val savedPath = com.bitchat.android.features.file.FileUtils.saveIncomingFile(application, file)
@@ -185,6 +189,8 @@ class NostrDirectMessageHandler(
Log.w(TAG, "⚠️ Failed to decode Nostr file transfer from $convKey")
}
}
NoisePayloadType.VERIFY_CHALLENGE,
NoisePayloadType.VERIFY_RESPONSE -> Unit // Ignore verification payloads in Nostr direct messages
}
}
@@ -215,6 +215,7 @@ object NostrKind {
const val SEAL = 13 // NIP-17 sealed event
const val GIFT_WRAP = 1059 // NIP-17 gift wrap
const val EPHEMERAL_EVENT = 20000 // For geohash channels
const val GEOHASH_PRESENCE = 20001 // For geohash presence heartbeat
}
/**
@@ -32,11 +32,11 @@ data class NostrFilter(
}
/**
* Create filter for geohash-scoped ephemeral events (kind 20000)
* Create filter for geohash-scoped ephemeral events (kind 20000 and 20001)
*/
fun geohashEphemeral(geohash: String, since: Long? = null, limit: Int = 200): NostrFilter {
fun geohashEphemeral(geohash: String, since: Long? = null, limit: Int = 1000): NostrFilter {
return NostrFilter(
kinds = listOf(NostrKind.EPHEMERAL_EVENT),
kinds = listOf(NostrKind.EPHEMERAL_EVENT, NostrKind.GEOHASH_PRESENCE),
since = since?.let { (it / 1000).toInt() },
tagFilters = mapOf("g" to listOf(geohash)),
limit = limit
@@ -118,6 +118,28 @@ object NostrProtocol {
return@withContext senderIdentity.signEvent(event)
}
/**
* Create a geohash-scoped presence event (kind 20001)
* Has no content and no nickname, used for participant counting
*/
suspend fun createGeohashPresenceEvent(
geohash: String,
senderIdentity: NostrIdentity
): NostrEvent = withContext(Dispatchers.Default) {
val tags = mutableListOf<List<String>>()
tags.add(listOf("g", geohash))
val event = NostrEvent(
pubkey = senderIdentity.publicKeyHex,
createdAt = (System.currentTimeMillis() / 1000).toInt(),
kind = NostrKind.GEOHASH_PRESENCE,
tags = tags,
content = ""
)
return@withContext senderIdentity.signEvent(event)
}
/**
* Create a geohash-scoped ephemeral public message (kind 20000)
* Includes Proof of Work mining if enabled in settings
@@ -0,0 +1,251 @@
package com.bitchat.android.onboarding
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.LocationOn
import androidx.compose.material.icons.filled.Security
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ColorScheme
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.res.stringResource
import com.bitchat.android.R
/**
* Explanation screen shown before requesting background location permission.
*/
@Composable
fun BackgroundLocationPermissionScreen(
modifier: Modifier,
onContinue: () -> Unit,
onRetry: () -> Unit,
onSkip: () -> Unit
) {
val colorScheme = MaterialTheme.colorScheme
val scrollState = rememberScrollState()
Box(modifier = modifier) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 24.dp)
.padding(bottom = 88.dp)
.verticalScroll(scrollState),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Spacer(modifier = Modifier.height(24.dp))
HeaderSection(colorScheme)
Surface(
modifier = Modifier.fillMaxWidth(),
color = colorScheme.surfaceVariant.copy(alpha = 0.25f),
shape = RoundedCornerShape(12.dp)
) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Row(
verticalAlignment = Alignment.Top,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
Icon(
imageVector = Icons.Filled.LocationOn,
contentDescription = stringResource(R.string.cd_location_services),
tint = colorScheme.primary,
modifier = Modifier
.padding(top = 2.dp)
.size(20.dp)
)
Column {
Text(
text = stringResource(R.string.background_location_required_title),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Medium,
color = colorScheme.onBackground
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = stringResource(R.string.background_location_explanation),
style = MaterialTheme.typography.bodySmall,
color = colorScheme.onBackground.copy(alpha = 0.8f)
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = stringResource(R.string.background_location_settings_tip),
style = MaterialTheme.typography.bodySmall.copy(
fontFamily = FontFamily.Monospace
),
color = colorScheme.onBackground.copy(alpha = 0.8f)
)
}
}
}
}
Surface(
modifier = Modifier.fillMaxWidth(),
color = colorScheme.surfaceVariant.copy(alpha = 0.25f),
shape = RoundedCornerShape(12.dp)
) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Row(
verticalAlignment = Alignment.Top,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
Icon(
imageVector = Icons.Filled.Security,
contentDescription = stringResource(R.string.cd_privacy_protected),
tint = colorScheme.primary,
modifier = Modifier
.padding(top = 2.dp)
.size(20.dp)
)
Column {
Text(
text = stringResource(R.string.background_location_needs_for),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Medium,
color = colorScheme.onBackground
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = stringResource(R.string.background_location_needs_bullets),
style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace,
color = colorScheme.onBackground.copy(alpha = 0.8f)
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = stringResource(R.string.background_location_privacy_note),
style = MaterialTheme.typography.bodySmall.copy(
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Medium
),
color = colorScheme.onBackground
)
}
}
}
}
Spacer(modifier = Modifier.height(24.dp))
}
Surface(
modifier = Modifier
.align(Alignment.BottomCenter)
.fillMaxWidth(),
color = colorScheme.surface,
shadowElevation = 8.dp
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp, vertical = 16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Button(
onClick = onContinue,
modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.buttonColors(
containerColor = colorScheme.primary
)
) {
Text(
text = stringResource(R.string.grant_background_location),
style = MaterialTheme.typography.bodyMedium.copy(
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold
),
modifier = Modifier.padding(vertical = 4.dp)
)
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
OutlinedButton(
onClick = onRetry,
modifier = Modifier.weight(1f)
) {
Text(
text = stringResource(R.string.check_again),
style = MaterialTheme.typography.bodyMedium.copy(
fontFamily = FontFamily.Monospace
)
)
}
TextButton(
onClick = onSkip,
modifier = Modifier.weight(1f)
) {
Text(
text = stringResource(R.string.battery_optimization_skip),
style = MaterialTheme.typography.bodyMedium.copy(
fontFamily = FontFamily.Monospace
)
)
}
}
}
}
}
}
@Composable
private fun HeaderSection(colorScheme: ColorScheme) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
text = stringResource(R.string.app_name),
style = MaterialTheme.typography.headlineLarge.copy(
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold,
fontSize = 32.sp
),
color = colorScheme.onBackground
)
Text(
text = stringResource(R.string.background_location_required_subtitle),
fontSize = 12.sp,
fontFamily = FontFamily.Monospace,
color = colorScheme.onBackground.copy(alpha = 0.7f)
)
}
}
@@ -0,0 +1,21 @@
package com.bitchat.android.onboarding
import android.content.Context
/**
* Preference manager for background location skip choice.
*/
object BackgroundLocationPreferenceManager {
private const val PREFS_NAME = "bitchat_settings"
private const val KEY_BACKGROUND_LOCATION_SKIP = "background_location_skipped"
fun setSkipped(context: Context, skipped: Boolean) {
val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
prefs.edit().putBoolean(KEY_BACKGROUND_LOCATION_SKIP, skipped).apply()
}
fun isSkipped(context: Context): Boolean {
val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
return prefs.getBoolean(KEY_BACKGROUND_LOCATION_SKIP, false)
}
}
@@ -19,6 +19,7 @@ class OnboardingCoordinator(
private val activity: ComponentActivity,
private val permissionManager: PermissionManager,
private val onOnboardingComplete: () -> Unit,
private val onBackgroundLocationRequired: () -> Unit,
private val onOnboardingFailed: (String) -> Unit
) {
@@ -27,9 +28,11 @@ class OnboardingCoordinator(
}
private var permissionLauncher: ActivityResultLauncher<Array<String>>? = null
private var backgroundLocationLauncher: ActivityResultLauncher<String>? = null
init {
setupPermissionLauncher()
setupBackgroundLocationLauncher()
}
/**
@@ -43,6 +46,14 @@ class OnboardingCoordinator(
}
}
private fun setupBackgroundLocationLauncher() {
backgroundLocationLauncher = activity.registerForActivityResult(
ActivityResultContracts.RequestPermission()
) { granted ->
handleBackgroundLocationResult(granted)
}
}
/**
* Start the onboarding process
*/
@@ -50,9 +61,14 @@ class OnboardingCoordinator(
Log.d(TAG, "Starting onboarding process")
permissionManager.logPermissionStatus()
if (permissionManager.areAllPermissionsGranted()) {
Log.d(TAG, "All permissions already granted, completing onboarding")
completeOnboarding()
if (permissionManager.areRequiredPermissionsGranted()) {
if (shouldRequestBackgroundLocation()) {
Log.d(TAG, "Foreground permissions granted; background location recommended")
onBackgroundLocationRequired()
} else {
Log.d(TAG, "Required permissions already granted, completing onboarding")
completeOnboarding()
}
} else {
Log.d(TAG, "Missing permissions, need to start explanation flow")
// The explanation screen will be shown by the calling activity
@@ -76,7 +92,11 @@ class OnboardingCoordinator(
val missingPermissions = (missingRequired + optionalToRequest).distinct()
if (missingPermissions.isEmpty()) {
completeOnboarding()
if (shouldRequestBackgroundLocation()) {
onBackgroundLocationRequired()
} else {
completeOnboarding()
}
return
}
@@ -98,13 +118,19 @@ class OnboardingCoordinator(
val criticalGranted = criticalPermissions.all { permissions[it] == true }
when {
allGranted -> {
Log.d(TAG, "All permissions granted successfully")
completeOnboarding()
}
criticalGranted -> {
Log.d(TAG, "Critical permissions granted, can proceed with limited functionality")
showPartialPermissionWarning(permissions)
if (shouldRequestBackgroundLocation()) {
Log.d(TAG, "Foreground permissions granted; requesting background location next")
onBackgroundLocationRequired()
return
}
if (allGranted) {
Log.d(TAG, "All permissions granted successfully")
completeOnboarding()
} else {
Log.d(TAG, "Critical permissions granted, can proceed with limited functionality")
showPartialPermissionWarning(permissions)
}
}
else -> {
Log.d(TAG, "Critical permissions denied")
@@ -113,15 +139,50 @@ class OnboardingCoordinator(
}
}
fun requestBackgroundLocation() {
val permission = permissionManager.getBackgroundLocationPermission()
if (permission == null) {
completeOnboarding()
return
}
Log.d(TAG, "Requesting background location permission")
backgroundLocationLauncher?.launch(permission)
}
private fun handleBackgroundLocationResult(granted: Boolean) {
if (granted) {
Log.d(TAG, "Background location permission granted")
} else {
Log.w(TAG, "Background location permission denied; continuing without it")
}
completeOnboarding()
}
fun skipBackgroundLocation() {
Log.d(TAG, "User skipped background location permission")
BackgroundLocationPreferenceManager.setSkipped(activity, true)
completeOnboarding()
}
fun checkBackgroundLocationAndProceed() {
if (!shouldRequestBackgroundLocation()) {
completeOnboarding()
}
}
private fun shouldRequestBackgroundLocation(): Boolean {
return permissionManager.needsBackgroundLocationPermission() &&
!permissionManager.isBackgroundLocationGranted() &&
!BackgroundLocationPreferenceManager.isSkipped(activity)
}
/**
* Get the list of critical permissions that are absolutely required
*/
private fun getCriticalPermissions(): List<String> {
// For bitchat, Bluetooth and location permissions are critical
// Notifications are nice-to-have but not critical
return permissionManager.getRequiredPermissions().filter { permission ->
!permission.contains("POST_NOTIFICATIONS")
}
// Notifications are nice-to-have but not critical and are not included in getRequiredPermissions()
return permissionManager.getRequiredPermissions()
}
/**
@@ -208,6 +269,7 @@ class OnboardingCoordinator(
private fun getPermissionDisplayName(permission: String): String {
return when {
permission.contains("BLUETOOTH") -> "Bluetooth/Nearby Devices"
permission.contains("BACKGROUND") -> "Background Location"
permission.contains("LOCATION") -> "Location (for Bluetooth scanning)"
permission.contains("NEARBY_WIFI") -> "Nearby WiFi Devices (for WiFi Aware)"
permission.contains("NOTIFICATION") -> "Notifications"
@@ -6,6 +6,7 @@ enum class OnboardingState {
LOCATION_CHECK,
BATTERY_OPTIMIZATION_CHECK,
PERMISSION_EXPLANATION,
BACKGROUND_LOCATION_EXPLANATION,
PERMISSION_REQUESTING,
INITIALIZING,
COMPLETE,
@@ -13,12 +13,10 @@ import androidx.compose.material.icons.filled.Mic
import androidx.compose.material.icons.filled.Security
import androidx.compose.material.icons.filled.Wifi
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
@@ -210,29 +208,6 @@ private fun PermissionCategoryCard(
color = colorScheme.onBackground.copy(alpha = 0.8f)
)
if (category.type == PermissionType.PRECISE_LOCATION) {
// Extra emphasis for location permission
Spacer(modifier = Modifier.height(4.dp))
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp)
) {
Icon(
imageVector = Icons.Filled.Warning,
contentDescription = stringResource(R.string.cd_warning),
tint = Color(0xFFFF9800),
modifier = Modifier.size(16.dp)
)
Text(
text = stringResource(R.string.location_tracking_warning),
style = MaterialTheme.typography.bodySmall.copy(
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Medium,
color = Color(0xFFFF9800)
)
)
}
}
}
}
}
@@ -241,6 +216,7 @@ private fun getPermissionIcon(permissionType: PermissionType): ImageVector {
return when (permissionType) {
PermissionType.NEARBY_DEVICES -> Icons.Filled.Bluetooth
PermissionType.PRECISE_LOCATION -> Icons.Filled.LocationOn
PermissionType.BACKGROUND_LOCATION -> Icons.Filled.LocationOn
PermissionType.MICROPHONE -> Icons.Filled.Mic
PermissionType.NOTIFICATIONS -> Icons.Filled.Notifications
PermissionType.WIFI_AWARE -> Icons.Filled.Wifi
@@ -7,6 +7,7 @@ import android.os.Build
import android.os.PowerManager
import android.util.Log
import androidx.core.content.ContextCompat
import com.bitchat.android.R
/**
* Centralized permission management for bitchat app
@@ -40,7 +41,8 @@ class PermissionManager(private val context: Context) {
}
/**
* Get all permissions required by the app
* Get required permissions that can be requested together.
* Background location is handled separately to ensure correct request order.
* Note: Notification permission is optional and not included here,
* so the app works without notification access.
*/
@@ -77,6 +79,27 @@ class PermissionManager(private val context: Context) {
return permissions
}
/**
* Background location permission is required on Android 10+ for background BLE scanning.
* Must be requested after foreground location permissions are granted.
*/
fun needsBackgroundLocationPermission(): Boolean {
return Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q
}
fun getBackgroundLocationPermission(): String? {
return if (needsBackgroundLocationPermission()) {
Manifest.permission.ACCESS_BACKGROUND_LOCATION
} else {
null
}
}
fun isBackgroundLocationGranted(): Boolean {
val permission = getBackgroundLocationPermission() ?: return true
return isPermissionGranted(permission)
}
/**
* Get optional permissions that improve the experience but aren't required.
* Currently includes POST_NOTIFICATIONS on Android 13+.
@@ -98,9 +121,13 @@ class PermissionManager(private val context: Context) {
}
/**
* Check if all required permissions are granted
* Check if all required permissions are granted (background location is optional).
*/
fun areAllPermissionsGranted(): Boolean {
return areRequiredPermissionsGranted()
}
fun areRequiredPermissionsGranted(): Boolean {
return getRequiredPermissions().all { isPermissionGranted(it) }
}
@@ -136,6 +163,11 @@ class PermissionManager(private val context: Context) {
return getRequiredPermissions().filter { !isPermissionGranted(it) }
}
fun getMissingBackgroundLocationPermission(): List<String> {
val permission = getBackgroundLocationPermission() ?: return emptyList()
return if (isPermissionGranted(permission)) emptyList() else listOf(permission)
}
/**
* Get categorized permission information for display
*/
@@ -196,6 +228,19 @@ class PermissionManager(private val context: Context) {
)
}
if (needsBackgroundLocationPermission()) {
val backgroundPermission = listOf(Manifest.permission.ACCESS_BACKGROUND_LOCATION)
categories.add(
PermissionCategory(
type = PermissionType.BACKGROUND_LOCATION,
description = context.getString(R.string.perm_background_location_desc),
permissions = backgroundPermission,
isGranted = backgroundPermission.all { isPermissionGranted(it) },
systemDescription = context.getString(R.string.perm_background_location_system)
)
)
}
// Notifications category (if applicable)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
categories.add(
@@ -235,7 +280,7 @@ class PermissionManager(private val context: Context) {
appendLine("Permission Diagnostics:")
appendLine("Android SDK: ${Build.VERSION.SDK_INT}")
appendLine("First time launch: ${isFirstTimeLaunch()}")
appendLine("All permissions granted: ${areAllPermissionsGranted()}")
appendLine("Required permissions granted: ${areAllPermissionsGranted()}")
appendLine()
getCategorizedPermissions().forEach { category ->
@@ -247,7 +292,7 @@ class PermissionManager(private val context: Context) {
appendLine()
}
val missing = getMissingPermissions()
val missing = getMissingPermissions() + getMissingBackgroundLocationPermission()
if (missing.isNotEmpty()) {
appendLine("Missing permissions:")
missing.forEach { permission ->
@@ -279,6 +324,7 @@ data class PermissionCategory(
enum class PermissionType(val nameValue: String) {
NEARBY_DEVICES("Nearby Devices"),
PRECISE_LOCATION("Precise Location"),
BACKGROUND_LOCATION("Background Location"),
MICROPHONE("Microphone"),
NOTIFICATIONS("Notifications"),
WIFI_AWARE("WiFi Aware"),
@@ -36,7 +36,7 @@ object SpecialRecipients {
/**
* Binary packet format - 100% backward compatible with iOS version
*
* Header (13 bytes for v1, 15 bytes for v2):
* Header (14 bytes for v1, 16 bytes for v2):
* - Version: 1 byte
* - Type: 1 byte
* - TTL: 1 byte
@@ -59,7 +59,8 @@ data class BitchatPacket(
val timestamp: ULong,
val payload: ByteArray,
var signature: ByteArray? = null, // Changed from val to var for packet signing
var ttl: UByte
var ttl: UByte,
var route: List<ByteArray>? = null // Optional source route: ordered list of peerIDs (8 bytes each), not including sender and final recipient
) : Parcelable {
constructor(
@@ -97,6 +98,7 @@ data class BitchatPacket(
timestamp = timestamp,
payload = payload,
signature = null, // Remove signature for signing
route = route,
ttl = com.bitchat.android.util.AppConstants.SYNC_TTL_HOPS // Use fixed TTL=0 for signing to ensure relay compatibility
)
return BinaryProtocol.encode(unsignedPacket)
@@ -149,6 +151,11 @@ data class BitchatPacket(
if (!signature.contentEquals(other.signature)) return false
} else if (other.signature != null) return false
if (ttl != other.ttl) return false
if (route != null || other.route != null) {
val a = route?.map { it.toList() } ?: emptyList()
val b = other.route?.map { it.toList() } ?: emptyList()
if (a != b) return false
}
return true
}
@@ -162,6 +169,7 @@ data class BitchatPacket(
result = 31 * result + payload.contentHashCode()
result = 31 * result + (signature?.contentHashCode() ?: 0)
result = 31 * result + ttl.hashCode()
result = 31 * result + (route?.fold(1) { acc, bytes -> 31 * acc + bytes.contentHashCode() } ?: 0)
return result
}
}
@@ -170,8 +178,8 @@ data class BitchatPacket(
* Binary Protocol implementation - supports v1 and v2, backward compatible
*/
object BinaryProtocol {
private const val HEADER_SIZE_V1 = 13
private const val HEADER_SIZE_V2 = 15
private const val HEADER_SIZE_V1 = 14
private const val HEADER_SIZE_V2 = 16
private const val SENDER_ID_SIZE = 8
private const val RECIPIENT_ID_SIZE = 8
private const val SIGNATURE_SIZE = 64
@@ -180,6 +188,7 @@ object BinaryProtocol {
const val HAS_RECIPIENT: UByte = 0x01u
const val HAS_SIGNATURE: UByte = 0x02u
const val IS_COMPRESSED: UByte = 0x04u
const val HAS_ROUTE: UByte = 0x08u
}
private fun getHeaderSize(version: UByte): Int {
@@ -193,12 +202,12 @@ object BinaryProtocol {
try {
// Try to compress payload if beneficial
var payload = packet.payload
var originalPayloadSize: UShort? = null
var originalPayloadSize: Int? = null
var isCompressed = false
if (CompressionUtil.shouldCompress(payload)) {
CompressionUtil.compress(payload)?.let { compressedPayload ->
originalPayloadSize = payload.size.toUShort()
originalPayloadSize = payload.size
payload = compressedPayload
isCompressed = true
}
@@ -208,8 +217,12 @@ object BinaryProtocol {
val headerSize = getHeaderSize(packet.version)
val recipientBytes = if (packet.recipientID != null) RECIPIENT_ID_SIZE else 0
val signatureBytes = if (packet.signature != null) SIGNATURE_SIZE else 0
val payloadBytes = payload.size + if (isCompressed) 2 else 0
val capacity = headerSize + SENDER_ID_SIZE + recipientBytes + payloadBytes + signatureBytes + 16 // small slack
val sizeFieldBytes = if (isCompressed) (if (packet.version >= 2u.toUByte()) 4 else 2) else 0
val payloadBytes = payload.size + sizeFieldBytes
val routeBytes = if (!packet.route.isNullOrEmpty() && packet.version >= 2u.toUByte()) {
1 + (packet.route!!.size.coerceAtMost(255) * SENDER_ID_SIZE)
} else 0
val capacity = headerSize + SENDER_ID_SIZE + recipientBytes + payloadBytes + signatureBytes + routeBytes + 16 // small slack
val buffer = ByteBuffer.allocate(capacity.coerceAtLeast(512)).apply { order(ByteOrder.BIG_ENDIAN) }
// Header
@@ -231,10 +244,14 @@ object BinaryProtocol {
if (isCompressed) {
flags = flags or Flags.IS_COMPRESSED
}
// HAS_ROUTE is only supported for v2+ packets
if (!packet.route.isNullOrEmpty() && packet.version >= 2u.toUByte()) {
flags = flags or Flags.HAS_ROUTE
}
buffer.put(flags.toByte())
// Payload length (2 or 4 bytes, big-endian) - includes original size if compressed
val payloadDataSize = payload.size + if (isCompressed) 2 else 0
val payloadDataSize = payload.size + sizeFieldBytes
if (packet.version >= 2u.toUByte()) {
buffer.putInt(payloadDataSize) // 4 bytes for v2+
} else {
@@ -257,11 +274,25 @@ object BinaryProtocol {
}
}
// Route (optional, v2+ only): 1 byte count + N*8 bytes
if (packet.version >= 2u.toUByte() && !packet.route.isNullOrEmpty()) {
packet.route?.let { routeList ->
val cleaned = routeList.map { bytes -> bytes.take(SENDER_ID_SIZE).toByteArray().let { if (it.size < SENDER_ID_SIZE) it + ByteArray(SENDER_ID_SIZE - it.size) else it } }
val count = cleaned.size.coerceAtMost(255)
buffer.put(count.toByte())
cleaned.take(count).forEach { hop -> buffer.put(hop) }
}
}
// Payload (with original size prepended if compressed)
if (isCompressed) {
val originalSize = originalPayloadSize
if (originalSize != null) {
buffer.putShort(originalSize.toShort())
if (packet.version >= 2u.toUByte()) {
buffer.putInt(originalSize.toInt())
} else {
buffer.putShort(originalSize.toShort())
}
}
}
buffer.put(payload)
@@ -324,6 +355,8 @@ object BinaryProtocol {
val hasRecipient = (flags and Flags.HAS_RECIPIENT) != 0u.toUByte()
val hasSignature = (flags and Flags.HAS_SIGNATURE) != 0u.toUByte()
val isCompressed = (flags and Flags.IS_COMPRESSED) != 0u.toUByte()
// HAS_ROUTE is only valid for v2+ packets; ignore the flag for v1
val hasRoute = (version >= 2u.toUByte()) && (flags and Flags.HAS_ROUTE) != 0u.toUByte()
// Payload length - version-dependent (2 or 4 bytes)
val payloadLength = if (version >= 2u.toUByte()) {
@@ -332,9 +365,29 @@ object BinaryProtocol {
buffer.getShort().toUShort().toUInt() // 2 bytes for v1, convert to UInt
}
// Calculate expected total size
if (payloadLength > com.bitchat.android.util.AppConstants.Protocol.MAX_PAYLOAD_LENGTH.toUInt()) {
Log.w("BinaryProtocol", "Payload length ${payloadLength} exceeds maximum allowed (${com.bitchat.android.util.AppConstants.Protocol.MAX_PAYLOAD_LENGTH})")
return null
}
var expectedSize = headerSize + SENDER_ID_SIZE + payloadLength.toInt()
if (hasRecipient) expectedSize += RECIPIENT_ID_SIZE
var routeCount = 0
if (hasRoute) {
// Peek count (1 byte) without consuming buffer for now
// The buffer is currently positioned at the start of SenderID (after fixed header)
// We must skip SenderID and RecipientID (if present) to find the route count
val currentPos = buffer.position()
var routeOffset = currentPos + SENDER_ID_SIZE
if (hasRecipient) {
routeOffset += RECIPIENT_ID_SIZE
}
if (raw.size >= routeOffset + 1) {
routeCount = raw[routeOffset].toUByte().toInt()
}
expectedSize += 1 + (routeCount * SENDER_ID_SIZE)
}
if (hasSignature) expectedSize += SIGNATURE_SIZE
if (raw.size < expectedSize) return null
@@ -350,16 +403,47 @@ object BinaryProtocol {
recipientBytes
} else null
// Route (optional)
val route: List<ByteArray>? = if (hasRoute) {
val count = buffer.get().toUByte().toInt()
if (count == 0) {
null // Treat empty route list as null to enforce canonical representation
} else {
val hops = mutableListOf<ByteArray>()
repeat(count) {
val hop = ByteArray(SENDER_ID_SIZE)
buffer.get(hop)
hops.add(hop)
}
hops
}
} else null
// Payload
val payload = if (isCompressed) {
// First 2 bytes are original size
if (payloadLength.toInt() < 2) return null
val originalSize = buffer.getShort().toInt()
val lengthFieldBytes = if (version >= 2u.toUByte()) 4 else 2
if (payloadLength.toInt() < lengthFieldBytes) return null
val originalSize = if (version >= 2u.toUByte()) {
buffer.getInt()
} else {
buffer.getShort().toUShort().toInt()
}
// Compressed payload
val compressedPayload = ByteArray(payloadLength.toInt() - 2)
val compressedSize = payloadLength.toInt() - lengthFieldBytes
val compressedPayload = ByteArray(compressedSize)
buffer.get(compressedPayload)
// Security check: Compression bomb protection
if (compressedSize > 0) {
val ratio = originalSize.toDouble() / compressedSize.toDouble()
if (ratio > 50_000.0) {
Log.w("BinaryProtocol", "🚫 Suspicious compression ratio: ${ratio}:1")
return null
}
}
// Decompress
CompressionUtil.decompress(compressedPayload, originalSize) ?: return null
} else {
@@ -383,10 +467,11 @@ object BinaryProtocol {
timestamp = timestamp,
payload = payload,
signature = signature,
ttl = ttl
ttl = ttl,
route = route
)
} catch (e: Exception) {
} catch (e: Throwable) {
Log.e("BinaryProtocol", "Error decoding packet: ${e.message}")
return null
}
@@ -7,8 +7,10 @@ import android.app.PendingIntent
import android.app.Service
import android.content.Context
import android.content.Intent
import android.content.pm.ServiceInfo
import android.os.Build
import android.os.IBinder
import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import com.bitchat.android.MainActivity
@@ -111,7 +113,8 @@ class MeshForegroundService : Service() {
private lateinit var notificationManager: NotificationManagerCompat
private var updateJob: Job? = null
private var meshService: BluetoothMeshService? = null
private val meshService: BluetoothMeshService?
get() = MeshServiceHolder.meshService
private val serviceJob = Job()
private val scope = CoroutineScope(Dispatchers.Default + serviceJob)
private var isInForeground: Boolean = false
@@ -122,15 +125,15 @@ class MeshForegroundService : Service() {
notificationManager = NotificationManagerCompat.from(this)
createChannel()
// Adopt or create the mesh service
// Ensure mesh service exists in holder (create if needed)
val existing = MeshServiceHolder.meshService
meshService = existing ?: MeshServiceHolder.getOrCreate(applicationContext)
if (existing != null) {
android.util.Log.d("MeshForegroundService", "Adopted existing BluetoothMeshService from holder")
Log.d("MeshForegroundService", "Using existing BluetoothMeshService from holder")
} else {
android.util.Log.i("MeshForegroundService", "Created/adopted new BluetoothMeshService via holder")
val created = MeshServiceHolder.getOrCreate(applicationContext)
Log.i("MeshForegroundService", "Created new BluetoothMeshService via holder")
MeshServiceHolder.attach(created)
}
MeshServiceHolder.attach(meshService!!)
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
@@ -176,7 +179,7 @@ class MeshForegroundService : Service() {
// If we became eligible and are not in foreground yet, promote once
if (MeshServicePreferences.isBackgroundEnabled(true) && hasAllRequiredPermissions() && !isInForeground) {
val n = buildNotification(meshService?.getActivePeerCount() ?: 0)
startForeground(NOTIFICATION_ID, n)
startForegroundCompat(n)
isInForeground = true
} else {
updateNotification(force = true)
@@ -191,7 +194,7 @@ class MeshForegroundService : Service() {
// Promote exactly once when eligible, otherwise stay background (or stop)
if (MeshServicePreferences.isBackgroundEnabled(true) && hasAllRequiredPermissions() && !isInForeground) {
val notification = buildNotification(meshService?.getActivePeerCount() ?: 0)
startForeground(NOTIFICATION_ID, notification)
startForegroundCompat(notification)
isInForeground = true
}
@@ -226,7 +229,8 @@ class MeshForegroundService : Service() {
if (!hasBluetoothPermissions()) return
try {
android.util.Log.d("MeshForegroundService", "Ensuring mesh service is started")
meshService?.startServices()
val service = MeshServiceHolder.getOrCreate(applicationContext)
service.startServices()
} catch (e: Exception) {
android.util.Log.e("MeshForegroundService", "Failed to start mesh service: ${e.message}")
}
@@ -326,6 +330,35 @@ class MeshForegroundService : Service() {
}
}
private fun hasLocationPermission(): Boolean {
val fine = androidx.core.content.ContextCompat.checkSelfPermission(this, android.Manifest.permission.ACCESS_FINE_LOCATION) == android.content.pm.PackageManager.PERMISSION_GRANTED
val coarse = androidx.core.content.ContextCompat.checkSelfPermission(this, android.Manifest.permission.ACCESS_COARSE_LOCATION) == android.content.pm.PackageManager.PERMISSION_GRANTED
return fine || coarse
}
private fun startForegroundCompat(notification: Notification) {
if (Build.VERSION.SDK_INT >= 34) {
val type = if (hasLocationPermission()) {
ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE or ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION
} else {
ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE
}
try {
startForeground(NOTIFICATION_ID, notification, type)
} catch (e: SecurityException) {
// Fallback for cases where "While In Use" permission exists but background start is restricted
if (type and ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION != 0) {
android.util.Log.w("MeshForegroundService", "Failed to start with LOCATION type, falling back to CONNECTED_DEVICE: ${e.message}")
startForeground(NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE)
} else {
throw e
}
}
} else {
startForeground(NOTIFICATION_ID, notification)
}
}
override fun onDestroy() {
updateJob?.cancel()
updateJob = null
@@ -13,6 +13,7 @@ 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 seenPublicMessageKeys = mutableSetOf<String>()
// Connected peer IDs (mesh ephemeral IDs)
private val _peers = MutableStateFlow<List<String>>(emptyList())
val peers: StateFlow<List<String>> = _peers.asStateFlow()
@@ -35,8 +36,10 @@ object AppStateStore {
fun addPublicMessage(msg: BitchatMessage) {
synchronized(this) {
if (seenMessageIds.contains(msg.id)) return
val publicKey = publicMessageKey(msg)
if (seenMessageIds.contains(msg.id) || seenPublicMessageKeys.contains(publicKey)) return
seenMessageIds.add(msg.id)
seenPublicMessageKeys.add(publicKey)
_publicMessages.value = _publicMessages.value + msg
}
}
@@ -100,10 +103,22 @@ object AppStateStore {
fun clear() {
synchronized(this) {
seenMessageIds.clear()
seenPublicMessageKeys.clear()
_peers.value = emptyList()
_publicMessages.value = emptyList()
_privateMessages.value = emptyMap()
_channelMessages.value = emptyMap()
}
}
private fun publicMessageKey(msg: BitchatMessage): String {
val sender = msg.senderPeerID ?: msg.sender
return listOf(
sender,
msg.timestamp.time.toString(),
msg.type.name,
msg.channel ?: "",
msg.content
).joinToString("\u001F")
}
}
@@ -76,7 +76,12 @@ object ConversationAliasResolver {
if (selected != null && keysToMerge.contains(selected)) {
state.setSelectedPrivateChatPeer(targetPeerID)
}
// Switch sheet peer if currently viewing an alias that got merged
val sheetPeer = state.getPrivateChatSheetPeerValue()
if (sheetPeer != null && keysToMerge.contains(sheetPeer)) {
state.setPrivateChatSheetPeer(targetPeerID)
}
}
}
}
@@ -11,7 +11,7 @@ import com.bitchat.android.nostr.NostrTransport
*/
class MessageRouter private constructor(
private val context: Context,
private val mesh: BluetoothMeshService,
private var mesh: BluetoothMeshService,
private val nostr: NostrTransport
) {
companion object {
@@ -19,22 +19,22 @@ class MessageRouter private constructor(
@Volatile private var INSTANCE: MessageRouter? = null
fun tryGetInstance(): MessageRouter? = INSTANCE
fun getInstance(context: Context, mesh: BluetoothMeshService): MessageRouter {
return INSTANCE ?: synchronized(this) {
val nostr = NostrTransport.getInstance(context)
INSTANCE?.also {
// Update mesh reference if needed and keep senderPeerID in sync
it.nostr.senderPeerID = mesh.myPeerID
return it
}
MessageRouter(context.applicationContext, mesh, nostr).also { instance ->
instance.nostr.senderPeerID = mesh.myPeerID
// Register for favorites changes to flush outbox
try {
com.bitchat.android.favorites.FavoritesPersistenceService.shared.addListener(instance.favoriteListener)
} catch (_: Exception) {}
INSTANCE = instance
val instance = INSTANCE ?: synchronized(this) {
INSTANCE ?: run {
val nostr = NostrTransport.getInstance(context)
MessageRouter(context.applicationContext, mesh, nostr).also { instance ->
// Register for favorites changes to flush outbox
try {
com.bitchat.android.favorites.FavoritesPersistenceService.shared.addListener(instance.favoriteListener)
} catch (_: Exception) {}
INSTANCE = instance
}
}
}
// Always update mesh reference and sync peer ID
instance.mesh = mesh
instance.nostr.senderPeerID = mesh.myPeerID
return instance
}
}
@@ -50,6 +50,12 @@ class SeenMessageStore private constructor(private val context: Context) {
persist()
}
@Synchronized fun clear() {
delivered.clear()
read.clear()
persist()
}
private fun trim(set: LinkedHashSet<String>) {
if (set.size <= MAX_IDS) return
val it = set.iterator()
@@ -0,0 +1,294 @@
package com.bitchat.android.services
import android.net.Uri
import android.util.Base64
import com.bitchat.android.crypto.EncryptionService
import com.bitchat.android.util.AppConstants
import com.bitchat.android.util.dataFromHexString
import com.bitchat.android.util.hexEncodedString
import java.io.ByteArrayOutputStream
import java.security.SecureRandom
import androidx.core.net.toUri
import java.lang.ref.WeakReference
/**
* QR verification helpers: schema, signing, and basic challenge/response helpers.
*/
object VerificationService {
private const val CONTEXT = "bitchat-verify-v1"
private const val RESPONSE_CONTEXT = "bitchat-verify-resp-v1"
private var encryptionServiceRef: WeakReference<EncryptionService>? = null
fun configure(encryptionService: EncryptionService) {
this.encryptionServiceRef = WeakReference(encryptionService)
}
data class VerificationQR(
val v: Int,
val noiseKeyHex: String,
val signKeyHex: String,
val npub: String?,
val nickname: String,
val ts: Long,
val nonceB64: String,
val sigHex: String
) {
fun canonicalBytes(): ByteArray {
val out = ByteArrayOutputStream()
fun appendField(value: String) {
val data = value.toByteArray(Charsets.UTF_8)
val len = minOf(data.size, 255)
out.write(len)
out.write(data, 0, len)
}
appendField(CONTEXT)
appendField(v.toString())
appendField(noiseKeyHex.lowercase())
appendField(signKeyHex.lowercase())
appendField(npub ?: "")
appendField(nickname)
appendField(ts.toString())
appendField(nonceB64)
return out.toByteArray()
}
fun toUrlString(): String {
val builder = Uri.Builder()
.scheme("bitchat")
.authority("verify")
.appendQueryParameter("v", v.toString())
.appendQueryParameter("noise", noiseKeyHex)
.appendQueryParameter("sign", signKeyHex)
.appendQueryParameter("nick", nickname)
.appendQueryParameter("ts", ts.toString())
.appendQueryParameter("nonce", nonceB64)
.appendQueryParameter("sig", sigHex)
if (npub != null) {
builder.appendQueryParameter("npub", npub)
}
return builder.build().toString()
}
companion object {
fun fromUrlString(urlString: String): VerificationQR? {
val uri = runCatching { urlString.toUri() }.getOrNull() ?: return null
if (uri.scheme != "bitchat" || uri.host != "verify") return null
val vStr = uri.getQueryParameter("v") ?: return null
val v = vStr.toIntOrNull() ?: return null
val noise = uri.getQueryParameter("noise") ?: return null
val sign = uri.getQueryParameter("sign") ?: return null
val nick = uri.getQueryParameter("nick") ?: return null
val tsStr = uri.getQueryParameter("ts") ?: return null
val ts = tsStr.toLongOrNull() ?: return null
val nonce = uri.getQueryParameter("nonce") ?: return null
val sig = uri.getQueryParameter("sig") ?: return null
val npub = uri.getQueryParameter("npub")
return VerificationQR(
v = v,
noiseKeyHex = noise,
signKeyHex = sign,
npub = npub,
nickname = nick,
ts = ts,
nonceB64 = nonce,
sigHex = sig
)
}
}
}
fun buildMyQRString(nickname: String, npub: String?): String? {
val service = encryptionServiceRef?.get() ?: return null
val cache = Cache.last
if (cache != null && cache.nickname == nickname && cache.npub == npub) {
if (System.currentTimeMillis() - cache.builtAtMs < 60_000L) {
return cache.value
}
}
val noiseKey = service.getStaticPublicKey()?.hexEncodedString() ?: return null
val signKey = service.getSigningPublicKey()?.hexEncodedString() ?: return null
val ts = System.currentTimeMillis() / 1000L
val nonce = ByteArray(16)
SecureRandom().nextBytes(nonce)
val nonceB64 = Base64.encodeToString(
nonce,
Base64.URL_SAFE or Base64.NO_WRAP or Base64.NO_PADDING
)
val payload = VerificationQR(
v = 1,
noiseKeyHex = noiseKey,
signKeyHex = signKey,
npub = npub,
nickname = nickname,
ts = ts,
nonceB64 = nonceB64,
sigHex = ""
)
val signature = service.signData(payload.canonicalBytes()) ?: return null
val signed = payload.copy(sigHex = signature.hexEncodedString())
val out = signed.toUrlString()
Cache.last = CacheEntry(nickname, npub, System.currentTimeMillis(), out)
return out
}
fun verifyScannedQR(
urlString: String,
maxAgeSeconds: Long = AppConstants.Verification.QR_MAX_AGE_SECONDS
): VerificationQR? {
val service = encryptionServiceRef?.get() ?: return null
val qr = VerificationQR.fromUrlString(urlString) ?: return null
val now = System.currentTimeMillis() / 1000L
if (now - qr.ts > maxAgeSeconds) return null
val sig = qr.sigHex.dataFromHexString() ?: return null
val signKey = qr.signKeyHex.dataFromHexString() ?: return null
val ok = service.verifyEd25519Signature(sig, qr.canonicalBytes(), signKey)
return if (ok) qr else null
}
fun buildVerifyChallenge(noiseKeyHex: String, nonceA: ByteArray): ByteArray {
val noiseData = noiseKeyHex.toByteArray(Charsets.UTF_8)
val out = ByteArrayOutputStream()
out.write(0x01)
out.write(minOf(noiseData.size, 255))
out.write(noiseData, 0, minOf(noiseData.size, 255))
out.write(0x02)
out.write(minOf(nonceA.size, 255))
out.write(nonceA, 0, minOf(nonceA.size, 255))
return out.toByteArray()
}
fun buildVerifyResponse(noiseKeyHex: String, nonceA: ByteArray): ByteArray? {
val service = encryptionServiceRef?.get() ?: return null
val noiseData = noiseKeyHex.toByteArray(Charsets.UTF_8)
val msg = ByteArrayOutputStream()
msg.write(RESPONSE_CONTEXT.toByteArray(Charsets.UTF_8))
msg.write(minOf(noiseData.size, 255))
msg.write(noiseData, 0, minOf(noiseData.size, 255))
msg.write(nonceA)
val sig = service.signData(msg.toByteArray()) ?: return null
val out = ByteArrayOutputStream()
out.write(0x01)
out.write(minOf(noiseData.size, 255))
out.write(noiseData, 0, minOf(noiseData.size, 255))
out.write(0x02)
out.write(minOf(nonceA.size, 255))
out.write(nonceA, 0, minOf(nonceA.size, 255))
out.write(0x03)
out.write(minOf(sig.size, 255))
out.write(sig, 0, minOf(sig.size, 255))
return out.toByteArray()
}
fun parseVerifyChallenge(data: ByteArray): Pair<String, ByteArray>? {
var idx = 0
fun take(n: Int): ByteArray? {
if (idx + n > data.size) return null
val out = data.copyOfRange(idx, idx + n)
idx += n
return out
}
val t1 = take(1) ?: return null
if (t1[0].toInt() != 0x01) return null
val l1 = take(1)?.get(0)?.toInt() ?: return null
val noiseBytes = take(l1) ?: return null
val noise = noiseBytes.toString(Charsets.UTF_8)
val t2 = take(1) ?: return null
if (t2[0].toInt() != 0x02) return null
val l2 = take(1)?.get(0)?.toInt() ?: return null
val nonce = take(l2) ?: return null
return noise to nonce
}
data class VerifyResponse(val noiseKeyHex: String, val nonceA: ByteArray, val signature: ByteArray) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as VerifyResponse
if (noiseKeyHex != other.noiseKeyHex) return false
if (!nonceA.contentEquals(other.nonceA)) return false
if (!signature.contentEquals(other.signature)) return false
return true
}
override fun hashCode(): Int {
var result = noiseKeyHex.hashCode()
result = 31 * result + nonceA.contentHashCode()
result = 31 * result + signature.contentHashCode()
return result
}
}
fun parseVerifyResponse(data: ByteArray): VerifyResponse? {
var idx = 0
fun take(n: Int): ByteArray? {
if (idx + n > data.size) return null
val out = data.copyOfRange(idx, idx + n)
idx += n
return out
}
val t1 = take(1) ?: return null
if (t1[0].toInt() != 0x01) return null
val l1 = take(1)?.get(0)?.toInt() ?: return null
val noiseBytes = take(l1) ?: return null
val noise = noiseBytes.toString(Charsets.UTF_8)
val t2 = take(1) ?: return null
if (t2[0].toInt() != 0x02) return null
val l2 = take(1)?.get(0)?.toInt() ?: return null
val nonce = take(l2) ?: return null
val t3 = take(1) ?: return null
if (t3[0].toInt() != 0x03) return null
val l3 = take(1)?.get(0)?.toInt() ?: return null
val sig = take(l3) ?: return null
return VerifyResponse(noise, nonce, sig)
}
fun verifyResponseSignature(
noiseKeyHex: String,
nonceA: ByteArray,
signature: ByteArray,
signerPublicKeyHex: String
): Boolean {
val service = encryptionServiceRef?.get() ?: return false
val noiseData = noiseKeyHex.toByteArray(Charsets.UTF_8)
val msg = ByteArrayOutputStream()
msg.write(RESPONSE_CONTEXT.toByteArray(Charsets.UTF_8))
msg.write(minOf(noiseData.size, 255))
msg.write(noiseData, 0, minOf(noiseData.size, 255))
msg.write(nonceA)
val signerKey = signerPublicKeyHex.dataFromHexString() ?: return false
return service.verifyEd25519Signature(signature, msg.toByteArray(), signerKey)
}
private data class CacheEntry(
val nickname: String,
val npub: String?,
val builtAtMs: Long,
val value: String
)
private object Cache {
var last: CacheEntry? = null
}
}
@@ -0,0 +1,77 @@
package com.bitchat.android.services.meshgraph
import android.util.Log
/**
* Gossip TLV helpers for embedding direct neighbor peer IDs in ANNOUNCE payloads.
* Uses compact TLV: [type=0x04][len=1 byte][value=N*8 bytes of peerIDs]
*/
object GossipTLV {
// TLV type for a compact list of direct neighbor peerIDs (each 8 bytes)
const val DIRECT_NEIGHBORS_TYPE: UByte = 0x04u
/**
* Encode up to 10 unique peerIDs (hex string up to 16 chars) as TLV value.
*/
fun encodeNeighbors(peerIDs: List<String>): ByteArray {
val unique = peerIDs.distinct().take(10)
val valueBytes = unique.flatMap { id -> hexStringPeerIdTo8Bytes(id).toList() }.toByteArray()
if (valueBytes.size > 255) {
// Safety check, though 10*8 = 80 bytes, so well under 255
Log.w("GossipTLV", "Neighbors value exceeds 255, truncating")
}
return byteArrayOf(DIRECT_NEIGHBORS_TYPE.toByte(), valueBytes.size.toByte()) + valueBytes
}
/**
* Scan a TLV-encoded announce payload and extract neighbor peerIDs.
* Returns null if the TLV is not present at all; returns an empty list if present with length 0.
*/
fun decodeNeighborsFromAnnouncementPayload(payload: ByteArray): List<String>? {
val result = mutableListOf<String>()
var offset = 0
while (offset + 2 <= payload.size) {
val type = payload[offset].toUByte()
val len = payload[offset + 1].toUByte().toInt()
offset += 2
if (offset + len > payload.size) break
val value = payload.sliceArray(offset until offset + len)
offset += len
if (type == DIRECT_NEIGHBORS_TYPE) {
// Value is N*8 bytes of peer IDs
var pos = 0
while (pos + 8 <= value.size) {
val idBytes = value.sliceArray(pos until pos + 8)
result.add(bytesToPeerIdHex(idBytes))
pos += 8
}
return result // present (possibly empty)
}
}
// Not present
return null
}
private fun hexStringPeerIdTo8Bytes(hexString: String): ByteArray {
val clean = hexString.lowercase().take(16)
val result = ByteArray(8) { 0 }
var idx = 0
var out = 0
while (idx + 1 < clean.length && out < 8) {
val byteStr = clean.substring(idx, idx + 2)
val b = byteStr.toIntOrNull(16)?.toByte() ?: 0
result[out++] = b
idx += 2
}
return result
}
private fun bytesToPeerIdHex(bytes: ByteArray): String {
val sb = StringBuilder()
for (b in bytes.take(8)) {
sb.append(String.format("%02x", b))
}
return sb.toString()
}
}
@@ -0,0 +1,127 @@
package com.bitchat.android.services.meshgraph
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import java.util.concurrent.ConcurrentHashMap
/**
* Maintains an internal graph of the mesh based on gossip.
* Nodes are peers (peerID), edges are direct connections.
*/
class MeshGraphService private constructor() {
data class GraphNode(val peerID: String, val nickname: String?)
data class GraphEdge(val a: String, val b: String, val isConfirmed: Boolean, val confirmedBy: String? = null)
data class GraphSnapshot(val nodes: List<GraphNode>, val edges: List<GraphEdge>)
// Map peerID -> nickname (may be null if unknown)
private val nicknames = ConcurrentHashMap<String, String?>()
// Announcements: peerID -> set of neighbor peerIDs that *this* peer claims to see
private val announcements = ConcurrentHashMap<String, Set<String>>()
// Latest announcement timestamp per peer (ULong from packet)
private val lastUpdate = ConcurrentHashMap<String, ULong>()
private val _graphState = MutableStateFlow(GraphSnapshot(emptyList(), emptyList()))
val graphState: StateFlow<GraphSnapshot> = _graphState.asStateFlow()
/**
* Update graph from a verified announcement.
* Replaces previous neighbors for origin if this is newer (by timestamp).
*/
fun updateFromAnnouncement(originPeerID: String, originNickname: String?, neighborsOrNull: List<String>?, timestamp: ULong) {
synchronized(this) {
// Always update nickname if provided
if (originNickname != null) nicknames[originPeerID] = originNickname
// 1. Check timestamp first to ensure this is the latest word from the peer
val prevTs = lastUpdate[originPeerID]
if (prevTs != null && prevTs >= timestamp) {
// Older or equal update: ignore
return
}
lastUpdate[originPeerID] = timestamp
// 2. Latest announcement determines state.
// If neighborsOrNull is null (TLV omitted), it means the peer is not reporting any neighbors (empty list).
val neighbors = neighborsOrNull ?: emptyList()
// Filter out self-loops just in case
val newSet = neighbors.distinct().take(10).filter { it != originPeerID }.toSet()
announcements[originPeerID] = newSet
publishSnapshot()
}
}
fun updateNickname(peerID: String, nickname: String?) {
if (nickname == null) return
nicknames[peerID] = nickname
publishSnapshot()
}
/**
* Remove a peer from the graph completely (e.g. when stale/offline).
*/
fun removePeer(peerID: String) {
synchronized(this) {
nicknames.remove(peerID)
announcements.remove(peerID)
lastUpdate.remove(peerID)
publishSnapshot()
}
}
private fun publishSnapshot() {
// Collect all known nodes from nicknames and announcements
val allNodes = mutableSetOf<String>()
allNodes.addAll(nicknames.keys)
announcements.forEach { (origin, neighbors) ->
allNodes.add(origin)
allNodes.addAll(neighbors)
}
val nodeList = allNodes.map { GraphNode(it, nicknames[it]) }.sortedBy { it.peerID }
val edges = mutableListOf<GraphEdge>()
val processedPairs = mutableSetOf<Pair<String, String>>()
// We only care about connections that exist in at least one direction.
// So iterating through all entries in `announcements` covers every declared edge.
announcements.forEach { (source, targets) ->
targets.forEach { target ->
val pair = if (source <= target) source to target else target to source
if (processedPairs.add(pair)) {
// This is a new pair we haven't evaluated yet
val (a, b) = pair
val aAnnouncesB = announcements[a]?.contains(b) == true
val bAnnouncesA = announcements[b]?.contains(a) == true
if (aAnnouncesB && bAnnouncesA) {
edges.add(GraphEdge(a, b, isConfirmed = true))
} else if (aAnnouncesB) {
edges.add(GraphEdge(a, b, isConfirmed = false, confirmedBy = a))
} else if (bAnnouncesA) {
edges.add(GraphEdge(a, b, isConfirmed = false, confirmedBy = b))
}
}
}
}
val sortedEdges = edges.sortedWith(compareBy({ it.a }, { it.b }))
_graphState.value = GraphSnapshot(nodeList, sortedEdges)
}
companion object {
@Volatile private var INSTANCE: MeshGraphService? = null
fun getInstance(): MeshGraphService = INSTANCE ?: synchronized(this) {
INSTANCE ?: MeshGraphService().also { INSTANCE = it }
}
@org.jetbrains.annotations.TestOnly
fun resetForTesting() {
synchronized(this) {
INSTANCE = null
}
}
}
}
@@ -0,0 +1,68 @@
package com.bitchat.android.services.meshgraph
import android.util.Log
import java.util.PriorityQueue
/**
* Computes shortest paths on the current mesh graph snapshot using Dijkstra.
* Assumes unit edge weights.
*/
object RoutePlanner {
private const val TAG = "RoutePlanner"
/**
* Return full path [src, ..., dst] if reachable, else null.
*/
fun shortestPath(src: String, dst: String): List<String>? {
if (src == dst) return listOf(src)
val snapshot = MeshGraphService.getInstance().graphState.value
val neighbors = mutableMapOf<String, MutableSet<String>>()
// Only consider confirmed edges for routing
snapshot.edges.filter { it.isConfirmed }.forEach { e ->
neighbors.getOrPut(e.a) { mutableSetOf() }.add(e.b)
neighbors.getOrPut(e.b) { mutableSetOf() }.add(e.a)
}
// Ensure nodes known even if isolated
snapshot.nodes.forEach { n -> neighbors.putIfAbsent(n.peerID, mutableSetOf()) }
if (!neighbors.containsKey(src) || !neighbors.containsKey(dst)) return null
val dist = mutableMapOf<String, Int>()
val prev = mutableMapOf<String, String?>()
val pq = PriorityQueue<Pair<String, Int>>(compareBy { it.second })
neighbors.keys.forEach { v ->
dist[v] = if (v == src) 0 else Int.MAX_VALUE
prev[v] = null
}
pq.add(src to 0)
while (pq.isNotEmpty()) {
val top = pq.poll() ?: break
val (u, d) = top
if (d > (dist[u] ?: Int.MAX_VALUE)) continue
if (u == dst) break
neighbors[u]?.forEach { v ->
val alt = d + 1
if (alt < (dist[v] ?: Int.MAX_VALUE)) {
dist[v] = alt
prev[v] = u
pq.add(v to alt)
}
}
}
if ((dist[dst] ?: Int.MAX_VALUE) == Int.MAX_VALUE) return null
val path = mutableListOf<String>()
var cur: String? = dst
while (cur != null) {
path.add(cur)
cur = prev[cur]
}
path.reverse()
Log.d(TAG, "Computed path $path")
return path
}
}
@@ -13,13 +13,10 @@ import androidx.compose.material.icons.filled.Lock
import androidx.compose.material.icons.filled.Public
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material.icons.filled.Security
import androidx.compose.material.icons.filled.NetworkCheck
import androidx.compose.material.icons.filled.Speed
import androidx.compose.material.icons.outlined.Info
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import kotlinx.coroutines.launch
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
@@ -27,15 +24,14 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.BaselineShift
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.bitchat.android.nostr.NostrProofOfWork
import com.bitchat.android.nostr.PoWPreferenceManager
import androidx.compose.ui.res.stringResource
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.bitchat.android.R
import com.bitchat.android.core.ui.component.button.CloseButton
import com.bitchat.android.core.ui.component.sheet.BitchatBottomSheet
import com.bitchat.android.net.TorMode
import com.bitchat.android.net.TorPreferenceManager
import com.bitchat.android.net.ArtiTorManager
@@ -217,10 +213,6 @@ fun AboutSheet(
}
}
val sheetState = rememberModalBottomSheetState(
skipPartiallyExpanded = true
)
val lazyListState = rememberLazyListState()
val isScrolled by remember {
derivedStateOf {
@@ -236,12 +228,9 @@ fun AboutSheet(
val isDark = colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f
if (isPresented) {
ModalBottomSheet(
modifier = modifier.statusBarsPadding(),
BitchatBottomSheet(
modifier = modifier,
onDismissRequest = onDismiss,
sheetState = sheetState,
containerColor = colorScheme.background,
dragHandle = null
) {
Box(modifier = Modifier.fillMaxWidth()) {
LazyColumn(
@@ -37,22 +37,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
*/
/**
* Reactive helper to compute favorite state from fingerprint mapping
* This eliminates the need for static isFavorite parameters and makes
* the UI reactive to fingerprint manager changes
*/
@Composable
fun isFavoriteReactive(
peerID: String,
peerFingerprints: Map<String, String>,
favoritePeers: Set<String>
): Boolean {
return remember(peerID, peerFingerprints, favoritePeers) {
val fingerprint = peerFingerprints[peerID]
fingerprint != null && favoritePeers.contains(fingerprint)
}
}
@Composable
fun TorStatusDot(
@@ -246,39 +231,6 @@ fun ChatHeaderContent(
val colorScheme = MaterialTheme.colorScheme
when {
selectedPrivatePeer != null -> {
// Private chat header - Fully reactive state tracking
val favoritePeers by viewModel.favoritePeers.collectAsStateWithLifecycle()
val peerFingerprints by viewModel.peerFingerprints.collectAsStateWithLifecycle()
val peerSessionStates by viewModel.peerSessionStates.collectAsStateWithLifecycle()
val peerNicknames by viewModel.peerNicknames.collectAsStateWithLifecycle()
// Reactive favorite computation - no more static lookups!
val isFavorite = isFavoriteReactive(
peerID = selectedPrivatePeer,
peerFingerprints = peerFingerprints,
favoritePeers = favoritePeers
)
val sessionState = peerSessionStates[selectedPrivatePeer]
Log.d("ChatHeader", "Header recomposing: peer=$selectedPrivatePeer, isFav=$isFavorite, sessionState=$sessionState")
// Pass geohash context and people for NIP-17 chat title formatting
val selectedLocationChannel by viewModel.selectedLocationChannel.collectAsStateWithLifecycle()
val geohashPeople by viewModel.geohashPeople.collectAsStateWithLifecycle()
PrivateChatHeader(
peerID = selectedPrivatePeer,
peerNicknames = peerNicknames,
isFavorite = isFavorite,
sessionState = sessionState,
selectedLocationChannel = selectedLocationChannel,
geohashPeople = geohashPeople,
onBackClick = onBackClick,
onToggleFavorite = { viewModel.toggleFavorite(selectedPrivatePeer) },
viewModel = viewModel
)
}
currentChannel != null -> {
// Channel header
ChannelHeader(
@@ -304,148 +256,7 @@ fun ChatHeaderContent(
}
}
@Composable
private fun PrivateChatHeader(
peerID: String,
peerNicknames: Map<String, String>,
isFavorite: Boolean,
sessionState: String?,
selectedLocationChannel: com.bitchat.android.geohash.ChannelID?,
geohashPeople: List<GeoPerson>,
onBackClick: () -> Unit,
onToggleFavorite: () -> Unit,
viewModel: ChatViewModel
) {
val colorScheme = MaterialTheme.colorScheme
val isNostrDM = peerID.startsWith("nostr_") || peerID.startsWith("nostr:")
// Determine mutual favorite state for this peer (supports mesh ephemeral 16-hex via favorites lookup)
val isMutualFavorite = remember(peerID, peerNicknames) {
try {
if (isNostrDM) return@remember false
if (peerID.length == 64 && peerID.matches(Regex("^[0-9a-fA-F]+$"))) {
val noiseKeyBytes = peerID.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
com.bitchat.android.favorites.FavoritesPersistenceService.shared.getFavoriteStatus(noiseKeyBytes)?.isMutual == true
} else if (peerID.length == 16 && peerID.matches(Regex("^[0-9a-fA-F]+$"))) {
com.bitchat.android.favorites.FavoritesPersistenceService.shared.getFavoriteStatus(peerID)?.isMutual == true
} else false
} catch (_: Exception) { false }
}
// Compute title text: for NIP-17 chats show "#geohash/@username" (iOS parity)
val titleText: String = if (isNostrDM) {
// For geohash DMs, get the actual source geohash and proper display name
val (conversationGeohash, baseName) = try {
val repoField = com.bitchat.android.ui.GeohashViewModel::class.java.getDeclaredField("repo")
repoField.isAccessible = true
val repo = repoField.get(viewModel.geohashViewModel) as com.bitchat.android.nostr.GeohashRepository
val gh = repo.getConversationGeohash(peerID) ?: "geohash"
val fullPubkey = com.bitchat.android.nostr.GeohashAliasRegistry.get(peerID) ?: ""
val displayName = if (fullPubkey.isNotEmpty()) {
repo.displayNameForGeohashConversation(fullPubkey, gh)
} else {
peerNicknames[peerID] ?: "unknown"
}
Pair(gh, displayName)
} catch (e: Exception) {
Pair("geohash", peerNicknames[peerID] ?: "unknown")
}
"#$conversationGeohash/@$baseName"
} else {
// Prefer live mesh nickname; fallback to favorites nickname (supports 16-hex), finally short key
peerNicknames[peerID] ?: run {
val titleFromFavorites = try {
if (peerID.length == 64 && peerID.matches(Regex("^[0-9a-fA-F]+$"))) {
val noiseKeyBytes = peerID.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
com.bitchat.android.favorites.FavoritesPersistenceService.shared.getFavoriteStatus(noiseKeyBytes)?.peerNickname
} else if (peerID.length == 16 && peerID.matches(Regex("^[0-9a-fA-F]+$"))) {
com.bitchat.android.favorites.FavoritesPersistenceService.shared.getFavoriteStatus(peerID)?.peerNickname
} else null
} catch (_: Exception) { null }
titleFromFavorites ?: peerID.take(12)
}
}
Box(modifier = Modifier.fillMaxWidth()) {
// Back button - positioned all the way to the left with minimal margin
Button(
onClick = onBackClick,
colors = ButtonDefaults.buttonColors(
containerColor = Color.Transparent,
contentColor = colorScheme.primary
),
contentPadding = PaddingValues(horizontal = 4.dp, vertical = 4.dp), // Reduced horizontal padding
modifier = Modifier
.align(Alignment.CenterStart)
.offset(x = (-8).dp) // Move even further left to minimize margin
) {
Row(
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = Icons.Filled.ArrowBack,
contentDescription = stringResource(R.string.back),
modifier = Modifier.size(16.dp),
tint = colorScheme.primary
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = stringResource(R.string.chat_back),
style = MaterialTheme.typography.bodyMedium,
color = colorScheme.primary
)
}
}
// Title - perfectly centered regardless of other elements
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.align(Alignment.Center)
) {
Text(
text = titleText,
style = MaterialTheme.typography.titleMedium,
color = Color(0xFFFF9500) // Orange
)
Spacer(modifier = Modifier.width(4.dp))
// Show a globe when chatting via Nostr alias, or when mesh session not established but mutual favorite exists
val showGlobe = isNostrDM || (sessionState != "established" && isMutualFavorite)
if (showGlobe) {
Icon(
imageVector = Icons.Outlined.Public,
contentDescription = stringResource(R.string.cd_nostr_reachable),
modifier = Modifier.size(14.dp),
tint = Color(0xFF9B59B6) // Purple like iOS
)
} else {
NoiseSessionIcon(
sessionState = sessionState,
modifier = Modifier.size(14.dp)
)
}
}
// Favorite button - positioned on the right
IconButton(
onClick = {
Log.d("ChatHeader", "Header toggle favorite: peerID=$peerID, currentFavorite=$isFavorite")
onToggleFavorite()
},
modifier = Modifier.align(Alignment.CenterEnd)
) {
Icon(
imageVector = if (isFavorite) Icons.Filled.Star else Icons.Outlined.Star,
contentDescription = if (isFavorite) stringResource(R.string.cd_remove_favorite) else stringResource(R.string.cd_add_favorite),
modifier = Modifier.size(18.dp), // Slightly larger than sidebar icon
tint = if (isFavorite) Color(0xFFFFD700) else Color(0x87878700) // Yellow or grey
)
}
}
}
@Composable
private fun ChannelHeader(
@@ -5,7 +5,6 @@ package com.bitchat.android.ui
import androidx.compose.animation.*
import androidx.compose.animation.core.*
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
@@ -51,12 +50,15 @@ fun ChatScreen(viewModel: ChatViewModel) {
val hasUnreadPrivateMessages by viewModel.unreadPrivateMessages.collectAsStateWithLifecycle()
val privateChats by viewModel.privateChats.collectAsStateWithLifecycle()
val channelMessages by viewModel.channelMessages.collectAsStateWithLifecycle()
val showSidebar by viewModel.showSidebar.collectAsStateWithLifecycle()
val showCommandSuggestions by viewModel.showCommandSuggestions.collectAsStateWithLifecycle()
val commandSuggestions by viewModel.commandSuggestions.collectAsStateWithLifecycle()
val showMentionSuggestions by viewModel.showMentionSuggestions.collectAsStateWithLifecycle()
val mentionSuggestions by viewModel.mentionSuggestions.collectAsStateWithLifecycle()
val showAppInfo by viewModel.showAppInfo.collectAsStateWithLifecycle()
val showMeshPeerListSheet by viewModel.showMeshPeerList.collectAsStateWithLifecycle()
val privateChatSheetPeer by viewModel.privateChatSheetPeer.collectAsStateWithLifecycle()
val showVerificationSheet by viewModel.showVerificationSheet.collectAsStateWithLifecycle()
val showSecurityVerificationSheet by viewModel.showSecurityVerificationSheet.collectAsStateWithLifecycle()
var messageText by remember { mutableStateOf(TextFieldValue("")) }
var showPasswordPrompt by remember { mutableStateOf(false) }
@@ -85,8 +87,8 @@ fun ChatScreen(viewModel: ChatViewModel) {
val selectedLocationChannel by viewModel.selectedLocationChannel.collectAsStateWithLifecycle()
// Determine what messages to show based on current context (unified timelines)
// Legacy private chat timeline removed - private chats now exclusively use PrivateChatSheet
val displayMessages = when {
selectedPrivatePeer != null -> privateChats[selectedPrivatePeer] ?: emptyList()
currentChannel != null -> channelMessages[currentChannel] ?: emptyList()
else -> {
val locationChannel = selectedLocationChannel
@@ -101,7 +103,6 @@ fun ChatScreen(viewModel: ChatViewModel) {
// Determine whether to show media buttons (only hide in geohash location chats)
val showMediaButtons = when {
selectedPrivatePeer != null -> true
currentChannel != null -> true
else -> selectedLocationChannel !is com.bitchat.android.geohash.ChannelID.Location
}
@@ -231,7 +232,7 @@ fun ChatScreen(viewModel: ChatViewModel) {
selection = TextRange(mentionText.length)
)
},
selectedPrivatePeer = selectedPrivatePeer,
selectedPrivatePeer = null,
currentChannel = currentChannel,
nickname = nickname,
colorScheme = colorScheme,
@@ -242,12 +243,12 @@ fun ChatScreen(viewModel: ChatViewModel) {
// Floating header - positioned absolutely at top, ignores keyboard
ChatFloatingHeader(
headerHeight = headerHeight,
selectedPrivatePeer = selectedPrivatePeer,
selectedPrivatePeer = null,
currentChannel = currentChannel,
nickname = nickname,
viewModel = viewModel,
colorScheme = colorScheme,
onSidebarToggle = { viewModel.showSidebar() },
onSidebarToggle = { viewModel.showMeshPeerList() },
onShowAppInfo = { viewModel.showAppInfo() },
onPanicClear = { viewModel.panicClearAllData() },
onLocationChannelsClick = { showLocationChannelsSheet = true },
@@ -264,28 +265,9 @@ fun ChatScreen(viewModel: ChatViewModel) {
color = colorScheme.outline.copy(alpha = 0.3f)
)
val alpha by animateFloatAsState(
targetValue = if (showSidebar) 0.5f else 0f,
animationSpec = tween(
durationMillis = 300,
easing = EaseOutCubic
), label = "overlayAlpha"
)
// Only render the background if it's visible
if (alpha > 0f) {
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black.copy(alpha = alpha))
.clickable { viewModel.hideSidebar() }
.zIndex(1f)
)
}
// Scroll-to-bottom floating button
AnimatedVisibility(
visible = isScrolledUp && !showSidebar,
visible = isScrolledUp,
enter = slideInVertically(initialOffsetY = { it / 2 }) + fadeIn(),
exit = slideOutVertically(targetOffsetY = { it / 2 }) + fadeOut(),
modifier = Modifier
@@ -311,25 +293,6 @@ fun ChatScreen(viewModel: ChatViewModel) {
}
}
}
AnimatedVisibility(
visible = showSidebar,
enter = slideInHorizontally(
initialOffsetX = { it },
animationSpec = tween(300, easing = EaseOutCubic)
) + fadeIn(animationSpec = tween(300)),
exit = slideOutHorizontally(
targetOffsetX = { it },
animationSpec = tween(250, easing = EaseInCubic)
) + fadeOut(animationSpec = tween(250)),
modifier = Modifier.zIndex(2f)
) {
SidebarOverlay(
viewModel = viewModel,
onDismiss = { viewModel.hideSidebar() },
modifier = Modifier.fillMaxSize()
)
}
}
// Full-screen image viewer - separate from other sheets to allow image browsing without navigation
@@ -373,12 +336,18 @@ fun ChatScreen(viewModel: ChatViewModel) {
},
selectedUserForSheet = selectedUserForSheet,
selectedMessageForSheet = selectedMessageForSheet,
viewModel = viewModel
viewModel = viewModel,
showVerificationSheet = showVerificationSheet,
onVerificationSheetDismiss = viewModel::hideVerificationSheet,
showSecurityVerificationSheet = showSecurityVerificationSheet,
onSecurityVerificationSheetDismiss = viewModel::hideSecurityVerificationSheet,
showMeshPeerListSheet = showMeshPeerListSheet,
onMeshPeerListDismiss = viewModel::hideMeshPeerList,
)
}
@Composable
private fun ChatInputSection(
fun ChatInputSection(
messageText: TextFieldValue,
onMessageTextChange: (TextFieldValue) -> Unit,
onSend: () -> Unit,
@@ -513,8 +482,16 @@ private fun ChatDialogs(
onUserSheetDismiss: () -> Unit,
selectedUserForSheet: String,
selectedMessageForSheet: BitchatMessage?,
viewModel: ChatViewModel
viewModel: ChatViewModel,
showVerificationSheet: Boolean,
onVerificationSheetDismiss: () -> Unit,
showSecurityVerificationSheet: Boolean,
onSecurityVerificationSheetDismiss: () -> Unit,
showMeshPeerListSheet: Boolean,
onMeshPeerListDismiss: () -> Unit,
) {
val privateChatSheetPeer by viewModel.privateChatSheetPeer.collectAsStateWithLifecycle()
// Password dialog
PasswordPromptDialog(
show = showPasswordDialog,
@@ -567,4 +544,44 @@ private fun ChatDialogs(
viewModel = viewModel
)
}
// MeshPeerList sheet (network view)
if (showMeshPeerListSheet){
MeshPeerListSheet(
isPresented = showMeshPeerListSheet,
viewModel = viewModel,
onDismiss = onMeshPeerListDismiss,
onShowVerification = {
onMeshPeerListDismiss()
viewModel.showVerificationSheet(fromSidebar = true)
}
)
}
if (showVerificationSheet) {
VerificationSheet(
isPresented = showVerificationSheet,
onDismiss = onVerificationSheetDismiss,
viewModel = viewModel
)
}
if (showSecurityVerificationSheet) {
SecurityVerificationSheet(
isPresented = showSecurityVerificationSheet,
onDismiss = onSecurityVerificationSheetDismiss,
viewModel = viewModel
)
}
if (privateChatSheetPeer != null) {
PrivateChatSheet(
isPresented = true,
peerID = privateChatSheetPeer!!,
viewModel = viewModel,
onDismiss = {
viewModel.hidePrivateChatSheet()
viewModel.endPrivateChat()
}
)
}
}
@@ -77,10 +77,6 @@ class ChatState(
private val _passwordPromptChannel = MutableStateFlow<String?>(null)
val passwordPromptChannel: StateFlow<String?> = _passwordPromptChannel.asStateFlow()
// Sidebar state
private val _showSidebar = MutableStateFlow(false)
val showSidebar: StateFlow<Boolean> = _showSidebar.asStateFlow()
// Command autocomplete
private val _showCommandSuggestions = MutableStateFlow(false)
val showCommandSuggestions: StateFlow<Boolean> = _showCommandSuggestions.asStateFlow()
@@ -123,6 +119,18 @@ class ChatState(
private val _showAppInfo = MutableStateFlow<Boolean>(false)
val showAppInfo: StateFlow<Boolean> = _showAppInfo.asStateFlow()
private val _showMeshPeerList = MutableStateFlow(false)
val showMeshPeerList: StateFlow<Boolean> = _showMeshPeerList.asStateFlow()
private val _privateChatSheetPeer = MutableStateFlow<String?>(null)
val privateChatSheetPeer: StateFlow<String?> = _privateChatSheetPeer.asStateFlow()
private val _showVerificationSheet = MutableStateFlow(false)
val showVerificationSheet: StateFlow<Boolean> = _showVerificationSheet.asStateFlow()
private val _showSecurityVerificationSheet = MutableStateFlow(false)
val showSecurityVerificationSheet: StateFlow<Boolean> = _showSecurityVerificationSheet.asStateFlow()
// Location channels state (for Nostr geohash features)
private val _selectedLocationChannel = MutableStateFlow<com.bitchat.android.geohash.ChannelID?>(com.bitchat.android.geohash.ChannelID.Mesh)
val selectedLocationChannel: StateFlow<com.bitchat.android.geohash.ChannelID?> = _selectedLocationChannel.asStateFlow()
@@ -172,7 +180,6 @@ class ChatState(
fun getPasswordProtectedChannelsValue() = _passwordProtectedChannels.value
fun getShowPasswordPromptValue() = _showPasswordPrompt.value
fun getPasswordPromptChannelValue() = _passwordPromptChannel.value
fun getShowSidebarValue() = _showSidebar.value
fun getShowCommandSuggestionsValue() = _showCommandSuggestions.value
fun getCommandSuggestionsValue() = _commandSuggestions.value
fun getShowMentionSuggestionsValue() = _showMentionSuggestions.value
@@ -182,6 +189,10 @@ class ChatState(
fun getPeerFingerprintsValue() = _peerFingerprints.value
fun getShowAppInfoValue() = _showAppInfo.value
fun getGeohashPeopleValue() = _geohashPeople.value
fun getShowMeshPeerListValue() = _showMeshPeerList.value
fun getPrivateChatSheetPeerValue() = _privateChatSheetPeer.value
fun getTeleportedGeoValue() = _teleportedGeo.value
fun getGeohashParticipantCountsValue() = _geohashParticipantCounts.value
@@ -246,10 +257,6 @@ class ChatState(
_passwordPromptChannel.value = channel
}
fun setShowSidebar(show: Boolean) {
_showSidebar.value = show
}
fun setShowCommandSuggestions(show: Boolean) {
_showCommandSuggestions.value = show
}
@@ -303,6 +310,14 @@ class ChatState(
_showAppInfo.value = show
}
fun setShowVerificationSheet(show: Boolean) {
_showVerificationSheet.value = show
}
fun setShowSecurityVerificationSheet(show: Boolean) {
_showSecurityVerificationSheet.value = show
}
fun setSelectedLocationChannel(channel: com.bitchat.android.geohash.ChannelID?) {
_selectedLocationChannel.value = channel
}
@@ -323,4 +338,11 @@ class ChatState(
_geohashParticipantCounts.value = counts
}
fun setShowMeshPeerList(show: Boolean) {
_showMeshPeerList.value = show
}
fun setPrivateChatSheetPeer(peerID: String?) {
_privateChatSheetPeer.value = peerID
}
}
@@ -4,7 +4,6 @@ import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontFamily
@@ -16,7 +15,7 @@ import androidx.compose.ui.res.stringResource
import com.bitchat.android.R
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.text.AnnotatedString
import kotlinx.coroutines.launch
import com.bitchat.android.core.ui.component.sheet.BitchatBottomSheet
import com.bitchat.android.model.BitchatMessage
/**
@@ -36,23 +35,18 @@ fun ChatUserSheet(
val coroutineScope = rememberCoroutineScope()
val clipboardManager = LocalClipboardManager.current
// Bottom sheet state
val sheetState = rememberModalBottomSheetState(
skipPartiallyExpanded = true
)
// iOS system colors (matches LocationChannelsSheet exactly)
val colorScheme = MaterialTheme.colorScheme
val isDark = colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f
val standardGreen = if (isDark) Color(0xFF32D74B) else Color(0xFF248A3D) // iOS green
val standardBlue = Color(0xFF007AFF) // iOS blue
val standardPurple = if (isDark) Color(0xFFBF5AF2) else Color(0xFFAF52DE) // iOS purple
val standardRed = Color(0xFFFF3B30) // iOS red
val standardGrey = if (isDark) Color(0xFF8E8E93) else Color(0xFF6D6D70) // iOS grey
if (isPresented) {
ModalBottomSheet(
BitchatBottomSheet(
onDismissRequest = onDismiss,
sheetState = sheetState,
modifier = modifier
) {
Column(
@@ -99,6 +93,33 @@ fun ChatUserSheet(
// Only show user actions for other users' messages or when no message is selected
if (selectedMessage?.sender != viewModel.nickname.value) {
// Send private message action
item {
UserActionRow(
title = stringResource(R.string.action_private_message_title, targetNickname),
subtitle = stringResource(R.string.action_private_message_subtitle),
titleColor = standardPurple,
onClick = {
val selectedLocationChannel = viewModel.selectedLocationChannel.value
if (selectedLocationChannel is com.bitchat.android.geohash.ChannelID.Location) {
if (selectedMessage?.senderPeerID?.startsWith("nostr:") == true) {
val shortId = selectedMessage.senderPeerID!!.substring(6)
viewModel.startGeohashDMByShortId(shortId)
} else {
viewModel.startGeohashDMByNickname(targetNickname)
}
} else {
// Mesh chat
val peerID = selectedMessage?.senderPeerID ?: viewModel.getPeerIDForNickname(targetNickname)
if (peerID != null) {
viewModel.showPrivateChatSheet(peerID)
}
}
onDismiss()
}
)
}
// Slap action
item {
UserActionRow(
@@ -5,11 +5,16 @@ import android.util.Log
import androidx.core.app.NotificationManagerCompat
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.bitchat.android.favorites.FavoritesPersistenceService
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import com.bitchat.android.mesh.BluetoothMeshDelegate
import com.bitchat.android.mesh.BluetoothMeshService
import com.bitchat.android.service.MeshServiceHolder
import com.bitchat.android.model.BitchatMessage
import com.bitchat.android.model.BitchatMessageType
import com.bitchat.android.nostr.NostrIdentityBridge
import com.bitchat.android.protocol.BitchatPacket
@@ -19,6 +24,13 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import java.util.Date
import kotlin.random.Random
import com.bitchat.android.services.VerificationService
import com.bitchat.android.identity.SecureIdentityStateManager
import com.bitchat.android.noise.NoiseSession
import com.bitchat.android.nostr.GeohashAliasRegistry
import com.bitchat.android.util.dataFromHexString
import com.bitchat.android.util.hexEncodedString
import java.security.MessageDigest
/**
* Refactored ChatViewModel - Main coordinator for bitchat functionality
@@ -26,8 +38,12 @@ import kotlin.random.Random
*/
class ChatViewModel(
application: Application,
val meshService: BluetoothMeshService
initialMeshService: BluetoothMeshService
) : AndroidViewModel(application), BluetoothMeshDelegate {
// Made var to support mesh service replacement after panic clear
var meshService: BluetoothMeshService = initialMeshService
private set
private val debugManager by lazy { try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance() } catch (e: Exception) { null } }
companion object {
@@ -46,6 +62,20 @@ class ChatViewModel(
mediaSendingManager.sendImageNote(toPeerIDOrNull, channelOrNull, filePath)
}
fun getCurrentNpub(): String? {
return try {
NostrIdentityBridge
.getCurrentNostrIdentity(getApplication())
?.npub
} catch (_: Exception) {
null
}
}
fun buildMyQRString(nickname: String, npub: String?): String {
return VerificationService.buildMyQRString(nickname, npub) ?: ""
}
// MARK: - State management
private val state = ChatState(
scope = viewModelScope,
@@ -57,6 +87,7 @@ class ChatViewModel(
// Specialized managers
private val dataManager = DataManager(application.applicationContext)
private val identityManager by lazy { SecureIdentityStateManager(getApplication()) }
private val messageManager = MessageManager(state)
private val channelManager = ChannelManager(state, messageManager, dataManager, viewModelScope)
@@ -75,8 +106,19 @@ class ChatViewModel(
NotificationIntervalManager()
)
private val verificationHandler = VerificationHandler(
context = application.applicationContext,
scope = viewModelScope,
getMeshService = { meshService },
identityManager = identityManager,
state = state,
notificationManager = notificationManager,
messageManager = messageManager
)
val verifiedFingerprints = verificationHandler.verifiedFingerprints
// Media file sending manager
private val mediaSendingManager = MediaSendingManager(state, messageManager, channelManager, meshService)
private val mediaSendingManager = MediaSendingManager(state, messageManager, channelManager) { meshService }
// Delegate handler for mesh callbacks
private val meshDelegateHandler = MeshDelegateHandler(
@@ -120,7 +162,6 @@ class ChatViewModel(
val passwordProtectedChannels: StateFlow<Set<String>> = state.passwordProtectedChannels
val showPasswordPrompt: StateFlow<Boolean> = state.showPasswordPrompt
val passwordPromptChannel: StateFlow<String?> = state.passwordPromptChannel
val showSidebar: StateFlow<Boolean> = state.showSidebar
val hasUnreadChannels = state.hasUnreadChannels
val hasUnreadPrivateMessages = state.hasUnreadPrivateMessages
val showCommandSuggestions: StateFlow<Boolean> = state.showCommandSuggestions
@@ -134,6 +175,10 @@ class ChatViewModel(
val peerRSSI: StateFlow<Map<String, Int>> = state.peerRSSI
val peerDirect: StateFlow<Map<String, Boolean>> = state.peerDirect
val showAppInfo: StateFlow<Boolean> = state.showAppInfo
val showMeshPeerList: StateFlow<Boolean> = state.showMeshPeerList
val privateChatSheetPeer: StateFlow<String?> = state.privateChatSheetPeer
val showVerificationSheet: StateFlow<Boolean> = state.showVerificationSheet
val showSecurityVerificationSheet: StateFlow<Boolean> = state.showSecurityVerificationSheet
val selectedLocationChannel: StateFlow<com.bitchat.android.geohash.ChannelID?> = state.selectedLocationChannel
val isTeleported: StateFlow<Boolean> = state.isTeleported
val geohashPeople: StateFlow<List<GeoPerson>> = state.geohashPeople
@@ -247,6 +292,9 @@ class ChatViewModel(
// Initialize favorites persistence service
com.bitchat.android.favorites.FavoritesPersistenceService.initialize(getApplication())
// Load verified fingerprints from secure storage
verificationHandler.loadVerifiedFingerprints()
// Ensure NostrTransport knows our mesh peer ID for embedded packets
try {
@@ -361,6 +409,8 @@ class ChatViewModel(
setCurrentPrivateChatPeer(null)
// Clear mesh mention notifications since user is now back in mesh chat
clearMeshMentionNotifications()
// Ensure sheet is hidden
hidePrivateChatSheet()
}
// MARK: - Open Latest Unread Private Chat
@@ -409,12 +459,7 @@ class ChatViewModel(
canonical ?: targetKey
}
startPrivateChat(openPeer)
// If sidebar visible, hide it to focus on the private chat
if (state.getShowSidebarValue()) {
state.setShowSidebar(false)
}
showPrivateChatSheet(openPeer)
} catch (e: Exception) {
Log.w(TAG, "openLatestUnreadPrivateChat failed: ${e.message}")
}
@@ -468,6 +513,10 @@ class ChatViewModel(
).also { canonical ->
if (canonical != state.getSelectedPrivateChatPeerValue()) {
privateChatManager.startPrivateChat(canonical, meshService)
// If we're in the private chat sheet, update its active peer too
if (state.getPrivateChatSheetPeerValue() != null) {
showPrivateChatSheet(canonical)
}
}
}
// Send private message
@@ -651,6 +700,18 @@ class ChatViewModel(
// Update fingerprint mappings from centralized manager
val fingerprints = privateChatManager.getAllPeerFingerprints()
state.setPeerFingerprints(fingerprints)
fingerprints.forEach { (peerID, fingerprint) ->
identityManager.cachePeerFingerprint(peerID, fingerprint)
val info = try { meshService.getPeerInfo(peerID) } catch (_: Exception) { null }
val noiseKeyHex = info?.noisePublicKey?.hexEncodedString()
if (noiseKeyHex != null) {
identityManager.cachePeerNoiseKey(peerID, noiseKeyHex)
identityManager.cacheNoiseFingerprint(noiseKeyHex, fingerprint)
}
info?.nickname?.takeIf { it.isNotBlank() }?.let { nickname ->
identityManager.cacheFingerprintNickname(fingerprint, nickname)
}
}
// Merge nicknames from BLE and WiFi Aware to display names for all peers
val bleNick = meshService.getPeerNicknames()
@@ -672,6 +733,34 @@ class ChatViewModel(
}
state.setPeerDirect(directMap)
} catch (_: Exception) { }
// Flush any pending QR verification once a Noise session is established
currentPeers.forEach { peerID ->
if (meshService.getSessionState(peerID) is NoiseSession.NoiseSessionState.Established) {
verificationHandler.sendPendingVerificationIfNeeded(peerID)
}
}
}
// MARK: - QR Verification
fun isPeerVerified(peerID: String, verifiedFingerprints: Set<String>): Boolean {
if (peerID.startsWith("nostr_") || peerID.startsWith("nostr:")) return false
val fingerprint = verificationHandler.getPeerFingerprintForDisplay(peerID)
return fingerprint != null && verifiedFingerprints.contains(fingerprint)
}
fun isNoisePublicKeyVerified(noisePublicKey: ByteArray, verifiedFingerprints: Set<String>): Boolean {
val fingerprint = verificationHandler.fingerprintFromNoiseBytes(noisePublicKey)
return verifiedFingerprints.contains(fingerprint)
}
fun unverifyFingerprint(peerID: String) {
verificationHandler.unverifyFingerprint(peerID)
}
fun beginQRVerification(qr: VerificationService.VerificationQR): Boolean {
return verificationHandler.beginQRVerification(qr)
}
// MARK: - Debug and Troubleshooting
@@ -680,41 +769,87 @@ class ChatViewModel(
return meshService.getDebugStatus()
}
// Note: Mesh service restart is now handled by MainActivity
// This function is no longer needed
fun setAppBackgroundState(inBackground: Boolean) {
// Forward to notification manager for notification logic
notificationManager.setAppBackgroundState(inBackground)
}
fun setCurrentPrivateChatPeer(peerID: String?) {
// Update notification manager with current private chat peer
notificationManager.setCurrentPrivateChatPeer(peerID)
}
fun setCurrentGeohash(geohash: String?) {
// Update notification manager with current geohash for notification logic
notificationManager.setCurrentGeohash(geohash)
}
fun clearNotificationsForSender(peerID: String) {
// Clear notifications when user opens a chat
notificationManager.clearNotificationsForSender(peerID)
}
fun clearNotificationsForGeohash(geohash: String) {
// Clear notifications when user opens a geohash chat
notificationManager.clearNotificationsForGeohash(geohash)
}
/**
* Clear mesh mention notifications when user opens mesh chat
*/
fun clearMeshMentionNotifications() {
notificationManager.clearMeshMentionNotifications()
}
private var reopenSidebarAfterVerification = false
fun showVerificationSheet(fromSidebar: Boolean = false) {
if (fromSidebar) {
reopenSidebarAfterVerification = true
}
state.setShowVerificationSheet(true)
}
fun hideVerificationSheet() {
state.setShowVerificationSheet(false)
if (reopenSidebarAfterVerification) {
reopenSidebarAfterVerification = false
state.setShowMeshPeerList(true)
}
}
fun showSecurityVerificationSheet() {
state.setShowSecurityVerificationSheet(true)
}
fun hideSecurityVerificationSheet() {
state.setShowSecurityVerificationSheet(false)
}
fun showMeshPeerList() {
state.setShowMeshPeerList(true)
}
fun hideMeshPeerList() {
state.setShowMeshPeerList(false)
}
fun showPrivateChatSheet(peerID: String) {
state.setPrivateChatSheetPeer(peerID)
}
fun hidePrivateChatSheet() {
state.setPrivateChatSheetPeer(null)
}
fun getPeerFingerprintForDisplay(peerID: String): String? {
return verificationHandler.getPeerFingerprintForDisplay(peerID)
}
fun getMyFingerprint(): String {
return verificationHandler.getMyFingerprint()
}
fun resolvePeerDisplayNameForFingerprint(peerID: String): String {
return verificationHandler.resolvePeerDisplayNameForFingerprint(peerID)
}
fun verifyFingerprintValue(fingerprint: String) {
verificationHandler.verifyFingerprintValue(fingerprint)
}
fun unverifyFingerprintValue(fingerprint: String) {
verificationHandler.unverifyFingerprintValue(fingerprint)
}
// MARK: - Command Autocomplete (delegated)
fun updateCommandSuggestions(input: String) {
@@ -761,6 +896,14 @@ class ChatViewModel(
meshDelegateHandler.didReceiveReadReceipt(messageID, recipientPeerID)
}
override fun didReceiveVerifyChallenge(peerID: String, payload: ByteArray, timestampMs: Long) {
verificationHandler.didReceiveVerifyChallenge(peerID, payload)
}
override fun didReceiveVerifyResponse(peerID: String, payload: ByteArray, timestampMs: Long) {
verificationHandler.didReceiveVerifyResponse(peerID, payload)
}
override fun decryptChannelMessage(encryptedContent: ByteArray, channel: String): String? {
return meshDelegateHandler.decryptChannelMessage(encryptedContent, channel)
}
@@ -786,6 +929,11 @@ class ChatViewModel(
privateChatManager.clearAllPrivateChats()
dataManager.clearAllData()
// Clear seen message store
try {
com.bitchat.android.services.SeenMessageStore.getInstance(getApplication()).clear()
} catch (_: Exception) { }
// Clear all mesh service data
clearAllMeshServiceData()
@@ -795,6 +943,9 @@ class ChatViewModel(
// Clear all notifications
notificationManager.clearAllNotifications()
// Clear all media files
com.bitchat.android.features.file.FileUtils.clearAllMedia(getApplication())
// Clear Nostr/geohash state, keys, connections, bookmarks, and reinitialize from scratch
try {
// Clear geohash bookmarks too (panic should remove everything)
@@ -813,10 +964,37 @@ class ChatViewModel(
state.setNickname(newNickname)
dataManager.saveNickname(newNickname)
Log.w(TAG, "🚨 PANIC MODE COMPLETED - All sensitive data cleared")
// Recreate mesh service with fresh identity
recreateMeshServiceAfterPanic()
// Note: Mesh service restart is now handled by MainActivity
// This method now only clears data, not mesh service lifecycle
Log.w(TAG, "🚨 PANIC MODE COMPLETED - New identity: ${meshService.myPeerID}")
}
/**
* Recreate the mesh service with a fresh identity after panic clear.
* This ensures the new cryptographic keys are used for a new peer ID.
*/
private fun recreateMeshServiceAfterPanic() {
val oldPeerID = meshService.myPeerID
// Clear the holder so getOrCreate() returns a fresh instance
MeshServiceHolder.clear()
// Create fresh mesh service with new identity (keys were regenerated in clearAllCryptographicData)
val freshMeshService = MeshServiceHolder.getOrCreate(getApplication())
// Replace our reference and set up the new service
meshService = freshMeshService
meshService.delegate = this
// Restart mesh operations with new identity
meshService.startServices()
meshService.sendBroadcastAnnounce()
Log.d(
TAG,
"✅ Mesh service recreated. Old peerID: $oldPeerID, New peerID: ${meshService.myPeerID}"
)
}
/**
@@ -843,7 +1021,7 @@ class ChatViewModel(
// Clear secure identity state (if used)
try {
val identityManager = com.bitchat.android.identity.SecureIdentityStateManager(getApplication())
val identityManager = SecureIdentityStateManager(getApplication())
identityManager.clearIdentityData()
// Also clear secure values used by FavoritesPersistenceService (favorites + peerID index)
try {
@@ -856,7 +1034,7 @@ class ChatViewModel(
// Clear FavoritesPersistenceService persistent relationships
try {
com.bitchat.android.favorites.FavoritesPersistenceService.shared.clearAllFavorites()
FavoritesPersistenceService.shared.clearAllFavorites()
Log.d(TAG, "✅ Cleared FavoritesPersistenceService relationships")
} catch (_: Exception) { }
@@ -884,7 +1062,7 @@ class ChatViewModel(
* End geohash sampling
*/
fun endGeohashSampling() {
// No-op in refactored architecture; sampling subscriptions are short-lived
geohashViewModel.endGeohashSampling()
}
/**
@@ -899,7 +1077,19 @@ class ChatViewModel(
*/
fun startGeohashDM(pubkeyHex: String) {
geohashViewModel.startGeohashDM(pubkeyHex) { convKey ->
startPrivateChat(convKey)
showPrivateChatSheet(convKey)
}
}
fun startGeohashDMByNickname(nickname: String) {
geohashViewModel.startGeohashDMByNickname(nickname) { convKey ->
showPrivateChatSheet(convKey)
}
}
fun startGeohashDMByShortId(shortId: String) {
geohashViewModel.startGeohashDMByShortId(shortId) { convKey ->
showPrivateChatSheet(convKey)
}
}
@@ -924,14 +1114,6 @@ class ChatViewModel(
state.setShowAppInfo(false)
}
fun showSidebar() {
state.setShowSidebar(true)
}
fun hideSidebar() {
state.setShowSidebar(false)
}
/**
* Handle Android back navigation
* Returns true if the back press was handled, false if it should be passed to the system
@@ -943,11 +1125,6 @@ class ChatViewModel(
hideAppInfo()
true
}
// Close sidebar
state.getShowSidebarValue() -> {
hideSidebar()
true
}
// Close password dialog
state.getShowPasswordPromptValue() -> {
state.setShowPasswordPrompt(false)
@@ -955,7 +1132,7 @@ class ChatViewModel(
true
}
// Exit private chat
state.getSelectedPrivateChatPeerValue() != null -> {
state.getSelectedPrivateChatPeerValue() != null || state.getPrivateChatSheetPeerValue() != null -> {
endPrivateChat()
true
}
@@ -985,5 +1162,6 @@ class ChatViewModel(
*/
fun colorForNostrPubkey(pubkeyHex: String, isDark: Boolean): androidx.compose.ui.graphics.Color {
return geohashViewModel.colorForNostrPubkey(pubkeyHex, isDark)
}
}
}
@@ -3,6 +3,10 @@ package com.bitchat.android.ui
import android.app.Application
import android.util.Log
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.ProcessLifecycleOwner
import androidx.lifecycle.viewModelScope
import com.bitchat.android.nostr.GeohashMessageHandler
import com.bitchat.android.nostr.GeohashRepository
@@ -12,11 +16,18 @@ import com.bitchat.android.nostr.NostrProtocol
import com.bitchat.android.nostr.NostrRelayManager
import com.bitchat.android.nostr.NostrSubscriptionManager
import com.bitchat.android.nostr.PoWPreferenceManager
import com.bitchat.android.nostr.GeohashAliasRegistry
import com.bitchat.android.nostr.GeohashConversationRegistry
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import java.util.Date
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.isActive
import kotlinx.coroutines.Dispatchers
import java.security.SecureRandom
import kotlin.random.asKotlinRandom
class GeohashViewModel(
application: Application,
@@ -26,9 +37,12 @@ class GeohashViewModel(
private val meshDelegateHandler: MeshDelegateHandler,
private val dataManager: DataManager,
private val notificationManager: NotificationManager
) : AndroidViewModel(application) {
) : AndroidViewModel(application), DefaultLifecycleObserver {
companion object { private const val TAG = "GeohashViewModel" }
companion object {
private const val TAG = "GeohashViewModel"
private val secureRandom = SecureRandom().asKotlinRandom()
}
private val repo = GeohashRepository(application, state, dataManager)
private val subscriptionManager = NostrSubscriptionManager(application, viewModelScope)
@@ -53,7 +67,9 @@ class GeohashViewModel(
private var currentGeohashSubId: String? = null
private var currentDmSubId: String? = null
private var geoTimer: Job? = null
private var globalPresenceJob: Job? = null
private var locationChannelManager: com.bitchat.android.geohash.LocationChannelManager? = null
private val activeSamplingGeohashes = mutableSetOf<String>()
val geohashPeople: StateFlow<List<GeoPerson>> = state.geohashPeople
val geohashParticipantCounts: StateFlow<Map<String, Int>> = state.geohashParticipantCounts
@@ -61,6 +77,10 @@ class GeohashViewModel(
fun initialize() {
subscriptionManager.connect()
// Observe process lifecycle to manage background sampling
kotlin.runCatching {
ProcessLifecycleOwner.get().lifecycle.addObserver(this)
}
val identity = NostrIdentityBridge.getCurrentNostrIdentity(getApplication())
if (identity != null) {
// Use global chat-messages only for full account DMs (mesh context). For geohash DMs, subscribe per-geohash below.
@@ -84,6 +104,10 @@ class GeohashViewModel(
state.setIsTeleported(teleported)
}
}
// Start global presence heartbeat loop
startGlobalPresenceHeartbeat()
} catch (e: Exception) {
Log.e(TAG, "Failed to initialize location channel state: ${e.message}")
state.setSelectedLocationChannel(com.bitchat.android.geohash.ChannelID.Mesh)
@@ -91,17 +115,77 @@ class GeohashViewModel(
}
}
private fun startGlobalPresenceHeartbeat() {
globalPresenceJob?.cancel()
globalPresenceJob = viewModelScope.launch(kotlinx.coroutines.Dispatchers.IO) {
// Reactively restart heartbeat whenever available channels change
locationChannelManager?.availableChannels?.collectLatest { channels ->
// Filter for REGION (2), PROVINCE (4), CITY (5) - precision <= 5
val targetGeohashes = channels.filter { it.level.precision <= 5 }.map { it.geohash }
if (targetGeohashes.isNotEmpty()) {
// Enter heartbeat loop for this set of channels
// If channels change (e.g. user moves), collectLatest cancels this loop and starts a new one immediately
while (true) {
// Randomize loop interval (40-80s, average 60s)
val loopInterval = secureRandom.nextLong(40000L, 80000L)
var timeSpent = 0L
try {
Log.v(TAG, "💓 Broadcasting global presence to ${targetGeohashes.size} channels")
targetGeohashes.forEach { geohash ->
// Decorrelate individual broadcasts with random delay (1s-5s)
val stepDelay = secureRandom.nextLong(1000L, 10000L)
delay(stepDelay)
timeSpent += stepDelay
broadcastPresence(geohash)
}
} catch (e: Exception) {
Log.w(TAG, "Global presence heartbeat error: ${e.message}")
}
// Wait remaining time to satisfy target average cadence
val remaining = loopInterval - timeSpent
if (remaining > 0) {
delay(remaining)
} else {
delay(10000L) // Minimum guard delay
}
}
}
}
}
}
fun panicReset() {
repo.clearAll()
GeohashAliasRegistry.clear()
GeohashConversationRegistry.clear()
subscriptionManager.disconnect()
currentGeohashSubId = null
currentDmSubId = null
geoTimer?.cancel()
geoTimer = null
globalPresenceJob?.cancel()
globalPresenceJob = null
try { NostrIdentityBridge.clearAllAssociations(getApplication()) } catch (_: Exception) {}
initialize()
}
private suspend fun broadcastPresence(geohash: String) {
try {
val identity = NostrIdentityBridge.deriveIdentity(geohash, getApplication())
val event = NostrProtocol.createGeohashPresenceEvent(geohash, identity)
val relayManager = NostrRelayManager.getInstance(getApplication())
// Presence is lightweight, send to geohash relays
relayManager.sendEventToGeohash(event, geohash, includeDefaults = false, nRelays = 5)
Log.v(TAG, "💓 Sent presence heartbeat for $geohash")
} catch (e: Exception) {
Log.w(TAG, "Failed to send presence for $geohash: ${e.message}")
}
}
fun sendGeohashMessage(content: String, channel: com.bitchat.android.geohash.GeohashChannel, myPeerID: String, nickname: String?) {
viewModelScope.launch {
try {
@@ -141,22 +225,46 @@ class GeohashViewModel(
}
fun beginGeohashSampling(geohashes: List<String>) {
if (geohashes.isEmpty()) return
Log.d(TAG, "🌍 Beginning geohash sampling for ${geohashes.size} geohashes")
viewModelScope.launch {
geohashes.forEach { geohash ->
subscriptionManager.subscribeGeohash(
geohash = geohash,
sinceMs = System.currentTimeMillis() - 86400000L,
limit = 200,
id = "sampling-$geohash",
handler = { event -> geohashMessageHandler.onEvent(event, geohash) }
)
if (geohashes.isEmpty()) {
endGeohashSampling()
return
}
// Diffing logic to avoid redundant REQ and leaks
val currentSet = activeSamplingGeohashes.toSet()
val newSet = geohashes.toSet()
val toRemove = currentSet - newSet
val toAdd = newSet - currentSet
if (toAdd.isEmpty() && toRemove.isEmpty()) return
Log.d(TAG, "🌍 Updating sampling: +${toAdd.size} new, -${toRemove.size} removed")
// Remove old subscriptions
toRemove.forEach { geohash ->
subscriptionManager.unsubscribe("sampling-$geohash")
activeSamplingGeohashes.remove(geohash)
}
// Add new subscriptions
activeSamplingGeohashes.addAll(toAdd)
if (isAppInForeground()) {
toAdd.forEach { geohash ->
performSubscribeSampling(geohash)
}
}
}
fun endGeohashSampling() { Log.d(TAG, "🌍 Ending geohash sampling") }
fun endGeohashSampling() {
if (activeSamplingGeohashes.isEmpty()) return
Log.d(TAG, "🌍 Ending geohash sampling (cleaning up ${activeSamplingGeohashes.size} subs)")
activeSamplingGeohashes.toList().forEach { geohash ->
subscriptionManager.unsubscribe("sampling-$geohash")
}
activeSamplingGeohashes.clear()
}
fun geohashParticipantCount(geohash: String): Int = repo.geohashParticipantCount(geohash)
fun isPersonTeleported(pubkeyHex: String): Boolean = repo.isPersonTeleported(pubkeyHex)
@@ -168,12 +276,31 @@ class GeohashViewModel(
val gh = (current as? com.bitchat.android.geohash.ChannelID.Location)?.channel?.geohash
if (!gh.isNullOrEmpty()) {
repo.setConversationGeohash(convKey, gh)
com.bitchat.android.nostr.GeohashConversationRegistry.set(convKey, gh)
GeohashConversationRegistry.set(convKey, gh)
}
onStartPrivateChat(convKey)
Log.d(TAG, "🗨️ Started geohash DM with ${pubkeyHex} -> ${convKey} (geohash=${gh})")
}
fun startGeohashDMByNickname(nickname: String, onStartPrivateChat: (String) -> Unit) {
val pubkey = repo.findPubkeyByNickname(nickname)
if (pubkey != null) {
startGeohashDM(pubkey, onStartPrivateChat)
} else {
Log.w(TAG, "Cannot start geohash DM: nickname '$nickname' not found in repo")
// Optionally notify user
}
}
fun startGeohashDMByShortId(shortId: String, onStartPrivateChat: (String) -> Unit) {
val pubkey = repo.findPubkeyByShortId(shortId)
if (pubkey != null) {
startGeohashDM(pubkey, onStartPrivateChat)
} else {
Log.w(TAG, "Cannot start geohash DM: shortId '$shortId' not found in repo")
}
}
fun getNostrKeyMapping(): Map<String, String> = repo.getNostrKeyMapping()
fun blockUserInGeohash(targetNickname: String) {
@@ -206,6 +333,7 @@ class GeohashViewModel(
}
fun displayNameForNostrPubkeyUI(pubkeyHex: String): String = repo.displayNameForNostrPubkeyUI(pubkeyHex)
fun displayNameForGeohashConversation(pubkeyHex: String, sourceGeohash: String): String = repo.displayNameForGeohashConversation(pubkeyHex, sourceGeohash)
fun colorForNostrPubkey(pubkeyHex: String, isDark: Boolean): androidx.compose.ui.graphics.Color {
val seed = "nostr:${pubkeyHex.lowercase()}"
@@ -234,7 +362,7 @@ class GeohashViewModel(
try {
val identity = NostrIdentityBridge.deriveIdentity(channel.channel.geohash, getApplication())
repo.updateParticipant(channel.channel.geohash, identity.publicKeyHex, Date())
// We don't update participant here anymore; presence loop handles it via Kind 20001
val teleported = state.isTeleported.value
if (teleported) repo.markTeleported(identity.publicKeyHex)
} catch (e: Exception) { Log.w(TAG, "Failed identity setup: ${e.message}") }
@@ -260,7 +388,7 @@ class GeohashViewModel(
handler = { event -> dmHandler.onGiftWrap(event, geohash, dmIdentity) }
)
// Also register alias in global registry for routing convenience
com.bitchat.android.nostr.GeohashAliasRegistry.put("nostr_${dmIdentity.publicKeyHex.take(16)}", dmIdentity.publicKeyHex)
GeohashAliasRegistry.put("nostr_${dmIdentity.publicKeyHex.take(16)}", dmIdentity.publicKeyHex)
}
}
null -> {
@@ -279,4 +407,35 @@ class GeohashViewModel(
}
}
}
override fun onCleared() {
super.onCleared()
kotlin.runCatching {
ProcessLifecycleOwner.get().lifecycle.removeObserver(this)
}
}
override fun onStart(owner: LifecycleOwner) {
Log.d(TAG, "🌍 App foregrounded: Resuming sampling for ${activeSamplingGeohashes.size} geohashes")
activeSamplingGeohashes.forEach { performSubscribeSampling(it) }
}
override fun onStop(owner: LifecycleOwner) {
Log.d(TAG, "🌍 App backgrounded: Pausing sampling for ${activeSamplingGeohashes.size} geohashes")
activeSamplingGeohashes.forEach { subscriptionManager.unsubscribe("sampling-$it") }
}
private fun performSubscribeSampling(geohash: String) {
subscriptionManager.subscribeGeohash(
geohash = geohash,
sinceMs = System.currentTimeMillis() - 86400000L,
limit = 200,
id = "sampling-$geohash",
handler = { event -> geohashMessageHandler.onEvent(event, geohash) }
)
}
private fun isAppInForeground(): Boolean {
return ProcessLifecycleOwner.get().lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)
}
}
@@ -4,7 +4,6 @@ import android.content.Intent
import android.net.Uri
import android.provider.Settings
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
@@ -39,7 +38,9 @@ import com.bitchat.android.ui.theme.BASE_FONT_SIZE
import androidx.compose.ui.res.stringResource
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.bitchat.android.R
import com.bitchat.android.core.ui.component.button.CloseButton
import com.bitchat.android.core.ui.component.sheet.BitchatBottomSheet
import com.bitchat.android.core.ui.component.sheet.BitchatSheetTopBar
import com.bitchat.android.core.ui.component.sheet.BitchatSheetTitle
/**
* Location Channels Sheet for selecting geohash-based location channels
@@ -62,7 +63,9 @@ fun LocationChannelsSheet(
val availableChannels by locationManager.availableChannels.collectAsStateWithLifecycle()
val selectedChannel by locationManager.selectedChannel.collectAsStateWithLifecycle()
val locationNames by locationManager.locationNames.collectAsStateWithLifecycle()
val locationServicesEnabled by locationManager.locationServicesEnabled.collectAsStateWithLifecycle()
val appLocationEnabled by locationManager.locationServicesEnabled.collectAsStateWithLifecycle()
val systemLocationEnabled by locationManager.systemLocationEnabled.collectAsStateWithLifecycle()
val locationServicesEnabled by locationManager.effectiveLocationEnabled.collectAsStateWithLifecycle()
// Observe bookmarks state
val bookmarks by bookmarksStore.bookmarks.collectAsStateWithLifecycle()
@@ -113,43 +116,27 @@ fun LocationChannelsSheet(
val standardBlue = Color(0xFF007AFF) // iOS blue
if (isPresented) {
ModalBottomSheet(
modifier = modifier.statusBarsPadding(),
BitchatBottomSheet(
modifier = modifier,
onDismissRequest = onDismiss,
sheetState = sheetState,
containerColor = MaterialTheme.colorScheme.background,
dragHandle = null
) {
Box(modifier = Modifier.fillMaxWidth()) {
LazyColumn(
state = listState,
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(top = 48.dp, bottom = 16.dp)
contentPadding = PaddingValues(top = 64.dp, bottom = 16.dp)
) {
// Header Section
item(key = "header") {
Column(
Text(
text = stringResource(R.string.location_channels_desc),
fontSize = 12.sp,
fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.7f),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp)
.padding(bottom = 8.dp),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
Text(
text = stringResource(R.string.location_channels_title),
style = MaterialTheme.typography.headlineSmall,
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onBackground
)
Text(
text = stringResource(R.string.location_channels_desc),
fontSize = 12.sp,
fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.7f)
)
}
)
}
// Permission controls if services enabled
@@ -163,24 +150,7 @@ fun LocationChannelsSheet(
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
when (permissionState) {
LocationChannelManager.PermissionState.NOT_DETERMINED -> {
Button(
onClick = { locationManager.enableLocationChannels() },
colors = ButtonDefaults.buttonColors(
containerColor = standardGreen.copy(alpha = 0.12f),
contentColor = standardGreen
),
modifier = Modifier.fillMaxWidth()
) {
Text(
text = stringResource(R.string.grant_location_permission),
fontSize = 12.sp,
fontFamily = FontFamily.Monospace
)
}
}
LocationChannelManager.PermissionState.DENIED,
LocationChannelManager.PermissionState.RESTRICTED -> {
LocationChannelManager.PermissionState.DENIED -> {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(
text = stringResource(R.string.location_permission_denied),
@@ -212,20 +182,6 @@ fun LocationChannelsSheet(
color = standardGreen
)
}
null -> {
Row(
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically
) {
CircularProgressIndicator(modifier = Modifier.size(12.dp))
Text(
text = stringResource(R.string.checking_permissions),
fontSize = 11.sp,
fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)
)
}
}
}
}
}
@@ -287,10 +243,17 @@ fun LocationChannelsSheet(
} else if (permissionState == LocationChannelManager.PermissionState.AUTHORIZED && locationServicesEnabled) {
item {
Row(
horizontalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 32.dp, vertical = 12.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
CircularProgressIndicator(modifier = Modifier.size(16.dp))
CircularProgressIndicator(
modifier = Modifier.size(16.dp),
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
)
Text(
text = stringResource(R.string.finding_nearby_channels),
fontSize = 12.sp,
@@ -545,50 +508,45 @@ fun LocationChannelsSheet(
}
// TopBar (animated)
Box(
modifier = Modifier
.align(Alignment.TopCenter)
.fillMaxWidth()
.height(56.dp)
.background(MaterialTheme.colorScheme.background.copy(alpha = topBarAlpha))
) {
CloseButton(
onClick = onDismiss,
modifier = modifier
.align(Alignment.CenterEnd)
.padding(horizontal = 16.dp),
)
}
BitchatSheetTopBar(
onClose = onDismiss,
modifier = modifier.align(Alignment.TopCenter),
title = {
BitchatSheetTitle(
text = stringResource(R.string.location_channels_title)
)
}
)
}
}
}
// Lifecycle management: when presented, sample both nearby and bookmarked geohashes
// Lifecycle management: when presented, manage location updates
DisposableEffect(isPresented, permissionState, locationServicesEnabled) {
if (isPresented && permissionState == LocationChannelManager.PermissionState.AUTHORIZED && locationServicesEnabled) {
locationManager.refreshChannels()
locationManager.beginLiveRefresh()
}
onDispose {
locationManager.endLiveRefresh()
}
}
// Sampling management: update sampling when channels/bookmarks change
LaunchedEffect(isPresented, availableChannels, bookmarks) {
if (isPresented) {
if (permissionState == LocationChannelManager.PermissionState.AUTHORIZED && locationServicesEnabled) {
locationManager.refreshChannels()
locationManager.beginLiveRefresh()
}
val geohashes = (availableChannels.map { it.geohash } + bookmarks).toSet().toList()
viewModel.beginGeohashSampling(geohashes)
} else {
locationManager.endLiveRefresh()
viewModel.endGeohashSampling()
}
}
// React to permission changes
LaunchedEffect(permissionState) {
if (permissionState == LocationChannelManager.PermissionState.AUTHORIZED && locationServicesEnabled) {
locationManager.refreshChannels()
}
}
// React to location services enable/disable
LaunchedEffect(locationServicesEnabled) {
if (locationServicesEnabled && permissionState == LocationChannelManager.PermissionState.AUTHORIZED) {
locationManager.refreshChannels()
// Ensure cleanup when the composable is destroyed (e.g. removed from parent composition)
DisposableEffect(Unit) {
onDispose {
viewModel.endGeohashSampling()
}
}
}
@@ -707,7 +665,16 @@ private fun meshCount(viewModel: ChatViewModel): Int {
@Composable
private fun geohashTitleWithCount(channel: GeohashChannel, participantCount: Int): String {
val ctx = androidx.compose.ui.platform.LocalContext.current
val peopleText = ctx.resources.getQuantityString(com.bitchat.android.R.plurals.people_count, participantCount, participantCount)
// For high precision channels (Neighborhood, Block) where we don't broadcast presence,
// show "? people" instead of "0 people" to avoid misleading "nobody is here" indication.
val isHighPrecision = channel.level.precision > 5
val peopleText = if (isHighPrecision && participantCount == 0) {
ctx.resources.getQuantityString(com.bitchat.android.R.plurals.people_count, 0, 0).replace("0", "?")
} else {
ctx.resources.getQuantityString(com.bitchat.android.R.plurals.people_count, participantCount, participantCount)
}
val levelName = when (channel.level) {
com.bitchat.android.geohash.GeohashChannelLevel.BUILDING -> "Building" // iOS: precision 8 for location notes
com.bitchat.android.geohash.GeohashChannelLevel.BLOCK -> stringResource(com.bitchat.android.R.string.location_level_block)
@@ -722,7 +689,15 @@ private fun geohashTitleWithCount(channel: GeohashChannel, participantCount: Int
@Composable
private fun geohashHashTitleWithCount(geohash: String, participantCount: Int): String {
val ctx = androidx.compose.ui.platform.LocalContext.current
val peopleText = ctx.resources.getQuantityString(com.bitchat.android.R.plurals.people_count, participantCount, participantCount)
val level = levelForLength(geohash.length)
val isHighPrecision = level.precision > 5
val peopleText = if (isHighPrecision && participantCount == 0) {
ctx.resources.getQuantityString(com.bitchat.android.R.plurals.people_count, 0, 0).replace("0", "?")
} else {
ctx.resources.getQuantityString(com.bitchat.android.R.plurals.people_count, participantCount, participantCount)
}
return "#$geohash [$peopleText]"
}
@@ -38,7 +38,7 @@ fun LocationNotesButton(
val selectedLocationChannel by viewModel.selectedLocationChannel.collectAsStateWithLifecycle()
val locationManager = remember { LocationChannelManager.getInstance(context) }
val permissionState by locationManager.permissionState.collectAsStateWithLifecycle()
val locationServicesEnabled by locationManager.locationServicesEnabled.collectAsStateWithLifecycle(false)
val locationServicesEnabled by locationManager.effectiveLocationEnabled.collectAsStateWithLifecycle(false)
// Check both permission AND location services enabled
val locationPermissionGranted = permissionState == LocationChannelManager.PermissionState.AUTHORIZED
@@ -1,5 +1,6 @@
package com.bitchat.android.ui
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.isSystemInDarkTheme
@@ -12,9 +13,9 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowUpward
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontFamily
@@ -25,6 +26,9 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.bitchat.android.core.ui.component.sheet.BitchatBottomSheet
import com.bitchat.android.core.ui.component.sheet.BitchatSheetTopBar
import com.bitchat.android.core.ui.component.sheet.BitchatSheetTitle
import com.bitchat.android.geohash.GeohashChannelLevel
import com.bitchat.android.geohash.LocationChannelManager
import com.bitchat.android.nostr.LocationNotesManager
@@ -49,7 +53,6 @@ fun LocationNotesSheet(
val isDark = isSystemInDarkTheme()
// iOS color scheme
val backgroundColor = if (isDark) Color.Black else Color.White
val accentGreen = if (isDark) Color.Green else Color(0xFF008000) // dark: green, light: dark green (0, 0.5, 0)
// Managers
@@ -76,6 +79,20 @@ fun LocationNotesSheet(
// Scroll state
val listState = rememberLazyListState()
val isScrolled by remember {
derivedStateOf {
listState.firstVisibleItemIndex > 0 || listState.firstVisibleItemScrollOffset > 0
}
}
val topBarAlpha by animateFloatAsState(
targetValue = if (isScrolled) 0.95f else 0f,
label = "topBarAlpha"
)
// Refresh location when sheet opens
LaunchedEffect(Unit) {
locationManager.refreshChannels()
}
// Effect to set geohash when sheet opens
LaunchedEffect(geohash) {
@@ -89,167 +106,132 @@ fun LocationNotesSheet(
}
}
ModalBottomSheet(
BitchatBottomSheet(
onDismissRequest = onDismiss,
modifier = modifier,
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
containerColor = backgroundColor,
contentColor = if (isDark) Color.White else Color.Black
) {
Column(
modifier = Modifier
.fillMaxWidth()
.fillMaxHeight(0.9f)
) {
// Header section (matches iOS headerSection)
LocationNotesHeader(
geohash = geohash,
count = count,
locationName = displayLocationName,
state = state,
accentGreen = accentGreen,
backgroundColor = backgroundColor,
onClose = onDismiss
)
// ScrollView with notes content
Box(
modifier = Modifier
.weight(1f)
.fillMaxWidth()
.background(backgroundColor)
Box(modifier = Modifier.fillMaxWidth()) {
LazyColumn(
state = listState,
modifier = Modifier.fillMaxSize().padding(horizontal = 16.dp),
contentPadding = PaddingValues(top = 64.dp, bottom = 20.dp)
) {
LazyColumn(
state = listState,
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp)
) {
// Notes content (matches iOS notesContent)
when {
state == LocationNotesManager.State.NO_RELAYS -> {
item {
NoRelaysRow(
onRetry = { notesManager.refresh() }
)
}
}
state == LocationNotesManager.State.LOADING && !initialLoadComplete -> {
item {
LoadingRow()
}
}
notes.isEmpty() -> {
item {
EmptyRow()
}
}
else -> {
items(notes, key = { it.id }) { note ->
NoteRow(note = note)
Spacer(modifier = Modifier.height(12.dp))
}
item(key = "notes_header") {
LocationNotesHeader(
locationName = displayLocationName,
state = state,
accentGreen = accentGreen,
)
}
// Notes content (matches iOS notesContent)
when {
state == LocationNotesManager.State.NO_RELAYS -> {
item {
NoRelaysRow(
onRetry = { notesManager.refresh() }
)
}
}
state == LocationNotesManager.State.LOADING && !initialLoadComplete -> {
item {
LoadingRow()
}
}
notes.isEmpty() -> {
item {
EmptyRow()
}
}
else -> {
items(notes, key = { it.id }) { note ->
NoteRow(note = note)
Spacer(modifier = Modifier.height(24.dp))
}
item {
Spacer(modifier = Modifier.height(24.dp))
}
}
}
// Error row (matches iOS errorRow)
errorMessage?.let { error ->
if (state != LocationNotesManager.State.NO_RELAYS) {
item {
ErrorRow(
message = error,
onDismiss = { notesManager.clearError() }
)
}
// Error row (matches iOS errorRow)
errorMessage?.let { error ->
if (state != LocationNotesManager.State.NO_RELAYS) {
item {
ErrorRow(
message = error,
onDismiss = { notesManager.clearError() }
)
}
}
}
}
// Divider before input (matches iOS overlay)
HorizontalDivider(
modifier = Modifier.fillMaxWidth(),
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.2f),
thickness = 1.dp
)
// Input section (matches iOS inputSection)
LocationNotesInputSection(
draft = draft,
onDraftChange = { draft = it },
sendButtonEnabled = sendButtonEnabled,
accentGreen = accentGreen,
backgroundColor = backgroundColor,
onSend = {
val content = draft.trim()
if (content.isNotEmpty()) {
notesManager.send(content, nickname)
draft = ""
}
// TopBar (animated)
BitchatSheetTopBar(
onClose = onDismiss,
modifier = Modifier.align(Alignment.TopCenter),
title = {
BitchatSheetTitle(
text = pluralStringResource(
id = R.plurals.location_notes_title,
count = count,
geohash,
count
)
)
}
)
Box(
modifier = Modifier
.align(Alignment.BottomCenter)
.fillMaxWidth()
){
Column {
// Divider before input (matches iOS overlay)
HorizontalDivider(
modifier = Modifier.fillMaxWidth(),
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.2f),
thickness = 1.dp
)
// Input section (matches iOS inputSection)
LocationNotesInputSection(
draft = draft,
onDraftChange = { draft = it },
sendButtonEnabled = sendButtonEnabled,
accentGreen = accentGreen,
onSend = {
val content = draft.trim()
if (content.isNotEmpty()) {
notesManager.send(content, nickname)
draft = ""
}
}
)
}
}
}
}
}
/**
* Header section - matches iOS headerSection exactly
* Shows: "#geohash • X notes", location name, description, and close button
* Shows: "#geohash • X notes", location name, description
*/
@Composable
private fun LocationNotesHeader(
geohash: String,
count: Int,
locationName: String?,
state: LocationNotesManager.State,
accentGreen: Color,
backgroundColor: Color,
onClose: () -> Unit
) {
Column(
modifier = Modifier
.fillMaxWidth()
.background(backgroundColor)
.padding(horizontal = 16.dp)
.padding(top = 16.dp, bottom = 12.dp)
.padding(bottom = 12.dp)
) {
// Title row with close button
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
// Localized title with ±1 and note count
Text(
text = pluralStringResource(
id = R.plurals.location_notes_title,
count = count,
geohash,
count
),
fontFamily = FontFamily.Monospace,
fontSize = 18.sp,
color = MaterialTheme.colorScheme.onSurface
)
// Close button - iOS style with xmark icon
Box(
modifier = Modifier
.size(32.dp)
.clickable(onClick = onClose),
contentAlignment = Alignment.Center
) {
Text(
text = "",
fontFamily = FontFamily.Monospace,
fontSize = 13.sp,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onSurface
)
}
}
Spacer(modifier = Modifier.height(8.dp))
// Location name in green (building or block)
locationName?.let { name ->
if (name.isNotEmpty()) {
@@ -469,7 +451,6 @@ private fun LocationNotesInputSection(
onDraftChange: (String) -> Unit,
sendButtonEnabled: Boolean,
accentGreen: Color,
backgroundColor: Color,
onSend: () -> Unit
) {
val isDark = isSystemInDarkTheme()
@@ -478,7 +459,7 @@ private fun LocationNotesInputSection(
Row(
modifier = Modifier
.fillMaxWidth()
.background(backgroundColor)
.background(color = colorScheme.background)
.padding(horizontal = 12.dp, vertical = 8.dp), // Match main chat padding
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp) // Match main chat spacing
@@ -9,9 +9,14 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.compose.ui.res.stringResource
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.bitchat.android.core.ui.component.sheet.BitchatBottomSheet
import com.bitchat.android.core.ui.component.sheet.BitchatSheetTopBar
import com.bitchat.android.core.ui.component.sheet.BitchatSheetTitle
import com.bitchat.android.geohash.GeohashChannelLevel
import com.bitchat.android.geohash.LocationChannelManager
import com.bitchat.android.R
/**
* Presenter component for LocationNotesSheet
@@ -27,6 +32,8 @@ fun LocationNotesSheetPresenter(
val context = LocalContext.current
val locationManager = remember { LocationChannelManager.getInstance(context) }
val availableChannels by locationManager.availableChannels.collectAsStateWithLifecycle()
val permissionState by locationManager.permissionState.collectAsStateWithLifecycle()
val isLoadingLocation by locationManager.isLoadingLocation.collectAsStateWithLifecycle()
val nickname by viewModel.nickname.collectAsStateWithLifecycle()
// iOS pattern: notesGeohash ?? LocationChannelManager.shared.availableChannels.first(where: { $0.level == .building })?.geohash
@@ -44,6 +51,8 @@ fun LocationNotesSheetPresenter(
nickname = nickname,
onDismiss = onDismiss
)
} else if (permissionState == LocationChannelManager.PermissionState.AUTHORIZED && isLoadingLocation) {
LocationNotesAcquiringSheet(onDismiss = onDismiss)
} else {
// No building geohash available - show error state (matches iOS)
LocationNotesErrorSheet(
@@ -53,6 +62,43 @@ fun LocationNotesSheetPresenter(
}
}
/**
* Loading sheet when location is being acquired
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun LocationNotesAcquiringSheet(
onDismiss: () -> Unit
) {
BitchatBottomSheet(
onDismissRequest = onDismiss,
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = "Acquiring Location",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface
)
Spacer(modifier = Modifier.height(24.dp))
CircularProgressIndicator(
modifier = Modifier.size(48.dp),
color = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.height(24.dp))
Text(
text = "Please wait while your location is being determined",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
/**
* Error sheet when location is unavailable
*/
@@ -62,39 +108,50 @@ private fun LocationNotesErrorSheet(
onDismiss: () -> Unit,
locationManager: LocationChannelManager
) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
ModalBottomSheet(
BitchatBottomSheet(
onDismissRequest = onDismiss,
sheetState = sheetState,
containerColor = MaterialTheme.colorScheme.surface
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = "Location Unavailable",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "Location permission is required for notes",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(24.dp))
Button(onClick = {
// UNIFIED FIX: Enable location services first (user toggle)
locationManager.enableLocationServices()
// Then request location channels (which will also request permission if needed)
locationManager.enableLocationChannels()
locationManager.refreshChannels()
}) {
Text("Enable Location")
Box(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp)
.padding(top = 80.dp, bottom = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Text(
text = "Location Unavailable",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "Location permission is required for notes",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(24.dp))
Button(onClick = {
// UNIFIED FIX: Enable location services first (user toggle)
locationManager.enableLocationServices()
// Then request location channels (which will also request permission if needed)
locationManager.enableLocationChannels()
locationManager.refreshChannels()
}) {
Text("Enable Location")
}
}
BitchatSheetTopBar(
onClose = onDismiss,
modifier = Modifier.align(Alignment.TopCenter),
title = {
BitchatSheetTitle(
text = stringResource(R.string.cd_location_notes).uppercase()
)
}
)
}
}
}
@@ -16,8 +16,11 @@ class MediaSendingManager(
private val state: ChatState,
private val messageManager: MessageManager,
private val channelManager: ChannelManager,
private val meshService: BluetoothMeshService
private val getMeshService: () -> BluetoothMeshService
) {
// Helper to get current mesh service (may change after panic clear)
private val meshService: BluetoothMeshService
get() = getMeshService()
companion object {
private const val TAG = "MediaSendingManager"
private const val MAX_FILE_SIZE = com.bitchat.android.util.AppConstants.Media.MAX_FILE_SIZE_BYTES // 50MB limit
@@ -239,6 +239,14 @@ class MeshDelegateHandler(
}
}
override fun didReceiveVerifyChallenge(peerID: String, payload: ByteArray, timestampMs: Long) {
// Handled by ChatViewModel for verification flow
}
override fun didReceiveVerifyResponse(peerID: String, payload: ByteArray, timestampMs: Long) {
// Handled by ChatViewModel for verification flow
}
override fun decryptChannelMessage(encryptedContent: ByteArray, channel: String): String? {
return channelManager.decryptChannelMessage(encryptedContent, channel)
}
@@ -0,0 +1,992 @@
package com.bitchat.android.ui
import com.bitchat.android.R
import android.util.Log
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.*
import androidx.compose.material.icons.outlined.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.text.style.TextOverflow
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.bitchat.android.core.ui.component.button.CloseButton
import com.bitchat.android.core.ui.component.sheet.BitchatBottomSheet
import com.bitchat.android.core.ui.component.sheet.BitchatSheetCenterTopBar
import com.bitchat.android.core.ui.component.sheet.BitchatSheetTitle
import com.bitchat.android.core.ui.component.sheet.BitchatSheetTopBar
import com.bitchat.android.geohash.ChannelID
import com.bitchat.android.ui.theme.BASE_FONT_SIZE
import com.bitchat.android.nostr.GeohashAliasRegistry
import com.bitchat.android.nostr.GeohashConversationRegistry
/**
* Sheet components for ChatScreen
* Extracted from ChatScreen.kt for better organization
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MeshPeerListSheet(
isPresented: Boolean,
viewModel: ChatViewModel,
onDismiss: () -> Unit,
onShowVerification: () -> Unit,
modifier: Modifier = Modifier
) {
val colorScheme = MaterialTheme.colorScheme
val connectedPeers by viewModel.connectedPeers.collectAsStateWithLifecycle()
val joinedChannels by viewModel.joinedChannels.collectAsStateWithLifecycle()
val currentChannel by viewModel.currentChannel.collectAsStateWithLifecycle()
val selectedPrivatePeer by viewModel.selectedPrivateChatPeer.collectAsStateWithLifecycle()
val nickname by viewModel.nickname.collectAsStateWithLifecycle()
val unreadChannelMessages by viewModel.unreadChannelMessages.collectAsStateWithLifecycle()
val peerNicknames by viewModel.peerNicknames.collectAsStateWithLifecycle()
val peerRSSI by viewModel.peerRSSI.collectAsStateWithLifecycle()
val selectedLocationChannel by viewModel.selectedLocationChannel.collectAsStateWithLifecycle()
// Bottom sheet state
val sheetState = rememberModalBottomSheetState(
skipPartiallyExpanded = true
)
// Scroll state for animated top bar
val listState = rememberLazyListState()
val isScrolled by remember {
derivedStateOf {
listState.firstVisibleItemIndex > 0 || listState.firstVisibleItemScrollOffset > 0
}
}
val topBarAlpha by animateFloatAsState(
targetValue = if (isScrolled) 0.95f else 0f,
label = "topBarAlpha"
)
if (isPresented) {
BitchatBottomSheet(
modifier = modifier,
onDismissRequest = onDismiss,
sheetState = sheetState,
) {
Box(modifier = Modifier.fillMaxWidth()) {
LazyColumn(
state = listState,
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(top = 64.dp, bottom = 20.dp)
) {
// Channels section
if (joinedChannels.isNotEmpty()) {
item(key = "channels_header") {
Text(
text = stringResource(id = R.string.channels).uppercase(),
style = MaterialTheme.typography.labelLarge,
color = colorScheme.onSurface.copy(alpha = 0.7f),
fontWeight = FontWeight.Bold,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp)
.padding(top = 8.dp, bottom = 4.dp)
)
}
items(
items = joinedChannels.toList(),
key = { "channel_$it" }
) { channel ->
val isSelected = channel == currentChannel
val unreadCount = unreadChannelMessages[channel] ?: 0
ChannelRow(
channel = channel,
isSelected = isSelected,
unreadCount = unreadCount,
colorScheme = colorScheme,
onChannelClick = {
// Check if this is a DM channel (starts with @)
if (channel.startsWith("@")) {
// Extract peer name and find the peer ID
val peerName = channel.removePrefix("@")
val peerID =
peerNicknames.entries.firstOrNull { it.value == peerName }?.key
if (peerID != null) {
viewModel.showPrivateChatSheet(peerID)
onDismiss()
}
} else {
// Regular channel switch
viewModel.switchToChannel(channel)
onDismiss()
}
},
onLeaveChannel = {
viewModel.leaveChannel(channel)
},
)
}
}
// People section - switch between mesh and geohash lists (iOS-compatible)
item(key = "people_section") {
when (selectedLocationChannel) {
is ChannelID.Location -> {
// Show geohash people list when in location channel
GeohashPeopleList(
viewModel = viewModel,
onTapPerson = onDismiss
)
}
else -> {
// Show mesh peer list when in mesh channel (default)
PeopleSection(
modifier = Modifier.padding(top = if (joinedChannels.isNotEmpty()) 16.dp else 0.dp),
connectedPeers = connectedPeers,
peerNicknames = peerNicknames,
peerRSSI = peerRSSI,
nickname = nickname,
colorScheme = colorScheme,
selectedPrivatePeer = selectedPrivatePeer,
viewModel = viewModel,
onPrivateChatStart = { peerID ->
viewModel.showPrivateChatSheet(peerID)
onDismiss()
}
)
}
}
}
}
// TopBar (animated)
BitchatSheetTopBar(
title = {
BitchatSheetTitle(text = stringResource(id = R.string.your_network))
},
backgroundAlpha = topBarAlpha,
actions = {
if (selectedLocationChannel !is ChannelID.Location) {
IconButton(
onClick = onShowVerification,
modifier = Modifier.size(24.dp)
) {
Icon(
imageVector = Icons.Outlined.QrCode,
contentDescription = stringResource(R.string.verify_title),
tint = colorScheme.onSurface.copy(alpha = 0.8f),
modifier = Modifier.size(18.dp)
)
}
}
},
onClose = onDismiss,
)
}
}
}
}
@Composable
private fun ChannelRow(
channel: String,
isSelected: Boolean,
unreadCount: Int,
colorScheme: ColorScheme,
onChannelClick: () -> Unit,
onLeaveChannel: () -> Unit,
) {
Surface(
onClick = onChannelClick,
color = if (isSelected) {
colorScheme.primaryContainer.copy(alpha = 0.15f)
} else {
Color.Transparent
},
shape = MaterialTheme.shapes.medium,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp, vertical = 2.dp)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 12.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Row(
modifier = Modifier.weight(1f),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
// Unread badge
if (unreadCount > 0) {
UnreadBadge(
count = unreadCount,
colorScheme = colorScheme
)
}
Text(
text = channel,
style = MaterialTheme.typography.bodyMedium.copy(
fontFamily = FontFamily.Monospace,
fontSize = BASE_FONT_SIZE.sp
),
color = if (isSelected) colorScheme.primary else colorScheme.onSurface,
fontWeight = if (isSelected) FontWeight.Medium else FontWeight.Normal
)
}
Row(
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically
) {
// Leave channel button
CloseButton(
onClick = onLeaveChannel,
)
}
}
}
}
@Composable
fun PeopleSection(
modifier: Modifier = Modifier,
connectedPeers: List<String>,
peerNicknames: Map<String, String>,
peerRSSI: Map<String, Int>,
nickname: String,
colorScheme: ColorScheme,
selectedPrivatePeer: String?,
viewModel: ChatViewModel,
onPrivateChatStart: (String) -> Unit
) {
Column(modifier = modifier) {
Text(
text = stringResource(id = R.string.people).uppercase(),
style = MaterialTheme.typography.labelLarge,
color = colorScheme.onSurface.copy(alpha = 0.7f),
fontWeight = FontWeight.Bold,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp)
.padding(top = 8.dp, bottom = 4.dp)
)
if (connectedPeers.isEmpty()) {
Text(
text = stringResource(id = R.string.no_one_connected),
style = MaterialTheme.typography.bodyMedium.copy(
fontFamily = FontFamily.Monospace,
fontSize = 12.sp
),
color = colorScheme.onSurface.copy(alpha = 0.5f),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 40.dp, vertical = 12.dp)
)
}
// Observe reactive state for favorites and fingerprints
val hasUnreadPrivateMessages by viewModel.unreadPrivateMessages.collectAsStateWithLifecycle()
val privateChats by viewModel.privateChats.collectAsStateWithLifecycle()
val favoritePeers by viewModel.favoritePeers.collectAsStateWithLifecycle()
val peerFingerprints by viewModel.peerFingerprints.collectAsStateWithLifecycle()
val verifiedFingerprints by viewModel.verifiedFingerprints.collectAsStateWithLifecycle()
// Reactive favorite computation for all peers
val peerFavoriteStates = remember(favoritePeers, peerFingerprints, connectedPeers) {
connectedPeers.associateWith { peerID ->
// Reactive favorite computation - same as ChatHeader
val fingerprint = peerFingerprints[peerID]
fingerprint != null && favoritePeers.contains(fingerprint)
}
}
val peerVerifiedStates = remember(verifiedFingerprints, peerFingerprints, connectedPeers) {
connectedPeers.associateWith { peerID ->
viewModel.isPeerVerified(peerID, verifiedFingerprints)
}
}
// Build mapping of connected peerID -> noise key hex to unify with offline favorites
val noiseHexByPeerID: Map<String, String> = connectedPeers.associateWith { pid ->
try {
viewModel.meshService.getPeerInfo(pid)?.noisePublicKey?.joinToString("") { b -> "%02x".format(b) }
} catch (_: Exception) { null }
}.filterValues { it != null }.mapValues { it.value!! }
Log.d("SidebarComponents", "Recomposing with ${favoritePeers.size} favorites, peer states: $peerFavoriteStates")
// Smart sorting: unread DMs first, then by most recent DM, then favorites, then alphabetical
val sortedPeers = connectedPeers.sortedWith(
compareBy<String> { !hasUnreadPrivateMessages.contains(it) } // Unread DM senders first
.thenByDescending { privateChats[it]?.maxByOrNull { msg -> msg.timestamp }?.timestamp?.time ?: 0L } // Most recent DM (convert Date to Long)
.thenBy { !(peerFavoriteStates[it] ?: false) } // Favorites first
.thenBy { (if (it == nickname) "You" else (peerNicknames[it] ?: it)).lowercase() } // Alphabetical
)
// Build a map of base name counts across all people shown in the list (connected + offline + nostr)
val hex64Regex = Regex("^[0-9a-fA-F]{64}$")
// Helper to compute display name used for a given key
fun computeDisplayNameForPeerId(key: String): String {
return if (key == nickname) "You" else (peerNicknames[key] ?: (privateChats[key]?.lastOrNull()?.sender ?: key.take(12)))
}
val baseNameCounts = mutableMapOf<String, Int>()
// Connected peers
sortedPeers.forEach { pid ->
val dn = computeDisplayNameForPeerId(pid)
val (b, _) = splitSuffix(dn)
if (b != "You") baseNameCounts[b] = (baseNameCounts[b] ?: 0) + 1
}
// Offline favorites (exclude ones mapped to connected)
val offlineFavorites = com.bitchat.android.favorites.FavoritesPersistenceService.shared.getOurFavorites()
offlineFavorites.forEach { fav ->
val favPeerID = fav.peerNoisePublicKey.joinToString("") { b -> "%02x".format(b) }
val isMappedToConnected = noiseHexByPeerID.values.any { it.equals(favPeerID, ignoreCase = true) }
if (!isMappedToConnected) {
val dn = peerNicknames[favPeerID] ?: fav.peerNickname
val (b, _) = splitSuffix(dn)
if (b != "You") baseNameCounts[b] = (baseNameCounts[b] ?: 0) + 1
}
}
// Nostr-only conversations
val connectedIds = sortedPeers.toSet()
val appendedOfflineIds = mutableSetOf<String>()
privateChats.keys
.filter { key ->
(key.startsWith("nostr_") || hex64Regex.matches(key)) &&
!connectedIds.contains(key) &&
!noiseHexByPeerID.values.any { it.equals(key, ignoreCase = true) }
}
.forEach { convKey ->
val dn = peerNicknames[convKey] ?: (privateChats[convKey]?.lastOrNull()?.sender ?: convKey.take(12))
val (b, _) = splitSuffix(dn)
if (b != "You") baseNameCounts[b] = (baseNameCounts[b] ?: 0) + 1
}
sortedPeers.forEach { peerID ->
val isFavorite = peerFavoriteStates[peerID] ?: false
val isVerified = peerVerifiedStates[peerID] ?: false
// fingerprint and favorite relationship resolution not needed here; UI will show Nostr globe for appended offline favorites below
val noiseHex = noiseHexByPeerID[peerID]
val meshUnread = hasUnreadPrivateMessages.contains(peerID)
val nostrUnread = if (noiseHex != null) hasUnreadPrivateMessages.contains(noiseHex) else false
val combinedHasUnread = meshUnread || nostrUnread
val combinedUnreadCount = (
privateChats[peerID]?.count { msg -> msg.sender != nickname && meshUnread } ?: 0
) + (
if (noiseHex != null) privateChats[noiseHex]?.count { msg -> msg.sender != nickname && nostrUnread } ?: 0 else 0
)
val displayName = if (peerID == nickname) "You" else (peerNicknames[peerID] ?: (privateChats[peerID]?.lastOrNull()?.sender ?: peerID.take(12)))
val (bName, _) = splitSuffix(displayName)
val showHash = (baseNameCounts[bName] ?: 0) > 1
val directMap by viewModel.peerDirect.collectAsStateWithLifecycle()
val isDirectLive = directMap[peerID] ?: try { viewModel.meshService.getPeerInfo(peerID)?.isDirectConnection == true } catch (_: Exception) { false }
PeerItem(
peerID = peerID,
displayName = displayName,
isDirect = isDirectLive,
isSelected = peerID == selectedPrivatePeer,
isFavorite = isFavorite,
isVerified = isVerified,
hasUnreadDM = combinedHasUnread,
colorScheme = colorScheme,
viewModel = viewModel,
onItemClick = { onPrivateChatStart(peerID) },
onToggleFavorite = {
Log.d("SidebarComponents", "Sidebar toggle favorite: peerID=$peerID, currentFavorite=$isFavorite")
viewModel.toggleFavorite(peerID)
},
unreadCount = if (combinedUnreadCount > 0) combinedUnreadCount else if (combinedHasUnread) 1 else 0,
showNostrGlobe = false,
showHashSuffix = showHash
)
}
// Append offline favorites we actively favorite (and not currently connected)
offlineFavorites.forEach { fav ->
val favPeerID = fav.peerNoisePublicKey.joinToString("") { b -> "%02x".format(b) }
// If any connected peer maps to this noise key, skip showing the offline entry
val isMappedToConnected = noiseHexByPeerID.values.any { it.equals(favPeerID, ignoreCase = true) }
if (isMappedToConnected) return@forEach
// Resolve potential Nostr conversation key for this favorite (for unread detection)
val nostrConvKey: String? = try {
val npubOrHex = com.bitchat.android.favorites.FavoritesPersistenceService.shared.findNostrPubkey(fav.peerNoisePublicKey)
if (npubOrHex != null) {
val hex = if (npubOrHex.startsWith("npub")) {
val (hrp, data) = com.bitchat.android.nostr.Bech32.decode(npubOrHex)
if (hrp == "npub") data.joinToString("") { "%02x".format(it) } else null
} else {
npubOrHex.lowercase()
}
hex?.let { "nostr_${it.take(16)}" }
} else null
} catch (_: Exception) { null }
val hasUnread = hasUnreadPrivateMessages.contains(favPeerID) || (nostrConvKey != null && hasUnreadPrivateMessages.contains(nostrConvKey))
// If user clicks an offline favorite and the mapped peer is currently connected under a different ID,
// open chat with the connected peerID instead of the noise hex for a seamless window
val mappedConnectedPeerID = noiseHexByPeerID.entries.firstOrNull { it.value.equals(favPeerID, ignoreCase = true) }?.key
val dn = peerNicknames[favPeerID] ?: fav.peerNickname
val (bName, _) = splitSuffix(dn)
val showHash = (baseNameCounts[bName] ?: 0) > 1
val isVerified = viewModel.isNoisePublicKeyVerified(fav.peerNoisePublicKey, verifiedFingerprints)
// Compute unreadCount from either noise conversation or Nostr conversation
val unreadCount = (
privateChats[favPeerID]?.count { msg -> msg.sender != nickname && hasUnreadPrivateMessages.contains(favPeerID) } ?: 0
) + (
if (nostrConvKey != null) privateChats[nostrConvKey]?.count { msg -> msg.sender != nickname && hasUnreadPrivateMessages.contains(nostrConvKey) } ?: 0 else 0
)
PeerItem(
peerID = favPeerID,
displayName = dn,
isDirect = false,
isSelected = (mappedConnectedPeerID ?: favPeerID) == selectedPrivatePeer,
isFavorite = true,
isVerified = isVerified,
hasUnreadDM = hasUnread,
colorScheme = colorScheme,
viewModel = viewModel,
onItemClick = { onPrivateChatStart(mappedConnectedPeerID ?: favPeerID) },
onToggleFavorite = {
Log.d("SidebarComponents", "Sidebar toggle favorite (offline): peerID=$favPeerID")
viewModel.toggleFavorite(favPeerID)
},
unreadCount = if (unreadCount > 0) unreadCount else if (hasUnread) 1 else 0,
showNostrGlobe = (fav.isMutual && fav.peerNostrPublicKey != null),
showHashSuffix = showHash
)
appendedOfflineIds.add(favPeerID)
}
// NOTE: Do NOT append Nostr-only (nostr_*) conversations to the mesh people list.
// Geohash DMs should appear in the GeohashPeople list for the active geohash, not in mesh offline contacts.
// We intentionally remove previously-added behavior that mixed geohash DMs into mesh sidebar.
// If you need to surface non-geohash offline mesh conversations in the future, do it here for 64-hex noise IDs only.
/*
val alreadyShownIds = connectedIds + appendedOfflineIds
privateChats.keys
.filter { key ->
// Only include 64-hex noise IDs (mesh identities); exclude any nostr_* aliases
hex64Regex.matches(key) &&
!alreadyShownIds.contains(key) &&
// Skip if this key maps to a connected peer via noiseHex mapping
!noiseHexByPeerID.values.any { it.equals(key, ignoreCase = true) }
}
.sortedBy { key -> privateChats[key]?.lastOrNull()?.timestamp }
.forEach { convKey ->
val lastSender = privateChats[convKey]?.lastOrNull()?.sender
val dn = peerNicknames[convKey] ?: (lastSender ?: convKey.take(12))
val (bName, _) = splitSuffix(dn)
val showHash = (baseNameCounts[bName] ?: 0) > 1
PeerItem(
peerID = convKey,
displayName = dn,
isDirect = false,
isSelected = convKey == selectedPrivatePeer,
isFavorite = false,
hasUnreadDM = hasUnreadPrivateMessages.contains(convKey),
colorScheme = colorScheme,
viewModel = viewModel,
onItemClick = { onPrivateChatStart(convKey) },
onToggleFavorite = { viewModel.toggleFavorite(convKey) },
unreadCount = privateChats[convKey]?.count { msg ->
msg.sender != nickname && hasUnreadPrivateMessages.contains(convKey)
} ?: if (hasUnreadPrivateMessages.contains(convKey)) 1 else 0,
showNostrGlobe = false,
showHashSuffix = showHash
)
}
*/
// End intentional removal
}
}
@Composable
private fun PeerItem(
peerID: String,
displayName: String,
isDirect: Boolean,
isSelected: Boolean,
isFavorite: Boolean,
isVerified: Boolean,
hasUnreadDM: Boolean,
colorScheme: ColorScheme,
viewModel: ChatViewModel,
onItemClick: () -> Unit,
onToggleFavorite: () -> Unit,
unreadCount: Int = 0,
showNostrGlobe: Boolean = false,
showHashSuffix: Boolean = true
) {
val currentNickname by viewModel.nickname.collectAsStateWithLifecycle()
// Split display name for hashtag suffix support (iOS-compatible)
val (baseNameRaw, suffixRaw) = splitSuffix(displayName)
val baseName = truncateNickname(baseNameRaw)
val suffix = if (showHashSuffix) suffixRaw else ""
val isMe = displayName == "You" || peerID == currentNickname
// Get consistent peer color (iOS-compatible)
val isDark = colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f
val assignedColor = viewModel.colorForMeshPeer(peerID, isDark)
val baseColor = if (isMe) Color(0xFFFF9500) else assignedColor
Surface(
onClick = onItemClick,
color = if (isSelected) {
colorScheme.primaryContainer.copy(alpha = 0.15f)
} else {
Color.Transparent
},
shape = MaterialTheme.shapes.medium,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp, vertical = 2.dp)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 12.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Row(
modifier = Modifier.weight(1f),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
// Connection/status indicator
if (hasUnreadDM) {
// Show mail icon for unread DMs (iOS orange)
Icon(
imageVector = Icons.Filled.Email,
contentDescription = stringResource(R.string.cd_unread_message),
modifier = Modifier.size(16.dp),
tint = Color(0xFFFF9500) // iOS orange
)
} else if (showNostrGlobe) {
// Purple globe to indicate Nostr availability
Icon(
imageVector = Icons.Filled.Public,
contentDescription = stringResource(R.string.cd_reachable_via_nostr),
modifier = Modifier.size(16.dp),
tint = Color(0xFF9C27B0) // Purple
)
} else if (!isDirect && isFavorite) {
// Offline favorited user: show outlined circle icon
Icon(
imageVector = Icons.Outlined.Circle,
contentDescription = stringResource(R.string.cd_offline_favorite),
modifier = Modifier.size(16.dp),
tint = Color.Gray
)
} else {
Icon(
imageVector = if (isDirect) Icons.Outlined.Bluetooth else Icons.Filled.Route,
contentDescription = if (isDirect) "Direct Bluetooth" else "Routed",
modifier = Modifier.size(16.dp),
tint = colorScheme.onSurface.copy(alpha = 0.6f)
)
}
// Display name with iOS-style color and hashtag suffix support
Row(verticalAlignment = Alignment.CenterVertically) {
// Base name with peer-specific color
Text(
text = baseName,
style = MaterialTheme.typography.bodyMedium.copy(
fontFamily = FontFamily.Monospace,
fontSize = BASE_FONT_SIZE.sp,
fontWeight = if (isMe) FontWeight.Bold else FontWeight.Normal
),
color = baseColor,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
// Hashtag suffix in lighter shade (iOS-style)
if (suffix.isNotEmpty()) {
Text(
text = suffix,
style = MaterialTheme.typography.bodyMedium.copy(
fontFamily = FontFamily.Monospace,
fontSize = BASE_FONT_SIZE.sp
),
color = baseColor.copy(alpha = 0.6f)
)
}
}
}
if (isVerified) {
Spacer(modifier = Modifier.width(4.dp))
Icon(
imageVector = Icons.Filled.Verified,
contentDescription = null,
modifier = Modifier.size(14.dp),
tint = Color(0xFF32D74B) // iOS Green
)
}
Row(
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically
) {
// Favorite star with proper filled/outlined states
IconButton(
onClick = onToggleFavorite,
modifier = Modifier.size(32.dp)
) {
Icon(
imageVector = if (isFavorite) Icons.Filled.Star else Icons.Outlined.Star,
contentDescription = if (isFavorite) "Remove from favorites" else "Add to favorites",
modifier = Modifier.size(16.dp),
tint = if (isFavorite) Color(0xFFFFD700) else Color(0xFF4CAF50)
)
}
}
}
}
}
/**
* Reusable unread badge component for both channels and private messages
*/
@Composable
private fun UnreadBadge(
count: Int,
colorScheme: ColorScheme,
modifier: Modifier = Modifier
) {
if (count > 0) {
Box(
modifier = modifier
.background(
color = Color(0xFFFFD700), // Yellow color
shape = RoundedCornerShape(10.dp)
)
.padding(horizontal = 6.dp, vertical = 2.dp)
.defaultMinSize(minWidth = 18.dp, minHeight = 18.dp),
contentAlignment = Alignment.Center
) {
Text(
text = if (count > 99) "99+" else count.toString(),
style = MaterialTheme.typography.labelSmall.copy(
fontSize = 10.sp,
fontWeight = FontWeight.Bold,
fontFamily = FontFamily.Monospace
),
color = Color.Black // Black text on yellow background
)
}
}
}
/**
* Convert RSSI value (dBm) to signal strength percentage (0-100)
* RSSI typically ranges from -30 (excellent) to -100 (very poor)
* Maps to 0-100 scale where:
* - 0-32: No signal (0 bars)
* - 33-65: Weak (1 bar)
* - 66-98: Good (2 bars)
* - 99-100: Excellent (3 bars)
*/
private fun convertRSSIToSignalStrength(rssi: Int?): Int {
if (rssi == null) return 0
return when {
rssi >= -40 -> 100 // Excellent signal
rssi >= -55 -> 85 // Very good signal
rssi >= -70 -> 70 // Good signal
rssi >= -85 -> 50 // Fair signal
rssi >= -100 -> 25 // Poor signal
else -> 0 // Very poor or no signal
}
}
/**
* Nested Private Chat Sheet - iOS-style nested bottom sheet
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PrivateChatSheet(
isPresented: Boolean,
peerID: String,
viewModel: ChatViewModel,
onDismiss: () -> Unit
) {
val colorScheme = MaterialTheme.colorScheme
val privateChats by viewModel.privateChats.collectAsStateWithLifecycle()
val peerNicknames by viewModel.peerNicknames.collectAsStateWithLifecycle()
val nickname by viewModel.nickname.collectAsStateWithLifecycle()
val connectedPeers by viewModel.connectedPeers.collectAsStateWithLifecycle()
val peerDirectMap by viewModel.peerDirect.collectAsStateWithLifecycle()
val peerSessionStates by viewModel.peerSessionStates.collectAsStateWithLifecycle()
val favoritePeers by viewModel.favoritePeers.collectAsStateWithLifecycle()
val peerFingerprints by viewModel.peerFingerprints.collectAsStateWithLifecycle()
val verifiedFingerprints by viewModel.verifiedFingerprints.collectAsStateWithLifecycle()
// Start private chat when screen opens
LaunchedEffect(peerID) {
viewModel.startPrivateChat(peerID)
}
val isNostrPeer = peerID.startsWith("nostr_") || peerID.startsWith("nostr:")
// Compute display name and title text reactively
val displayName = peerNicknames[peerID] ?: peerID.take(12)
val titleText = remember(peerID, peerNicknames) {
if (isNostrPeer) {
val gh = GeohashConversationRegistry.get(peerID) ?: "geohash"
val fullPubkey = GeohashAliasRegistry.get(peerID) ?: ""
val name = if (fullPubkey.isNotEmpty()) {
viewModel.geohashViewModel.displayNameForGeohashConversation(fullPubkey, gh)
} else {
peerNicknames[peerID] ?: "unknown"
}
"#$gh/@$name"
} else {
peerNicknames[peerID] ?: peerID.take(12)
}
}
val messages = privateChats[peerID] ?: emptyList()
val isDirect = peerDirectMap[peerID] == true
val isConnected = connectedPeers.contains(peerID) || isDirect
val sessionState = peerSessionStates[peerID]
val fingerprint = peerFingerprints[peerID]
val isFavorite = remember(favoritePeers, fingerprint) {
if (fingerprint != null) favoritePeers.contains(fingerprint) else viewModel.isFavorite(peerID)
}
val isVerified = remember(peerID, verifiedFingerprints) {
viewModel.isPeerVerified(peerID, verifiedFingerprints)
}
val securityModifier = if (!isNostrPeer) {
Modifier.clickable { viewModel.showSecurityVerificationSheet() }
} else {
Modifier
}
val sheetState = rememberModalBottomSheetState(
skipPartiallyExpanded = true
)
if (isPresented) {
BitchatBottomSheet(
onDismissRequest = onDismiss,
sheetState = sheetState,
) {
Box(modifier = Modifier.fillMaxSize()) {
Column(
modifier = Modifier.fillMaxSize()
) {
Spacer(modifier = Modifier.height(64.dp))
HorizontalDivider(color = colorScheme.outline.copy(alpha = 0.3f))
// Messages list
var forceScrollToBottom by remember { mutableStateOf(false) }
var isScrolledUp by remember { mutableStateOf(false) }
MessagesList(
messages = messages,
currentUserNickname = nickname,
meshService = viewModel.meshService,
modifier = Modifier.weight(1f),
forceScrollToBottom = forceScrollToBottom,
onScrolledUpChanged = { isUp -> isScrolledUp = isUp },
onNicknameClick = { /* handle mention */ },
onMessageLongPress = { /* handle long press */ },
onCancelTransfer = { msg -> viewModel.cancelMediaSend(msg.id) },
onImageClick = { _, _, _ -> /* handle image click */ }
)
HorizontalDivider(color = colorScheme.outline.copy(alpha = 0.3f))
// Input section
var messageText by remember {
mutableStateOf(
androidx.compose.ui.text.input.TextFieldValue(
""
)
)
}
ChatInputSection(
messageText = messageText,
onMessageTextChange = { newText ->
messageText = newText
viewModel.updateMentionSuggestions(newText.text)
},
onSend = {
if (messageText.text.trim().isNotEmpty()) {
viewModel.sendMessage(messageText.text.trim())
messageText = androidx.compose.ui.text.input.TextFieldValue("")
forceScrollToBottom = !forceScrollToBottom
}
},
onSendVoiceNote = { peer, channel, path ->
viewModel.sendVoiceNote(peer, channel, path)
},
onSendImageNote = { peer, channel, path ->
viewModel.sendImageNote(peer, channel, path)
},
onSendFileNote = { peer, channel, path ->
viewModel.sendFileNote(peer, channel, path)
},
showCommandSuggestions = false,
commandSuggestions = emptyList(),
showMentionSuggestions = false,
mentionSuggestions = emptyList(),
onCommandSuggestionClick = { },
onMentionSuggestionClick = { },
selectedPrivatePeer = peerID,
currentChannel = null,
nickname = nickname,
colorScheme = colorScheme,
showMediaButtons = true
)
}
// TopBar (fixed at top, iOS-style)
BitchatSheetCenterTopBar(
onClose = onDismiss,
modifier = Modifier.align(Alignment.TopCenter),
navigationIcon = {
IconButton(
onClick = onDismiss,
modifier = Modifier
.align(Alignment.CenterStart)
.padding(start = 16.dp)
.size(32.dp)
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(R.string.chat_back),
tint = colorScheme.onSurface
)
}
},
title = {
// Center content: connection status + name + encryption
Row(
modifier = Modifier.align(Alignment.Center),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp)
) {
when {
isDirect -> {
Icon(
imageVector = Icons.Outlined.SettingsInputAntenna,
contentDescription = stringResource(R.string.cd_connected_peers),
modifier = Modifier.size(14.dp),
tint = colorScheme.onSurface.copy(alpha = 0.6f)
)
}
isConnected -> {
Icon(
imageVector = Icons.Filled.Route,
contentDescription = stringResource(R.string.cd_ready_for_handshake),
modifier = Modifier.size(14.dp),
tint = colorScheme.onSurface.copy(alpha = 0.6f)
)
}
isNostrPeer -> {
Icon(
imageVector = Icons.Filled.Public,
contentDescription = stringResource(R.string.cd_nostr_reachable),
modifier = Modifier.size(14.dp),
tint = Color(0xFF9C27B0)
)
}
}
Text(
text = titleText,
style = MaterialTheme.typography.titleMedium.copy(
fontWeight = FontWeight.Bold,
fontFamily = FontFamily.Monospace
),
color = if (isNostrPeer) Color(0xFFFF9500) else colorScheme.onSurface
)
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.then(securityModifier)
) {
if (!isNostrPeer) {
NoiseSessionIcon(
sessionState = sessionState,
modifier = Modifier.size(14.dp)
)
}
if (isVerified) {
Spacer(modifier = Modifier.width(4.dp))
Icon(
imageVector = Icons.Filled.Verified,
contentDescription = stringResource(R.string.verify_title),
modifier = Modifier.size(14.dp),
tint = Color(0xFF32D74B) // iOS Green
)
}
}
IconButton(
onClick = { viewModel.toggleFavorite(peerID) },
modifier = Modifier.size(28.dp)
) {
Icon(
imageVector = if (isFavorite) Icons.Filled.Star else Icons.Outlined.Star,
contentDescription = if (isFavorite) stringResource(R.string.cd_remove_favorite) else stringResource(R.string.cd_add_favorite),
modifier = Modifier.size(16.dp),
tint = if (isFavorite) Color(0xFFFFD700) else colorScheme.onSurface.copy(alpha = 0.6f)
)
}
}
}
)
}
}
}
}
@@ -71,19 +71,14 @@ fun MessagesList(
// Track if this is the first time messages are being loaded
var hasScrolledToInitialPosition by remember { mutableStateOf(false) }
var followIncomingMessages by remember { mutableStateOf(true) }
// Smart scroll: auto-scroll to bottom for initial load, then only when user is at or near the bottom
// Smart scroll: auto-scroll to bottom for initial load, then follow unless user scrolls away
LaunchedEffect(messages.size) {
if (messages.isNotEmpty()) {
val layoutInfo = listState.layoutInfo
val firstVisibleIndex = layoutInfo.visibleItemsInfo.firstOrNull()?.index ?: -1
// With reverseLayout=true and reversed data, index 0 is the latest message at the bottom
val isFirstLoad = !hasScrolledToInitialPosition
val isNearLatest = firstVisibleIndex <= 2
if (isFirstLoad || isNearLatest) {
listState.animateScrollToItem(0)
if (isFirstLoad || followIncomingMessages) {
listState.scrollToItem(0)
if (isFirstLoad) {
hasScrolledToInitialPosition = true
}
@@ -99,6 +94,7 @@ fun MessagesList(
}
}
LaunchedEffect(isAtLatest) {
followIncomingMessages = isAtLatest
onScrolledUpChanged?.invoke(!isAtLatest)
}
@@ -106,7 +102,8 @@ fun MessagesList(
LaunchedEffect(forceScrollToBottom) {
if (messages.isNotEmpty()) {
// With reverseLayout=true and reversed data, latest is at index 0
listState.animateScrollToItem(0)
followIncomingMessages = true
listState.scrollToItem(0)
}
}
@@ -280,6 +280,37 @@ class NotificationManager(
Log.d(TAG, "Displayed notification for $contentTitle with ID $notificationId")
}
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
if (peerID != null) {
putExtra(EXTRA_OPEN_PRIVATE_CHAT, true)
putExtra(EXTRA_PEER_ID, peerID)
putExtra(EXTRA_SENDER_NICKNAME, body)
}
}
val pendingIntent = PendingIntent.getActivity(
context,
(System.currentTimeMillis() and 0x7FFFFFFF).toInt(),
intent,
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
)
val builder = NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle(title)
.setContentText(body)
.setContentIntent(pendingIntent)
.setAutoCancel(true)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setCategory(NotificationCompat.CATEGORY_STATUS)
.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 {
@@ -298,6 +298,16 @@ class PrivateChatManager(
if (!isPeerBlocked(senderPeerID)) {
// Ensure chat exists
messageManager.initializePrivateChat(senderPeerID)
// Exception: Nostr messages (nostr_ prefix) originate in Kotlin layer and MUST be added here.
if (senderPeerID.startsWith("nostr_")) {
if (suppressUnread) {
messageManager.addPrivateMessageNoUnread(senderPeerID, message)
} else {
messageManager.addPrivateMessage(senderPeerID, message)
}
}
// Track as unread for read receipt purposes if not focused
if (!suppressUnread && state.getSelectedPrivateChatPeerValue() != senderPeerID) {
val unreadList = unreadReceivedMessages.getOrPut(senderPeerID) { mutableListOf() }
@@ -474,6 +484,12 @@ class PrivateChatManager(
unread.add(targetPeerID)
state.setUnreadPrivateMessages(unread)
}
// If we're currently viewing one of the temp aliases in the sheet, switch to the permanent ID
val sheetPeer = state.getPrivateChatSheetPeerValue()
if (sheetPeer != null && tryMergeKeys.contains(sheetPeer)) {
state.setPrivateChatSheetPeer(targetPeerID)
}
}
}
@@ -0,0 +1,438 @@
package com.bitchat.android.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material.icons.filled.Verified
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material.icons.outlined.NoEncryption
import androidx.compose.material.icons.outlined.Sync
import androidx.compose.material.icons.outlined.Warning as OutlinedWarning
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.bitchat.android.R
import com.bitchat.android.core.ui.component.button.CloseButton
import com.bitchat.android.core.ui.component.sheet.BitchatBottomSheet
private data class SecurityStatusInfo(
val text: String,
val icon: ImageVector,
val tint: Color
)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SecurityVerificationSheet(
isPresented: Boolean,
onDismiss: () -> Unit,
viewModel: ChatViewModel,
modifier: Modifier = Modifier
) {
if (!isPresented) return
val peerID by viewModel.selectedPrivateChatPeer.collectAsStateWithLifecycle()
val verifiedFingerprints by viewModel.verifiedFingerprints.collectAsStateWithLifecycle()
val peerSessionStates by viewModel.peerSessionStates.collectAsStateWithLifecycle()
val isDark = isSystemInDarkTheme()
val accent = if (isDark) Color.Green else Color(0xFF008000)
val boxColor = if (isDark) Color.White.copy(alpha = 0.06f) else Color.Black.copy(alpha = 0.06f)
val peerHexRegex = remember { Regex("^[0-9a-fA-F]{16}$") }
BitchatBottomSheet(
modifier = modifier,
onDismissRequest = onDismiss,
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
SecurityVerificationHeader(
accent = accent,
onClose = onDismiss
)
if (peerID == null) {
Text(
text = stringResource(R.string.fingerprint_no_peer),
style = MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace),
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)
)
} else {
val selectedPeerID = peerID!!
val displayName = viewModel.resolvePeerDisplayNameForFingerprint(selectedPeerID)
val fingerprint = viewModel.getPeerFingerprintForDisplay(selectedPeerID)
val isVerified = fingerprint != null && verifiedFingerprints.contains(fingerprint)
val sessionState = peerSessionStates[selectedPeerID]
val statusInfo = buildStatusInfo(
isVerified = isVerified,
sessionState = sessionState,
accent = accent
)
SecurityStatusCard(
displayName = displayName,
accent = accent,
boxColor = boxColor,
statusInfo = statusInfo
)
FingerprintBlock(
title = stringResource(R.string.fingerprint_their),
fingerprint = fingerprint,
boxColor = boxColor,
accent = accent
)
FingerprintBlock(
title = stringResource(R.string.fingerprint_yours),
fingerprint = viewModel.getMyFingerprint(),
boxColor = boxColor,
accent = accent
)
SecurityVerificationActions(
isVerified = isVerified,
fingerprint = fingerprint,
displayName = displayName,
accent = accent,
canStartHandshake = fingerprint == null && selectedPeerID.matches(peerHexRegex),
onStartHandshake = { viewModel.meshService.initiateNoiseHandshake(selectedPeerID) },
onVerify = { fp -> viewModel.verifyFingerprintValue(fp) },
onUnverify = { fp -> viewModel.unverifyFingerprintValue(fp) }
)
}
}
}
}
@Composable
private fun SecurityVerificationHeader(
accent: Color,
onClose: () -> Unit
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = stringResource(R.string.security_verification_title),
style = MaterialTheme.typography.titleSmall.copy(
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold
),
color = accent
)
Spacer(modifier = Modifier.weight(1f))
CloseButton(onClick = onClose)
}
}
@Composable
private fun buildStatusInfo(
isVerified: Boolean,
sessionState: String?,
accent: Color
): SecurityStatusInfo {
val text = when {
isVerified -> stringResource(R.string.fingerprint_status_verified)
sessionState == "established" -> stringResource(R.string.fingerprint_status_encrypted)
sessionState == "handshaking" -> stringResource(R.string.fingerprint_status_handshaking)
sessionState == "failed" -> stringResource(R.string.fingerprint_status_failed)
else -> stringResource(R.string.fingerprint_status_uninitialized)
}
val icon = when {
isVerified -> Icons.Filled.Verified
sessionState == "handshaking" -> Icons.Outlined.Sync
sessionState == "failed" -> Icons.Outlined.OutlinedWarning
sessionState == "established" -> Icons.Filled.Lock
else -> Icons.Outlined.NoEncryption
}
val tint = when {
isVerified -> Color(0xFF32D74B)
sessionState == "failed" -> Color(0xFFFF3B30)
sessionState == "handshaking" -> Color(0xFFFF9500)
sessionState == "established" -> Color(0xFF32D74B)
else -> accent.copy(alpha = 0.6f)
}
return SecurityStatusInfo(text, icon, tint)
}
@Composable
private fun SecurityStatusCard(
displayName: String,
accent: Color,
boxColor: Color,
statusInfo: SecurityStatusInfo
) {
Row(
modifier = Modifier
.fillMaxWidth()
.background(boxColor, shape = MaterialTheme.shapes.medium)
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = statusInfo.icon,
contentDescription = null,
tint = statusInfo.tint
)
Spacer(modifier = Modifier.width(12.dp))
Column {
Text(
text = displayName,
style = MaterialTheme.typography.titleMedium.copy(
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold
),
color = accent
)
Text(
text = statusInfo.text,
style = MaterialTheme.typography.bodySmall.copy(
fontFamily = FontFamily.Monospace
),
color = accent.copy(alpha = 0.8f)
)
}
}
}
@Composable
private fun SecurityVerificationActions(
isVerified: Boolean,
fingerprint: String?,
displayName: String,
accent: Color,
canStartHandshake: Boolean,
onStartHandshake: () -> Unit,
onVerify: (String) -> Unit,
onUnverify: (String) -> Unit
) {
if (canStartHandshake) {
Button(
onClick = onStartHandshake,
colors = ButtonDefaults.buttonColors(
containerColor = accent,
contentColor = Color.White
),
modifier = Modifier.fillMaxWidth()
) {
Text(
text = stringResource(R.string.fingerprint_start_handshake),
fontFamily = FontFamily.Monospace,
fontSize = 12.sp
)
}
}
if (isVerified) {
VerificationStatusRow(
icon = Icons.Filled.Verified,
iconTint = Color(0xFF32D74B),
text = stringResource(R.string.fingerprint_verified_label),
textTint = Color(0xFF32D74B)
)
Text(
text = stringResource(R.string.fingerprint_verified_message),
style = MaterialTheme.typography.bodySmall.copy(
fontFamily = FontFamily.Monospace
),
color = accent.copy(alpha = 0.7f),
modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.Center
)
Button(
onClick = { fingerprint?.let(onUnverify) },
colors = ButtonDefaults.buttonColors(
containerColor = Color(0xFFFF3B30),
contentColor = Color.White
),
modifier = Modifier.fillMaxWidth()
) {
Text(
text = stringResource(R.string.verify_remove),
fontFamily = FontFamily.Monospace,
fontSize = 12.sp
)
}
} else {
VerificationStatusRow(
icon = Icons.Filled.Warning,
iconTint = Color(0xFFFF9500),
text = stringResource(R.string.fingerprint_not_verified_label),
textTint = Color(0xFFFF9500)
)
Text(
text = stringResource(R.string.fingerprint_not_verified_message_fmt, displayName),
style = MaterialTheme.typography.bodySmall.copy(
fontFamily = FontFamily.Monospace
),
color = accent.copy(alpha = 0.7f),
modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.Center
)
if (fingerprint != null) {
Button(
onClick = { onVerify(fingerprint) },
colors = ButtonDefaults.buttonColors(
containerColor = Color(0xFF34C759),
contentColor = Color.White
),
modifier = Modifier.fillMaxWidth()
) {
Text(
text = stringResource(R.string.fingerprint_mark_verified),
fontFamily = FontFamily.Monospace,
fontSize = 12.sp
)
}
}
}
}
@Composable
private fun VerificationStatusRow(
icon: ImageVector,
iconTint: Color,
text: String,
textTint: Color
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = icon,
contentDescription = null,
tint = iconTint
)
Spacer(modifier = Modifier.width(6.dp))
Text(
text = text,
style = MaterialTheme.typography.bodyMedium.copy(
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold
),
color = textTint
)
}
}
@Composable
private fun FingerprintBlock(
title: String,
fingerprint: String?,
boxColor: Color,
accent: Color
) {
val clipboardManager = LocalClipboardManager.current
var showMenu by remember(fingerprint) { mutableStateOf(false) }
val interactionSource = remember { MutableInteractionSource() }
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
text = title,
style = MaterialTheme.typography.labelSmall.copy(
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold
),
color = accent.copy(alpha = 0.8f)
)
if (fingerprint != null) {
Column {
Text(
text = formatFingerprint(fingerprint),
style = MaterialTheme.typography.bodyMedium.copy(
fontFamily = FontFamily.Monospace,
fontSize = 14.sp
),
color = accent,
textAlign = TextAlign.Center,
modifier = Modifier
.fillMaxWidth()
.combinedClickable(
interactionSource = interactionSource,
indication = null,
onClick = {},
onLongClick = { showMenu = true }
)
.background(boxColor, shape = MaterialTheme.shapes.small)
.padding(16.dp),
)
DropdownMenu(
expanded = showMenu,
onDismissRequest = { showMenu = false }
) {
DropdownMenuItem(
text = { Text(text = stringResource(R.string.fingerprint_copy)) },
onClick = {
clipboardManager.setText(AnnotatedString(fingerprint))
showMenu = false
}
)
}
}
} else {
Text(
text = stringResource(R.string.fingerprint_pending),
style = MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace),
color = Color(0xFFFF9500),
modifier = Modifier.padding(16.dp)
)
}
}
}
private fun formatFingerprint(fingerprint: String): String {
val upper = fingerprint.uppercase()
val sb = StringBuilder()
upper.forEachIndexed { index, c ->
if (index > 0 && index % 4 == 0) {
if (index % 16 == 0) sb.append('\n') else sb.append(' ')
}
sb.append(c)
}
return sb.toString()
}
@@ -1,731 +0,0 @@
package com.bitchat.android.ui
import com.bitchat.android.R
import android.util.Log
import androidx.compose.foundation.*
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material.icons.outlined.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.text.style.TextOverflow
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.bitchat.android.ui.theme.BASE_FONT_SIZE
/**
* Sidebar components for ChatScreen
* Extracted from ChatScreen.kt for better organization
*/
@Composable
fun SidebarOverlay(
viewModel: ChatViewModel,
onDismiss: () -> Unit,
modifier: Modifier = Modifier
) {
val colorScheme = MaterialTheme.colorScheme
val interactionSource = remember { MutableInteractionSource() }
val connectedPeers by viewModel.connectedPeers.collectAsStateWithLifecycle()
val joinedChannels by viewModel.joinedChannels.collectAsStateWithLifecycle()
val currentChannel by viewModel.currentChannel.collectAsStateWithLifecycle()
val selectedPrivatePeer by viewModel.selectedPrivateChatPeer.collectAsStateWithLifecycle()
val nickname by viewModel.nickname.collectAsStateWithLifecycle()
val unreadChannelMessages by viewModel.unreadChannelMessages.collectAsStateWithLifecycle()
val peerNicknames by viewModel.peerNicknames.collectAsStateWithLifecycle()
val peerRSSI by viewModel.peerRSSI.collectAsStateWithLifecycle()
Box(
modifier = modifier
.background(Color.Black.copy(alpha = 0.5f))
.clickable(indication = null, interactionSource = interactionSource) { onDismiss() }
) {
Row(
modifier = Modifier
.fillMaxHeight()
.width(280.dp)
.align(Alignment.CenterEnd)
.clickable { /* Prevent dismissing when clicking sidebar */ }
) {
// Grey vertical bar for visual continuity (matches iOS)
Box(
modifier = Modifier
.fillMaxHeight()
.width(1.dp)
.background(Color.Gray.copy(alpha = 0.3f))
)
Column(
modifier = Modifier
.fillMaxHeight()
.weight(1f)
.background(colorScheme.background.copy(alpha = 0.95f))
.windowInsetsPadding(WindowInsets.statusBars) // Add status bar padding
) {
SidebarHeader()
HorizontalDivider()
// Scrollable content
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
// Channels section
if (joinedChannels.isNotEmpty()) {
item {
ChannelsSection(
channels = joinedChannels.toList(), // Convert Set to List
currentChannel = currentChannel,
colorScheme = colorScheme,
onChannelClick = { channel ->
viewModel.switchToChannel(channel)
onDismiss()
},
onLeaveChannel = { channel ->
viewModel.leaveChannel(channel)
},
unreadChannelMessages = unreadChannelMessages
)
}
item {
HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
}
}
// People section - switch between mesh and geohash lists (iOS-compatible)
item {
val selectedLocationChannel by viewModel.selectedLocationChannel.collectAsState()
when (selectedLocationChannel) {
is com.bitchat.android.geohash.ChannelID.Location -> {
// Show geohash people list when in location channel
GeohashPeopleList(
viewModel = viewModel,
onTapPerson = onDismiss
)
}
else -> {
// Show mesh peer list when in mesh channel (default)
PeopleSection(
modifier = modifier.padding(bottom = 16.dp),
connectedPeers = connectedPeers,
peerNicknames = peerNicknames,
peerRSSI = peerRSSI,
nickname = nickname,
colorScheme = colorScheme,
selectedPrivatePeer = selectedPrivatePeer,
viewModel = viewModel,
onPrivateChatStart = { peerID ->
viewModel.startPrivateChat(peerID)
onDismiss()
}
)
}
}
}
}
}
}
}
}
@Composable
private fun SidebarHeader() {
val colorScheme = MaterialTheme.colorScheme
Row(
modifier = Modifier
.height(42.dp) // Match reduced main header height
.fillMaxWidth()
.background(colorScheme.background.copy(alpha = 0.95f))
.padding(horizontal = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = stringResource(id = R.string.your_network).uppercase(),
style = MaterialTheme.typography.titleMedium.copy(
fontWeight = FontWeight.Bold,
fontFamily = FontFamily.Monospace
),
color = colorScheme.onSurface
)
Spacer(modifier = Modifier.weight(1f))
}
}
@Composable
fun ChannelsSection(
channels: List<String>,
currentChannel: String?,
colorScheme: ColorScheme,
onChannelClick: (String) -> Unit,
onLeaveChannel: (String) -> Unit,
unreadChannelMessages: Map<String, Int> = emptyMap()
) {
Column {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = Icons.Default.Person, // Using Person icon as placeholder
contentDescription = null,
modifier = Modifier.size(10.dp),
tint = colorScheme.onSurface.copy(alpha = 0.6f)
)
Spacer(modifier = Modifier.width(6.dp))
Text(
text = stringResource(id = R.string.channels).uppercase(),
style = MaterialTheme.typography.labelSmall,
color = colorScheme.onSurface.copy(alpha = 0.6f),
fontWeight = FontWeight.Bold
)
}
channels.forEach { channel ->
val isSelected = channel == currentChannel
val unreadCount = unreadChannelMessages[channel] ?: 0
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { onChannelClick(channel) }
.background(
if (isSelected) colorScheme.primaryContainer.copy(alpha = 0.3f)
else Color.Transparent
)
.padding(horizontal = 24.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
// Unread badge for channels
UnreadBadge(
count = unreadCount,
colorScheme = colorScheme,
modifier = Modifier.padding(end = 8.dp)
)
Text(
text = channel, // Channel already contains the # prefix
style = MaterialTheme.typography.bodyMedium,
color = if (isSelected) colorScheme.primary else colorScheme.onSurface,
fontWeight = if (isSelected) FontWeight.Medium else FontWeight.Normal,
modifier = Modifier.weight(1f)
)
// Leave channel button
IconButton(
onClick = { onLeaveChannel(channel) },
modifier = Modifier.size(24.dp)
) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = stringResource(com.bitchat.android.R.string.cd_leave_channel),
modifier = Modifier.size(14.dp),
tint = colorScheme.onSurface.copy(alpha = 0.5f)
)
}
}
}
}
}
@Composable
fun PeopleSection(
modifier: Modifier = Modifier,
connectedPeers: List<String>,
peerNicknames: Map<String, String>,
peerRSSI: Map<String, Int>,
nickname: String,
colorScheme: ColorScheme,
selectedPrivatePeer: String?,
viewModel: ChatViewModel,
onPrivateChatStart: (String) -> Unit
) {
Column(modifier = modifier) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = Icons.Default.Group, // Using Person icon for people
contentDescription = null,
modifier = Modifier.size(12.dp),
tint = colorScheme.onSurface.copy(alpha = 0.6f)
)
Spacer(modifier = Modifier.width(6.dp))
Text(
text = stringResource(id = R.string.people).uppercase(),
style = MaterialTheme.typography.labelSmall,
color = colorScheme.onSurface.copy(alpha = 0.6f),
fontWeight = FontWeight.Bold
)
}
if (connectedPeers.isEmpty()) {
Text(
text = stringResource(id = R.string.no_one_connected),
style = MaterialTheme.typography.bodyMedium,
color = colorScheme.onSurface.copy(alpha = 0.5f),
modifier = Modifier.padding(horizontal = 24.dp, vertical = 8.dp)
)
}
// Observe reactive state for favorites and fingerprints
val hasUnreadPrivateMessages by viewModel.unreadPrivateMessages.collectAsStateWithLifecycle()
val privateChats by viewModel.privateChats.collectAsStateWithLifecycle()
val favoritePeers by viewModel.favoritePeers.collectAsStateWithLifecycle()
val peerFingerprints by viewModel.peerFingerprints.collectAsStateWithLifecycle()
// Reactive favorite computation for all peers
val peerFavoriteStates = remember(favoritePeers, peerFingerprints, connectedPeers) {
connectedPeers.associateWith { peerID ->
// Reactive favorite computation - same as ChatHeader
val fingerprint = peerFingerprints[peerID]
fingerprint != null && favoritePeers.contains(fingerprint)
}
}
// Build mapping of connected peerID -> noise key hex to unify with offline favorites
val noiseHexByPeerID: Map<String, String> = connectedPeers.associateWith { pid ->
try {
viewModel.meshService.getPeerInfo(pid)?.noisePublicKey?.joinToString("") { b -> "%02x".format(b) }
} catch (_: Exception) { null }
}.filterValues { it != null }.mapValues { it.value!! }
Log.d("SidebarComponents", "Recomposing with ${favoritePeers.size} favorites, peer states: $peerFavoriteStates")
// Smart sorting: unread DMs first, then by most recent DM, then favorites, then alphabetical
val sortedPeers = connectedPeers.sortedWith(
compareBy<String> { !hasUnreadPrivateMessages.contains(it) } // Unread DM senders first
.thenByDescending { privateChats[it]?.maxByOrNull { msg -> msg.timestamp }?.timestamp?.time ?: 0L } // Most recent DM (convert Date to Long)
.thenBy { !(peerFavoriteStates[it] ?: false) } // Favorites first
.thenBy { (if (it == nickname) "You" else (peerNicknames[it] ?: it)).lowercase() } // Alphabetical
)
// Build a map of base name counts across all people shown in the list (connected + offline + nostr)
val hex64Regex = Regex("^[0-9a-fA-F]{64}$")
// Helper to compute display name used for a given key
fun computeDisplayNameForPeerId(key: String): String {
return if (key == nickname) "You" else (peerNicknames[key] ?: (privateChats[key]?.lastOrNull()?.sender ?: key.take(12)))
}
val baseNameCounts = mutableMapOf<String, Int>()
// Connected peers
sortedPeers.forEach { pid ->
val dn = computeDisplayNameForPeerId(pid)
val (b, _) = com.bitchat.android.ui.splitSuffix(dn)
if (b != "You") baseNameCounts[b] = (baseNameCounts[b] ?: 0) + 1
}
// Offline favorites (exclude ones mapped to connected)
val offlineFavorites = com.bitchat.android.favorites.FavoritesPersistenceService.shared.getOurFavorites()
offlineFavorites.forEach { fav ->
val favPeerID = fav.peerNoisePublicKey.joinToString("") { b -> "%02x".format(b) }
val isMappedToConnected = noiseHexByPeerID.values.any { it.equals(favPeerID, ignoreCase = true) }
if (!isMappedToConnected) {
val dn = peerNicknames[favPeerID] ?: fav.peerNickname
val (b, _) = com.bitchat.android.ui.splitSuffix(dn)
if (b != "You") baseNameCounts[b] = (baseNameCounts[b] ?: 0) + 1
}
}
// Nostr-only conversations
val connectedIds = sortedPeers.toSet()
val appendedOfflineIds = mutableSetOf<String>()
privateChats.keys
.filter { key ->
(key.startsWith("nostr_") || hex64Regex.matches(key)) &&
!connectedIds.contains(key) &&
!noiseHexByPeerID.values.any { it.equals(key, ignoreCase = true) }
}
.forEach { convKey ->
val dn = peerNicknames[convKey] ?: (privateChats[convKey]?.lastOrNull()?.sender ?: convKey.take(12))
val (b, _) = com.bitchat.android.ui.splitSuffix(dn)
if (b != "You") baseNameCounts[b] = (baseNameCounts[b] ?: 0) + 1
}
sortedPeers.forEach { peerID ->
val isFavorite = peerFavoriteStates[peerID] ?: false
// fingerprint and favorite relationship resolution not needed here; UI will show Nostr globe for appended offline favorites below
val noiseHex = noiseHexByPeerID[peerID]
val meshUnread = hasUnreadPrivateMessages.contains(peerID)
val nostrUnread = if (noiseHex != null) hasUnreadPrivateMessages.contains(noiseHex) else false
val combinedHasUnread = meshUnread || nostrUnread
val combinedUnreadCount = (
privateChats[peerID]?.count { msg -> msg.sender != nickname && meshUnread } ?: 0
) + (
if (noiseHex != null) privateChats[noiseHex]?.count { msg -> msg.sender != nickname && nostrUnread } ?: 0 else 0
)
val displayName = if (peerID == nickname) "You" else (peerNicknames[peerID] ?: (privateChats[peerID]?.lastOrNull()?.sender ?: peerID.take(12)))
val (bName, _) = com.bitchat.android.ui.splitSuffix(displayName)
val showHash = (baseNameCounts[bName] ?: 0) > 1
val directMap by viewModel.peerDirect.collectAsStateWithLifecycle()
val isDirectLive = directMap[peerID] ?: try { viewModel.meshService.getPeerInfo(peerID)?.isDirectConnection == true } catch (_: Exception) { false }
PeerItem(
peerID = peerID,
displayName = displayName,
isDirect = isDirectLive,
isSelected = peerID == selectedPrivatePeer,
isFavorite = isFavorite,
hasUnreadDM = combinedHasUnread,
colorScheme = colorScheme,
viewModel = viewModel,
onItemClick = { onPrivateChatStart(peerID) },
onToggleFavorite = {
Log.d("SidebarComponents", "Sidebar toggle favorite: peerID=$peerID, currentFavorite=$isFavorite")
viewModel.toggleFavorite(peerID)
},
unreadCount = if (combinedUnreadCount > 0) combinedUnreadCount else if (combinedHasUnread) 1 else 0,
showNostrGlobe = false,
showHashSuffix = showHash
)
}
// Append offline favorites we actively favorite (and not currently connected)
offlineFavorites.forEach { fav ->
val favPeerID = fav.peerNoisePublicKey.joinToString("") { b -> "%02x".format(b) }
// If any connected peer maps to this noise key, skip showing the offline entry
val isMappedToConnected = noiseHexByPeerID.values.any { it.equals(favPeerID, ignoreCase = true) }
if (isMappedToConnected) return@forEach
// Resolve potential Nostr conversation key for this favorite (for unread detection)
val nostrConvKey: String? = try {
val npubOrHex = com.bitchat.android.favorites.FavoritesPersistenceService.shared.findNostrPubkey(fav.peerNoisePublicKey)
if (npubOrHex != null) {
val hex = if (npubOrHex.startsWith("npub")) {
val (hrp, data) = com.bitchat.android.nostr.Bech32.decode(npubOrHex)
if (hrp == "npub") data.joinToString("") { "%02x".format(it) } else null
} else {
npubOrHex.lowercase()
}
hex?.let { "nostr_${it.take(16)}" }
} else null
} catch (_: Exception) { null }
val hasUnread = hasUnreadPrivateMessages.contains(favPeerID) || (nostrConvKey != null && hasUnreadPrivateMessages.contains(nostrConvKey))
// If user clicks an offline favorite and the mapped peer is currently connected under a different ID,
// open chat with the connected peerID instead of the noise hex for a seamless window
val mappedConnectedPeerID = noiseHexByPeerID.entries.firstOrNull { it.value.equals(favPeerID, ignoreCase = true) }?.key
val dn = peerNicknames[favPeerID] ?: fav.peerNickname
val (bName, _) = com.bitchat.android.ui.splitSuffix(dn)
val showHash = (baseNameCounts[bName] ?: 0) > 1
// Compute unreadCount from either noise conversation or Nostr conversation
val unreadCount = (
privateChats[favPeerID]?.count { msg -> msg.sender != nickname && hasUnreadPrivateMessages.contains(favPeerID) } ?: 0
) + (
if (nostrConvKey != null) privateChats[nostrConvKey]?.count { msg -> msg.sender != nickname && hasUnreadPrivateMessages.contains(nostrConvKey) } ?: 0 else 0
)
PeerItem(
peerID = favPeerID,
displayName = dn,
isDirect = false,
isSelected = (mappedConnectedPeerID ?: favPeerID) == selectedPrivatePeer,
isFavorite = true,
hasUnreadDM = hasUnread,
colorScheme = colorScheme,
viewModel = viewModel,
onItemClick = { onPrivateChatStart(mappedConnectedPeerID ?: favPeerID) },
onToggleFavorite = {
Log.d("SidebarComponents", "Sidebar toggle favorite (offline): peerID=$favPeerID")
viewModel.toggleFavorite(favPeerID)
},
unreadCount = if (unreadCount > 0) unreadCount else if (hasUnread) 1 else 0,
showNostrGlobe = (fav.isMutual && fav.peerNostrPublicKey != null),
showHashSuffix = showHash
)
appendedOfflineIds.add(favPeerID)
}
// NOTE: Do NOT append Nostr-only (nostr_*) conversations to the mesh people list.
// Geohash DMs should appear in the GeohashPeople list for the active geohash, not in mesh offline contacts.
// We intentionally remove previously-added behavior that mixed geohash DMs into mesh sidebar.
// If you need to surface non-geohash offline mesh conversations in the future, do it here for 64-hex noise IDs only.
/*
val alreadyShownIds = connectedIds + appendedOfflineIds
privateChats.keys
.filter { key ->
// Only include 64-hex noise IDs (mesh identities); exclude any nostr_* aliases
hex64Regex.matches(key) &&
!alreadyShownIds.contains(key) &&
// Skip if this key maps to a connected peer via noiseHex mapping
!noiseHexByPeerID.values.any { it.equals(key, ignoreCase = true) }
}
.sortedBy { key -> privateChats[key]?.lastOrNull()?.timestamp }
.forEach { convKey ->
val lastSender = privateChats[convKey]?.lastOrNull()?.sender
val dn = peerNicknames[convKey] ?: (lastSender ?: convKey.take(12))
val (bName, _) = com.bitchat.android.ui.splitSuffix(dn)
val showHash = (baseNameCounts[bName] ?: 0) > 1
PeerItem(
peerID = convKey,
displayName = dn,
isDirect = false,
isSelected = convKey == selectedPrivatePeer,
isFavorite = false,
hasUnreadDM = hasUnreadPrivateMessages.contains(convKey),
colorScheme = colorScheme,
viewModel = viewModel,
onItemClick = { onPrivateChatStart(convKey) },
onToggleFavorite = { viewModel.toggleFavorite(convKey) },
unreadCount = privateChats[convKey]?.count { msg ->
msg.sender != nickname && hasUnreadPrivateMessages.contains(convKey)
} ?: if (hasUnreadPrivateMessages.contains(convKey)) 1 else 0,
showNostrGlobe = false,
showHashSuffix = showHash
)
}
*/
// End intentional removal
}
}
@Composable
private fun PeerItem(
peerID: String,
displayName: String,
isDirect: Boolean,
isSelected: Boolean,
isFavorite: Boolean,
hasUnreadDM: Boolean,
colorScheme: ColorScheme,
viewModel: ChatViewModel,
onItemClick: () -> Unit,
onToggleFavorite: () -> Unit,
unreadCount: Int = 0,
showNostrGlobe: Boolean = false,
showHashSuffix: Boolean = true
) {
// Split display name for hashtag suffix support (iOS-compatible)
val (baseNameRaw, suffixRaw) = com.bitchat.android.ui.splitSuffix(displayName)
val baseName = truncateNickname(baseNameRaw)
val suffix = if (showHashSuffix) suffixRaw else ""
val isMe = displayName == "You" || peerID == viewModel.nickname.value
// Get consistent peer color (iOS-compatible)
val isDark = colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f
val assignedColor = viewModel.colorForMeshPeer(peerID, isDark)
val baseColor = if (isMe) Color(0xFFFF9500) else assignedColor
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { onItemClick() }
.background(
if (isSelected) colorScheme.primaryContainer.copy(alpha = 0.3f)
else Color.Transparent
)
.padding(horizontal = 24.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
// Show unread badge or signal strength
if (hasUnreadDM) {
// Show mail icon for unread DMs (iOS orange)
Icon(
imageVector = Icons.Filled.Email,
contentDescription = stringResource(com.bitchat.android.R.string.cd_unread_message),
modifier = Modifier.size(16.dp),
tint = Color(0xFFFF9500) // iOS orange
)
} else {
// Connection indicator icons
if (showNostrGlobe) {
// Purple globe to indicate Nostr availability
Icon(
imageVector = Icons.Filled.Public,
contentDescription = stringResource(com.bitchat.android.R.string.cd_reachable_via_nostr),
modifier = Modifier.size(16.dp),
tint = Color(0xFF9C27B0) // Purple
)
} else if (!isDirect && isFavorite) {
// Offline favorited user: show outlined circle icon
Icon(
//painter = androidx.compose.ui.res.painterResource(id = R.drawable.ic_offline_favorite),
imageVector = Icons.Outlined.Circle,
contentDescription = stringResource(com.bitchat.android.R.string.cd_offline_favorite),
modifier = Modifier.size(16.dp),
tint = Color.Gray
)
} else {
val awareConnected by com.bitchat.android.wifiaware.WifiAwareController.connectedPeers.collectAsState()
val awareDiscovered by com.bitchat.android.wifiaware.WifiAwareController.discoveredPeers.collectAsState()
val isWifiDirect = awareConnected.containsKey(peerID)
val isBleDirect = isDirect
val icon = when {
isWifiDirect -> Icons.Filled.Wifi
isBleDirect -> Icons.Outlined.SettingsInputAntenna
// Routed: show Route icon; optionally prefer WiFi Aware if discovered there
awareDiscovered.contains(peerID) -> Icons.Filled.WifiTethering
else -> Icons.Filled.Route
}
val cd = when {
isWifiDirect -> "Direct WiFi Aware"
isBleDirect -> "Direct Bluetooth"
awareDiscovered.contains(peerID) -> "Routed over WiFi"
else -> "Routed"
}
Icon(
imageVector = icon,
contentDescription = cd,
modifier = Modifier.size(16.dp),
tint = colorScheme.onSurface.copy(alpha = 0.8f)
)
}
}
Spacer(modifier = Modifier.width(8.dp))
// Display name with iOS-style color and hashtag suffix support
Row(
modifier = Modifier.weight(1f),
verticalAlignment = Alignment.CenterVertically
) {
// Base name with peer-specific color
Text(
text = baseName,
style = MaterialTheme.typography.bodyMedium.copy(
fontFamily = FontFamily.Monospace,
fontSize = BASE_FONT_SIZE.sp,
fontWeight = if (isMe) FontWeight.Bold else FontWeight.Normal
),
color = baseColor,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
// Hashtag suffix in lighter shade (iOS-style)
if (suffix.isNotEmpty()) {
Text(
text = suffix,
style = MaterialTheme.typography.bodyMedium.copy(
fontFamily = FontFamily.Monospace,
fontSize = BASE_FONT_SIZE.sp
),
color = baseColor.copy(alpha = 0.6f)
)
}
}
// Favorite star with proper filled/outlined states
IconButton(
onClick = onToggleFavorite,
modifier = Modifier.size(24.dp)
) {
Icon(
imageVector = if (isFavorite) Icons.Filled.Star else Icons.Outlined.Star,
contentDescription = if (isFavorite) "Remove from favorites" else "Add to favorites",
modifier = Modifier.size(16.dp),
tint = if (isFavorite) Color(0xFFFFD700) else Color(0xFF4CAF50)
)
}
}
}
@Composable
private fun SignalStrengthIndicator(
signalStrength: Int,
colorScheme: ColorScheme
) {
Row(modifier = Modifier.width(24.dp)) {
repeat(3) { index ->
val opacity = when {
signalStrength >= (index + 1) * 33 -> 1f
else -> 0.2f
}
Box(
modifier = Modifier
.size(width = 3.dp, height = (4 + index * 2).dp)
.background(
colorScheme.onSurface.copy(alpha = opacity),
RoundedCornerShape(1.dp)
)
)
if (index < 2) Spacer(modifier = Modifier.width(2.dp))
}
}
}
/**
* Reusable unread badge component for both channels and private messages
*/
@Composable
private fun UnreadBadge(
count: Int,
colorScheme: ColorScheme,
modifier: Modifier = Modifier
) {
if (count > 0) {
Box(
modifier = modifier
.background(
color = Color(0xFFFFD700), // Yellow color
shape = RoundedCornerShape(10.dp)
)
.padding(horizontal = 2.dp, vertical = 0.dp)
.defaultMinSize(minWidth = 14.dp, minHeight = 14.dp),
contentAlignment = Alignment.Center
) {
Text(
text = if (count > 99) "99+" else count.toString(),
style = MaterialTheme.typography.labelSmall.copy(
fontSize = 10.sp,
fontWeight = FontWeight.Bold
),
color = Color.Black // Black text on yellow background
)
}
}
}
/**
* Convert RSSI value (dBm) to signal strength percentage (0-100)
* RSSI typically ranges from -30 (excellent) to -100 (very poor)
* Maps to 0-100 scale where:
* - 0-32: No signal (0 bars)
* - 33-65: Weak (1 bar)
* - 66-98: Good (2 bars)
* - 99-100: Excellent (3 bars)
*/
private fun convertRSSIToSignalStrength(rssi: Int?): Int {
if (rssi == null) return 0
return when {
rssi >= -40 -> 100 // Excellent signal
rssi >= -55 -> 85 // Very good signal
rssi >= -70 -> 70 // Good signal
rssi >= -85 -> 50 // Fair signal
rssi >= -100 -> 25 // Poor signal
else -> 0 // Very poor or no signal
}
}
@@ -0,0 +1,371 @@
package com.bitchat.android.ui
import android.content.Context
import com.bitchat.android.R
import com.bitchat.android.favorites.FavoritesPersistenceService
import com.bitchat.android.identity.SecureIdentityStateManager
import com.bitchat.android.mesh.BluetoothMeshService
import com.bitchat.android.model.BitchatMessage
import com.bitchat.android.noise.NoiseSession
import com.bitchat.android.nostr.GeohashAliasRegistry
import com.bitchat.android.services.VerificationService
import com.bitchat.android.util.dataFromHexString
import com.bitchat.android.util.hexEncodedString
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import java.security.MessageDigest
import java.util.Date
import java.util.concurrent.ConcurrentHashMap
/**
* Handles QR verification logic and state, extracted from ChatViewModel.
*/
class VerificationHandler(
private val context: Context,
private val scope: CoroutineScope,
private val getMeshService: () -> BluetoothMeshService,
private val identityManager: SecureIdentityStateManager,
private val state: ChatState,
private val notificationManager: NotificationManager,
private val messageManager: MessageManager
) {
// Helper to get current mesh service (may change after panic clear)
private val meshService: BluetoothMeshService
get() = getMeshService()
private val _verifiedFingerprints = MutableStateFlow<Set<String>>(emptySet())
val verifiedFingerprints: StateFlow<Set<String>> = _verifiedFingerprints.asStateFlow()
private val pendingQRVerifications = ConcurrentHashMap<String, PendingVerification>()
private val lastVerifyNonceByPeer = ConcurrentHashMap<String, ByteArray>()
private val lastInboundVerifyChallengeAt = ConcurrentHashMap<String, Long>()
private val lastMutualToastAt = ConcurrentHashMap<String, Long>()
fun loadVerifiedFingerprints() {
_verifiedFingerprints.value = identityManager.getVerifiedFingerprints()
}
fun isPeerVerified(peerID: String): Boolean {
if (peerID.startsWith("nostr_") || peerID.startsWith("nostr:")) return false
val fingerprint = getPeerFingerprintForDisplay(peerID)
return fingerprint != null && _verifiedFingerprints.value.contains(fingerprint)
}
fun isNoisePublicKeyVerified(noisePublicKey: ByteArray): Boolean {
val fingerprint = fingerprintFromNoiseBytes(noisePublicKey)
return _verifiedFingerprints.value.contains(fingerprint)
}
fun unverifyFingerprint(peerID: String) {
val fingerprint = meshService.getPeerFingerprint(peerID) ?: return
identityManager.setVerifiedFingerprint(fingerprint, false)
val current = _verifiedFingerprints.value.toMutableSet()
current.remove(fingerprint)
_verifiedFingerprints.value = current
}
fun beginQRVerification(qr: VerificationService.VerificationQR): Boolean {
val targetNoise = qr.noiseKeyHex.lowercase()
val peerID = state.getConnectedPeersValue().firstOrNull { pid ->
val noiseKeyHex = meshService.getPeerInfo(pid)?.noisePublicKey?.hexEncodedString()?.lowercase()
noiseKeyHex == targetNoise
} ?: return false
if (pendingQRVerifications.containsKey(peerID)) return true
val nonce = ByteArray(16)
java.security.SecureRandom().nextBytes(nonce)
val pending = PendingVerification(qr.noiseKeyHex, qr.signKeyHex, nonce, System.currentTimeMillis(), false)
pendingQRVerifications[peerID] = pending
if (meshService.getSessionState(peerID) is NoiseSession.NoiseSessionState.Established) {
meshService.sendVerifyChallenge(peerID, qr.noiseKeyHex, nonce)
pendingQRVerifications[peerID] = pending.copy(sent = true)
} else {
meshService.initiateNoiseHandshake(peerID)
}
fingerprintFromNoiseHex(qr.noiseKeyHex)?.let { fp ->
identityManager.cacheFingerprintNickname(fp, qr.nickname)
identityManager.cacheNoiseFingerprint(qr.noiseKeyHex, fp)
identityManager.cachePeerNoiseKey(peerID, qr.noiseKeyHex)
}
return true
}
fun sendPendingVerificationIfNeeded(peerID: String) {
val pending = pendingQRVerifications[peerID] ?: return
if (pending.sent) return
meshService.sendVerifyChallenge(peerID, pending.noiseKeyHex, pending.nonceA)
pendingQRVerifications[peerID] = pending.copy(sent = true)
}
fun didReceiveVerifyChallenge(peerID: String, payload: ByteArray) {
scope.launch {
val parsed = VerificationService.parseVerifyChallenge(payload) ?: return@launch
val myNoiseHex = meshService.getStaticNoisePublicKey()?.hexEncodedString()?.lowercase() ?: return@launch
if (parsed.first.lowercase() != myNoiseHex) return@launch
val lastNonce = lastVerifyNonceByPeer[peerID]
if (lastNonce != null && lastNonce.contentEquals(parsed.second)) return@launch
lastVerifyNonceByPeer[peerID] = parsed.second
val fp = meshService.getPeerFingerprint(peerID)
if (fp != null) {
lastInboundVerifyChallengeAt[fp] = System.currentTimeMillis()
if (_verifiedFingerprints.value.contains(fp)) {
val lastToast = lastMutualToastAt[fp] ?: 0L
if (System.currentTimeMillis() - lastToast > 60_000L) {
lastMutualToastAt[fp] = System.currentTimeMillis()
val name = resolvePeerDisplayName(peerID)
val body = context.getString(R.string.verify_mutual_match_body, name)
addVerificationSystemMessage(peerID, context.getString(R.string.verify_mutual_system_message, name))
sendVerificationNotification(context.getString(R.string.verify_mutual_match_title), body, peerID)
}
}
}
meshService.sendVerifyResponse(peerID, parsed.first, parsed.second)
}
}
fun didReceiveVerifyResponse(peerID: String, payload: ByteArray) {
scope.launch {
val resp = VerificationService.parseVerifyResponse(payload) ?: return@launch
val pending = pendingQRVerifications[peerID] ?: return@launch
if (!resp.noiseKeyHex.equals(pending.noiseKeyHex, ignoreCase = true)) return@launch
if (!resp.nonceA.contentEquals(pending.nonceA)) return@launch
val ok = VerificationService.verifyResponseSignature(
noiseKeyHex = resp.noiseKeyHex,
nonceA = resp.nonceA,
signature = resp.signature,
signerPublicKeyHex = pending.signKeyHex
)
if (!ok) return@launch
pendingQRVerifications.remove(peerID)
val fp = meshService.getPeerFingerprint(peerID) ?: return@launch
identityManager.setVerifiedFingerprint(fp, true)
val current = _verifiedFingerprints.value.toMutableSet()
current.add(fp)
_verifiedFingerprints.value = current
val name = resolvePeerDisplayName(peerID)
identityManager.cacheFingerprintNickname(fp, name)
val noiseKeyHex = try {
meshService.getPeerInfo(peerID)?.noisePublicKey?.hexEncodedString()
} catch (_: Exception) {
null
}
if (noiseKeyHex != null) {
identityManager.cachePeerNoiseKey(peerID, noiseKeyHex)
identityManager.cacheNoiseFingerprint(noiseKeyHex, fp)
}
addVerificationSystemMessage(peerID, context.getString(R.string.verify_success_system_message, name))
sendVerificationNotification(context.getString(R.string.verify_success_title), context.getString(R.string.verify_success_body, name), peerID)
val lastChallenge = lastInboundVerifyChallengeAt[fp] ?: 0L
if (System.currentTimeMillis() - lastChallenge < 600_000L) {
val lastToast = lastMutualToastAt[fp] ?: 0L
if (System.currentTimeMillis() - lastToast > 60_000L) {
lastMutualToastAt[fp] = System.currentTimeMillis()
val body = context.getString(R.string.verify_mutual_match_body, name)
addVerificationSystemMessage(peerID, context.getString(R.string.verify_mutual_system_message, name))
sendVerificationNotification(context.getString(R.string.verify_mutual_match_title), body, peerID)
}
}
}
}
fun getPeerFingerprintForDisplay(peerID: String): String? {
val fromMap = state.getPeerFingerprintsValue()[peerID]
if (fromMap != null) return fromMap
val hexRegex = Regex("^[0-9a-fA-F]+$")
return try {
when {
peerID.length == 64 && peerID.matches(hexRegex) -> {
identityManager.getCachedNoiseFingerprint(peerID)?.let { return it }
fingerprintFromNoiseHex(peerID)?.also { identityManager.cacheNoiseFingerprint(peerID, it) }
}
peerID.length == 16 && peerID.matches(hexRegex) -> {
val meshFp = meshService.getPeerFingerprint(peerID)
if (meshFp != null) return meshFp
identityManager.getCachedPeerFingerprint(peerID)?.let { return it }
identityManager.getCachedNoiseKey(peerID)?.let { noiseHex ->
identityManager.getCachedNoiseFingerprint(noiseHex)?.let { return it }
return fingerprintFromNoiseHex(noiseHex)?.also { identityManager.cacheNoiseFingerprint(noiseHex, it) }
}
val favorite = try {
FavoritesPersistenceService.shared.getFavoriteStatus(peerID)
} catch (_: Exception) {
null
}
favorite?.peerNoisePublicKey?.let { fingerprintFromNoiseBytes(it) }
}
peerID.startsWith("nostr_") -> {
val pubHex = GeohashAliasRegistry.get(peerID)
val noiseKey = pubHex?.let {
FavoritesPersistenceService.shared.findNoiseKey(it)
}
noiseKey?.let {
val noiseHex = it.hexEncodedString()
identityManager.getCachedNoiseFingerprint(noiseHex) ?: fingerprintFromNoiseBytes(it)
}
}
peerID.startsWith("nostr:") -> {
val prefix = peerID.removePrefix("nostr:").lowercase()
val pubHex = GeohashAliasRegistry
.snapshot()
.values
.firstOrNull { it.lowercase().startsWith(prefix) }
val noiseKey = pubHex?.let {
FavoritesPersistenceService.shared.findNoiseKey(it)
}
noiseKey?.let {
val noiseHex = it.hexEncodedString()
identityManager.getCachedNoiseFingerprint(noiseHex) ?: fingerprintFromNoiseBytes(it)
}
}
else -> {
val meshFp = meshService.getPeerFingerprint(peerID)
if (meshFp != null) return meshFp
identityManager.getCachedPeerFingerprint(peerID)?.let { return it }
identityManager.getCachedNoiseKey(peerID)?.let { noiseHex ->
identityManager.getCachedNoiseFingerprint(noiseHex)?.let { return it }
return fingerprintFromNoiseHex(noiseHex)?.also { identityManager.cacheNoiseFingerprint(noiseHex, it) }
}
val favorite = try {
FavoritesPersistenceService.shared.getFavoriteStatus(peerID)
} catch (_: Exception) {
null
}
favorite?.peerNoisePublicKey?.let { fingerprintFromNoiseBytes(it) }
}
}
} catch (_: Exception) {
null
}
}
fun resolvePeerDisplayNameForFingerprint(peerID: String): String {
val nicknameMap = state.peerNicknames.value
nicknameMap[peerID]?.let { return it }
try {
meshService.getPeerInfo(peerID)?.nickname?.let { return it }
} catch (_: Exception) { }
val fingerprint = getPeerFingerprintForDisplay(peerID)
fingerprint?.let { fp ->
identityManager.getCachedFingerprintNickname(fp)?.let { cached ->
if (cached.isNotBlank()) return cached
}
}
val hexRegex = Regex("^[0-9a-fA-F]+$")
if (peerID.length == 64 && peerID.matches(hexRegex)) {
val noiseKeyBytes = try {
peerID.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
} catch (_: Exception) { null }
val favorite = noiseKeyBytes?.let {
FavoritesPersistenceService.shared.getFavoriteStatus(it)
}
favorite?.peerNickname?.takeIf { it.isNotBlank() }?.let { return it }
}
if (peerID.length == 16 && peerID.matches(hexRegex)) {
val favorite = try {
FavoritesPersistenceService.shared.getFavoriteStatus(peerID)
} catch (_: Exception) {
null
}
favorite?.peerNickname?.takeIf { it.isNotBlank() }?.let { return it }
}
return peerID.take(8)
}
fun getMyFingerprint(): String {
return meshService.getIdentityFingerprint()
}
fun verifyFingerprintValue(fingerprint: String) {
if (fingerprint.isBlank()) return
identityManager.setVerifiedFingerprint(fingerprint, true)
val current = _verifiedFingerprints.value.toMutableSet()
current.add(fingerprint)
_verifiedFingerprints.value = current
}
fun unverifyFingerprintValue(fingerprint: String) {
if (fingerprint.isBlank()) return
identityManager.setVerifiedFingerprint(fingerprint, false)
val current = _verifiedFingerprints.value.toMutableSet()
current.remove(fingerprint)
_verifiedFingerprints.value = current
}
private fun addVerificationSystemMessage(peerID: String, text: String) {
val msg = BitchatMessage(
sender = "system",
content = text,
timestamp = Date(),
isRelay = false,
isPrivate = true,
senderPeerID = peerID
)
messageManager.addPrivateMessageNoUnread(peerID, msg)
}
private fun resolvePeerDisplayName(peerID: String): String {
val nick = try { meshService.getPeerInfo(peerID)?.nickname } catch (_: Exception) { null }
return nick ?: peerID.take(8)
}
private fun sendVerificationNotification(title: String, body: String, peerID: String) {
notificationManager.showVerificationNotification(title, body, peerID)
}
private fun fingerprintFromNoiseHex(noiseHex: String): String? {
val bytes = noiseHex.dataFromHexString() ?: return null
return fingerprintFromNoiseBytes(bytes)
}
fun fingerprintFromNoiseBytes(bytes: ByteArray): String {
val hash = MessageDigest.getInstance("SHA-256").digest(bytes)
return hash.hexEncodedString()
}
private data class PendingVerification(
val noiseKeyHex: String,
val signKeyHex: String,
val nonceA: ByteArray,
val startedAtMs: Long,
val sent: Boolean
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as PendingVerification
if (startedAtMs != other.startedAtMs) return false
if (sent != other.sent) return false
if (noiseKeyHex != other.noiseKeyHex) return false
if (signKeyHex != other.signKeyHex) return false
if (!nonceA.contentEquals(other.nonceA)) return false
return true
}
override fun hashCode(): Int {
var result = startedAtMs.hashCode()
result = 31 * result + sent.hashCode()
result = 31 * result + noiseKeyHex.hashCode()
result = 31 * result + signKeyHex.hashCode()
result = 31 * result + nonceA.contentHashCode()
return result
}
}
}
@@ -0,0 +1,538 @@
package com.bitchat.android.ui
import android.graphics.Bitmap
import android.os.Handler
import android.os.Looper
import android.util.Log
import androidx.camera.compose.CameraXViewfinder
import androidx.camera.core.CameraSelector
import androidx.camera.core.ExperimentalGetImage
import androidx.camera.core.ImageAnalysis
import androidx.camera.core.ImageProxy
import androidx.camera.core.Preview
import androidx.camera.core.SurfaceRequest
import androidx.camera.lifecycle.ProcessCameraProvider
import androidx.camera.viewfinder.core.ImplementationMode
import androidx.compose.animation.Crossfade
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.QrCodeScanner
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Tab
import androidx.compose.material3.TabRow
import androidx.compose.material3.TabRowDefaults
import androidx.compose.material3.TabRowDefaults.tabIndicatorOffset
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.content.ContextCompat
import androidx.core.graphics.createBitmap
import androidx.core.graphics.set
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.bitchat.android.R
import com.bitchat.android.core.ui.component.button.CloseButton
import com.bitchat.android.core.ui.component.sheet.BitchatBottomSheet
import com.bitchat.android.services.VerificationService
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberPermissionState
import com.google.mlkit.vision.barcode.BarcodeScannerOptions
import com.google.mlkit.vision.barcode.BarcodeScanning
import com.google.mlkit.vision.barcode.common.Barcode
import com.google.mlkit.vision.common.InputImage
import com.google.zxing.BarcodeFormat
import com.google.zxing.common.BitMatrix
import com.google.zxing.qrcode.QRCodeWriter
import kotlinx.coroutines.flow.MutableStateFlow
import java.util.concurrent.ExecutorService
import java.util.concurrent.Executors
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun VerificationSheet(
isPresented: Boolean,
onDismiss: () -> Unit,
viewModel: ChatViewModel,
modifier: Modifier = Modifier
) {
if (!isPresented) return
val isDark = isSystemInDarkTheme()
val accent = if (isDark) Color.Green else Color(0xFF008000)
var selectedTab by remember { mutableStateOf(0) } // 0 = My Code, 1 = Scan
val nickname by viewModel.nickname.collectAsStateWithLifecycle()
val npub = remember { viewModel.getCurrentNpub() }
val qrString = remember(nickname, npub) {
viewModel.buildMyQRString(nickname, npub)
}
BitchatBottomSheet(
modifier = modifier,
onDismissRequest = onDismiss,
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(bottom = 16.dp),
verticalArrangement = Arrangement.Top
) {
// Header
VerificationHeader(
accent = accent,
onClose = onDismiss,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp)
)
// Tabs
TabRow(
selectedTabIndex = selectedTab,
containerColor = Color.Transparent,
contentColor = accent,
indicator = { tabPositions ->
TabRowDefaults.Indicator(
Modifier.tabIndicatorOffset(tabPositions[selectedTab]),
color = accent
)
}
) {
Tab(
selected = selectedTab == 0,
onClick = { selectedTab = 0 },
text = {
Text(
text = "My QR",
fontFamily = FontFamily.Monospace,
fontSize = 14.sp
)
}
)
Tab(
selected = selectedTab == 1,
onClick = { selectedTab = 1 },
text = {
Text(
text = "Scan",
fontFamily = FontFamily.Monospace,
fontSize = 14.sp
)
}
)
}
Spacer(modifier = Modifier.height(24.dp))
// Content
Crossfade(
targetState = selectedTab,
label = "VerificationTabCrossfade",
modifier = Modifier.weight(1f)
) { tab ->
when (tab) {
0 -> MyQrTabContent(
qrString = qrString,
nickname = nickname,
accent = accent
)
1 -> ScanTabContent(
accent = accent,
onScan = { code ->
val qr = VerificationService.verifyScannedQR(code)
if (qr != null && viewModel.beginQRVerification(qr)) {
selectedTab = 0
}
}
)
}
}
// Unverify Action
val peerID by viewModel.selectedPrivateChatPeer.collectAsStateWithLifecycle()
val fingerprints by viewModel.verifiedFingerprints.collectAsStateWithLifecycle()
if (peerID != null) {
val fingerprint = viewModel.meshService.getPeerFingerprint(peerID!!)
if (fingerprint != null && fingerprints.contains(fingerprint)) {
Spacer(modifier = Modifier.height(16.dp))
Button(
onClick = { viewModel.unverifyFingerprint(peerID!!) },
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.errorContainer,
contentColor = MaterialTheme.colorScheme.onErrorContainer
),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
) {
Text(
text = stringResource(R.string.verify_remove),
fontFamily = FontFamily.Monospace,
fontSize = 12.sp
)
}
}
}
}
}
}
@Composable
private fun VerificationHeader(
accent: Color,
onClose: () -> Unit,
modifier: Modifier = Modifier
) {
Row(
modifier = modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = stringResource(R.string.verify_title).uppercase(),
fontSize = 14.sp,
fontFamily = FontFamily.Monospace,
color = accent
)
CloseButton(onClick = onClose)
}
}
@Composable
private fun MyQrTabContent(
qrString: String,
nickname: String,
accent: Color
) {
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(horizontal = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Top
) {
Spacer(modifier = Modifier.height(24.dp))
Text(
text = stringResource(R.string.verify_my_qr_title),
style = MaterialTheme.typography.titleMedium,
fontFamily = FontFamily.Monospace,
color = accent
)
Spacer(modifier = Modifier.height(32.dp))
if (qrString.isNotBlank()) {
Box(
modifier = Modifier
.clip(RoundedCornerShape(24.dp))
.background(Color.White)
.padding(20.dp) // Quiet zone
) {
QRCodeImage(data = qrString, size = 260.dp)
}
} else {
Box(
modifier = Modifier
.size(260.dp)
.clip(RoundedCornerShape(24.dp))
.background(Color.White.copy(alpha = 0.5f)),
contentAlignment = Alignment.Center
) {
Text(
text = stringResource(R.string.verify_qr_unavailable),
fontFamily = FontFamily.Monospace,
fontSize = 12.sp,
color = Color.Black.copy(alpha = 0.6f)
)
}
}
Spacer(modifier = Modifier.height(32.dp))
// User Nickname
Text(
text = nickname,
style = MaterialTheme.typography.headlineSmall,
fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.onSurface,
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(8.dp))
// Helper text
Text(
text = stringResource(R.string.app_name).lowercase(),
style = MaterialTheme.typography.bodyMedium,
fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f),
textAlign = TextAlign.Center
)
}
}
@OptIn(ExperimentalPermissionsApi::class)
@Composable
private fun ScanTabContent(
accent: Color,
onScan: (String) -> Unit
) {
val permissionState = rememberPermissionState(android.Manifest.permission.CAMERA)
Column(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
if (permissionState.status.isGranted) {
Box(
modifier = Modifier
.weight(1f)
.fillMaxWidth()
.clip(RoundedCornerShape(24.dp))
.background(Color.Black),
contentAlignment = Alignment.Center
) {
ScannerView(onScan = onScan)
// Overlay border
Box(
modifier = Modifier
.size(280.dp)
.border(2.dp, accent.copy(alpha = 0.8f), RoundedCornerShape(16.dp))
)
// Corner accents for the overlay
Box(modifier = Modifier.size(260.dp)) {
// This could be drawn with Canvas for cooler effect, but simple border is cleaner for now
}
Text(
text = stringResource(R.string.verify_scan_prompt_friend),
color = Color.White,
fontFamily = FontFamily.Monospace,
fontSize = 12.sp,
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(bottom = 32.dp)
.background(Color.Black.copy(alpha = 0.6f), RoundedCornerShape(8.dp))
.padding(horizontal = 12.dp, vertical = 8.dp)
)
}
} else {
Column(
modifier = Modifier
.fillMaxWidth()
.weight(1f)
.background(
MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f),
RoundedCornerShape(24.dp)
)
.padding(24.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Icon(
imageVector = Icons.Outlined.QrCodeScanner,
contentDescription = null,
modifier = Modifier.size(64.dp),
tint = accent
)
Spacer(modifier = Modifier.height(24.dp))
Text(
text = stringResource(R.string.verify_camera_permission),
fontFamily = FontFamily.Monospace,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurface
)
Spacer(modifier = Modifier.height(32.dp))
Button(
onClick = { permissionState.launchPermissionRequest() },
colors = ButtonDefaults.buttonColors(containerColor = accent)
) {
Text(
text = stringResource(R.string.verify_request_camera),
fontFamily = FontFamily.Monospace
)
}
}
}
}
}
@Composable
private fun ScannerView(
onScan: (String) -> Unit
) {
val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current
var lastValid by remember { mutableStateOf<String?>(null) }
val cameraProviderFuture = remember { ProcessCameraProvider.getInstance(context) }
val cameraExecutor: ExecutorService = remember { Executors.newSingleThreadExecutor() }
val surfaceRequests = remember { MutableStateFlow<SurfaceRequest?>(null) }
val surfaceRequest by surfaceRequests.collectAsState(initial = null)
val mainHandler = remember { Handler(Looper.getMainLooper()) }
val onCodeState = rememberUpdatedState(onScan)
val analyzer = remember {
QRCodeAnalyzer { text ->
mainHandler.post {
if (text == lastValid) return@post
lastValid = text
onCodeState.value(text)
}
}
}
DisposableEffect(Unit) {
val executor = ContextCompat.getMainExecutor(context)
var cameraProvider: ProcessCameraProvider? = null
cameraProviderFuture.addListener(
{
val provider = cameraProviderFuture.get()
cameraProvider = provider
val preview = Preview.Builder().build().also {
it.setSurfaceProvider { request -> surfaceRequests.value = request }
}
val analysis = ImageAnalysis.Builder()
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
.build()
.also { it.setAnalyzer(cameraExecutor, analyzer) }
runCatching {
provider.unbindAll()
provider.bindToLifecycle(
lifecycleOwner,
CameraSelector.DEFAULT_BACK_CAMERA,
preview,
analysis
)
}.onFailure {
Log.w("VerificationSheet", "Failed to bind camera: ${it.message}")
}
},
executor
)
onDispose {
surfaceRequests.value = null
runCatching { cameraProvider?.unbindAll() }
cameraExecutor.shutdown()
}
}
surfaceRequest?.let { request ->
CameraXViewfinder(
surfaceRequest = request,
implementationMode = ImplementationMode.EMBEDDED,
modifier = Modifier.fillMaxSize()
)
}
}
@Composable
private fun QRCodeImage(data: String, size: Dp) {
val sizePx = with(LocalDensity.current) { size.toPx().toInt() }
val bitmap = remember(data, sizePx) { generateQrBitmap(data, sizePx) }
if (bitmap != null) {
Image(
bitmap = bitmap.asImageBitmap(),
contentDescription = null,
modifier = Modifier.size(size)
)
}
}
private fun generateQrBitmap(data: String, sizePx: Int): Bitmap? {
if (data.isBlank() || sizePx <= 0) return null
return try {
val matrix = QRCodeWriter().encode(data, BarcodeFormat.QR_CODE, sizePx, sizePx)
bitmapFromMatrix(matrix)
} catch (_: Exception) {
null
}
}
private fun bitmapFromMatrix(matrix: BitMatrix): Bitmap {
val width = matrix.width
val height = matrix.height
val bitmap = createBitmap(width, height)
for (x in 0 until width) {
for (y in 0 until height) {
bitmap[x, y] =
if (matrix[x, y]) android.graphics.Color.BLACK else android.graphics.Color.WHITE
}
}
return bitmap
}
private class QRCodeAnalyzer(
private val onCode: (String) -> Unit
) : ImageAnalysis.Analyzer {
private val scanner = BarcodeScanning.getClient(
BarcodeScannerOptions.Builder()
.setBarcodeFormats(Barcode.FORMAT_QR_CODE)
.build()
)
@ExperimentalGetImage
override fun analyze(imageProxy: ImageProxy) {
val mediaImage = imageProxy.image ?: run {
imageProxy.close()
return
}
val input = InputImage.fromMediaImage(mediaImage, imageProxy.imageInfo.rotationDegrees)
scanner.process(input)
.addOnSuccessListener { barcodes ->
val text = barcodes.firstOrNull()?.rawValue
if (!text.isNullOrBlank()) onCode(text)
}
.addOnCompleteListener { imageProxy.close() }
}
}
@@ -3,8 +3,12 @@ package com.bitchat.android.ui.debug
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import java.util.Date
import java.util.concurrent.ConcurrentLinkedQueue
import com.bitchat.android.protocol.BitchatPacket
import com.bitchat.android.util.toHexString
/**
* Debug settings manager for controlling debug features and collecting debug data
@@ -464,7 +468,9 @@ class DebugSettingsManager private constructor() {
toNickname: String?,
toDeviceAddress: String?,
ttl: UByte?,
isRelay: Boolean = true
isRelay: Boolean = true,
packetVersion: UByte = 1u,
routeInfo: String? = null
) {
// Build message only if verbose logging is enabled, but always update stats
val senderLabel = when {
@@ -487,18 +493,20 @@ class DebugSettingsManager private constructor() {
val fromAddr = fromDeviceAddress ?: "?"
val toAddr = toDeviceAddress ?: "?"
val ttlStr = ttl?.toString() ?: "?"
val routeStr = if (routeInfo != null) " $routeInfo" else ""
if (verboseLoggingEnabled.value) {
if (isRelay) {
// Relay: show [previousPeer] -> [nextPeer]
addDebugMessage(
DebugMessage.RelayEvent(
"♻️ Relayed $packetType by $senderLabel from $fromName (${fromPeerID ?: "?"}, $fromAddr) to $toName (${toPeerID ?: "?"}, $toAddr) with TTL $ttlStr"
"♻️ Relayed v$packetVersion $packetType by $senderLabel from $fromName (${fromPeerID ?: "?"}, $fromAddr) to $toName (${toPeerID ?: "?"}, $toAddr) with TTL $ttlStr$routeStr"
)
)
} else {
addDebugMessage(
DebugMessage.PacketEvent(
"📤 Sent $packetType by $senderLabel to $toName (${toPeerID ?: "?"}, $toAddr) with TTL $ttlStr"
"📤 Sent v$packetVersion $packetType by $senderLabel to $toName (${toPeerID ?: "?"}, $toAddr) with TTL $ttlStr$routeStr"
)
)
}
@@ -507,12 +515,52 @@ class DebugSettingsManager private constructor() {
// Do not update counters here; this path is for readable logs only.
}
// Explicit incoming/outgoing logging to avoid double counting
fun logIncoming(packetType: String, fromPeerID: String?, fromNickname: String?, fromDeviceAddress: String?) {
if (verboseLoggingEnabled.value) {
val who = fromNickname ?: fromPeerID ?: "unknown"
addDebugMessage(DebugMessage.PacketEvent("📥 Incoming $packetType from $who (${fromPeerID ?: "?"}, ${fromDeviceAddress ?: "?"})"))
// MARK: - Debug Events for Animation
sealed class MeshVisualEvent {
data class PacketActivity(val peerID: String) : MeshVisualEvent()
data class RouteActivity(val route: List<String>) : MeshVisualEvent()
}
private val _meshVisualEvents = kotlinx.coroutines.flow.MutableSharedFlow<MeshVisualEvent>(
extraBufferCapacity = 64,
onBufferOverflow = kotlinx.coroutines.channels.BufferOverflow.DROP_OLDEST
)
val meshVisualEvents: kotlinx.coroutines.flow.SharedFlow<MeshVisualEvent> = _meshVisualEvents.asSharedFlow()
fun emitVisualEvent(event: MeshVisualEvent) {
if (_debugSheetVisible.value) {
_meshVisualEvents.tryEmit(event)
}
}
// Peer nickname resolver
private var nicknameResolver: ((String) -> String?)? = null
fun setNicknameResolver(resolver: (String) -> String?) { nicknameResolver = resolver }
// Explicit incoming/outgoing logging to avoid double counting
fun logIncoming(packet: BitchatPacket, fromPeerID: String, fromNickname: String?, fromDeviceAddress: String?, myPeerID: String) {
val packetType = packet.type.toString()
val packetVersion = packet.version
val route = packet.route
val routeInfo = if (!route.isNullOrEmpty()) "routed: ${route.size} hops" else null
if (verboseLoggingEnabled.value) {
val resolvedNick = fromNickname ?: nicknameResolver?.invoke(fromPeerID) ?: "unknown"
val who = if (resolvedNick != "unknown") "$resolvedNick ($fromPeerID)" else fromPeerID
val routeStr = if (routeInfo != null) " $routeInfo" else ""
addDebugMessage(DebugMessage.PacketEvent("📥 Incoming v$packetVersion $packetType from $who (${fromDeviceAddress ?: "?"})$routeStr"))
}
emitVisualEvent(MeshVisualEvent.PacketActivity(fromPeerID))
if (!route.isNullOrEmpty()) {
val fullRoute = mutableListOf<String>()
fullRoute.add(packet.senderID.toHexString())
route.forEach { fullRoute.add(it.toHexString()) }
packet.recipientID?.let { fullRoute.add(it.toHexString()) }
emitVisualEvent(MeshVisualEvent.RouteActivity(fullRoute))
}
val now = System.currentTimeMillis()
val visible = _debugSheetVisible.value
if (visible) incomingTimestamps.offer(now)
@@ -521,11 +569,11 @@ class DebugSettingsManager private constructor() {
deviceIncomingTotalsMap[it] = (deviceIncomingTotalsMap[it] ?: 0L) + 1L
_perDeviceIncomingTotalsFlow.value = deviceIncomingTotalsMap.toMap()
}
fromPeerID?.let {
perPeerIncoming.getOrPut(it) { ConcurrentLinkedQueue() }.offer(now)
peerIncomingTotalsMap[it] = (peerIncomingTotalsMap[it] ?: 0L) + 1L
_perPeerIncomingTotalsFlow.value = peerIncomingTotalsMap.toMap()
}
perPeerIncoming.getOrPut(fromPeerID) { ConcurrentLinkedQueue() }.offer(now)
peerIncomingTotalsMap[fromPeerID] = (peerIncomingTotalsMap[fromPeerID] ?: 0L) + 1L
_perPeerIncomingTotalsFlow.value = peerIncomingTotalsMap.toMap()
// bump totals
val cur = _relayStats.value
_relayStats.value = cur.copy(
@@ -535,10 +583,11 @@ class DebugSettingsManager private constructor() {
if (visible) updateRelayStatsFromTimestamps()
}
fun logOutgoing(packetType: String, toPeerID: String?, toNickname: String?, toDeviceAddress: String?, previousHopPeerID: String? = null) {
fun logOutgoing(packetType: String, toPeerID: String?, toNickname: String?, toDeviceAddress: String?, previousHopPeerID: String? = null, packetVersion: UByte = 1u, routeInfo: String? = null) {
if (verboseLoggingEnabled.value) {
val who = toNickname ?: toPeerID ?: "unknown"
addDebugMessage(DebugMessage.PacketEvent("📤 Outgoing $packetType to $who (${toPeerID ?: "?"}, ${toDeviceAddress ?: "?"})"))
val routeStr = if (routeInfo != null) " $routeInfo" else ""
addDebugMessage(DebugMessage.PacketEvent("📤 Outgoing v$packetVersion $packetType to $who (${toPeerID ?: "?"}, ${toDeviceAddress ?: "?"})$routeStr"))
}
val now = System.currentTimeMillis()
val visible = _debugSheetVisible.value
@@ -5,15 +5,15 @@ import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Bluetooth
import androidx.compose.material.icons.filled.Wifi
import androidx.compose.material.icons.filled.WifiTethering
import androidx.compose.material.icons.filled.BugReport
import androidx.compose.material.icons.filled.Cancel
import androidx.compose.material.icons.filled.Devices
import androidx.compose.material.icons.filled.PowerSettingsNew
import androidx.compose.material.icons.filled.SettingsEthernet
@@ -29,12 +29,65 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.draw.rotate
import com.bitchat.android.mesh.BluetoothMeshService
import com.bitchat.android.services.meshgraph.MeshGraphService
import kotlinx.coroutines.launch
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.graphics.drawscope.drawIntoCanvas
import androidx.compose.ui.graphics.nativeCanvas
import androidx.compose.ui.res.stringResource
import com.bitchat.android.R
import androidx.compose.ui.platform.LocalContext
import com.bitchat.android.service.MeshServicePreferences
import com.bitchat.android.service.MeshForegroundService
import com.bitchat.android.core.ui.component.sheet.BitchatBottomSheet
import com.bitchat.android.core.ui.component.sheet.BitchatSheetTopBar
import com.bitchat.android.core.ui.component.sheet.BitchatSheetTitle
@Composable
fun MeshTopologySection() {
val colorScheme = MaterialTheme.colorScheme
val graphService = remember { MeshGraphService.getInstance() }
val snapshot by graphService.graphState.collectAsState()
Surface(shape = RoundedCornerShape(12.dp), color = colorScheme.surfaceVariant.copy(alpha = 0.2f)) {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Icon(Icons.Filled.SettingsEthernet, contentDescription = null, tint = Color(0xFF8E8E93))
Text("mesh topology", fontFamily = FontFamily.Monospace, fontSize = 14.sp, fontWeight = FontWeight.Medium)
}
val nodes = snapshot.nodes
val edges = snapshot.edges
val empty = nodes.isEmpty()
if (empty) {
Text("no gossip yet", fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.6f))
} else {
ForceDirectedMeshGraph(
nodes = nodes,
edges = edges,
modifier = Modifier
.fillMaxWidth()
.height(300.dp)
.background(colorScheme.surface.copy(alpha = 0.4f))
)
// Flexible peer list
FlowRow(
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
nodes.forEach { node ->
val label = "${node.peerID.take(8)}${node.nickname ?: "unknown"}"
Text(
text = label,
fontFamily = FontFamily.Monospace,
fontSize = 11.sp,
color = colorScheme.onSurface.copy(alpha = 0.85f)
)
}
}
}
}
}
}
private enum class GraphMode { OVERALL, PER_DEVICE, PER_PEER }
@@ -45,7 +98,6 @@ fun DebugSettingsSheet(
onDismiss: () -> Unit,
meshService: BluetoothMeshService
) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = false)
val colorScheme = MaterialTheme.colorScheme
val manager = remember { DebugSettingsManager.getInstance() }
@@ -71,6 +123,16 @@ fun DebugSettingsSheet(
val wifiAwareDiscovered by manager.wifiAwareDiscovered.collectAsState()
val wifiAwareConnected by manager.wifiAwareConnected.collectAsState()
// Persistent notification is now controlled solely by MeshServicePreferences.isBackgroundEnabled
val listState = rememberLazyListState()
val isScrolled by remember {
derivedStateOf {
listState.firstVisibleItemIndex > 0 || listState.firstVisibleItemScrollOffset > 0
}
}
val topBarAlpha by animateFloatAsState(
targetValue = if (isScrolled) 0.95f else 0f,
label = "topBarAlpha"
)
// Push live connected devices from mesh service whenever sheet is visible
LaunchedEffect(isPresented) {
@@ -112,35 +174,31 @@ fun DebugSettingsSheet(
if (!isPresented) return
ModalBottomSheet(
BitchatBottomSheet(
onDismissRequest = onDismiss,
sheetState = sheetState
) {
// Mark debug sheet visible/invisible to gate heavy work
LaunchedEffect(Unit) { DebugSettingsManager.getInstance().setDebugSheetVisible(true) }
DisposableEffect(Unit) {
onDispose { DebugSettingsManager.getInstance().setDebugSheetVisible(false) }
}
LazyColumn(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.padding(bottom = 24.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
item {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Icon(Icons.Filled.BugReport, contentDescription = null, tint = Color(0xFFFF9500))
Text(stringResource(R.string.debug_tools), fontFamily = FontFamily.Monospace, fontSize = 18.sp, fontWeight = FontWeight.Medium)
Box(modifier = Modifier.fillMaxWidth()) {
LazyColumn(
state = listState,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
contentPadding = PaddingValues(top = 80.dp, bottom = 24.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
item {
Text(
text = stringResource(R.string.debug_tools_desc),
fontFamily = FontFamily.Monospace,
fontSize = 12.sp,
color = colorScheme.onSurface.copy(alpha = 0.7f)
)
}
Text(
text = stringResource(R.string.debug_tools_desc),
fontFamily = FontFamily.Monospace,
fontSize = 12.sp,
color = colorScheme.onSurface.copy(alpha = 0.7f)
)
}
// Verbose logging toggle
item {
Surface(shape = RoundedCornerShape(12.dp), color = colorScheme.surfaceVariant.copy(alpha = 0.2f)) {
@@ -161,6 +219,11 @@ fun DebugSettingsSheet(
}
}
// Mesh topology visualization (moved below verbose logging)
item {
MeshTopologySection()
}
// GATT controls
item {
Surface(shape = RoundedCornerShape(12.dp), color = colorScheme.surfaceVariant.copy(alpha = 0.2f)) {
@@ -587,9 +650,6 @@ fun DebugSettingsSheet(
}
}
// Connected devices
item {
Surface(shape = RoundedCornerShape(12.dp), color = colorScheme.surfaceVariant.copy(alpha = 0.2f)) {
@@ -672,7 +732,29 @@ fun DebugSettingsSheet(
}
}
item { Spacer(Modifier.height(16.dp)) }
item { Spacer(Modifier.height(16.dp)) }
}
BitchatSheetTopBar(
onClose = onDismiss,
modifier = Modifier.align(Alignment.TopCenter),
backgroundAlpha = topBarAlpha,
title = {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Icon(
imageVector = Icons.Filled.BugReport,
contentDescription = null,
tint = Color(0xFFFF9500)
)
BitchatSheetTitle(
text = stringResource(R.string.debug_tools)
)
}
}
)
}
}
}
@@ -0,0 +1,409 @@
package com.bitchat.android.ui.debug
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.PathEffect
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.nativeCanvas
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.bitchat.android.services.meshgraph.MeshGraphService
import kotlin.math.*
import kotlin.random.Random
import androidx.compose.material3.MaterialTheme
import com.bitchat.android.ui.debug.DebugSettingsManager.MeshVisualEvent
// Physics constants
private const val REPULSION_FORCE = 100000f
private const val SPRING_LENGTH = 150f
private const val SPRING_STRENGTH = 0.02f
private const val CENTER_GRAVITY = 0.02f
private const val DAMPING = 0.85f
private const val MAX_VELOCITY = 30f
private const val PULSE_DECAY = 0.05f
private const val ROUTE_DECAY = 0.02f
private class GraphNodeState(
val id: String,
var label: String,
var x: Float,
var y: Float
) {
var vx: Float = 0f
var vy: Float = 0f
var isDragged: Boolean = false
var pulseLevel: Float = 0f // 0f to 1f, used for size/glow animation
}
private class Simulation {
val nodes = mutableMapOf<String, GraphNodeState>()
// Storing edges as pairs of IDs
val edges = mutableListOf<MeshGraphService.GraphEdge>()
// Active routes being animated: List of peerIDs in order -> intensity (1.0..0.0)
val activeRoutes = mutableListOf<Pair<List<String>, Float>>()
// Bounds for initial placement and centering
var width: Float = 1000f
var height: Float = 1000f
fun updateTopology(
newNodes: List<MeshGraphService.GraphNode>,
newEdges: List<MeshGraphService.GraphEdge>
) {
// Remove stale nodes
val newIds = newNodes.map { it.peerID }.toSet()
nodes.keys.toList().forEach { id ->
if (id !in newIds) nodes.remove(id)
}
// Add/Update nodes
newNodes.forEach { n ->
val existing = nodes[n.peerID]
val displayLabel = n.nickname ?: n.peerID.take(8)
if (existing != null) {
existing.label = displayLabel
} else {
// Spawn near center with random jitter
val angle = Random.nextFloat() * 2 * PI
val radius = 50f + Random.nextFloat() * 50f
nodes[n.peerID] = GraphNodeState(
id = n.peerID,
label = displayLabel,
x = (width / 2f) + (cos(angle) * radius).toFloat(),
y = (height / 2f) + (sin(angle) * radius).toFloat()
)
}
}
// Update edges
edges.clear()
edges.addAll(newEdges)
}
fun triggerNodePulse(peerID: String) {
nodes[peerID]?.pulseLevel = 1f
}
fun triggerRouteAnimation(route: List<String>) {
if (route.size > 1) {
activeRoutes.add(route to 1f)
}
}
fun step() {
val nodeList = nodes.values.toList()
val cx = width / 2f
val cy = height / 2f
// 1. Repulsion (Node-Node)
for (i in nodeList.indices) {
val n1 = nodeList[i]
for (j in i + 1 until nodeList.size) {
val n2 = nodeList[j]
val dx = n1.x - n2.x
val dy = n1.y - n2.y
val distSq = dx * dx + dy * dy
if (distSq > 0.1f) {
val dist = sqrt(distSq)
val force = REPULSION_FORCE / distSq
val fx = (dx / dist) * force
val fy = (dy / dist) * force
if (!n1.isDragged) {
n1.vx += fx
n1.vy += fy
}
if (!n2.isDragged) {
n2.vx -= fx
n2.vy -= fy
}
}
}
}
// 2. Attraction (Edges)
edges.forEach { edge ->
val n1 = nodes[edge.a]
val n2 = nodes[edge.b]
if (n1 != null && n2 != null) {
val dx = n1.x - n2.x
val dy = n1.y - n2.y
val dist = sqrt(dx * dx + dy * dy)
if (dist > 0.1f) {
val force = (dist - SPRING_LENGTH) * SPRING_STRENGTH
val fx = (dx / dist) * force
val fy = (dy / dist) * force
if (!n1.isDragged) {
n1.vx -= fx
n1.vy -= fy
}
if (!n2.isDragged) {
n2.vx += fx
n2.vy += fy
}
}
}
}
// 3. Center Gravity & Integration & Animation Decay
nodeList.forEach { n ->
if (!n.isDragged) {
// Pull to center
val dx = n.x - cx
val dy = n.y - cy
n.vx -= dx * CENTER_GRAVITY
n.vy -= dy * CENTER_GRAVITY
// Apply velocity
val vMag = sqrt(n.vx * n.vx + n.vy * n.vy)
if (vMag > MAX_VELOCITY) {
n.vx = (n.vx / vMag) * MAX_VELOCITY
n.vy = (n.vy / vMag) * MAX_VELOCITY
}
n.x += n.vx
n.y += n.vy
// Damping
n.vx *= DAMPING
n.vy *= DAMPING
} else {
n.vx = 0f
n.vy = 0f
}
// Decay pulse
if (n.pulseLevel > 0f) {
n.pulseLevel = (n.pulseLevel - PULSE_DECAY).coerceAtLeast(0f)
}
}
// Decay active routes
val iter = activeRoutes.iterator()
while (iter.hasNext()) {
val (route, intensity) = iter.next()
val newIntensity = intensity - ROUTE_DECAY
if (newIntensity <= 0f) {
iter.remove()
} else {
// Ugly mutation but efficient for simulation loop
// We need to replace the pair since pairs are immutable
// Finding index is O(N) but N is small
val idx = activeRoutes.indexOfFirst { it.first === route && it.second == intensity }
if (idx >= 0) {
activeRoutes[idx] = route to newIntensity
}
}
}
}
}
@Composable
fun ForceDirectedMeshGraph(
nodes: List<MeshGraphService.GraphNode>,
edges: List<MeshGraphService.GraphEdge>,
modifier: Modifier = Modifier
) {
val density = LocalDensity.current
val simulation = remember { Simulation() }
val colorScheme = MaterialTheme.colorScheme
// Listen for visual events
val debugManager = remember { DebugSettingsManager.getInstance() }
LaunchedEffect(Unit) {
debugManager.meshVisualEvents.collect { event ->
when (event) {
is MeshVisualEvent.PacketActivity -> simulation.triggerNodePulse(event.peerID)
is MeshVisualEvent.RouteActivity -> simulation.triggerRouteAnimation(event.route)
}
}
}
// We need a state that changes on every tick to trigger redraw
var tick by remember { mutableLongStateOf(0L) }
// Update topology when input data changes
LaunchedEffect(nodes, edges) {
simulation.updateTopology(nodes, edges)
}
// Animation Loop
LaunchedEffect(Unit) {
while (true) {
withFrameNanos {
simulation.step()
tick++
}
}
}
BoxWithConstraints(modifier = modifier) {
val w = maxWidth.value * density.density
val h = maxHeight.value * density.density
// Update simulation bounds if size changes
SideEffect {
simulation.width = w
simulation.height = h
}
Canvas(
modifier = Modifier
.fillMaxSize()
.pointerInput(Unit) {
detectDragGestures(
onDragStart = { offset ->
// Find closest node
val closest = simulation.nodes.values.minByOrNull {
val dx = it.x - offset.x
val dy = it.y - offset.y
dx*dx + dy*dy
}
if (closest != null) {
val dist = sqrt((closest.x - offset.x).pow(2) + (closest.y - offset.y).pow(2))
if (dist < 80f) { // Touch radius
closest.isDragged = true
}
}
},
onDragEnd = {
simulation.nodes.values.forEach { it.isDragged = false }
},
onDragCancel = {
simulation.nodes.values.forEach { it.isDragged = false }
},
onDrag = { change, dragAmount ->
change.consume()
val dragged = simulation.nodes.values.find { it.isDragged }
if (dragged != null) {
dragged.x += dragAmount.x
dragged.y += dragAmount.y
}
}
)
}
) {
// Read tick to ensure recomposition
val t = tick
val nodeMap = simulation.nodes
// Draw Edges
simulation.edges.forEach { edge ->
val n1 = nodeMap[edge.a]
val n2 = nodeMap[edge.b]
if (n1 != null && n2 != null) {
val start = Offset(n1.x, n1.y)
val end = Offset(n2.x, n2.y)
val baseColor = Color(0xFF4A90E2)
if (edge.isConfirmed) {
drawLine(
color = baseColor,
start = start,
end = end,
strokeWidth = 5f
)
} else {
// Unconfirmed: draw "solid" from declarer, "dashed" from other
val isA = (edge.confirmedBy == edge.a)
val solidStart = if (isA) start else end
val solidEnd = if (isA) end else start
val midX = (start.x + end.x) / 2
val midY = (start.y + end.y) / 2
val mid = Offset(midX, midY)
drawLine(
color = baseColor,
start = solidStart,
end = mid,
strokeWidth = 4f
)
drawLine(
color = baseColor.copy(alpha = 0.6f),
start = mid,
end = solidEnd,
strokeWidth = 4f,
pathEffect = PathEffect.dashPathEffect(floatArrayOf(10f, 10f), 0f)
)
}
}
}
// Draw Active Routes (overlay)
simulation.activeRoutes.forEach { (route, intensity) ->
val routeColor = Color(0xFFFFD700).copy(alpha = intensity) // Gold
val strokeW = 4f * intensity + 2f
for (i in 0 until route.size - 1) {
val p1 = nodeMap[route[i]]
val p2 = nodeMap[route[i+1]]
if (p1 != null && p2 != null) {
drawLine(
color = routeColor,
start = Offset(p1.x, p1.y),
end = Offset(p2.x, p2.y),
strokeWidth = strokeW,
cap = androidx.compose.ui.graphics.StrokeCap.Round
)
}
}
}
// Draw Nodes
val labelColor = colorScheme.onSurface.toArgb()
val textPaint = android.graphics.Paint().apply {
isAntiAlias = true
textSize = 12.sp.toPx()
this.color = labelColor
}
nodeMap.values.forEach { node ->
val center = Offset(node.x, node.y)
val pulse = node.pulseLevel
// Pulse glow
if (pulse > 0.05f) {
drawCircle(
color = Color(0xFF00FF00).copy(alpha = pulse * 0.6f),
radius = 16f + (pulse * 20f),
center = center
)
}
drawCircle(
color = Color(0xFF00C851),
radius = 16f + (pulse * 4f), // Slight scale up
center = center
)
drawCircle(
color = Color.White,
radius = 12f + (pulse * 3f),
center = center,
style = Stroke(width = 2f)
)
// Label
drawContext.canvas.nativeCanvas.drawText(
node.label,
node.x + 22f + (pulse * 5f),
node.y + 4f,
textPaint
)
}
}
}
}
@@ -1,5 +1,7 @@
package com.bitchat.android.ui.media
import android.Manifest
import android.content.pm.PackageManager
import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
@@ -19,6 +21,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import androidx.core.content.FileProvider
import com.bitchat.android.features.media.ImageUtils
import java.io.File
@@ -70,8 +73,16 @@ fun ImagePickerButton(
)
capturedImagePath = file.absolutePath
takePictureLauncher.launch(uri)
} catch (_: Exception) {
// Ignore errors; no-op
} catch (e: Exception) {
android.util.Log.e("ImagePickerButton", "Camera capture failed", e)
}
}
val permissionLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestPermission()
) { isGranted ->
if (isGranted) {
startCameraCapture()
}
}
@@ -80,7 +91,13 @@ fun ImagePickerButton(
.size(32.dp)
.combinedClickable(
onClick = { imagePicker.launch("image/*") },
onLongClick = { startCameraCapture() }
onLongClick = {
if (ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED) {
startCameraCapture()
} else {
permissionLauncher.launch(Manifest.permission.CAMERA)
}
}
),
contentAlignment = Alignment.Center
) {
@@ -41,6 +41,10 @@ object AppConstants {
const val MAX_FRAGMENT_SIZE: Int = 469
const val FRAGMENT_TIMEOUT_MS: Long = 30_000L
const val CLEANUP_INTERVAL_MS: Long = 10_000L
const val MAX_FRAGMENTS_PER_ID: Int = 256
const val MAX_FRAGMENT_TOTAL_BYTES: Int = 1_048_576
const val MAX_ACTIVE_FRAGMENT_SETS: Int = 64
const val MAX_GLOBAL_FRAGMENT_TOTAL_BYTES: Long = 4L * 1_048_576L
}
object Security {
@@ -58,8 +62,13 @@ object AppConstants {
const val HIGH_NONCE_WARNING_THRESHOLD: Long = 1_000_000_000L
}
object Verification {
const val QR_MAX_AGE_SECONDS: Long = 300L // 5 minutes
}
object Protocol {
const val COMPRESSION_THRESHOLD_BYTES: Int = 100
const val MAX_PAYLOAD_LENGTH: Int = 10_485_760
}
object StoreForward {
@@ -76,9 +85,9 @@ object AppConstants {
const val SCAN_ON_DURATION_NORMAL_MS: Long = 8_000L
const val SCAN_OFF_DURATION_NORMAL_MS: Long = 2_000L
const val SCAN_ON_DURATION_POWER_SAVE_MS: Long = 2_000L
const val SCAN_OFF_DURATION_POWER_SAVE_MS: Long = 8_000L
const val SCAN_OFF_DURATION_POWER_SAVE_MS: Long = 28_000L
const val SCAN_ON_DURATION_ULTRA_LOW_MS: Long = 1_000L
const val SCAN_OFF_DURATION_ULTRA_LOW_MS: Long = 10_000L
const val SCAN_OFF_DURATION_ULTRA_LOW_MS: Long = 29_000L
const val MAX_CONNECTIONS_NORMAL: Int = 8
const val MAX_CONNECTIONS_POWER_SAVE: Int = 8
const val MAX_CONNECTIONS_ULTRA_LOW: Int = 4
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+36
View File
@@ -364,4 +364,40 @@
<string name="location_notes_input_placeholder">أضف ملاحظة لهذا المكان</string>
<string name="bluetooth_recommended">بلوتوث موصى به</string>
<string name="mesh_service_notification_content">الشبكة تعمل — %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 &amp; 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>
</resources>
+36
View File
@@ -351,4 +351,40 @@
<string name="bluetooth_recommended">ব্লুটুথ প্রস্তাবিত</string>
<string name="mesh_service_notification_content">মেশ চলছে — %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 &amp; 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>
</resources>
+38 -2
View File
@@ -70,7 +70,7 @@
<string name="cd_remove_favorite">Aus Favoriten entfernen</string>
<string name="cd_add_bookmark">Lesezeichen hinzufügen</string>
<!-- ChatKopf & Barrierefreiheit -->
<!-- ChatKopf &amp; Barrierefreiheit -->
<string name="chat_back">zurück</string>
<string name="chat_channel_prefix">Kanal: %1$s</string>
<string name="chat_leave">verlassen</string>
@@ -170,7 +170,7 @@
<string name="cd_online_geohash_channels">OnlineGeohashKanäle</string>
<string name="cd_end_to_end_encryption">EndezuEndeVerschlüsselung</string>
<!-- Bilder & Dateien -->
<!-- Bilder &amp; Dateien -->
<string name="image_page_of">Bild %1$d von %2$d</string>
<string name="image_unavailable">Bild nicht verfügbar</string>
<string name="image_saved_to_downloads">Bild in "Downloads" gespeichert</string>
@@ -365,4 +365,40 @@
<string name="location_notes_input_placeholder">füge eine Notiz zu diesem Ort hinzu</string>
<string name="bluetooth_recommended">Bluetooth empfohlen</string>
<string name="mesh_service_notification_content">Mesh läuft — %1$d Peers</string>
<string name="verify_title">verifizieren</string>
<string name="verify_my_qr_title">scannen zur verifizierung</string>
<string name="verify_scan_prompt_friend">anderen qr scannen</string>
<string name="verify_scan_someone">anderen qr scannen</string>
<string name="verify_show_my_qr">mein qr zeigen</string>
<string name="verify_remove">verifizierung entfernen</string>
<string name="verify_qr_unavailable">qr nicht verfügbar</string>
<string name="verify_camera_permission">kamera-berechtigung nötig</string>
<string name="verify_request_camera">kamera aktivieren</string>
<string name="verify_paste_label">verifizierungs-url einfügen</string>
<string name="verify_validate">validieren</string>
<string name="verify_scanned">verifizierung angefordert</string>
<string name="security_verification_title">sicherheitsüberprüfung</string>
<string name="fingerprint_their">ihr fingerabdruck</string>
<string name="fingerprint_yours">dein fingerabdruck</string>
<string name="fingerprint_pending">handshake ausstehend</string>
<string name="fingerprint_no_peer">privatchat öffnen</string>
<string name="fingerprint_status_verified">verschlüsselt &amp; verifiziert</string>
<string name="fingerprint_status_encrypted">verschlüsselt</string>
<string name="fingerprint_status_handshaking">handshake</string>
<string name="fingerprint_status_failed">handshake fehlgeschlagen</string>
<string name="fingerprint_status_uninitialized">unverschlüsselt</string>
<string name="fingerprint_verified_label">verifiziert</string>
<string name="fingerprint_verified_message">du hast diese person verifiziert.</string>
<string name="fingerprint_not_verified_label">nicht verifiziert</string>
<string name="fingerprint_not_verified_message_fmt">vergleiche fingerabdrücke mit %1$s.</string>
<string name="fingerprint_mark_verified">als verifiziert markieren</string>
<string name="fingerprint_start_handshake">handshake starten</string>
<string name="fingerprint_copy">kopieren</string>
<string name="verify_mutual_match_title">Gegenseitige Verifizierung</string>
<string name="verify_mutual_match_body">Du und %1$s habt euch verifiziert</string>
<string name="verify_mutual_system_message">gegenseitige verifizierung mit %1$s</string>
<string name="verify_success_title">Verifiziert</string>
<string name="verify_success_body">Du hast %1$s verifiziert</string>
<string name="verify_success_system_message">verifiziert %1$s</string>
</resources>

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