diff --git a/.github/workflows/android-build.yml b/.github/workflows/android-build.yml
index 2f8a7906..9911c273 100644
--- a/.github/workflows/android-build.yml
+++ b/.github/workflows/android-build.yml
@@ -1,6 +1,7 @@
name: Android CI
on:
+ workflow_dispatch:
push:
branches: [ "main", "develop" ]
pull_request:
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 79495ee9..f1e1841a 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -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 }}
diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 00000000..5244db5f
--- /dev/null
+++ b/AGENTS.md
@@ -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.*
diff --git a/LICENSE.md b/LICENSE.md
index b77bf2ab..f288702d 100644
--- a/LICENSE.md
+++ b/LICENSE.md
@@ -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.
+ 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.
+
+
+ Copyright (C)
+
+ 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 .
+
+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:
+
+ Copyright (C)
+ 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
+.
+
+ 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
+.
diff --git a/PRIVACY_POLICY.md b/PRIVACY_POLICY.md
index af2349c6..a90dd78d 100644
--- a/PRIVACY_POLICY.md
+++ b/PRIVACY_POLICY.md
@@ -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
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index f6de2444..700655fe 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -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,12 +98,22 @@ dependencies {
// Lifecycle
implementation(libs.bundles.lifecycle)
+ implementation(libs.androidx.lifecycle.process)
// Navigation
implementation(libs.androidx.navigation.compose)
// 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)
diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro
index d3e004db..df941012 100644
--- a/app/proguard-rules.pro
+++ b/app/proguard-rules.pro
@@ -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 ;
+}
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index e79fa8eb..f83ecefb 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -17,6 +17,7 @@
+
@@ -34,10 +35,14 @@
+
+
+
+
@@ -56,6 +61,7 @@
+
+
+
+
+
+
+
diff --git a/app/src/main/assets/nostr_relays.csv b/app/src/main/assets/nostr_relays.csv
index ab075fb4..97652073 100644
--- a/app/src/main/assets/nostr_relays.csv
+++ b/app/src/main/assets/nostr_relays.csv
@@ -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-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
-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
-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
-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.mom,50.4754,12.3683
-relay.nostr-check.me,43.6532,-79.3832
-relay.ditto.pub,43.6532,-79.3832
+Relay URL,Latitude,Longitude
+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
+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
+relay.nostrdice.com,-33.8688,151.209
+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
+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
+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
+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
diff --git a/app/src/main/java/com/bitchat/android/BitchatApplication.kt b/app/src/main/java/com/bitchat/android/BitchatApplication.kt
index 012f388d..32f11d0b 100644
--- a/app/src/main/java/com/bitchat/android/BitchatApplication.kt
+++ b/app/src/main/java/com/bitchat/android/BitchatApplication.kt
@@ -43,9 +43,16 @@ class BitchatApplication : Application() {
// Initialize Wi‑Fi Aware controller with persisted default
try {
- val enabled = com.bitchat.android.ui.debug.DebugPreferenceManager.getWifiAwareEnabled(false)
+ val enabled = com.bitchat.android.ui.debug.DebugPreferenceManager.getWifiAwareEnabled(true)
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) { }
diff --git a/app/src/main/java/com/bitchat/android/MainActivity.kt b/app/src/main/java/com/bitchat/android/MainActivity.kt
index 553ad387..3e6b482e 100644
--- a/app/src/main/java/com/bitchat/android/MainActivity.kt
+++ b/app/src/main/java/com/bitchat/android/MainActivity.kt
@@ -19,6 +19,7 @@ import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.repeatOnLifecycle
import androidx.lifecycle.Lifecycle
import com.bitchat.android.mesh.BluetoothMeshService
+import com.bitchat.android.mesh.MeshService
import com.bitchat.android.onboarding.BluetoothCheckScreen
import com.bitchat.android.onboarding.BluetoothStatus
import com.bitchat.android.onboarding.BluetoothStatusManager
@@ -26,6 +27,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
@@ -39,7 +41,9 @@ import com.bitchat.android.ui.ChatScreen
import com.bitchat.android.ui.ChatViewModel
import com.bitchat.android.ui.OrientationAwareActivity
import com.bitchat.android.ui.theme.BitchatTheme
+import com.bitchat.android.wifiaware.WifiAwareController
import com.bitchat.android.nostr.PoWPreferenceManager
+import com.bitchat.android.services.VerificationService
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@@ -53,12 +57,13 @@ class MainActivity : OrientationAwareActivity() {
// Core mesh service - provided by the foreground service holder
private lateinit var meshService: BluetoothMeshService
+ private lateinit var unifiedMeshService: MeshService
private val mainViewModel: MainViewModel by viewModels()
private val chatViewModel: ChatViewModel by viewModels {
object : ViewModelProvider.Factory {
override fun create(modelClass: Class): T {
@Suppress("UNCHECKED_CAST")
- return ChatViewModel(application, meshService) as T
+ return ChatViewModel(application, meshService, unifiedMeshService) as T
}
}
}
@@ -112,6 +117,7 @@ class MainActivity : OrientationAwareActivity() {
// Ensure foreground service is running and get mesh instance from holder
try { com.bitchat.android.service.MeshForegroundService.start(applicationContext) } catch (_: Exception) { }
meshService = com.bitchat.android.service.MeshServiceHolder.getOrCreate(applicationContext)
+ unifiedMeshService = com.bitchat.android.service.MeshServiceHolder.getUnifiedOrCreate(applicationContext)
// Expose BLE mesh to Wi‑Fi Aware controller for cross-transport relays - DEPRECATED
// Bridging is now handled by TransportBridgeService automatically
@@ -137,6 +143,9 @@ class MainActivity : OrientationAwareActivity() {
activity = this,
permissionManager = permissionManager,
onOnboardingComplete = ::handleOnboardingComplete,
+ onBackgroundLocationRequired = {
+ mainViewModel.updateOnboardingState(OnboardingState.BACKGROUND_LOCATION_EXPLANATION)
+ },
onOnboardingFailed = ::handleOnboardingFailed
)
@@ -163,47 +172,12 @@ class MainActivity : OrientationAwareActivity() {
}
}
- // Bridge Wi‑Fi Aware callbacks into ChatViewModel (reusing BLE delegate methods)
+ // Keep the unified mesh delegate attached when Wi-Fi Aware starts after the UI.
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
- com.bitchat.android.wifiaware.WifiAwareController.running.collect { running ->
- val svc = com.bitchat.android.wifiaware.WifiAwareController.getService()
- if (running && svc != null) {
- svc.delegate = object : com.bitchat.android.wifiaware.WifiAwareMeshDelegate {
- override fun didReceiveMessage(message: com.bitchat.android.model.BitchatMessage) {
- if (message.isPrivate) {
- message.senderPeerID?.let { pid -> com.bitchat.android.services.AppStateStore.addPrivateMessage(pid, message) }
- } else if (message.channel != null) {
- com.bitchat.android.services.AppStateStore.addChannelMessage(message.channel, message)
- } else {
- com.bitchat.android.services.AppStateStore.addPublicMessage(message)
- }
- chatViewModel.didReceiveMessage(message)
- }
- override fun didUpdatePeerList(peers: List) {
- chatViewModel.onWifiPeersUpdated(peers)
- }
- override fun didReceiveChannelLeave(channel: String, fromPeer: String) {
- chatViewModel.didReceiveChannelLeave(channel, fromPeer)
- }
- override fun didReceiveDeliveryAck(messageID: String, recipientPeerID: String) {
- chatViewModel.didReceiveDeliveryAck(messageID, recipientPeerID)
- }
- override fun didReceiveReadReceipt(messageID: String, recipientPeerID: String) {
- chatViewModel.didReceiveReadReceipt(messageID, recipientPeerID)
- }
- override fun decryptChannelMessage(encryptedContent: ByteArray, channel: String): String? {
- return chatViewModel.decryptChannelMessage(encryptedContent, channel)
- }
- override fun getNickname(): String? {
- return chatViewModel.getNickname()
- }
- override fun isFavorite(peerID: String): Boolean {
- return try {
- com.bitchat.android.favorites.FavoritesPersistenceService.shared.getFavoriteStatus(peerID)?.isMutual == true
- } catch (_: Exception) { false }
- }
- }
+ WifiAwareController.running.collect { running ->
+ if (running && lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)) {
+ unifiedMeshService.delegate = chatViewModel
}
}
}
@@ -319,6 +293,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) {
@@ -445,10 +434,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)
@@ -511,6 +507,8 @@ class MainActivity : OrientationAwareActivity() {
Log.d("MainActivity", "Location services enabled by user")
mainViewModel.updateLocationLoading(false)
mainViewModel.updateLocationStatus(LocationStatus.ENABLED)
+ // Ensure Wi-Fi Aware starts now that location is enabled
+ com.bitchat.android.wifiaware.WifiAwareController.startIfPossible()
checkBatteryOptimizationAndProceed()
}
@@ -714,14 +712,15 @@ class MainActivity : OrientationAwareActivity() {
return@launch
}
- // Set up mesh service delegate and start services
- meshService.delegate = chatViewModel
- meshService.startServices()
+ // Set up unified mesh delegate and start enabled transports
+ unifiedMeshService.delegate = chatViewModel
+ unifiedMeshService.startServices()
Log.d("MainActivity", "Mesh service started successfully")
// Handle any notification intent
handleNotificationIntent(intent)
+ handleVerificationIntent(intent)
// Small delay to ensure mesh service is fully initialized
delay(500)
@@ -750,6 +749,7 @@ class MainActivity : OrientationAwareActivity() {
// Handle notification intents when app is already running
if (mainViewModel.onboardingState.value == OnboardingState.COMPLETE) {
handleNotificationIntent(intent)
+ handleVerificationIntent(intent)
}
}
@@ -758,14 +758,12 @@ class MainActivity : OrientationAwareActivity() {
// Check Bluetooth and Location status on resume and handle accordingly
if (mainViewModel.onboardingState.value == OnboardingState.COMPLETE) {
// Reattach mesh delegate to new ChatViewModel instance after Activity recreation
- try { meshService.delegate = chatViewModel } catch (_: Exception) { }
- // Set app foreground state
- meshService.connectionManager.setAppBackgroundState(false)
- chatViewModel.setAppBackgroundState(false)
+ try { unifiedMeshService.delegate = chatViewModel } catch (_: Exception) { }
// Check if Bluetooth was disabled while app was backgrounded
val currentBluetoothStatus = bluetoothStatusManager.checkBluetoothStatus()
- if (currentBluetoothStatus != BluetoothStatus.ENABLED && !mainViewModel.isBluetoothCheckSkipped.value) {
+ val bleRequired = try { com.bitchat.android.ui.debug.DebugPreferenceManager.getBleEnabled(true) } catch (_: Exception) { true }
+ if (bleRequired && currentBluetoothStatus != BluetoothStatus.ENABLED && !mainViewModel.isBluetoothCheckSkipped.value) {
Log.w("MainActivity", "Bluetooth disabled while app was backgrounded")
mainViewModel.updateBluetoothStatus(currentBluetoothStatus)
mainViewModel.updateOnboardingState(OnboardingState.BLUETOOTH_CHECK)
@@ -780,6 +778,9 @@ class MainActivity : OrientationAwareActivity() {
mainViewModel.updateLocationStatus(currentLocationStatus)
mainViewModel.updateOnboardingState(OnboardingState.LOCATION_CHECK)
mainViewModel.updateLocationLoading(false)
+ } else {
+ // If location is enabled, ensure Wi-Fi Aware starts if it was blocked by location earlier
+ com.bitchat.android.wifiaware.WifiAwareController.startIfPossible()
}
}
}
@@ -788,11 +789,8 @@ 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 { unifiedMeshService.delegate = null } catch (_: Exception) { }
}
}
@@ -818,8 +816,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)
@@ -855,6 +854,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()
diff --git a/app/src/main/java/com/bitchat/android/core/ui/component/sheet/BitchatBottomSheet.kt b/app/src/main/java/com/bitchat/android/core/ui/component/sheet/BitchatBottomSheet.kt
new file mode 100644
index 00000000..948fd6d9
--- /dev/null
+++ b/app/src/main/java/com/bitchat/android/core/ui/component/sheet/BitchatBottomSheet.kt
@@ -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,
+ )
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/bitchat/android/core/ui/component/sheet/BitchatSheetTopBar.kt b/app/src/main/java/com/bitchat/android/core/ui/component/sheet/BitchatSheetTopBar.kt
new file mode 100644
index 00000000..036cf01b
--- /dev/null
+++ b/app/src/main/java/com/bitchat/android/core/ui/component/sheet/BitchatSheetTopBar.kt
@@ -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
+ )
+ )
+}
diff --git a/app/src/main/java/com/bitchat/android/crypto/EncryptionService.kt b/app/src/main/java/com/bitchat/android/crypto/EncryptionService.kt
index 449d705f..4b51fd31 100644
--- a/app/src/main/java/com/bitchat/android/crypto/EncryptionService.kt
+++ b/app/src/main/java/com/bitchat/android/crypto/EncryptionService.kt
@@ -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() // 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,18 +431,21 @@ 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()
-
+
// Store private key in preferences
try {
val privateKey = keyPair.private as Ed25519PrivateKeyParameters
val privateKeyBytes = privateKey.encoded
- val encodedKey = android.util.Base64.encodeToString(privateKeyBytes, android.util.Base64.DEFAULT)
-
- val prefs = context.getSharedPreferences("bitchat_crypto", Context.MODE_PRIVATE)
- prefs.edit().putString(ED25519_PRIVATE_KEY_PREF, encodedKey).apply()
+ val encodedKey = Base64.encodeToString(privateKeyBytes, Base64.DEFAULT)
+
+ 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}")
+ }
+ }
}
diff --git a/app/src/main/java/com/bitchat/android/features/file/FileUtils.kt b/app/src/main/java/com/bitchat/android/features/file/FileUtils.kt
index e1016b11..10765416 100644
--- a/app/src/main/java/com/bitchat/android/features/file/FileUtils.kt
+++ b/app/src/main/java/com/bitchat/android/features/file/FileUtils.kt
@@ -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)
+ }
+ }
}
diff --git a/app/src/main/java/com/bitchat/android/geohash/AndroidGeocoderProvider.kt b/app/src/main/java/com/bitchat/android/geohash/AndroidGeocoderProvider.kt
new file mode 100644
index 00000000..670d4cfc
--- /dev/null
+++ b/app/src/main/java/com/bitchat/android/geohash/AndroidGeocoderProvider.kt
@@ -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 {
+ 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) {
+ 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()
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/bitchat/android/geohash/FusedLocationProvider.kt b/app/src/main/java/com/bitchat/android/geohash/FusedLocationProvider.kt
new file mode 100644
index 00000000..b6d29c90
--- /dev/null
+++ b/app/src/main/java/com/bitchat/android/geohash/FusedLocationProvider.kt
@@ -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}")
+ }
+ }
+}
diff --git a/app/src/main/java/com/bitchat/android/geohash/GeocoderFactory.kt b/app/src/main/java/com/bitchat/android/geohash/GeocoderFactory.kt
new file mode 100644
index 00000000..2c1e809c
--- /dev/null
+++ b/app/src/main/java/com/bitchat/android/geohash/GeocoderFactory.kt
@@ -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()
+ }
+ }
+}
diff --git a/app/src/main/java/com/bitchat/android/geohash/GeocoderProvider.kt b/app/src/main/java/com/bitchat/android/geohash/GeocoderProvider.kt
new file mode 100644
index 00000000..bb4d69c2
--- /dev/null
+++ b/app/src/main/java/com/bitchat/android/geohash/GeocoderProvider.kt
@@ -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
+}
diff --git a/app/src/main/java/com/bitchat/android/geohash/GeohashBookmarksStore.kt b/app/src/main/java/com/bitchat/android/geohash/GeohashBookmarksStore.kt
index d4ccb9bf..81d83b79 100644
--- a/app/src/main/java/com/bitchat/android/geohash/GeohashBookmarksStore.kt
+++ b/app/src/main/java/com/bitchat/android/geohash/GeohashBookmarksStore.kt
@@ -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()
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)
}
diff --git a/app/src/main/java/com/bitchat/android/geohash/LocationChannel.kt b/app/src/main/java/com/bitchat/android/geohash/LocationChannel.kt
index 41ef168d..a475713a 100644
--- a/app/src/main/java/com/bitchat/android/geohash/LocationChannel.kt
+++ b/app/src/main/java/com/bitchat/android/geohash/LocationChannel.kt
@@ -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.
diff --git a/app/src/main/java/com/bitchat/android/geohash/LocationChannelManager.kt b/app/src/main/java/com/bitchat/android/geohash/LocationChannelManager.kt
index 9c18a859..222cc54e 100644
--- a/app/src/main/java/com/bitchat/android/geohash/LocationChannelManager.kt
+++ b/app/src/main/java/com/bitchat/android/geohash/LocationChannelManager.kt
@@ -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
private val _availableChannels = MutableStateFlow>(emptyList())
@@ -74,12 +102,40 @@ class LocationChannelManager private constructor(private val context: Context) {
private val _locationServicesEnabled = MutableStateFlow(false)
val locationServicesEnabled: StateFlow = _locationServicesEnabled
+ private val _systemLocationEnabled = MutableStateFlow(checkSystemLocationEnabled())
+ val systemLocationEnabled: StateFlow = _systemLocationEnabled
+
+ val effectiveLocationEnabled: StateFlow = 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,35 +173,30 @@ 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")
return
}
-
+
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
- }
-
- if (isGeocoding) {
- Log.d(TAG, "Already geocoding, skipping")
- return
- }
+ // Cancel any pending geocoding job to avoid race conditions
+ geocodingJob?.cancel()
- 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)
-
- if (!addresses.isNullOrEmpty()) {
+ val addresses = geocoderProvider.getFromLocation(location.latitude, location.longitude, 1)
+
+ 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
- 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 {
@@ -618,7 +516,23 @@ class LocationChannelManager private constructor(private val context: Context) {
_selectedChannel.value = ChannelID.Mesh
}
}
-
+
+ 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) {}
}
}
diff --git a/app/src/main/java/com/bitchat/android/geohash/LocationProvider.kt b/app/src/main/java/com/bitchat/android/geohash/LocationProvider.kt
new file mode 100644
index 00000000..0ead523d
--- /dev/null
+++ b/app/src/main/java/com/bitchat/android/geohash/LocationProvider.kt
@@ -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()
+}
diff --git a/app/src/main/java/com/bitchat/android/geohash/OpenStreetMapGeocoderProvider.kt b/app/src/main/java/com/bitchat/android/geohash/OpenStreetMapGeocoderProvider.kt
new file mode 100644
index 00000000..587d2970
--- /dev/null
+++ b/app/src/main/java/com/bitchat/android/geohash/OpenStreetMapGeocoderProvider.kt
@@ -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 {
+ 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()
+ }
+
+ val body = response.body?.string()
+ response.close()
+
+ if (body.isNullOrEmpty()) return@withContext emptyList()
+
+ try {
+ val osmResponse = gson.fromJson(body, OsmResponse::class.java)
+ if (osmResponse?.address == null) return@withContext emptyList()
+
+ val address = mapToAddress(osmResponse, latitude, longitude)
+ listOf(address)
+ } catch (e: Exception) {
+ Log.e(TAG, "OSM Parse failed: ${e.message}")
+ emptyList()
+ }
+ } catch (e: Exception) {
+ Log.e(TAG, "OSM Geocoding failed", e)
+ emptyList()
+ }
+ }
+ }
+
+ 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?
+ )
+}
diff --git a/app/src/main/java/com/bitchat/android/geohash/SystemLocationProvider.kt b/app/src/main/java/com/bitchat/android/geohash/SystemLocationProvider.kt
new file mode 100644
index 00000000..61c9d267
--- /dev/null
+++ b/app/src/main/java/com/bitchat/android/geohash/SystemLocationProvider.kt
@@ -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}")
+ }
+ }
+}
diff --git a/app/src/main/java/com/bitchat/android/identity/SecureIdentityStateManager.kt b/app/src/main/java/com/bitchat/android/identity/SecureIdentityStateManager.kt
index 2b0b2bdd..012ea3c4 100644
--- a/app/src/main/java/com/bitchat/android/identity/SecureIdentityStateManager.kt
+++ b/app/src/main/java/com/bitchat/android/identity/SecureIdentityStateManager.kt
@@ -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()
}
/**
@@ -178,6 +187,112 @@ class SecureIdentityStateManager(private val context: Context) {
// SHA-256 fingerprint should be 64 hex characters
return fingerprint.matches(Regex("^[a-fA-F0-9]{64}$"))
}
+
+ // MARK: - Verified Fingerprints
+
+ fun getVerifiedFingerprints(): Set {
+ 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.
diff --git a/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionManager.kt b/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionManager.kt
index 8e0fa15b..328d361f 100644
--- a/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionManager.kt
+++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionManager.kt
@@ -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,57 +88,114 @@ 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)
-
+ private fun isBleTransportEnabled(): Boolean {
+ return try {
+ com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().bleEnabled.value
+ } catch (_: Exception) {
+ try { com.bitchat.android.ui.debug.DebugPreferenceManager.getBleEnabled(true) } catch (_: Exception) { true }
+ }
+ }
+
+ private fun isGattServerEnabled(): Boolean {
+ return isBleTransportEnabled() &&
+ (try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().gattServerEnabled.value } catch (_: Exception) { true })
+ }
+
+ private fun isGattClientEnabled(): Boolean {
+ return isBleTransportEnabled() &&
+ (try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().gattClientEnabled.value } catch (_: Exception) { true })
+ }
+
init {
powerManager.delegate = this
// Observe debug settings to enforce role state while active
try {
val dbg = com.bitchat.android.ui.debug.DebugSettingsManager.getInstance()
+ // Master transport enable/disable
+ connectionScope.launch {
+ dbg.bleEnabled.collect { enabled ->
+ if (enabled) return@collect
+ if (isActive) {
+ disableTransport()
+ }
+ }
+ }
// Role enable/disable
connectionScope.launch {
dbg.gattServerEnabled.collect { enabled ->
if (!isActive) return@collect
- if (enabled) startServer() else stopServer()
+ if (enabled && isBleTransportEnabled()) startServer() else stopServer()
}
}
connectionScope.launch {
dbg.gattClientEnabled.collect { enabled ->
if (!isActive) return@collect
- if (enabled) startClient() else stopClient()
+ if (enabled && isBleTransportEnabled()) 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
*/
fun startServices(): Boolean {
Log.i(TAG, "Starting power-optimized Bluetooth services...")
+
+ if (!isBleTransportEnabled()) {
+ Log.i(TAG, "BLE transport disabled by debug settings; not starting Bluetooth services")
+ disableTransport()
+ return false
+ }
if (!permissionManager.hasBluetoothPermissions()) {
Log.e(TAG, "Missing Bluetooth permissions")
@@ -170,9 +230,8 @@ class BluetoothConnectionManager(
powerManager.start()
// Start server/client based on debug settings
- val dbg = try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance() } catch (_: Exception) { null }
- val startServer = dbg?.gattServerEnabled?.value != false
- val startClient = dbg?.gattClientEnabled?.value != false
+ val startServer = isGattServerEnabled()
+ val startClient = isGattClientEnabled()
if (startServer) {
if (!serverManager.start()) {
@@ -207,6 +266,19 @@ class BluetoothConnectionManager(
return false
}
}
+
+ /**
+ * Disable BLE without cancelling this manager's coroutine scope, so it can be re-enabled.
+ */
+ fun disableTransport() {
+ Log.i(TAG, "Disabling BLE transport")
+ isActive = false
+ connectionScope.launch {
+ clientManager.stop()
+ serverManager.stop()
+ connectionTracker.stop()
+ }
+ }
/**
* Stop all Bluetooth services with proper cleanup
@@ -247,19 +319,12 @@ 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
*/
fun broadcastPacket(routed: RoutedPacket) {
- if (!isActive) return
+ if (!isActive || !isBleTransportEnabled()) return
packetBroadcaster.broadcastPacket(
routed,
@@ -268,6 +333,16 @@ class BluetoothConnectionManager(
)
}
+ fun sendToPeer(peerID: String, routed: RoutedPacket): Boolean {
+ if (!isActive || !isBleTransportEnabled()) return false
+ return packetBroadcaster.sendToPeer(
+ peerID,
+ routed,
+ serverManager.getGattServer(),
+ serverManager.getCharacteristic()
+ )
+ }
+
fun cancelTransfer(transferId: String): Boolean {
return packetBroadcaster.cancelTransfer(transferId)
}
@@ -276,7 +351,7 @@ class BluetoothConnectionManager(
* Send a packet directly to a specific peer, without broadcasting to others.
*/
fun sendPacketToPeer(peerID: String, packet: BitchatPacket): Boolean {
- if (!isActive) return false
+ if (!isActive || !isBleTransportEnabled()) return false
return packetBroadcaster.sendPacketToPeer(
RoutedPacket(packet),
peerID,
@@ -287,9 +362,15 @@ class BluetoothConnectionManager(
// Expose role controls for debug UI
- fun startServer() { connectionScope.launch { serverManager.start() } }
+ fun startServer() {
+ if (!isActive || !isBleTransportEnabled()) return
+ connectionScope.launch { if (isGattServerEnabled()) serverManager.start() }
+ }
fun stopServer() { connectionScope.launch { serverManager.stop() } }
- fun startClient() { connectionScope.launch { clientManager.start() } }
+ fun startClient() {
+ if (!isActive || !isBleTransportEnabled()) return
+ connectionScope.launch { if (isGattClientEnabled()) clientManager.start() }
+ }
fun stopClient() { connectionScope.launch { clientManager.stop() } }
// Inject nickname resolver for broadcaster logs
@@ -317,7 +398,10 @@ class BluetoothConnectionManager(
/**
* Public: connect/disconnect helpers for debug UI
*/
- fun connectToAddress(address: String): Boolean = clientManager.connectToAddress(address)
+ fun connectToAddress(address: String): Boolean {
+ if (!isActive || !isBleTransportEnabled()) return false
+ return clientManager.connectToAddress(address)
+ }
fun disconnectAddress(address: String) { connectionTracker.disconnectDevice(address) }
@@ -328,10 +412,10 @@ class BluetoothConnectionManager(
clientManager.stop()
serverManager.stop()
delay(200)
- if (isActive) {
+ if (isActive && isBleTransportEnabled()) {
// Restart managers if service is active
- serverManager.start()
- clientManager.start()
+ if (isGattServerEnabled()) serverManager.start()
+ if (isGattClientEnabled()) clientManager.start()
}
}
}
@@ -366,11 +450,17 @@ class BluetoothConnectionManager(
Log.i(TAG, "Power mode changed to: $newMode")
connectionScope.launch {
+ if (!isActive || !isBleTransportEnabled()) {
+ serverManager.stop()
+ clientManager.stop()
+ return@launch
+ }
+
// Avoid rapid scan restarts by checking if we need to change scan behavior
val wasUsingDutyCycle = powerManager.shouldUseDutyCycle()
// Update advertising with new power settings if server enabled
- val serverEnabled = try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().gattServerEnabled.value } catch (_: Exception) { true }
+ val serverEnabled = isGattServerEnabled()
if (serverEnabled) {
serverManager.restartAdvertising()
} else {
@@ -381,7 +471,7 @@ class BluetoothConnectionManager(
val nowUsingDutyCycle = powerManager.shouldUseDutyCycle()
if (wasUsingDutyCycle != nowUsingDutyCycle) {
Log.d(TAG, "Duty cycle behavior changed (${wasUsingDutyCycle} -> ${nowUsingDutyCycle}), restarting scan")
- val clientEnabled = try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().gattClientEnabled.value } catch (_: Exception) { true }
+ val clientEnabled = isGattClientEnabled()
if (clientEnabled) {
clientManager.restartScanning()
} else {
@@ -392,16 +482,15 @@ 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()
}
}
override fun onScanStateChanged(shouldScan: Boolean) {
+ if (!isActive || !isBleTransportEnabled()) {
+ clientManager.onScanStateChanged(false)
+ return
+ }
clientManager.onScanStateChanged(shouldScan)
}
diff --git a/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionTracker.kt b/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionTracker.kt
index 1412732d..f139b9d3 100644
--- a/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionTracker.kt
+++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionTracker.kt
@@ -29,7 +29,6 @@ class BluetoothConnectionTracker(
val addressPeerMap = ConcurrentHashMap()
// Track whether we have seen the first ANNOUNCE on a given device connection
private val firstAnnounceSeen = ConcurrentHashMap()
-
// RSSI tracking from scan results (for devices we discover but may connect as servers)
private val scanRSSI = ConcurrentHashMap()
@@ -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() {
@@ -150,6 +150,14 @@ class BluetoothConnectionTracker(
* Check if device is already connected
*/
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
-
- val clients = connectedDevices.values.filter { it.isClient }
- val servers = connectedDevices.values.filter { !it.isClient }
-
- // Enforce client cap first (we can actively disconnect)
+ fun getConnectionsToEvict(maxOverall: Int, maxServer: Int, maxClient: Int): List {
+ val toEvict = mutableSetOf()
+ val currentDevices = connectedDevices.values.toList()
+
+ // 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))
}
-
- // 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()
+
+ 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)
}
}
+
+ return toEvict.toList()
}
/**
diff --git a/app/src/main/java/com/bitchat/android/mesh/BluetoothGattClientManager.kt b/app/src/main/java/com/bitchat/android/mesh/BluetoothGattClientManager.kt
index e5feea0a..3885a523 100644
--- a/app/src/main/java/com/bitchat/android/mesh/BluetoothGattClientManager.kt
+++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothGattClientManager.kt
@@ -32,6 +32,11 @@ class BluetoothGattClientManager(
companion object {
private const val TAG = "BluetoothGattClientManager"
+ // Self-healing scan recovery tuning
+ private const val SCAN_RETRY_BASE_MS = 3_000L // base backoff for transient scan failures
+ private const val SCAN_MAX_RETRY_DELAY_MS = 30_000L // cap on backoff delay
+ private const val SCAN_WATCHDOG_INTERVAL_MS = 30_000L // how often to verify the scanner is alive
+ private const val SCAN_STALE_RESULT_MS = 120_000L // force a scan restart if no results for this long
}
// Core Bluetooth components
@@ -39,11 +44,28 @@ class BluetoothGattClientManager(
context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
private val bluetoothAdapter: BluetoothAdapter? = bluetoothManager.adapter
private val bleScanner: BluetoothLeScanner? = bluetoothAdapter?.bluetoothLeScanner
+
+ private fun isBleTransportEnabled(): Boolean {
+ return try {
+ com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().bleEnabled.value
+ } catch (_: Exception) {
+ try { com.bitchat.android.ui.debug.DebugPreferenceManager.getBleEnabled(true) } catch (_: Exception) { true }
+ }
+ }
+
+ private fun isClientRoleEnabled(): Boolean {
+ return isBleTransportEnabled() &&
+ (try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().gattClientEnabled.value } catch (_: Exception) { true })
+ }
/**
* Public: Connect to a device by MAC address (for debug UI)
*/
fun connectToAddress(deviceAddress: String): Boolean {
+ if (!isClientRoleEnabled()) {
+ Log.i(TAG, "connectToAddress skipped: BLE client disabled")
+ return false
+ }
val device = bluetoothAdapter?.getRemoteDevice(deviceAddress)
return if (device != null) {
val rssi = connectionTracker.getBestRSSI(deviceAddress) ?: -50
@@ -61,8 +83,16 @@ class BluetoothGattClientManager(
// Scan rate limiting to prevent "scanning too frequently" errors
private var lastScanStartTime = 0L
private var lastScanStopTime = 0L
- private var isCurrentlyScanning = false
+ @Volatile private var isCurrentlyScanning = false
private val scanRateLimit = 5000L // Minimum 5 seconds between scan start attempts
+
+ // Self-healing scan state.
+ // scanningDesired distinguishes "we want to be scanning but it isn't running" (a fault to recover
+ // from) from "scanning is intentionally off" (e.g. duty-cycle OFF window or client disabled).
+ @Volatile private var scanningDesired = false
+ @Volatile private var lastScanResultTime = 0L
+ private var scanRetryCount = 0
+ private var scanWatchdogJob: Job? = null
// RSSI monitoring state
private var rssiMonitoringJob: Job? = null
@@ -75,12 +105,10 @@ class BluetoothGattClientManager(
*/
fun start(): Boolean {
// Respect debug setting
- try {
- if (!com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().gattClientEnabled.value) {
- Log.i(TAG, "Client start skipped: GATT Client disabled in debug settings")
- return false
- }
- } catch (_: Exception) { }
+ if (!isClientRoleEnabled()) {
+ Log.i(TAG, "Client start skipped: BLE/GATT Client disabled in debug settings")
+ return false
+ }
if (isActive) {
Log.d(TAG, "GATT client already active; start is a no-op")
@@ -106,12 +134,16 @@ class BluetoothGattClientManager(
connectionScope.launch {
if (powerManager.shouldUseDutyCycle()) {
Log.i(TAG, "Using power-aware duty cycling")
+ // Duty cycle drives onScanStateChanged(true/false); scanningDesired follows that.
} else {
+ scanningDesired = true
startScanning()
}
// Start RSSI monitoring
startRSSIMonitoring()
+ // Start the scan watchdog so a silently-dead or wedged scanner self-heals.
+ startScanWatchdog()
}
return true
@@ -121,6 +153,8 @@ class BluetoothGattClientManager(
* Stop client manager
*/
fun stop() {
+ scanningDesired = false
+ stopScanWatchdog()
if (!isActive) {
// Idempotent stop
stopScanning()
@@ -150,7 +184,8 @@ class BluetoothGattClientManager(
* Handle scan state changes from power manager
*/
fun onScanStateChanged(shouldScan: Boolean) {
- val enabled = try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().gattClientEnabled.value } catch (_: Exception) { true }
+ val enabled = isClientRoleEnabled()
+ scanningDesired = shouldScan && enabled
if (shouldScan && enabled) {
startScanning()
} else {
@@ -199,7 +234,7 @@ class BluetoothGattClientManager(
@Suppress("DEPRECATION")
private fun startScanning() {
// Respect debug setting
- val enabled = try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().gattClientEnabled.value } catch (_: Exception) { true }
+ val enabled = isClientRoleEnabled()
if (!permissionManager.hasBluetoothPermissions() || bleScanner == null || !isActive || !enabled) return
// Rate limit scan starts to prevent "scanning too frequently" errors
@@ -217,7 +252,7 @@ class BluetoothGattClientManager(
// Schedule delayed scan start
connectionScope.launch {
delay(remainingWait)
- if (isActive && !isCurrentlyScanning) {
+ if (isActive && !isCurrentlyScanning && isClientRoleEnabled()) {
startScanning()
}
}
@@ -251,22 +286,39 @@ class BluetoothGattClientManager(
lastScanStopTime = System.currentTimeMillis()
when (errorCode) {
- 1 -> Log.e(TAG, "SCAN_FAILED_ALREADY_STARTED")
- 2 -> Log.e(TAG, "SCAN_FAILED_APPLICATION_REGISTRATION_FAILED")
- 3 -> Log.e(TAG, "SCAN_FAILED_INTERNAL_ERROR")
- 4 -> Log.e(TAG, "SCAN_FAILED_FEATURE_UNSUPPORTED")
- 5 -> Log.e(TAG, "SCAN_FAILED_OUT_OF_HARDWARE_RESOURCES")
+ 1 -> {
+ // Already started: the stack thinks a scan is running. Re-arm from a clean
+ // state so we don't stay wedged (stop then restart with backoff).
+ Log.e(TAG, "SCAN_FAILED_ALREADY_STARTED")
+ stopScanning()
+ scheduleScanRestart("already-started", SCAN_RETRY_BASE_MS)
+ }
+ 2 -> {
+ // App registration failed: common transient stack fault. Previously had NO
+ // retry, which left discovery dead until a manual BLE toggle.
+ Log.e(TAG, "SCAN_FAILED_APPLICATION_REGISTRATION_FAILED")
+ scheduleScanRestart("registration-failed", SCAN_RETRY_BASE_MS)
+ }
+ 3 -> {
+ Log.e(TAG, "SCAN_FAILED_INTERNAL_ERROR")
+ scheduleScanRestart("internal-error", SCAN_RETRY_BASE_MS)
+ }
+ 4 -> Log.e(TAG, "SCAN_FAILED_FEATURE_UNSUPPORTED") // permanent: don't retry
+ 5 -> {
+ // Out of hardware resources: back off longer so other scanners/connections
+ // can free up before we try again.
+ Log.e(TAG, "SCAN_FAILED_OUT_OF_HARDWARE_RESOURCES")
+ scheduleScanRestart("out-of-resources", SCAN_RETRY_BASE_MS * 3)
+ }
6 -> {
Log.e(TAG, "SCAN_FAILED_SCANNING_TOO_FREQUENTLY")
Log.w(TAG, "Scan failed due to rate limiting - will retry after delay")
- connectionScope.launch {
- delay(10000) // Wait 10 seconds before retrying
- if (isActive) {
- startScanning()
- }
- }
+ scheduleScanRestart("too-frequently", 10_000L)
+ }
+ else -> {
+ Log.e(TAG, "Unknown scan failure code: $errorCode")
+ scheduleScanRestart("unknown-$errorCode", SCAN_RETRY_BASE_MS)
}
- else -> Log.e(TAG, "Unknown scan failure code: $errorCode")
}
}
}
@@ -304,6 +356,76 @@ class BluetoothGattClientManager(
lastScanStopTime = System.currentTimeMillis()
}
}
+
+ /**
+ * Schedule a scan restart with incremental backoff. Used to recover from transient scan
+ * failures that previously had no retry path (codes 2/3/5), leaving discovery dead until a
+ * manual BLE toggle.
+ */
+ private fun scheduleScanRestart(reason: String, baseDelayMs: Long) {
+ scanRetryCount++
+ val delayMs = (baseDelayMs * scanRetryCount).coerceAtMost(SCAN_MAX_RETRY_DELAY_MS)
+ Log.w(TAG, "Scheduling scan restart in ${delayMs}ms (attempt $scanRetryCount, reason=$reason)")
+ connectionScope.launch {
+ delay(delayMs)
+ if (isActive && scanningDesired && isClientRoleEnabled() && !isCurrentlyScanning) {
+ startScanning()
+ }
+ }
+ }
+
+ /**
+ * Periodic watchdog that self-heals the scanner. Android can stop a scan without ever invoking
+ * onScanFailed (internal stack reset, Doze, background throttling), which leaves the app
+ * believing it is scanning while it is not. This re-arms the scanner in those cases.
+ */
+ private fun startScanWatchdog() {
+ scanWatchdogJob?.cancel()
+ scanWatchdogJob = connectionScope.launch {
+ while (isActive) {
+ delay(SCAN_WATCHDOG_INTERVAL_MS)
+ try {
+ // Only act when we are supposed to be scanning. Honors duty-cycle OFF windows
+ // and the client-disabled state via scanningDesired.
+ if (!isActive || !scanningDesired || !isClientRoleEnabled()) continue
+ if (!permissionManager.hasBluetoothPermissions() || bluetoothAdapter?.isEnabled != true) continue
+
+ val now = System.currentTimeMillis()
+ if (!isCurrentlyScanning) {
+ Log.w(TAG, "Watchdog: scan desired but not running -> restarting scan")
+ startScanning()
+ } else if (lastScanResultTime > 0L &&
+ now - lastScanResultTime > SCAN_STALE_RESULT_MS &&
+ now - lastScanStartTime > SCAN_STALE_RESULT_MS) {
+ // We think we're scanning but haven't seen anything for a long time. The scan
+ // may have silently died (flag wedged true). Force a clean re-arm.
+ Log.w(TAG, "Watchdog: no scan results for ${(now - lastScanResultTime) / 1000}s -> forcing scan restart")
+ forceRestartScan()
+ }
+ } catch (e: Exception) {
+ Log.w(TAG, "Scan watchdog error: ${e.message}")
+ }
+ }
+ }
+ }
+
+ private fun stopScanWatchdog() {
+ scanWatchdogJob?.cancel()
+ scanWatchdogJob = null
+ }
+
+ /**
+ * Force a clean scan restart, clearing a possibly-wedged isCurrentlyScanning flag.
+ */
+ private fun forceRestartScan() {
+ stopScanning()
+ connectionScope.launch {
+ delay(500)
+ if (isActive && scanningDesired && isClientRoleEnabled() && !isCurrentlyScanning) {
+ startScanning()
+ }
+ }
+ }
/**
* Handle scan result and initiate connection if appropriate
@@ -320,6 +442,26 @@ class BluetoothGattClientManager(
return
}
+ // Proof the scanner is alive and finding our network: refresh liveness and clear backoff.
+ lastScanResultTime = System.currentTimeMillis()
+ scanRetryCount = 0
+
+ // 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 +474,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 +484,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 +507,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 +527,12 @@ 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 (!isClientRoleEnabled()) return
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 +582,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)
@@ -541,7 +689,7 @@ class BluetoothGattClientManager(
*/
fun restartScanning() {
// Respect debug setting
- val enabled = try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().gattClientEnabled.value } catch (_: Exception) { true }
+ val enabled = isClientRoleEnabled()
if (!isActive || !enabled) return
connectionScope.launch {
diff --git a/app/src/main/java/com/bitchat/android/mesh/BluetoothGattServerManager.kt b/app/src/main/java/com/bitchat/android/mesh/BluetoothGattServerManager.kt
index 6a1c6fbf..0c7aabc3 100644
--- a/app/src/main/java/com/bitchat/android/mesh/BluetoothGattServerManager.kt
+++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothGattServerManager.kt
@@ -24,11 +24,15 @@ 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 {
private const val TAG = "BluetoothGattServerManager"
+ // Self-healing advertising recovery tuning
+ private const val ADVERTISE_RETRY_BASE_MS = 3_000L // base backoff for transient advertise failures
+ private const val ADVERTISE_MAX_RETRY_DELAY_MS = 30_000L // cap on backoff delay
}
// Core Bluetooth components
@@ -41,22 +45,33 @@ class BluetoothGattServerManager(
private var gattServer: BluetoothGattServer? = null
private var characteristic: BluetoothGattCharacteristic? = null
private var advertiseCallback: AdvertiseCallback? = null
+ private var advertiseRetryCount = 0
// 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
+ private fun isBleTransportEnabled(): Boolean {
+ return try {
+ com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().bleEnabled.value
+ } catch (_: Exception) {
+ try { com.bitchat.android.ui.debug.DebugPreferenceManager.getBleEnabled(true) } catch (_: Exception) { true }
+ }
+ }
+
+ private fun isServerRoleEnabled(): Boolean {
+ return isBleTransportEnabled() &&
+ (try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().gattServerEnabled.value } catch (_: Exception) { true })
+ }
+
+ /**
+ * 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}")
+ }
}
/**
@@ -64,12 +79,10 @@ class BluetoothGattServerManager(
*/
fun start(): Boolean {
// Respect debug setting
- try {
- if (!com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().gattServerEnabled.value) {
- Log.i(TAG, "Server start skipped: GATT Server disabled in debug settings")
- return false
- }
- } catch (_: Exception) { }
+ if (!isServerRoleEnabled()) {
+ Log.i(TAG, "Server start skipped: BLE/GATT Server disabled in debug settings")
+ return false
+ }
if (isActive) {
Log.d(TAG, "GATT server already active; start is a no-op")
@@ -122,9 +135,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) { }
@@ -323,7 +337,7 @@ class BluetoothGattServerManager(
@Suppress("DEPRECATION")
private fun startAdvertising() {
// Respect debug setting
- val enabled = try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().gattServerEnabled.value } catch (_: Exception) { true }
+ val enabled = isServerRoleEnabled()
// Guard conditions – never throw here to avoid crashing the app from a background coroutine
if (!permissionManager.hasBluetoothPermissions()) {
@@ -358,22 +372,59 @@ class BluetoothGattServerManager(
.setIncludeTxPowerLevel(false)
.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) {
+ advertiseRetryCount = 0
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) {
Log.e(TAG, "Advertising failed: $errorCode")
+ // Previously this only logged, so if advertising failed this device became
+ // undiscoverable until a manual BLE toggle. Retry transient failures with backoff.
+ when (errorCode) {
+ ADVERTISE_FAILED_ALREADY_STARTED ->
+ Log.w(TAG, "ADVERTISE_FAILED_ALREADY_STARTED - already advertising, no retry")
+ ADVERTISE_FAILED_DATA_TOO_LARGE ->
+ Log.e(TAG, "ADVERTISE_FAILED_DATA_TOO_LARGE - config issue, not retrying")
+ ADVERTISE_FAILED_FEATURE_UNSUPPORTED ->
+ Log.e(TAG, "ADVERTISE_FAILED_FEATURE_UNSUPPORTED - unsupported, not retrying")
+ ADVERTISE_FAILED_TOO_MANY_ADVERTISERS -> {
+ Log.w(TAG, "ADVERTISE_FAILED_TOO_MANY_ADVERTISERS - will retry after backoff")
+ scheduleAdvertiseRestart("too-many-advertisers")
+ }
+ ADVERTISE_FAILED_INTERNAL_ERROR -> {
+ Log.w(TAG, "ADVERTISE_FAILED_INTERNAL_ERROR - will retry after backoff")
+ scheduleAdvertiseRestart("internal-error")
+ }
+ else -> {
+ Log.w(TAG, "Unknown advertise failure $errorCode - will retry after backoff")
+ scheduleAdvertiseRestart("unknown-$errorCode")
+ }
+ }
}
}
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) {
@@ -394,12 +445,29 @@ class BluetoothGattServerManager(
}
}
+ /**
+ * Schedule an advertising restart with incremental backoff after a transient failure.
+ */
+ private fun scheduleAdvertiseRestart(reason: String) {
+ advertiseRetryCount++
+ val delayMs = (ADVERTISE_RETRY_BASE_MS * advertiseRetryCount).coerceAtMost(ADVERTISE_MAX_RETRY_DELAY_MS)
+ Log.w(TAG, "Scheduling advertising restart in ${delayMs}ms (attempt $advertiseRetryCount, reason=$reason)")
+ connectionScope.launch {
+ delay(delayMs)
+ if (isActive && isServerRoleEnabled()) {
+ stopAdvertising()
+ delay(100)
+ startAdvertising()
+ }
+ }
+ }
+
/**
* Restart advertising (for power mode changes)
*/
fun restartAdvertising() {
// Respect debug setting
- val enabled = try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().gattServerEnabled.value } catch (_: Exception) { true }
+ val enabled = isServerRoleEnabled()
if (!isActive || !enabled) {
stopAdvertising()
return
diff --git a/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt b/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt
index bc920f9b..c6c10473 100644
--- a/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt
+++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt
@@ -7,12 +7,15 @@ import com.bitchat.android.model.BitchatMessage
import com.bitchat.android.protocol.MessagePadding
import com.bitchat.android.model.RoutedPacket
import com.bitchat.android.model.IdentityAnnouncement
+import com.bitchat.android.model.NoisePayload
+import com.bitchat.android.model.NoisePayloadType
import com.bitchat.android.protocol.BitchatPacket
import com.bitchat.android.protocol.MessageType
import com.bitchat.android.protocol.SpecialRecipients
import com.bitchat.android.model.RequestSyncPacket
import com.bitchat.android.sync.GossipSyncManager
import com.bitchat.android.util.toHexString
+import com.bitchat.android.services.VerificationService
import com.bitchat.android.service.TransportBridgeService
import kotlinx.coroutines.*
import java.util.*
@@ -68,11 +71,13 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
// Coroutines
private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
+ private var announceJob: Job? = null
// Tracks whether this instance has been terminated via stopServices()
private var terminated = false
init {
Log.i(TAG, "Initializing BluetoothMeshService for peer=$myPeerID")
+ VerificationService.configure(encryptionService)
setupDelegates()
messageHandler.packetProcessor = packetProcessor
//startPeriodicDebugLogging()
@@ -95,50 +100,46 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
} catch (_: Exception) { 0.01 }
}
)
-
- // Register as shared instance for Wi-Fi Aware transport
- com.bitchat.android.service.MeshServiceHolder.setGossipManager(gossipSyncManager)
- // Wire sync manager delegate
- gossipSyncManager.delegate = object : GossipSyncManager.Delegate {
- override fun sendPacket(packet: BitchatPacket) {
- dispatchGlobal(RoutedPacket(packet))
- }
- override fun sendPacketToPeer(peerID: String, packet: BitchatPacket) {
- // Point-to-point optimization if possible, but for bridge safety
- // we might want to consider dispatchGlobal if peer is on another transport.
- // However, sendPacketToPeer in connectionManager is BLE-specific unicast.
- // If peer is on Wi-Fi, this won't reach.
- // For now, let's keep unicast as-is (it's mostly for sync)
- // and assume routing handles the rest via broadcasts if needed.
- connectionManager.sendPacketToPeer(peerID, packet)
- }
- override fun signPacketForBroadcast(packet: BitchatPacket): BitchatPacket {
- return signPacketBeforeBroadcast(packet)
- }
+ com.bitchat.android.service.MeshServiceHolder.setGossipManager(gossipSyncManager) { packet ->
+ signPacketBeforeBroadcast(packet)
+ }
+ if (isBleTransportEnabled()) {
+ TransportBridgeService.register("BLE", this)
}
- Log.d(TAG, "Delegates set up; GossipSyncManager initialized")
- // Register with cross-layer transport bridge
- TransportBridgeService.register("BLE", this)
+ // Inject dynamic direct connection check into PeerManager
+ // Matches iOS logic: checks if we have an active hardware mapping for this peer
+ peerManager.isPeerDirectlyConnected = { peerID ->
+ connectionManager.addressPeerMap.containsValue(peerID)
+ }
+
+ Log.d(TAG, "Delegates set up; GossipSyncManager initialized")
}
- // TransportLayer implementation
override fun send(packet: RoutedPacket) {
- // Received from bridge (e.g. Wi-Fi) -> Send via BLE
- // Direct injection prevents routing loops (bridge handles source check)
+ if (!isBleTransportEnabled()) return
connectionManager.broadcastPacket(packet)
}
- /**
- * unified dispatch: Send to local BLE and bridge to other transports
- */
- private fun dispatchGlobal(routed: RoutedPacket) {
- // 1. Send to local BLE transport
+ override fun sendToPeer(peerID: String, packet: BitchatPacket) {
+ if (!isBleTransportEnabled()) return
+ connectionManager.sendPacketToPeer(peerID, packet)
+ }
+
+ private fun broadcastRoutedPacket(routed: RoutedPacket) {
+ if (!isBleTransportEnabled()) return
connectionManager.broadcastPacket(routed)
- // 2. Bridge to other transports (e.g. Wi-Fi)
TransportBridgeService.broadcast("BLE", routed)
}
+
+ private fun isBleTransportEnabled(): Boolean {
+ return try {
+ com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().bleEnabled.value
+ } catch (_: Exception) {
+ try { com.bitchat.android.ui.debug.DebugPreferenceManager.getBleEnabled(true) } catch (_: Exception) { true }
+ }
+ }
/**
* Start periodic debug logging every 10 seconds
@@ -165,7 +166,8 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
* Send broadcast announcement every 30 seconds
*/
private fun sendPeriodicBroadcastAnnounce() {
- serviceScope.launch {
+ announceJob?.cancel()
+ announceJob = serviceScope.launch {
Log.d(TAG, "Starting periodic announce loop")
while (isActive) {
try {
@@ -184,18 +186,25 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
*/
private fun setupDelegates() {
Log.d(TAG, "Setting up component delegates")
- // Provide nickname resolver to BLE broadcaster for detailed logs
+ // Provide nickname resolver to BLE broadcaster and debug manager
try {
- connectionManager.setNicknameResolver { pid -> peerManager.getPeerNickname(pid) }
+ val resolver: (String) -> String? = { pid -> peerManager.getPeerNickname(pid) }
+ connectionManager.setNicknameResolver(resolver)
+ debugManager?.setNicknameResolver(resolver)
} catch (_: Exception) { }
// PeerManager delegates to main mesh service delegate
peerManager.delegate = object : PeerManagerDelegate {
override fun onPeerListUpdated(peerIDs: List) {
+ // Update process-wide state first
+ try { com.bitchat.android.services.AppStateStore.setTransportPeers("BLE", peerIDs) } catch (_: Exception) { }
// Then notify UI delegate if attached
delegate?.didUpdatePeerList(peerIDs)
}
override fun onPeerRemoved(peerID: String) {
try { gossipSyncManager.removeAnnouncementForPeer(peerID) } catch (_: Exception) { }
+ // Remove from mesh graph topology to prevent routing through stale peers
+ try { com.bitchat.android.services.meshgraph.MeshGraphService.getInstance().removePeer(peerID) } catch (_: Exception) { }
+
// Also drop any Noise session state for this peer when they go offline
try {
encryptionService.removePeer(peerID)
@@ -233,7 +242,7 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
)
// Sign the handshake response
val signedPacket = signPacketBeforeBroadcast(responsePacket)
- dispatchGlobal(RoutedPacket(signedPacket))
+ broadcastRoutedPacket(RoutedPacket(signedPacket))
Log.d(TAG, "Sent Noise handshake response to $peerID (${response.size} bytes)")
}
@@ -253,7 +262,7 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
}
override fun sendPacket(packet: BitchatPacket) {
- dispatchGlobal(RoutedPacket(packet))
+ broadcastRoutedPacket(RoutedPacket(packet))
}
}
@@ -296,12 +305,11 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
override fun sendPacket(packet: BitchatPacket) {
// Sign the packet before broadcasting
val signedPacket = signPacketBeforeBroadcast(packet)
- val routed = RoutedPacket(signedPacket)
- dispatchGlobal(routed)
+ broadcastRoutedPacket(RoutedPacket(signedPacket))
}
override fun relayPacket(routed: RoutedPacket) {
- dispatchGlobal(routed)
+ broadcastRoutedPacket(routed)
}
override fun getBroadcastRecipient(): ByteArray {
@@ -348,7 +356,7 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
// Sign the handshake packet before broadcasting
val signedPacket = signPacketBeforeBroadcast(packet)
- dispatchGlobal(RoutedPacket(signedPacket))
+ broadcastRoutedPacket(RoutedPacket(signedPacket))
Log.d(TAG, "Initiated Noise handshake with $peerID (${handshakeData.size} bytes)")
} else {
Log.w(TAG, "Failed to generate Noise handshake data for $peerID")
@@ -443,6 +451,14 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
override fun onReadReceiptReceived(messageID: String, peerID: String) {
delegate?.didReceiveReadReceipt(messageID, peerID)
}
+
+ override fun onVerifyChallengeReceived(peerID: String, payload: ByteArray, timestampMs: Long) {
+ delegate?.didReceiveVerifyChallenge(peerID, payload, timestampMs)
+ }
+
+ override fun onVerifyResponseReceived(peerID: String, payload: ByteArray, timestampMs: Long) {
+ delegate?.didReceiveVerifyResponse(peerID, payload, timestampMs)
+ }
}
// PacketProcessor delegates
@@ -481,21 +497,24 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
// Process the announce
val isFirst = messageHandler.handleAnnounce(routed)
- // Map device address -> peerID on first announce seen over this device connection
+ // Map device address -> peerID based on TTL (max TTL = direct neighbor)
+ // Matches iOS logic: any announce with max TTL on a link defines the direct peer
val deviceAddress = routed.relayAddress
val pid = routed.peerID
if (deviceAddress != null && pid != null) {
- // First ANNOUNCE over a device connection defines a direct neighbor.
- if (!connectionManager.hasSeenFirstAnnounce(deviceAddress)) {
+ // Check if this is a direct connection (MAX TTL)
+ // Note: packet.ttl is UByte, compare with AppConstants.MESSAGE_TTL_HOPS
+ val isDirect = routed.packet.ttl == com.bitchat.android.util.AppConstants.MESSAGE_TTL_HOPS
+
+ if (isDirect) {
// Bind or rebind this device address to the announcing peer
connectionManager.addressPeerMap[deviceAddress] = pid
- connectionManager.noteAnnounceReceived(deviceAddress)
- Log.d(TAG, "Mapped device $deviceAddress to peer $pid on FIRST-ANNOUNCE for this connection")
+ Log.d(TAG, "Mapped device $deviceAddress to peer $pid (TTL=${routed.packet.ttl})")
- // Mark as directly connected (upgrades from routed if needed)
- try { peerManager.setDirectConnection(pid, true) } catch (_: Exception) { }
+ // Mark as directly connected - refresh UI state
+ try { peerManager.refreshPeerList() } catch (_: Exception) { }
- // Initial sync for this newly direct peer
+ // Initial sync for this direct peer
try { gossipSyncManager.scheduleInitialSyncToPeer(pid, 1_000) } catch (_: Exception) { }
}
}
@@ -540,9 +559,15 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
}
override fun relayPacket(routed: RoutedPacket) {
- dispatchGlobal(routed)
+ broadcastRoutedPacket(routed)
}
+ override fun sendToPeer(peerID: String, routed: RoutedPacket): Boolean {
+ val sentOverBle = connectionManager.sendToPeer(peerID, routed)
+ TransportBridgeService.sendToPeer("BLE", peerID, routed.packet)
+ return sentOverBle
+ }
+
override fun handleRequestSync(routed: RoutedPacket) {
// Decode request and respond with missing packets
val fromPeer = routed.peerID ?: return
@@ -553,19 +578,19 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
// BluetoothConnectionManager delegates
connectionManager.delegate = object : BluetoothConnectionManagerDelegate {
- override fun onPacketReceived(packet: BitchatPacket, peerID: String, device: android.bluetooth.BluetoothDevice?) {
- // Log incoming for debug graphs (do not double-count anywhere else)
- try {
- val nick = getPeerNicknames()[peerID]
- com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().logIncoming(
- packetType = packet.type.toString(),
- fromPeerID = peerID,
- fromNickname = nick,
- fromDeviceAddress = device?.address
- )
- } catch (_: Exception) { }
- packetProcessor.processPacket(RoutedPacket(packet, peerID, device?.address))
- }
+ override fun onPacketReceived(packet: BitchatPacket, peerID: String, device: android.bluetooth.BluetoothDevice?) {
+ // Log incoming for debug graphs (do not double-count anywhere else)
+ try {
+ com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().logIncoming(
+ packet = packet,
+ fromPeerID = peerID,
+ fromNickname = null,
+ fromDeviceAddress = device?.address,
+ myPeerID = myPeerID
+ )
+ } catch (_: Exception) { }
+ packetProcessor.processPacket(RoutedPacket(packet, peerID, device?.address))
+ }
override fun onDeviceConnected(device: android.bluetooth.BluetoothDevice) {
// Send initial announcements after services are ready
@@ -591,12 +616,11 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
val peer = connectionManager.addressPeerMap[addr]
// ConnectionTracker has already removed the address mapping; be defensive either way
connectionManager.addressPeerMap.remove(addr)
+
+ // refresh peer list on disconnect.
+ try { peerManager.refreshPeerList() } catch (_: Exception) { }
+
if (peer != null) {
- val stillMapped = connectionManager.addressPeerMap.values.any { it == peer }
- if (!stillMapped) {
- // Peer might still be reachable indirectly; mark as not-direct
- try { peerManager.setDirectConnection(peer, false) } catch (_: Exception) { }
- }
// Verbose debug: device disconnected
try {
val nick = peerManager.getPeerNickname(peer) ?: "unknown"
@@ -624,6 +648,15 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
Log.w(TAG, "Mesh service already active, ignoring duplicate start request")
return
}
+ if (!isBleTransportEnabled()) {
+ Log.i(TAG, "BLE transport disabled by debug settings; not starting mesh service")
+ connectionManager.disableTransport()
+ TransportBridgeService.unregister("BLE")
+ com.bitchat.android.service.MeshServiceHolder.stopSharedGossip("BLE")
+ try { com.bitchat.android.services.AppStateStore.clearTransportPeers("BLE") } catch (_: Exception) { }
+ try { com.bitchat.android.services.AppStateStore.clearTransportDirectPeers("BLE") } catch (_: Exception) { }
+ return
+ }
if (terminated) {
// This instance's scope was cancelled previously; refuse to start to avoid using dead scopes.
Log.e(TAG, "Mesh service instance was terminated; create a new instance instead of restarting")
@@ -634,17 +667,42 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
if (connectionManager.startServices()) {
isActive = true
+ TransportBridgeService.register("BLE", this)
// Start periodic announcements for peer discovery and connectivity
sendPeriodicBroadcastAnnounce()
Log.d(TAG, "Started periodic broadcast announcements (every 30 seconds)")
// Start periodic syncs
- gossipSyncManager.start()
+ com.bitchat.android.service.MeshServiceHolder.startSharedGossip("BLE")
Log.d(TAG, "GossipSyncManager started")
} else {
Log.e(TAG, "Failed to start Bluetooth services")
}
}
+
+ /**
+ * Apply the debug master transport toggle without destroying this mesh instance.
+ */
+ fun setBleTransportEnabled(enabled: Boolean) {
+ if (enabled) {
+ startServices()
+ } else {
+ pauseServicesForTransportDisable()
+ }
+ }
+
+ private fun pauseServicesForTransportDisable() {
+ Log.i(TAG, "Disabling BLE mesh transport")
+ isActive = false
+ announceJob?.cancel()
+ announceJob = null
+ com.bitchat.android.service.MeshServiceHolder.stopSharedGossip("BLE")
+ TransportBridgeService.unregister("BLE")
+ try { com.bitchat.android.services.AppStateStore.clearTransportPeers("BLE") } catch (_: Exception) { }
+ try { com.bitchat.android.services.AppStateStore.clearTransportDirectPeers("BLE") } catch (_: Exception) { }
+ connectionManager.disableTransport()
+ try { peerManager.refreshPeerList() } catch (_: Exception) { }
+ }
/**
* Stop all mesh services
@@ -657,9 +715,11 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
Log.i(TAG, "Stopping Bluetooth mesh service")
isActive = false
-
- // Unregister from bridge
+ announceJob?.cancel()
+ announceJob = null
TransportBridgeService.unregister("BLE")
+ try { com.bitchat.android.services.AppStateStore.clearTransportPeers("BLE") } catch (_: Exception) { }
+ try { com.bitchat.android.services.AppStateStore.clearTransportDirectPeers("BLE") } catch (_: Exception) { }
// Send leave announcement
sendLeaveAnnouncement()
@@ -669,7 +729,7 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
delay(200) // Give leave message time to send
// Stop all components
- gossipSyncManager.stop()
+ com.bitchat.android.service.MeshServiceHolder.stopSharedGossip("BLE")
Log.d(TAG, "GossipSyncManager stopped")
connectionManager.stopServices()
Log.d(TAG, "BluetoothConnectionManager stop requested")
@@ -719,7 +779,7 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
// Sign the packet before broadcasting
val signedPacket = signPacketBeforeBroadcast(packet)
- dispatchGlobal(RoutedPacket(signedPacket))
+ broadcastRoutedPacket(RoutedPacket(signedPacket))
// Track our own broadcast message for sync
try { gossipSyncManager.onPublicPacketSeen(signedPacket) } catch (_: Exception) { }
}
@@ -751,7 +811,7 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
val signed = signPacketBeforeBroadcast(packet)
// Use a stable transferId based on the file TLV payload for progress tracking
val transferId = sha256Hex(payload)
- dispatchGlobal(RoutedPacket(signed, transferId = transferId))
+ broadcastRoutedPacket(RoutedPacket(signed, transferId = transferId))
try { gossipSyncManager.onPublicPacketSeen(signed) } catch (_: Exception) { }
}
} catch (e: Exception) {
@@ -795,7 +855,7 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
// Create NOISE_ENCRYPTED packet (not FILE_TRANSFER!)
val packet = BitchatPacket(
- version = 1u,
+ version = if (encrypted.size > 0xFFFF) 2u else 1u,
type = MessageType.NOISE_ENCRYPTED.value,
senderID = hexStringToByteArray(myPeerID),
recipientID = hexStringToByteArray(recipientPeerID),
@@ -809,7 +869,7 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
val signed = signPacketBeforeBroadcast(packet)
// Use a stable transferId based on the unencrypted file TLV payload for progress tracking
val transferId = sha256Hex(filePayload)
- dispatchGlobal(RoutedPacket(signed, transferId = transferId))
+ broadcastRoutedPacket(RoutedPacket(signed, transferId = transferId))
Log.d(TAG, "✅ Sent encrypted file to $recipientPeerID")
} catch (e: Exception) {
@@ -889,7 +949,7 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
// Sign the packet before broadcasting
val signedPacket = signPacketBeforeBroadcast(packet)
- dispatchGlobal(RoutedPacket(signedPacket))
+ broadcastRoutedPacket(RoutedPacket(signedPacket))
Log.d(TAG, "📤 Sent encrypted private message to $recipientPeerID (${encrypted.size} bytes)")
// FIXED: Don't send didReceiveMessage for our own sent messages
@@ -960,7 +1020,7 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
// Sign the packet before broadcasting
val signedPacket = signPacketBeforeBroadcast(packet)
- dispatchGlobal(RoutedPacket(signedPacket))
+ broadcastRoutedPacket(RoutedPacket(signedPacket))
Log.d(TAG, "📤 Sent read receipt to $recipientPeerID for message $messageID")
// Persist as read after successful send
@@ -971,6 +1031,50 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
}
}
}
+
+ // MARK: QR Verification over Noise
+
+ fun sendVerifyChallenge(peerID: String, noiseKeyHex: String, nonceA: ByteArray) {
+ val tlv = VerificationService.buildVerifyChallenge(noiseKeyHex, nonceA)
+ val payload = NoisePayload(
+ type = NoisePayloadType.VERIFY_CHALLENGE,
+ data = tlv
+ )
+ sendNoisePayloadToPeer(payload, peerID, "verify challenge")
+ }
+
+ fun sendVerifyResponse(peerID: String, noiseKeyHex: String, nonceA: ByteArray) {
+ val tlv = VerificationService.buildVerifyResponse(noiseKeyHex, nonceA) ?: return
+ val payload = NoisePayload(
+ type = NoisePayloadType.VERIFY_RESPONSE,
+ data = tlv
+ )
+ sendNoisePayloadToPeer(payload, peerID, "verify response")
+ }
+
+ private fun sendNoisePayloadToPeer(payload: NoisePayload, recipientPeerID: String, label: String) {
+ serviceScope.launch {
+ try {
+ val encrypted = encryptionService.encrypt(payload.encode(), recipientPeerID)
+ val packet = BitchatPacket(
+ version = 1u,
+ type = MessageType.NOISE_ENCRYPTED.value,
+ senderID = hexStringToByteArray(myPeerID),
+ recipientID = hexStringToByteArray(recipientPeerID),
+ timestamp = System.currentTimeMillis().toULong(),
+ payload = encrypted,
+ signature = null,
+ ttl = com.bitchat.android.util.AppConstants.MESSAGE_TTL_HOPS
+ )
+
+ val signedPacket = signPacketBeforeBroadcast(packet)
+ broadcastRoutedPacket(RoutedPacket(signedPacket))
+ Log.d(TAG, "📤 Sent $label to $recipientPeerID (${payload.data.size} bytes)")
+ } catch (e: Exception) {
+ Log.e(TAG, "Failed to send $label to $recipientPeerID: ${e.message}")
+ }
+ }
+ }
/**
* Send broadcast announce with TLV-encoded identity announcement - exactly like iOS
@@ -996,11 +1100,25 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
// Create iOS-compatible IdentityAnnouncement with TLV encoding
val announcement = IdentityAnnouncement(nickname, staticKey, signingKey)
- val tlvPayload = announcement.encode()
+ var tlvPayload = announcement.encode()
if (tlvPayload == null) {
Log.e(TAG, "Failed to encode announcement as TLV")
return@launch
}
+
+ // Append gossip TLV containing up to 10 direct neighbors (compact IDs)
+ try {
+ val directPeers = getDirectPeerIDsForGossip()
+ if (directPeers.isNotEmpty()) {
+ val gossip = com.bitchat.android.services.meshgraph.GossipTLV.encodeNeighbors(directPeers)
+ tlvPayload = tlvPayload + gossip
+ }
+ // Always update our own node in the mesh graph with the neighbor list we used
+ try {
+ com.bitchat.android.services.meshgraph.MeshGraphService.getInstance()
+ .updateFromAnnouncement(myPeerID, nickname, directPeers, System.currentTimeMillis().toULong())
+ } catch (_: Exception) { }
+ } catch (_: Exception) { }
val announcePacket = BitchatPacket(
type = MessageType.ANNOUNCE.value,
@@ -1014,7 +1132,7 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
announcePacket.copy(signature = signature)
} ?: announcePacket
- dispatchGlobal(RoutedPacket(signedPacket))
+ broadcastRoutedPacket(RoutedPacket(signedPacket))
Log.d(TAG, "Sent iOS-compatible signed TLV announce (${tlvPayload.size} bytes)")
// Track announce for sync
try { gossipSyncManager.onPublicPacketSeen(signedPacket) } catch (_: Exception) { }
@@ -1045,11 +1163,25 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
// Create iOS-compatible IdentityAnnouncement with TLV encoding
val announcement = IdentityAnnouncement(nickname, staticKey, signingKey)
- val tlvPayload = announcement.encode()
+ var tlvPayload = announcement.encode()
if (tlvPayload == null) {
Log.e(TAG, "Failed to encode peer announcement as TLV")
return
}
+
+ // Append gossip TLV containing up to 10 direct neighbors (compact IDs)
+ try {
+ val directPeers = getDirectPeerIDsForGossip()
+ if (directPeers.isNotEmpty()) {
+ val gossip = com.bitchat.android.services.meshgraph.GossipTLV.encodeNeighbors(directPeers)
+ tlvPayload = tlvPayload + gossip
+ }
+ // Always update our own node in the mesh graph with the neighbor list we used
+ try {
+ com.bitchat.android.services.meshgraph.MeshGraphService.getInstance()
+ .updateFromAnnouncement(myPeerID, nickname, directPeers, System.currentTimeMillis().toULong())
+ } catch (_: Exception) { }
+ } catch (_: Exception) { }
val packet = BitchatPacket(
type = MessageType.ANNOUNCE.value,
@@ -1063,7 +1195,7 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
packet.copy(signature = signature)
} ?: packet
- dispatchGlobal(RoutedPacket(signedPacket))
+ broadcastRoutedPacket(RoutedPacket(signedPacket))
peerManager.markPeerAsAnnouncedTo(peerID)
Log.d(TAG, "Sent iOS-compatible signed TLV peer announce to $peerID (${tlvPayload.size} bytes)")
@@ -1071,6 +1203,26 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
try { gossipSyncManager.onPublicPacketSeen(signedPacket) } catch (_: Exception) { }
}
+ /**
+ * Collect up to 10 direct neighbors for gossip TLV.
+ */
+ private fun getDirectPeerIDsForGossip(): List {
+ return try {
+ // Prefer verified peers that are currently marked as direct
+ val verified = peerManager.getVerifiedPeers()
+ val direct = verified.filter { it.value.isDirectConnection }.keys.toSet()
+ // Publish this transport's direct peers and gossip the cross-transport union so a
+ // node connected via multiple transports advertises a complete neighbor list.
+ try { com.bitchat.android.services.AppStateStore.setTransportDirectPeers("BLE", direct) } catch (_: Exception) { }
+ val union = try {
+ com.bitchat.android.services.AppStateStore.getDirectPeers().ifEmpty { direct }
+ } catch (_: Exception) { direct }
+ union.distinct().take(10)
+ } catch (_: Exception) {
+ emptyList()
+ }
+ }
+
/**
* Send leave announcement
*/
@@ -1084,7 +1236,7 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
// Sign the packet before broadcasting
val signedPacket = signPacketBeforeBroadcast(packet)
- dispatchGlobal(RoutedPacket(signedPacket))
+ broadcastRoutedPacket(RoutedPacket(signedPacket))
}
/**
@@ -1159,6 +1311,10 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
fun getIdentityFingerprint(): String {
return encryptionService.getIdentityFingerprint()
}
+
+ fun getStaticNoisePublicKey(): ByteArray? {
+ return encryptionService.getStaticPublicKey()
+ }
/**
* Check if encryption icon should be shown for a peer
@@ -1249,21 +1405,38 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
*/
private fun signPacketBeforeBroadcast(packet: BitchatPacket): BitchatPacket {
return try {
+ // Optionally compute and attach a source route for addressed packets
+ val withRoute = try {
+ val rec = packet.recipientID
+ if (rec != null && !rec.contentEquals(SpecialRecipients.BROADCAST)) {
+ val dest = rec.joinToString("") { b -> "%02x".format(b) }
+ val path = com.bitchat.android.services.meshgraph.RoutePlanner.shortestPath(myPeerID, dest)
+ if (path != null && path.size >= 3) {
+ // Exclude first (sender) and last (recipient); only intermediates
+ val intermediates = path.subList(1, path.size - 1)
+ val hopsBytes = intermediates.map { hexStringToByteArray(it) }
+ Log.d(TAG, "✅ Signed packet type ${packet.type} (route ${hopsBytes.size} hops: $intermediates)")
+ // Attach route and upgrade to v2 (required for HAS_ROUTE flag)
+ packet.copy(route = hopsBytes, version = 2u)
+ } else packet.copy(route = null)
+ } else packet
+ } catch (_: Exception) { packet }
+
// Get the canonical packet data for signing (without signature)
- val packetDataForSigning = packet.toBinaryDataForSigning()
+ val packetDataForSigning = withRoute.toBinaryDataForSigning()
if (packetDataForSigning == null) {
Log.w(TAG, "Failed to encode packet type ${packet.type} for signing, sending unsigned")
- return packet
+ return withRoute
}
// Sign the packet data using our signing key
val signature = encryptionService.signData(packetDataForSigning)
if (signature != null) {
Log.d(TAG, "✅ Signed packet type ${packet.type} (signature ${signature.size} bytes)")
- packet.copy(signature = signature)
+ withRoute.copy(signature = signature)
} else {
Log.w(TAG, "Failed to sign packet type ${packet.type}, sending unsigned")
- packet
+ withRoute
}
} catch (e: Exception) {
Log.w(TAG, "Error signing packet type ${packet.type}: ${e.message}, sending unsigned")
@@ -1279,6 +1452,9 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
fun clearAllInternalData() {
Log.w(TAG, "🚨 Clearing all mesh service internal data")
try {
+ // Stop services to cease broadcasting old ID immediately
+ stopServices()
+
// Clear all managers
fragmentManager.clearAllFragments()
storeForwardManager.clearAllCache()
@@ -1307,16 +1483,10 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
}
/**
- * Delegate interface for mesh service callbacks (maintains exact same interface)
+ * Delegate interface for BLE mesh callbacks. Extends the shared mesh delegate so
+ * transport-agnostic facades can receive the same callback stream.
*/
-interface BluetoothMeshDelegate {
- fun didReceiveMessage(message: BitchatMessage)
- fun didUpdatePeerList(peers: List)
- fun didReceiveChannelLeave(channel: String, fromPeer: String)
- fun didReceiveDeliveryAck(messageID: String, recipientPeerID: String)
- fun didReceiveReadReceipt(messageID: String, recipientPeerID: String)
- fun decryptChannelMessage(encryptedContent: ByteArray, channel: String): String?
- fun getNickname(): String?
- fun isFavorite(peerID: String): Boolean
- // registerPeerPublicKey REMOVED - fingerprints now handled centrally in PeerManager
+interface BluetoothMeshDelegate : MeshDelegate {
+ override fun didReceiveVerifyChallenge(peerID: String, payload: ByteArray, timestampMs: Long)
+ override fun didReceiveVerifyResponse(peerID: String, payload: ByteArray, timestampMs: Long)
}
diff --git a/app/src/main/java/com/bitchat/android/mesh/BluetoothPacketBroadcaster.kt b/app/src/main/java/com/bitchat/android/mesh/BluetoothPacketBroadcaster.kt
index 04e5d756..efb3c4df 100644
--- a/app/src/main/java/com/bitchat/android/mesh/BluetoothPacketBroadcaster.kt
+++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothPacketBroadcaster.kt
@@ -18,8 +18,6 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.channels.Channel
-import kotlinx.coroutines.Job
-import java.util.concurrent.ConcurrentHashMap
import kotlinx.coroutines.channels.actor
/**
@@ -42,7 +40,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 +67,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 +81,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 +97,9 @@ class BluetoothPacketBroadcaster(
toNickname = toNick,
toDeviceAddress = toDeviceAddress,
ttl = ttl,
- isRelay = true
+ isRelay = true,
+ packetVersion = packetVersion,
+ routeInfo = routeInfo
)
} catch (_: Exception) {
// Silently ignore debug logging failures
@@ -110,7 +115,7 @@ class BluetoothPacketBroadcaster(
// Actor scope for the broadcaster
private val broadcasterScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
- private val transferJobs = ConcurrentHashMap()
+ private val fragmentingSender = FragmentingPacketSender(connectionScope, fragmentManager, TAG)
// SERIALIZATION: Actor to serialize all broadcast operations
@OptIn(kotlinx.coroutines.ObsoleteCoroutinesApi::class)
@@ -132,71 +137,14 @@ class BluetoothPacketBroadcaster(
gattServer: BluetoothGattServer?,
characteristic: BluetoothGattCharacteristic?
) {
- val packet = routed.packet
- val isFile = packet.type == MessageType.FILE_TRANSFER.value
- if (isFile) {
- Log.d(TAG, "📤 Broadcasting FILE_TRANSFER: ${packet.payload.size} bytes")
- }
- // Prefer caller-provided transferId (e.g., for encrypted media), else derive for FILE_TRANSFER
- val transferId = routed.transferId ?: (if (isFile) sha256Hex(packet.payload) else null)
- // Check if we need to fragment
- if (fragmentManager != null) {
- val fragments = try {
- fragmentManager.createFragments(packet)
- } catch (e: Exception) {
- Log.e(TAG, "❌ Fragment creation failed: ${e.message}", e)
- if (isFile) {
- Log.e(TAG, "❌ File fragmentation failed for ${packet.payload.size} byte file")
- }
- return
- }
- if (fragments.size > 1) {
- if (isFile) {
- Log.d(TAG, "🔀 File needs ${fragments.size} fragments")
- }
- Log.d(TAG, "Fragmenting packet into ${fragments.size} fragments")
- if (transferId != null) {
- TransferProgressManager.start(transferId, fragments.size)
- }
- val job = connectionScope.launch {
- var sent = 0
- fragments.forEach { fragment ->
- if (!isActive) return@launch
- // If cancelled, stop sending remaining fragments
- if (transferId != null && transferJobs[transferId]?.isCancelled == true) return@launch
- broadcastSinglePacket(RoutedPacket(fragment, transferId = transferId), gattServer, characteristic)
- // 20ms delay between fragments
- delay(20)
- if (transferId != null) {
- sent += 1
- TransferProgressManager.progress(transferId, sent, fragments.size)
- if (sent == fragments.size) TransferProgressManager.complete(transferId, fragments.size)
- }
- }
- }
- if (transferId != null) {
- transferJobs[transferId] = job
- job.invokeOnCompletion { transferJobs.remove(transferId) }
- }
- return
- }
- }
-
- // Send single packet if no fragmentation needed
- if (transferId != null) {
- TransferProgressManager.start(transferId, 1)
- }
- broadcastSinglePacket(routed, gattServer, characteristic)
- if (transferId != null) {
- TransferProgressManager.progress(transferId, 1, 1)
- TransferProgressManager.complete(transferId, 1)
+ fragmentingSender.send(routed, "BLE broadcast") { packet ->
+ broadcastSinglePacket(packet, gattServer, characteristic)
+ true
}
}
fun cancelTransfer(transferId: String): Boolean {
- val job = transferJobs.remove(transferId) ?: return false
- job.cancel()
- return true
+ return fragmentingSender.cancelTransfer(transferId)
}
/**
@@ -208,6 +156,18 @@ class BluetoothPacketBroadcaster(
targetPeerID: String,
gattServer: BluetoothGattServer?,
characteristic: BluetoothGattCharacteristic?
+ ): Boolean {
+ if (!hasPeerConnection(targetPeerID)) return false
+ return fragmentingSender.send(routed, "BLE peer ${targetPeerID.take(8)}") { packet ->
+ sendSinglePacketToPeer(packet, targetPeerID, gattServer, characteristic)
+ }
+ }
+
+ private fun sendSinglePacketToPeer(
+ routed: RoutedPacket,
+ targetPeerID: String,
+ gattServer: BluetoothGattServer?,
+ characteristic: BluetoothGattCharacteristic?
): Boolean {
val packet = routed.packet
val data = packet.toBinaryData() ?: return false
@@ -215,27 +175,20 @@ class BluetoothPacketBroadcaster(
if (isFile) {
Log.d(TAG, "📤 Broadcasting FILE_TRANSFER: ${packet.payload.size} bytes")
}
- // Prefer caller-provided transferId (e.g., for encrypted media), else derive for FILE_TRANSFER
- val transferId = routed.transferId ?: (if (isFile) sha256Hex(packet.payload) else null)
- if (transferId != null) {
- 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)
- if (transferId != null) {
- TransferProgressManager.progress(transferId, 1, 1)
- TransferProgressManager.complete(transferId, 1)
- }
+ logPacketRelay(typeName, senderPeerID, senderNick, incomingPeer, incomingAddr, targetPeerID, serverTarget.address, packet.ttl, packet.version, routeInfo)
return true
}
}
@@ -245,11 +198,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)
- if (transferId != null) {
- TransferProgressManager.progress(transferId, 1, 1)
- TransferProgressManager.complete(transferId, 1)
- }
+ logPacketRelay(typeName, senderPeerID, senderNick, incomingPeer, incomingAddr, targetPeerID, clientTarget.device.address, packet.ttl, packet.version, routeInfo)
return true
}
}
@@ -257,12 +206,6 @@ class BluetoothPacketBroadcaster(
return false
}
- private fun sha256Hex(bytes: ByteArray): String = try {
- val md = java.security.MessageDigest.getInstance("SHA-256")
- md.update(bytes)
- md.digest().joinToString("") { "%02x".format(it) }
- } catch (_: Exception) { bytes.size.toString(16) }
-
/**
* Public entry point for broadcasting - submits request to actor for serialization
@@ -283,6 +226,31 @@ 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 {
+ if (!hasPeerConnection(targetPeerID)) return false
+ return fragmentingSender.send(routed, "BLE peer ${targetPeerID.take(8)}") { packet ->
+ sendSinglePacketToPeer(packet, targetPeerID, gattServer, characteristic)
+ }
+ }
+
+ private fun hasPeerConnection(targetPeerID: String): Boolean {
+ val hasServerTarget = connectionTracker.getSubscribedDevices()
+ .any { connectionTracker.addressPeerMap[it.address] == targetPeerID }
+ if (hasServerTarget) return true
+
+ return connectionTracker.getConnectedDevices().values
+ .any { connectionTracker.addressPeerMap[it.device.address] == targetPeerID }
+ }
/**
* Internal broadcast implementation - runs in serialized actor context
@@ -299,11 +267,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 +323,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 +337,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 +347,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 +364,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 +382,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)
}
}
}
diff --git a/app/src/main/java/com/bitchat/android/mesh/BluetoothPermissionManager.kt b/app/src/main/java/com/bitchat/android/mesh/BluetoothPermissionManager.kt
index 917de66e..45d48ac6 100644
--- a/app/src/main/java/com/bitchat/android/mesh/BluetoothPermissionManager.kt
+++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothPermissionManager.kt
@@ -33,9 +33,9 @@ class BluetoothPermissionManager(private val context: Context) {
Manifest.permission.ACCESS_COARSE_LOCATION,
Manifest.permission.ACCESS_FINE_LOCATION
))
-
+
return permissions.all {
ActivityCompat.checkSelfPermission(context, it) == PackageManager.PERMISSION_GRANTED
}
}
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/com/bitchat/android/mesh/FragmentManager.kt b/app/src/main/java/com/bitchat/android/mesh/FragmentManager.kt
index 16f6844d..2be19841 100644
--- a/app/src/main/java/com/bitchat/android/mesh/FragmentManager.kt
+++ b/app/src/main/java/com/bitchat/android/mesh/FragmentManager.kt
@@ -32,7 +32,11 @@ class FragmentManager {
private val incomingFragments = ConcurrentHashMap>()
// iOS equivalent: fragmentMetadata: [String: (type: UInt8, total: Int, timestamp: Date)]
private val fragmentMetadata = ConcurrentHashMap>() // originalType, totalFragments, timestamp
-
+ private val fragmentCumulativeSize = ConcurrentHashMap()
+
+ 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.. 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..()
- 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)
-
- // 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
+
+ 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
+ }
+
+ 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..()
+ 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) {
@@ -201,6 +288,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
@@ -221,20 +317,20 @@ class FragmentManager {
* Clean old fragments (> 30 seconds old)
*/
private fun cleanupOldFragments() {
- 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: for fragmentID in oldFragments { incomingFragments.removeValue(forKey: fragmentID) }
- for (fragmentID in oldFragments) {
- incomingFragments.remove(fragmentID)
- fragmentMetadata.remove(fragmentID)
- }
-
- if (oldFragments.isNotEmpty()) {
- Log.d(TAG, "Cleaned up ${oldFragments.size} old fragment sets (iOS compatible)")
+ 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 }
+
+ for (fragmentID in oldFragments) {
+ removeFragmentSetLocked(fragmentID)
+ }
+
+ 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")
-
- 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")
+ 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
+ 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
+ }
}
/**
diff --git a/app/src/main/java/com/bitchat/android/mesh/FragmentingPacketSender.kt b/app/src/main/java/com/bitchat/android/mesh/FragmentingPacketSender.kt
new file mode 100644
index 00000000..115254dc
--- /dev/null
+++ b/app/src/main/java/com/bitchat/android/mesh/FragmentingPacketSender.kt
@@ -0,0 +1,138 @@
+package com.bitchat.android.mesh
+
+import android.util.Log
+import com.bitchat.android.model.RoutedPacket
+import com.bitchat.android.protocol.BitchatPacket
+import com.bitchat.android.protocol.MessageType
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.CoroutineStart
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.isActive
+import kotlinx.coroutines.launch
+import java.security.MessageDigest
+import java.util.concurrent.ConcurrentHashMap
+
+/**
+ * Shared transport send wrapper that applies bitchat packet fragmentation and
+ * transfer progress before a transport writes packets to its concrete medium.
+ */
+class FragmentingPacketSender(
+ private val scope: CoroutineScope,
+ private val fragmentManager: FragmentManager?,
+ private val logTag: String,
+ private val interFragmentDelayMs: Long = 20L
+) {
+ private val transferJobs = ConcurrentHashMap()
+
+ fun send(
+ routed: RoutedPacket,
+ description: String,
+ sendSingle: (RoutedPacket) -> Boolean
+ ): Boolean {
+ val transferId = transferIdFor(routed)
+ val packets = packetsForTransport(routed.packet) ?: return false
+ val total = packets.size
+
+ if (total <= 1) {
+ if (transferId != null) {
+ TransferProgressManager.start(transferId, 1)
+ }
+ val sent = sendSingle(routed.copy(packet = packets.first(), transferId = transferId))
+ if (sent && transferId != null) {
+ TransferProgressManager.progress(transferId, 1, 1)
+ TransferProgressManager.complete(transferId, 1)
+ }
+ return sent
+ }
+
+ Log.d(logTag, "Fragmenting packet type ${routed.packet.type} into $total fragments for $description")
+ if (transferId != null) {
+ TransferProgressManager.start(transferId, total)
+ }
+
+ val job = scope.launch(start = CoroutineStart.LAZY) {
+ var sent = 0
+ for (packet in packets) {
+ if (!isActive) return@launch
+ if (transferId != null && transferJobs[transferId]?.isCancelled == true) return@launch
+
+ val fragment = routed.copy(packet = packet, transferId = transferId)
+ val delivered = try {
+ sendSingle(fragment)
+ } catch (e: Exception) {
+ Log.e(logTag, "Fragment send failed for $description: ${e.message}", e)
+ false
+ }
+
+ if (!delivered) {
+ Log.w(logTag, "Stopping fragmented send for $description after $sent/$total fragments")
+ return@launch
+ }
+
+ sent += 1
+ if (transferId != null) {
+ TransferProgressManager.progress(transferId, sent, total)
+ }
+ if (sent < total) {
+ delay(interFragmentDelayMs)
+ }
+ }
+
+ if (transferId != null) {
+ TransferProgressManager.complete(transferId, total)
+ }
+ }
+
+ if (transferId != null) {
+ transferJobs[transferId] = job
+ job.invokeOnCompletion { transferJobs.remove(transferId, job) }
+ }
+ job.start()
+ return true
+ }
+
+ fun cancelTransfer(transferId: String): Boolean {
+ val job = transferJobs.remove(transferId) ?: return false
+ job.cancel()
+ return true
+ }
+
+ private fun packetsForTransport(packet: BitchatPacket): List? {
+ if (packet.type == MessageType.FRAGMENT.value) {
+ return listOf(packet)
+ }
+
+ val manager = fragmentManager ?: return listOf(packet)
+ return try {
+ val fragments = manager.createFragments(packet)
+ if (fragments.isEmpty()) {
+ Log.e(logTag, "Fragment manager returned no packets for packet type ${packet.type}")
+ null
+ } else {
+ fragments
+ }
+ } catch (e: Exception) {
+ Log.e(logTag, "Fragment creation failed for packet type ${packet.type}: ${e.message}", e)
+ null
+ }
+ }
+
+ private fun transferIdFor(routed: RoutedPacket): String? {
+ routed.transferId?.let { return it }
+ val packet = routed.packet
+ return if (packet.type == MessageType.FILE_TRANSFER.value) {
+ sha256Hex(packet.payload)
+ } else {
+ null
+ }
+ }
+
+ private fun sha256Hex(bytes: ByteArray): String = try {
+ val md = MessageDigest.getInstance("SHA-256")
+ md.update(bytes)
+ md.digest().joinToString("") { "%02x".format(it) }
+ } catch (_: Exception) {
+ bytes.size.toString(16)
+ }
+}
diff --git a/app/src/main/java/com/bitchat/android/mesh/MeshConnectionTracker.kt b/app/src/main/java/com/bitchat/android/mesh/MeshConnectionTracker.kt
index 01df23c8..0dd6a552 100644
--- a/app/src/main/java/com/bitchat/android/mesh/MeshConnectionTracker.kt
+++ b/app/src/main/java/com/bitchat/android/mesh/MeshConnectionTracker.kt
@@ -1,6 +1,7 @@
package com.bitchat.android.mesh
import android.util.Log
+import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@@ -131,6 +132,8 @@ abstract class MeshConnectionTracker(
if (expired.isNotEmpty()) {
Log.d(tag, "Cleaned up ${expired.size} expired connection attempts")
}
+ } catch (e: CancellationException) {
+ break
} catch (e: Exception) {
Log.w(tag, "Error in periodic cleanup: ${e.message}")
}
diff --git a/app/src/main/java/com/bitchat/android/mesh/MeshCore.kt b/app/src/main/java/com/bitchat/android/mesh/MeshCore.kt
new file mode 100644
index 00000000..46518428
--- /dev/null
+++ b/app/src/main/java/com/bitchat/android/mesh/MeshCore.kt
@@ -0,0 +1,843 @@
+package com.bitchat.android.mesh
+
+import android.content.Context
+import android.util.Log
+import com.bitchat.android.crypto.EncryptionService
+import com.bitchat.android.model.BitchatMessage
+import com.bitchat.android.model.BitchatFilePacket
+import com.bitchat.android.model.IdentityAnnouncement
+import com.bitchat.android.model.NoisePayload
+import com.bitchat.android.model.NoisePayloadType
+import com.bitchat.android.model.PrivateMessagePacket
+import com.bitchat.android.model.RequestSyncPacket
+import com.bitchat.android.model.RoutedPacket
+import com.bitchat.android.protocol.BitchatPacket
+import com.bitchat.android.protocol.MessageType
+import com.bitchat.android.protocol.SpecialRecipients
+import com.bitchat.android.service.TransportBridgeService
+import com.bitchat.android.sync.GossipSyncManager
+import com.bitchat.android.util.toHexString
+import kotlinx.coroutines.CoroutineScope
+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
+ * for send/receive operations across transports.
+ */
+class MeshCore(
+ private val context: Context,
+ private val scope: CoroutineScope,
+ private val transport: MeshTransport,
+ private val encryptionService: EncryptionService,
+ val myPeerID: String,
+ private val maxTtl: UByte,
+ sharedGossipManager: GossipSyncManager?,
+ gossipConfigProvider: GossipSyncManager.ConfigProvider,
+ private val hooks: Hooks = Hooks()
+) {
+ data class Hooks(
+ val onMessageReceived: ((BitchatMessage) -> Unit)? = null,
+ val onPeerIdBindingUpdated: ((String, String, ByteArray, String?) -> Unit)? = null,
+ val onAnnounceProcessed: ((RoutedPacket, Boolean) -> Unit)? = null,
+ val readReceiptInterceptor: ((String, String) -> Boolean)? = null,
+ val onReadReceiptSent: ((String) -> Unit)? = null,
+ val announcementNicknameProvider: (() -> String?)? = null,
+ val leavePayloadProvider: (() -> ByteArray)? = null
+ )
+
+ private val peerManager = PeerManager()
+ val fragmentManager = FragmentManager()
+ private val securityManager = SecurityManager(encryptionService, myPeerID)
+ private val storeForwardManager = StoreForwardManager()
+ private val messageHandler = MessageHandler(myPeerID, context.applicationContext)
+ private val packetProcessor = PacketProcessor(myPeerID)
+ private val directPeers = ConcurrentHashMap.newKeySet()
+
+ val gossipSyncManager: GossipSyncManager =
+ sharedGossipManager ?: GossipSyncManager(myPeerID = myPeerID, scope = scope, configProvider = gossipConfigProvider)
+ private val ownsGossipManager: Boolean = sharedGossipManager == null
+
+ var delegate: MeshDelegate? = null
+
+ private var announceJob: Job? = null
+ private var isActive = false
+
+ init {
+ messageHandler.packetProcessor = packetProcessor
+ peerManager.isPeerDirectlyConnected = { peerID -> directPeers.contains(peerID) }
+ setupDelegates()
+
+ if (sharedGossipManager == null) {
+ gossipSyncManager.delegate = object : GossipSyncManager.Delegate {
+ override fun sendPacket(packet: BitchatPacket) {
+ dispatchGlobal(RoutedPacket(packet))
+ }
+
+ override fun sendPacketToPeer(peerID: String, packet: BitchatPacket) {
+ transport.sendPacketToPeer(peerID, packet)
+ TransportBridgeService.sendToPeer(transport.id, peerID, packet)
+ }
+
+ override fun signPacketForBroadcast(packet: BitchatPacket): BitchatPacket {
+ return signPacketBeforeBroadcast(packet)
+ }
+ }
+ }
+ }
+
+ fun startCore() {
+ if (isActive) return
+ isActive = true
+ startPeriodicBroadcastAnnounce()
+ if (ownsGossipManager) {
+ gossipSyncManager.start()
+ }
+ }
+
+ fun stopCore() {
+ if (!isActive) return
+ isActive = false
+ announceJob?.cancel()
+ announceJob = null
+ if (ownsGossipManager) {
+ gossipSyncManager.stop()
+ }
+ }
+
+ fun shutdown() {
+ peerManager.shutdown()
+ fragmentManager.shutdown()
+ securityManager.shutdown()
+ storeForwardManager.shutdown()
+ messageHandler.shutdown()
+ packetProcessor.shutdown()
+ }
+
+ fun processIncoming(packet: BitchatPacket, peerID: String?, relayAddress: String?) {
+ packetProcessor.processPacket(RoutedPacket(packet, peerID, relayAddress))
+ }
+
+ fun sendFromBridge(packet: RoutedPacket) {
+ transport.broadcastPacket(packet)
+ }
+
+ private fun dispatchGlobal(routed: RoutedPacket) {
+ transport.broadcastPacket(routed)
+ TransportBridgeService.broadcast(transport.id, routed)
+ }
+
+ private fun startPeriodicBroadcastAnnounce() {
+ announceJob?.cancel()
+ announceJob = scope.launch {
+ while (isActive) {
+ try {
+ delay(30_000)
+ sendBroadcastAnnounce()
+ } catch (_: Exception) { }
+ }
+ }
+ }
+
+ private fun setupDelegates() {
+ peerManager.delegate = object : PeerManagerDelegate {
+ override fun onPeerListUpdated(peerIDs: List) {
+ try { com.bitchat.android.services.AppStateStore.setTransportPeers(transport.id, peerIDs) } catch (_: Exception) { }
+ delegate?.didUpdatePeerList(peerIDs)
+ }
+
+ override fun onPeerRemoved(peerID: String) {
+ try { gossipSyncManager.removeAnnouncementForPeer(peerID) } catch (_: Exception) { }
+ try { encryptionService.removePeer(peerID) } catch (_: Exception) { }
+ try { peerManager.refreshPeerList() } catch (_: Exception) { }
+ }
+ }
+
+ securityManager.delegate = object : SecurityManagerDelegate {
+ override fun onKeyExchangeCompleted(peerID: String, peerPublicKeyData: ByteArray) {
+ scope.launch {
+ delay(100)
+ sendAnnouncementToPeer(peerID)
+ delay(1000)
+ storeForwardManager.sendCachedMessages(peerID)
+ }
+ }
+
+ override fun sendHandshakeResponse(peerID: String, response: ByteArray) {
+ val responsePacket = BitchatPacket(
+ version = 1u,
+ type = MessageType.NOISE_HANDSHAKE.value,
+ senderID = MeshPacketUtils.hexStringToByteArray(myPeerID),
+ recipientID = MeshPacketUtils.hexStringToByteArray(peerID),
+ timestamp = System.currentTimeMillis().toULong(),
+ payload = response,
+ ttl = maxTtl
+ )
+ dispatchGlobal(RoutedPacket(signPacketBeforeBroadcast(responsePacket)))
+ }
+
+ override fun getPeerInfo(peerID: String): PeerInfo? = peerManager.getPeerInfo(peerID)
+ }
+
+ storeForwardManager.delegate = object : StoreForwardManagerDelegate {
+ override fun isFavorite(peerID: String): Boolean {
+ return delegate?.isFavorite(peerID) ?: false
+ }
+
+ override fun isPeerOnline(peerID: String): Boolean {
+ return peerManager.isPeerActive(peerID)
+ }
+
+ override fun sendPacket(packet: BitchatPacket) {
+ dispatchGlobal(RoutedPacket(packet))
+ }
+ }
+
+ messageHandler.delegate = object : MessageHandlerDelegate {
+ override fun addOrUpdatePeer(peerID: String, nickname: String): Boolean {
+ return peerManager.addOrUpdatePeer(peerID, nickname)
+ }
+
+ override fun removePeer(peerID: String) {
+ peerManager.removePeer(peerID)
+ }
+
+ override fun updatePeerNickname(peerID: String, nickname: String) {
+ peerManager.addOrUpdatePeer(peerID, nickname)
+ }
+
+ override fun getPeerNickname(peerID: String): String? {
+ return peerManager.getPeerNickname(peerID)
+ }
+
+ override fun getNetworkSize(): Int {
+ return peerManager.getActivePeerCount()
+ }
+
+ override fun getMyNickname(): String? {
+ return delegate?.getNickname()
+ }
+
+ override fun getPeerInfo(peerID: String): PeerInfo? {
+ return peerManager.getPeerInfo(peerID)
+ }
+
+ override fun updatePeerInfo(
+ peerID: String,
+ nickname: String,
+ noisePublicKey: ByteArray,
+ signingPublicKey: ByteArray,
+ isVerified: Boolean
+ ): Boolean {
+ return peerManager.updatePeerInfo(peerID, nickname, noisePublicKey, signingPublicKey, isVerified)
+ }
+
+ override fun sendPacket(packet: BitchatPacket) {
+ val signedPacket = signPacketBeforeBroadcast(packet)
+ dispatchGlobal(RoutedPacket(signedPacket))
+ }
+
+ override fun relayPacket(routed: RoutedPacket) {
+ dispatchGlobal(routed)
+ }
+
+ override fun getBroadcastRecipient(): ByteArray {
+ return SpecialRecipients.BROADCAST
+ }
+
+ override fun verifySignature(packet: BitchatPacket, peerID: String): Boolean {
+ return securityManager.verifySignature(packet, peerID)
+ }
+
+ override fun encryptForPeer(data: ByteArray, recipientPeerID: String): ByteArray? {
+ return securityManager.encryptForPeer(data, recipientPeerID)
+ }
+
+ override fun decryptFromPeer(encryptedData: ByteArray, senderPeerID: String): ByteArray? {
+ return securityManager.decryptFromPeer(encryptedData, senderPeerID)
+ }
+
+ override fun verifyEd25519Signature(signature: ByteArray, data: ByteArray, publicKey: ByteArray): Boolean {
+ return encryptionService.verifyEd25519Signature(signature, data, publicKey)
+ }
+
+ override fun hasNoiseSession(peerID: String): Boolean {
+ return encryptionService.hasEstablishedSession(peerID)
+ }
+
+ override fun initiateNoiseHandshake(peerID: String) {
+ this@MeshCore.initiateNoiseHandshake(peerID)
+ }
+
+ override fun processNoiseHandshakeMessage(payload: ByteArray, peerID: String): ByteArray? {
+ return try {
+ encryptionService.processHandshakeMessage(payload, peerID)
+ } catch (_: Exception) {
+ null
+ }
+ }
+
+ override fun updatePeerIDBinding(
+ newPeerID: String,
+ nickname: String,
+ publicKey: ByteArray,
+ previousPeerID: String?
+ ) {
+ peerManager.addOrUpdatePeer(newPeerID, nickname)
+ val fingerprint = peerManager.storeFingerprintForPeer(newPeerID, publicKey)
+ previousPeerID?.let { peerManager.removePeer(it) }
+ Log.d("MeshCore", "Updated peer ID binding: $newPeerID fp=${fingerprint.take(16)}")
+ hooks.onPeerIdBindingUpdated?.invoke(newPeerID, nickname, publicKey, previousPeerID)
+ }
+
+ override fun decryptChannelMessage(encryptedContent: ByteArray, channel: String): String? {
+ return delegate?.decryptChannelMessage(encryptedContent, channel)
+ }
+
+ override fun onMessageReceived(message: BitchatMessage) {
+ hooks.onMessageReceived?.invoke(message)
+ delegate?.didReceiveMessage(message)
+ }
+
+ override fun onChannelLeave(channel: String, fromPeer: String) {
+ delegate?.didReceiveChannelLeave(channel, fromPeer)
+ }
+
+ override fun onDeliveryAckReceived(messageID: String, peerID: String) {
+ delegate?.didReceiveDeliveryAck(messageID, peerID)
+ }
+
+ override fun onReadReceiptReceived(messageID: String, peerID: String) {
+ delegate?.didReceiveReadReceipt(messageID, peerID)
+ }
+
+ override fun onVerifyChallengeReceived(peerID: String, payload: ByteArray, timestampMs: Long) {
+ delegate?.didReceiveVerifyChallenge(peerID, payload, timestampMs)
+ }
+
+ override fun onVerifyResponseReceived(peerID: String, payload: ByteArray, timestampMs: Long) {
+ delegate?.didReceiveVerifyResponse(peerID, payload, timestampMs)
+ }
+ }
+
+ packetProcessor.delegate = object : PacketProcessorDelegate {
+ override fun validatePacketSecurity(packet: BitchatPacket, peerID: String): Boolean {
+ return securityManager.validatePacket(packet, peerID)
+ }
+
+ override fun updatePeerLastSeen(peerID: String) {
+ peerManager.updatePeerLastSeen(peerID)
+ }
+
+ override fun getPeerNickname(peerID: String): String? {
+ return peerManager.getPeerNickname(peerID)
+ }
+
+ override fun getNetworkSize(): Int {
+ return peerManager.getActivePeerCount()
+ }
+
+ override fun getBroadcastRecipient(): ByteArray {
+ return SpecialRecipients.BROADCAST
+ }
+
+ override fun handleNoiseHandshake(routed: RoutedPacket): Boolean {
+ return runBlocking { securityManager.handleNoiseHandshake(routed) }
+ }
+
+ override fun handleNoiseEncrypted(routed: RoutedPacket) {
+ scope.launch { messageHandler.handleNoiseEncrypted(routed) }
+ }
+
+ override fun handleAnnounce(routed: RoutedPacket) {
+ scope.launch {
+ val isFirst = messageHandler.handleAnnounce(routed)
+ hooks.onAnnounceProcessed?.invoke(routed, isFirst)
+ try { gossipSyncManager.onPublicPacketSeen(routed.packet) } catch (_: Exception) { }
+ }
+ }
+
+ override fun handleMessage(routed: RoutedPacket) {
+ scope.launch { messageHandler.handleMessage(routed) }
+ try {
+ val pkt = routed.packet
+ val isBroadcast = (pkt.recipientID == null || pkt.recipientID.contentEquals(SpecialRecipients.BROADCAST))
+ if (isBroadcast && pkt.type == MessageType.MESSAGE.value) {
+ gossipSyncManager.onPublicPacketSeen(pkt)
+ }
+ } catch (_: Exception) { }
+ }
+
+ override fun handleLeave(routed: RoutedPacket) {
+ scope.launch { messageHandler.handleLeave(routed) }
+ }
+
+ override fun handleFragment(packet: BitchatPacket): BitchatPacket? {
+ try {
+ val isBroadcast = (packet.recipientID == null || packet.recipientID.contentEquals(SpecialRecipients.BROADCAST))
+ if (isBroadcast && packet.type == MessageType.FRAGMENT.value) {
+ gossipSyncManager.onPublicPacketSeen(packet)
+ }
+ } catch (_: Exception) { }
+ return fragmentManager.handleFragment(packet)
+ }
+
+ override fun sendAnnouncementToPeer(peerID: String) {
+ this@MeshCore.sendAnnouncementToPeer(peerID)
+ }
+
+ override fun sendCachedMessages(peerID: String) {
+ storeForwardManager.sendCachedMessages(peerID)
+ }
+
+ override fun relayPacket(routed: RoutedPacket) {
+ dispatchGlobal(routed)
+ }
+
+ override fun sendToPeer(peerID: String, routed: RoutedPacket): Boolean {
+ val sent = transport.sendPacketToPeer(peerID, routed.packet)
+ TransportBridgeService.sendToPeer(transport.id, peerID, routed.packet)
+ return sent
+ }
+
+ override fun handleRequestSync(routed: RoutedPacket) {
+ val fromPeer = routed.peerID ?: return
+ val req = RequestSyncPacket.decode(routed.packet.payload) ?: return
+ gossipSyncManager.handleRequestSync(fromPeer, req)
+ }
+ }
+ }
+
+ fun sendMessage(content: String, mentions: List = emptyList(), channel: String? = null) {
+ if (content.isEmpty()) return
+ scope.launch {
+ val packet = BitchatPacket(
+ version = 1u,
+ type = MessageType.MESSAGE.value,
+ senderID = MeshPacketUtils.hexStringToByteArray(myPeerID),
+ recipientID = SpecialRecipients.BROADCAST,
+ timestamp = System.currentTimeMillis().toULong(),
+ payload = content.toByteArray(Charsets.UTF_8),
+ signature = null,
+ ttl = maxTtl
+ )
+ val signedPacket = signPacketBeforeBroadcast(packet)
+ dispatchGlobal(RoutedPacket(signedPacket))
+ try { gossipSyncManager.onPublicPacketSeen(signedPacket) } catch (_: Exception) { }
+ }
+ }
+
+ fun sendFileBroadcast(file: BitchatFilePacket) {
+ try {
+ val payload = file.encode() ?: return
+ scope.launch {
+ val packet = BitchatPacket(
+ version = 2u,
+ type = MessageType.FILE_TRANSFER.value,
+ senderID = MeshPacketUtils.hexStringToByteArray(myPeerID),
+ recipientID = SpecialRecipients.BROADCAST,
+ timestamp = System.currentTimeMillis().toULong(),
+ payload = payload,
+ signature = null,
+ ttl = maxTtl
+ )
+ val signed = signPacketBeforeBroadcast(packet)
+ val transferId = MeshPacketUtils.sha256Hex(payload)
+ dispatchGlobal(RoutedPacket(signed, transferId = transferId))
+ try { gossipSyncManager.onPublicPacketSeen(signed) } catch (_: Exception) { }
+ }
+ } catch (e: Exception) {
+ Log.e("MeshCore", "sendFileBroadcast failed: ${e.message}", e)
+ }
+ }
+
+ fun sendFilePrivate(recipientPeerID: String, file: BitchatFilePacket) {
+ try {
+ scope.launch {
+ if (!encryptionService.hasEstablishedSession(recipientPeerID)) {
+ initiateNoiseHandshake(recipientPeerID)
+ return@launch
+ }
+ val tlv = file.encode() ?: return@launch
+ val np = NoisePayload(type = NoisePayloadType.FILE_TRANSFER, data = tlv).encode()
+ val enc = encryptionService.encrypt(np, recipientPeerID)
+ val packet = BitchatPacket(
+ version = if (enc.size > 0xFFFF) 2u else 1u,
+ type = MessageType.NOISE_ENCRYPTED.value,
+ senderID = MeshPacketUtils.hexStringToByteArray(myPeerID),
+ recipientID = MeshPacketUtils.hexStringToByteArray(recipientPeerID),
+ timestamp = System.currentTimeMillis().toULong(),
+ payload = enc,
+ signature = null,
+ ttl = maxTtl
+ )
+ val signed = signPacketBeforeBroadcast(packet)
+ val transferId = MeshPacketUtils.sha256Hex(tlv)
+ dispatchGlobal(RoutedPacket(signed, transferId = transferId))
+ }
+ } catch (e: Exception) {
+ Log.e("MeshCore", "sendFilePrivate failed: ${e.message}", e)
+ }
+ }
+
+ fun cancelFileTransfer(transferId: String): Boolean {
+ return transport.cancelTransfer(transferId)
+ }
+
+ fun sendPrivateMessage(content: String, recipientPeerID: String, recipientNickname: String, messageID: String? = null) {
+ if (content.isEmpty() || recipientPeerID.isEmpty()) return
+ scope.launch {
+ val finalMessageID = messageID ?: java.util.UUID.randomUUID().toString()
+
+ if (encryptionService.hasEstablishedSession(recipientPeerID)) {
+ try {
+ val privateMessage = PrivateMessagePacket(messageID = finalMessageID, content = content)
+ val tlvData = privateMessage.encode() ?: return@launch
+ val messagePayload = NoisePayload(
+ type = NoisePayloadType.PRIVATE_MESSAGE,
+ data = tlvData
+ )
+ val encrypted = encryptionService.encrypt(messagePayload.encode(), recipientPeerID)
+ val packet = BitchatPacket(
+ version = 1u,
+ type = MessageType.NOISE_ENCRYPTED.value,
+ senderID = MeshPacketUtils.hexStringToByteArray(myPeerID),
+ recipientID = MeshPacketUtils.hexStringToByteArray(recipientPeerID),
+ timestamp = System.currentTimeMillis().toULong(),
+ payload = encrypted,
+ signature = null,
+ ttl = maxTtl
+ )
+ val signedPacket = signPacketBeforeBroadcast(packet)
+ dispatchGlobal(RoutedPacket(signedPacket))
+ } catch (e: Exception) {
+ Log.e("MeshCore", "Failed to encrypt private message: ${e.message}")
+ }
+ } else {
+ initiateNoiseHandshake(recipientPeerID)
+ }
+ }
+ }
+
+ fun sendReadReceipt(messageID: String, recipientPeerID: String, readerNickname: String) {
+ scope.launch {
+ if (hooks.readReceiptInterceptor?.invoke(messageID, recipientPeerID) == true) return@launch
+ try {
+ val payload = NoisePayload(
+ type = NoisePayloadType.READ_RECEIPT,
+ data = messageID.toByteArray(Charsets.UTF_8)
+ ).encode()
+ val enc = encryptionService.encrypt(payload, recipientPeerID)
+ val packet = BitchatPacket(
+ version = 1u,
+ type = MessageType.NOISE_ENCRYPTED.value,
+ senderID = MeshPacketUtils.hexStringToByteArray(myPeerID),
+ recipientID = MeshPacketUtils.hexStringToByteArray(recipientPeerID),
+ timestamp = System.currentTimeMillis().toULong(),
+ payload = enc,
+ signature = null,
+ ttl = maxTtl
+ )
+ dispatchGlobal(RoutedPacket(signPacketBeforeBroadcast(packet)))
+ hooks.onReadReceiptSent?.invoke(messageID)
+ } catch (e: Exception) {
+ Log.e("MeshCore", "Failed to send read receipt: ${e.message}")
+ }
+ }
+ }
+
+ fun sendVerifyChallenge(peerID: String, noiseKeyHex: String, nonceA: ByteArray) {
+ val payload = NoisePayload(
+ type = NoisePayloadType.VERIFY_CHALLENGE,
+ data = com.bitchat.android.services.VerificationService.buildVerifyChallenge(noiseKeyHex, nonceA)
+ )
+ sendNoisePayloadToPeer(payload, peerID)
+ }
+
+ fun sendVerifyResponse(peerID: String, noiseKeyHex: String, nonceA: ByteArray) {
+ val tlv = com.bitchat.android.services.VerificationService.buildVerifyResponse(noiseKeyHex, nonceA) ?: return
+ val payload = NoisePayload(
+ type = NoisePayloadType.VERIFY_RESPONSE,
+ data = tlv
+ )
+ sendNoisePayloadToPeer(payload, peerID)
+ }
+
+ private fun sendNoisePayloadToPeer(payload: NoisePayload, recipientPeerID: String) {
+ scope.launch {
+ try {
+ val encrypted = encryptionService.encrypt(payload.encode(), recipientPeerID)
+ val packet = BitchatPacket(
+ version = 1u,
+ type = MessageType.NOISE_ENCRYPTED.value,
+ senderID = MeshPacketUtils.hexStringToByteArray(myPeerID),
+ recipientID = MeshPacketUtils.hexStringToByteArray(recipientPeerID),
+ timestamp = System.currentTimeMillis().toULong(),
+ payload = encrypted,
+ signature = null,
+ ttl = maxTtl
+ )
+ dispatchGlobal(RoutedPacket(signPacketBeforeBroadcast(packet)))
+ } catch (e: Exception) {
+ Log.e("MeshCore", "Failed to send Noise payload to $recipientPeerID: ${e.message}")
+ }
+ }
+ }
+
+ fun sendBroadcastAnnounce() {
+ scope.launch {
+ val nickname = hooks.announcementNicknameProvider?.invoke()
+ ?: delegate?.getNickname()
+ ?: myPeerID
+ val staticKey = encryptionService.getStaticPublicKey() ?: run {
+ Log.e("MeshCore", "No static public key available for announcement")
+ return@launch
+ }
+ val signingKey = encryptionService.getSigningPublicKey() ?: run {
+ Log.e("MeshCore", "No signing public key available for announcement")
+ return@launch
+ }
+ val announcement = IdentityAnnouncement(nickname, staticKey, signingKey)
+ val tlvPayload = buildAnnouncementPayload(announcement, nickname) ?: return@launch
+ val announcePacket = BitchatPacket(
+ type = MessageType.ANNOUNCE.value,
+ ttl = maxTtl,
+ senderID = myPeerID,
+ payload = tlvPayload
+ )
+ val signedPacket = signPacketBeforeBroadcast(announcePacket)
+ dispatchGlobal(RoutedPacket(signedPacket))
+ try { gossipSyncManager.onPublicPacketSeen(signedPacket) } catch (_: Exception) { }
+ }
+ }
+
+ fun sendAnnouncementToPeer(peerID: String) {
+ if (peerManager.hasAnnouncedToPeer(peerID)) return
+ val nickname = hooks.announcementNicknameProvider?.invoke()
+ ?: delegate?.getNickname()
+ ?: myPeerID
+ val staticKey = encryptionService.getStaticPublicKey() ?: return
+ val signingKey = encryptionService.getSigningPublicKey() ?: return
+ val announcement = IdentityAnnouncement(nickname, staticKey, signingKey)
+ val tlvPayload = buildAnnouncementPayload(announcement, nickname) ?: return
+ val packet = BitchatPacket(
+ type = MessageType.ANNOUNCE.value,
+ ttl = maxTtl,
+ senderID = myPeerID,
+ payload = tlvPayload
+ )
+ val signedPacket = signPacketBeforeBroadcast(packet)
+ dispatchGlobal(RoutedPacket(signedPacket))
+ peerManager.markPeerAsAnnouncedTo(peerID)
+ try { gossipSyncManager.onPublicPacketSeen(signedPacket) } catch (_: Exception) { }
+ }
+
+ private fun buildAnnouncementPayload(announcement: IdentityAnnouncement, nickname: String): ByteArray? {
+ var tlvPayload = announcement.encode() ?: return null
+ val directPeersForGossip = getDirectPeerIDsForGossip()
+ try {
+ if (directPeersForGossip.isNotEmpty()) {
+ tlvPayload += com.bitchat.android.services.meshgraph.GossipTLV.encodeNeighbors(directPeersForGossip)
+ }
+ com.bitchat.android.services.meshgraph.MeshGraphService.getInstance()
+ .updateFromAnnouncement(myPeerID, nickname, directPeersForGossip, System.currentTimeMillis().toULong())
+ } catch (_: Exception) { }
+ return tlvPayload
+ }
+
+ private fun getDirectPeerIDsForGossip(): List {
+ return try {
+ val verifiedDirect = peerManager.getVerifiedPeers()
+ .filter { it.value.isDirectConnection }
+ .keys
+ val localDirect = (verifiedDirect + directPeers).toSet()
+ // Publish this transport's direct peers and gossip the cross-transport union so a
+ // node connected via multiple transports advertises a complete neighbor list.
+ try { com.bitchat.android.services.AppStateStore.setTransportDirectPeers(transport.id, localDirect) } catch (_: Exception) { }
+ val union = try {
+ com.bitchat.android.services.AppStateStore.getDirectPeers().ifEmpty { localDirect }
+ } catch (_: Exception) { localDirect }
+ union.distinct().take(10)
+ } catch (_: Exception) {
+ directPeers.toList().take(10)
+ }
+ }
+
+ fun sendLeaveAnnouncement() {
+ val payload = hooks.leavePayloadProvider?.invoke() ?: byteArrayOf()
+ val packet = BitchatPacket(
+ type = MessageType.LEAVE.value,
+ ttl = maxTtl,
+ senderID = myPeerID,
+ payload = payload
+ )
+ val signedPacket = signPacketBeforeBroadcast(packet)
+ dispatchGlobal(RoutedPacket(signedPacket))
+ }
+
+ fun getPeerNicknames(): Map = peerManager.getAllPeerNicknames()
+
+ fun getPeerRSSI(): Map = peerManager.getAllPeerRSSI()
+
+ fun getPeerNickname(peerID: String): String? = peerManager.getPeerNickname(peerID)
+
+ fun addOrUpdatePeer(peerID: String, nickname: String): Boolean {
+ return peerManager.addOrUpdatePeer(peerID, nickname)
+ }
+
+ fun removePeer(peerID: String) {
+ peerManager.removePeer(peerID)
+ }
+
+ fun setDirectConnection(peerID: String, isDirect: Boolean) {
+ if (isDirect) {
+ directPeers.add(peerID)
+ } else {
+ directPeers.remove(peerID)
+ }
+ peerManager.refreshPeerList()
+ }
+
+ fun updatePeerRSSI(peerID: String, rssi: Int) {
+ peerManager.updatePeerRSSI(peerID, rssi)
+ }
+
+ fun getDebugInfoWithDeviceAddresses(deviceMap: Map): String {
+ return peerManager.getDebugInfoWithDeviceAddresses(deviceMap)
+ }
+
+ fun getFingerprintDebugInfo(): String {
+ return peerManager.getFingerprintDebugInfo()
+ }
+
+ fun hasEstablishedSession(peerID: String): Boolean {
+ return encryptionService.hasEstablishedSession(peerID)
+ }
+
+ fun getSessionState(peerID: String): com.bitchat.android.noise.NoiseSession.NoiseSessionState {
+ return encryptionService.getSessionState(peerID)
+ }
+
+ fun initiateNoiseHandshake(peerID: String) {
+ scope.launch {
+ try {
+ val handshakeData = encryptionService.initiateHandshake(peerID) ?: return@launch
+ val packet = BitchatPacket(
+ version = 1u,
+ type = MessageType.NOISE_HANDSHAKE.value,
+ senderID = MeshPacketUtils.hexStringToByteArray(myPeerID),
+ recipientID = MeshPacketUtils.hexStringToByteArray(peerID),
+ timestamp = System.currentTimeMillis().toULong(),
+ payload = handshakeData,
+ ttl = maxTtl
+ )
+ val signedPacket = signPacketBeforeBroadcast(packet)
+ dispatchGlobal(RoutedPacket(signedPacket))
+ } catch (e: Exception) {
+ Log.e("MeshCore", "Failed to initiate Noise handshake with $peerID: ${e.message}")
+ }
+ }
+ }
+
+ fun getPeerFingerprint(peerID: String): String? = peerManager.getFingerprintForPeer(peerID)
+
+ fun getPeerInfo(peerID: String): PeerInfo? = peerManager.getPeerInfo(peerID)
+
+ fun updatePeerInfo(
+ peerID: String,
+ nickname: String,
+ noisePublicKey: ByteArray,
+ signingPublicKey: ByteArray,
+ isVerified: Boolean
+ ): Boolean = peerManager.updatePeerInfo(peerID, nickname, noisePublicKey, signingPublicKey, isVerified)
+
+ fun getIdentityFingerprint(): String = encryptionService.getIdentityFingerprint()
+
+ fun getStaticNoisePublicKey(): ByteArray? = encryptionService.getStaticPublicKey()
+
+ fun shouldShowEncryptionIcon(peerID: String): Boolean = encryptionService.hasEstablishedSession(peerID)
+
+ fun getEncryptedPeers(): List = emptyList()
+
+ fun getActivePeerCount(): Int = try { peerManager.getActivePeerCount() } catch (_: Exception) { 0 }
+
+ fun refreshPeerList() {
+ try { peerManager.refreshPeerList() } catch (_: Exception) { }
+ }
+
+ fun getDeviceAddressForPeer(peerID: String): String? = transport.getDeviceAddressForPeer(peerID)
+
+ fun getDeviceAddressToPeerMapping(): Map = transport.getDeviceAddressToPeerMapping()
+
+ fun getDebugStatus(
+ transportInfo: String,
+ deviceMap: Map,
+ extraLines: List = emptyList(),
+ title: String? = null
+ ): String {
+ return buildString {
+ appendLine("=== ${title ?: "${transport.id} Mesh Debug Status"} ===")
+ appendLine("My Peer ID: $myPeerID")
+ if (extraLines.isNotEmpty()) {
+ extraLines.forEach { appendLine(it) }
+ }
+ appendLine(transportInfo)
+ appendLine(peerManager.getDebugInfo(deviceMap))
+ appendLine(fragmentManager.getDebugInfo())
+ appendLine(securityManager.getDebugInfo())
+ appendLine(storeForwardManager.getDebugInfo())
+ appendLine(messageHandler.getDebugInfo())
+ appendLine(packetProcessor.getDebugInfo())
+ }
+ }
+
+ fun clearAllInternalData() {
+ fragmentManager.clearAllFragments()
+ storeForwardManager.clearAllCache()
+ securityManager.clearAllData()
+ peerManager.clearAllPeers()
+ peerManager.clearAllFingerprints()
+ }
+
+ fun clearAllEncryptionData() {
+ encryptionService.clearPersistentIdentity()
+ }
+
+ private fun signPacketBeforeBroadcast(packet: BitchatPacket): BitchatPacket {
+ return try {
+ val withRoute = try {
+ val recipient = packet.recipientID
+ if (recipient != null && !recipient.contentEquals(SpecialRecipients.BROADCAST)) {
+ val destination = recipient.toHexString()
+ val path = com.bitchat.android.services.meshgraph.RoutePlanner.shortestPath(myPeerID, destination)
+ if (path != null && path.size >= 3) {
+ val intermediates = path.subList(1, path.size - 1)
+ packet.copy(
+ route = intermediates.map { MeshPacketUtils.hexStringToByteArray(it) },
+ version = 2u
+ )
+ } else {
+ packet.copy(route = null)
+ }
+ } else {
+ packet
+ }
+ } catch (_: Exception) {
+ packet
+ }
+
+ val packetDataForSigning = withRoute.toBinaryDataForSigning() ?: return withRoute
+ val signature = encryptionService.signData(packetDataForSigning)
+ if (signature != null) {
+ withRoute.copy(signature = signature)
+ } else {
+ withRoute
+ }
+ } catch (_: Exception) {
+ packet
+ }
+ }
+}
diff --git a/app/src/main/java/com/bitchat/android/mesh/MeshDelegate.kt b/app/src/main/java/com/bitchat/android/mesh/MeshDelegate.kt
new file mode 100644
index 00000000..de662384
--- /dev/null
+++ b/app/src/main/java/com/bitchat/android/mesh/MeshDelegate.kt
@@ -0,0 +1,19 @@
+package com.bitchat.android.mesh
+
+import com.bitchat.android.model.BitchatMessage
+
+/**
+ * Shared mesh delegate interface for transport-agnostic callbacks.
+ */
+interface MeshDelegate {
+ fun didReceiveMessage(message: BitchatMessage)
+ fun didUpdatePeerList(peers: List)
+ fun didReceiveChannelLeave(channel: String, fromPeer: String)
+ fun didReceiveDeliveryAck(messageID: String, recipientPeerID: String)
+ fun didReceiveReadReceipt(messageID: String, recipientPeerID: String)
+ fun didReceiveVerifyChallenge(peerID: String, payload: ByteArray, timestampMs: Long) {}
+ fun didReceiveVerifyResponse(peerID: String, payload: ByteArray, timestampMs: Long) {}
+ fun decryptChannelMessage(encryptedContent: ByteArray, channel: String): String?
+ fun getNickname(): String?
+ fun isFavorite(peerID: String): Boolean
+}
diff --git a/app/src/main/java/com/bitchat/android/mesh/MeshPacketUtils.kt b/app/src/main/java/com/bitchat/android/mesh/MeshPacketUtils.kt
new file mode 100644
index 00000000..514e7a99
--- /dev/null
+++ b/app/src/main/java/com/bitchat/android/mesh/MeshPacketUtils.kt
@@ -0,0 +1,37 @@
+package com.bitchat.android.mesh
+
+/**
+ * Shared helpers for mesh packet handling.
+ */
+object MeshPacketUtils {
+ /**
+ * Convert hex string peer ID to binary data (8 bytes), matching iOS behavior.
+ */
+ fun hexStringToByteArray(hexString: String): ByteArray {
+ val result = ByteArray(8) { 0 }
+ var tempID = hexString
+ var index = 0
+
+ while (tempID.length >= 2 && index < 8) {
+ val hexByte = tempID.substring(0, 2)
+ val byte = hexByte.toIntOrNull(16)?.toByte()
+ if (byte != null) {
+ result[index] = byte
+ }
+ tempID = tempID.substring(2)
+ index++
+ }
+ return result
+ }
+
+ /**
+ * Hash payloads to a stable hex ID for transfer tracking.
+ */
+ fun sha256Hex(bytes: ByteArray): String = try {
+ val md = java.security.MessageDigest.getInstance("SHA-256")
+ md.update(bytes)
+ md.digest().joinToString("") { "%02x".format(it) }
+ } catch (_: Exception) {
+ bytes.size.toString(16)
+ }
+}
diff --git a/app/src/main/java/com/bitchat/android/mesh/MeshService.kt b/app/src/main/java/com/bitchat/android/mesh/MeshService.kt
new file mode 100644
index 00000000..c612e8ed
--- /dev/null
+++ b/app/src/main/java/com/bitchat/android/mesh/MeshService.kt
@@ -0,0 +1,56 @@
+package com.bitchat.android.mesh
+
+import com.bitchat.android.model.BitchatFilePacket
+
+/**
+ * Transport-agnostic mesh service API for UI and routing layers.
+ */
+interface MeshService {
+ val myPeerID: String
+ var delegate: MeshDelegate?
+
+ fun startServices()
+ fun stopServices()
+
+ fun sendMessage(content: String, mentions: List = emptyList(), channel: String? = null)
+ fun sendPrivateMessage(content: String, recipientPeerID: String, recipientNickname: String, messageID: String? = null)
+ fun sendReadReceipt(messageID: String, recipientPeerID: String, readerNickname: String)
+ fun sendDeliveryAck(messageID: String, recipientPeerID: String) {}
+ fun sendFavoriteNotification(peerID: String, isFavorite: Boolean) {}
+ fun sendVerifyChallenge(peerID: String, noiseKeyHex: String, nonceA: ByteArray)
+ fun sendVerifyResponse(peerID: String, noiseKeyHex: String, nonceA: ByteArray)
+ fun sendFileBroadcast(file: BitchatFilePacket)
+ fun sendFilePrivate(recipientPeerID: String, file: BitchatFilePacket)
+ fun cancelFileTransfer(transferId: String): Boolean
+
+ fun sendBroadcastAnnounce()
+ fun sendAnnouncementToPeer(peerID: String)
+
+ fun getPeerNicknames(): Map
+ fun getPeerRSSI(): Map
+ fun getActivePeerCount(): Int
+ fun hasEstablishedSession(peerID: String): Boolean
+ fun getSessionState(peerID: String): com.bitchat.android.noise.NoiseSession.NoiseSessionState
+ fun initiateNoiseHandshake(peerID: String)
+ fun getPeerFingerprint(peerID: String): String?
+ fun getPeerInfo(peerID: String): PeerInfo?
+ fun updatePeerInfo(
+ peerID: String,
+ nickname: String,
+ noisePublicKey: ByteArray,
+ signingPublicKey: ByteArray,
+ isVerified: Boolean
+ ): Boolean
+ fun getIdentityFingerprint(): String
+ fun getStaticNoisePublicKey(): ByteArray?
+ fun shouldShowEncryptionIcon(peerID: String): Boolean
+ fun getEncryptedPeers(): List
+
+ fun getDeviceAddressForPeer(peerID: String): String?
+ fun getDeviceAddressToPeerMapping(): Map
+ fun printDeviceAddressesForPeers(): String
+ fun getDebugStatus(): String
+
+ fun clearAllInternalData()
+ fun clearAllEncryptionData()
+}
diff --git a/app/src/main/java/com/bitchat/android/mesh/MeshTransport.kt b/app/src/main/java/com/bitchat/android/mesh/MeshTransport.kt
new file mode 100644
index 00000000..26a63848
--- /dev/null
+++ b/app/src/main/java/com/bitchat/android/mesh/MeshTransport.kt
@@ -0,0 +1,23 @@
+package com.bitchat.android.mesh
+
+import com.bitchat.android.model.RoutedPacket
+import com.bitchat.android.protocol.BitchatPacket
+
+/**
+ * Transport abstraction used by MeshCore to send packets via a specific medium.
+ */
+interface MeshTransport {
+ val id: String
+
+ fun broadcastPacket(routed: RoutedPacket)
+
+ fun sendPacketToPeer(peerID: String, packet: BitchatPacket): Boolean
+
+ fun cancelTransfer(transferId: String): Boolean = false
+
+ fun getDeviceAddressForPeer(peerID: String): String? = null
+
+ fun getDeviceAddressToPeerMapping(): Map = emptyMap()
+
+ fun getTransportDebugInfo(): String = ""
+}
diff --git a/app/src/main/java/com/bitchat/android/mesh/MessageHandler.kt b/app/src/main/java/com/bitchat/android/mesh/MessageHandler.kt
index d016dd37..d6daa547 100644
--- a/app/src/main/java/com/bitchat/android/mesh/MessageHandler.kt
+++ b/app/src/main/java/com/bitchat/android/mesh/MessageHandler.kt
@@ -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.*
@@ -20,6 +21,7 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro
companion object {
private const val TAG = "MessageHandler"
+ private const val ANNOUNCE_CLOCK_SKEW_TOLERANCE_MS = 10 * 60 * 1000L
}
// Delegate for callbacks
@@ -157,6 +159,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) {
@@ -211,12 +221,15 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro
if (peerID == myPeerID) return false
- // Ignore stale announcements older than STALE_PEER_TIMEOUT
+ // Peers use wall-clock packet timestamps; tolerate moderate device clock skew
+ // during identity learning, or later signed messages cannot be verified.
val now = System.currentTimeMillis()
- val age = now - packet.timestamp.toLong()
- if (age > com.bitchat.android.util.AppConstants.Mesh.STALE_PEER_TIMEOUT_MS) {
- Log.w(TAG, "Ignoring stale ANNOUNCE from ${peerID.take(8)} (age=${age}ms > ${com.bitchat.android.util.AppConstants.Mesh.STALE_PEER_TIMEOUT_MS}ms)")
+ val clockSkewMs = kotlin.math.abs(now - packet.timestamp.toLong())
+ if (clockSkewMs > ANNOUNCE_CLOCK_SKEW_TOLERANCE_MS) {
+ Log.w(TAG, "Ignoring ANNOUNCE from ${peerID.take(8)} with excessive clock skew (${clockSkewMs}ms > ${ANNOUNCE_CLOCK_SKEW_TOLERANCE_MS}ms)")
return false
+ } else if (clockSkewMs > com.bitchat.android.util.AppConstants.Mesh.STALE_PEER_TIMEOUT_MS) {
+ Log.w(TAG, "Accepting ANNOUNCE from ${peerID.take(8)} within clock skew tolerance (${clockSkewMs}ms)")
}
// Try to decode as iOS-compatible IdentityAnnouncement with TLV format
@@ -278,6 +291,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 +405,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 +421,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 +632,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)
}
diff --git a/app/src/main/java/com/bitchat/android/mesh/PacketProcessor.kt b/app/src/main/java/com/bitchat/android/mesh/PacketProcessor.kt
index 2b5fac10..fb47f40f 100644
--- a/app/src/main/java/com/bitchat/android/mesh/PacketProcessor.kt
+++ b/app/src/main/java/com/bitchat/android/mesh/PacketProcessor.kt
@@ -78,8 +78,6 @@ class PacketProcessor(private val myPeerID: String) {
Log.w(TAG, "Received packet with no peer ID, skipping")
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
}
diff --git a/app/src/main/java/com/bitchat/android/mesh/PacketRelayManager.kt b/app/src/main/java/com/bitchat/android/mesh/PacketRelayManager.kt
index bc401daa..a82746c4 100644
--- a/app/src/main/java/com/bitchat/android/mesh/PacketRelayManager.kt
+++ b/app/src/main/java/com/bitchat/android/mesh/PacketRelayManager.kt
@@ -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
}
diff --git a/app/src/main/java/com/bitchat/android/mesh/PeerManager.kt b/app/src/main/java/com/bitchat/android/mesh/PeerManager.kt
index 4c279d4a..01132ae7 100644
--- a/app/src/main/java/com/bitchat/android/mesh/PeerManager.kt
+++ b/app/src/main/java/com/bitchat/android/mesh/PeerManager.kt
@@ -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())
@@ -107,10 +110,21 @@ class PeerManager {
isVerified: Boolean
): Boolean {
if (peerID == "unknown") return false
+
+ fun keysMatch(a: ByteArray?, b: ByteArray?): Boolean {
+ if (a == null && b == null) return true
+ if (a == null || b == null) return false
+ return a.contentEquals(b)
+ }
val now = System.currentTimeMillis()
val existingPeer = peers[peerID]
val isNewPeer = existingPeer == null
+ val wasVerified = existingPeer?.isVerifiedNickname == true
+ val nicknameChanged = existingPeer != null && existingPeer.nickname != nickname
+ val noiseKeyChanged = existingPeer != null && !keysMatch(existingPeer.noisePublicKey, noisePublicKey)
+ val signingKeyChanged = existingPeer != null && !keysMatch(existingPeer.signingPublicKey, signingPublicKey)
+ val connectedChanged = existingPeer != null && existingPeer.isConnected != true
// Update or create peer info
val peerInfo = PeerInfo(
@@ -130,25 +144,42 @@ class PeerManager {
// No legacy maps; peers map is the single source of truth
// Maintain announcedPeers for first-time announce semantics
+ val shouldNotify = when {
+ isNewPeer && isVerified -> true
+ wasVerified != isVerified -> true
+ nicknameChanged || noiseKeyChanged || signingKeyChanged || connectedChanged -> true
+ else -> false
+ }
+
if (isNewPeer && isVerified) {
announcedPeers.add(peerID)
- notifyPeerListUpdate()
Log.d(TAG, "🆕 New verified peer: $nickname ($peerID)")
- return true
} else if (isVerified) {
Log.d(TAG, "🔄 Updated verified peer: $nickname ($peerID)")
} else {
Log.d(TAG, "⚠️ Unverified peer announcement from: $nickname ($peerID)")
}
+
+ if (shouldNotify) {
+ notifyPeerListUpdate()
+ }
- return false
+ return isNewPeer && isVerified
}
/**
- * 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
+ }
+ }
}
/**
@@ -159,27 +190,12 @@ class PeerManager {
}
/**
- * Get all verified peers
+ * Get all verified peers with dynamic direct connection status
*/
fun getVerifiedPeers(): Map {
- 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
}
}
@@ -299,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
}
/**
@@ -328,8 +343,7 @@ class PeerManager {
* Get list of active peer IDs
*/
fun getActivePeerIDs(): List {
- val now = System.currentTimeMillis()
- return peers.filterValues { (now - it.lastSeen) <= stalePeerTimeoutMs && it.isConnected }
+ return peers.filterValues { it.isConnected }
.keys
.toList()
.sorted()
@@ -366,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
@@ -408,6 +426,10 @@ class PeerManager {
val peerList = getActivePeerIDs()
delegate?.onPeerListUpdated(peerList)
}
+
+ fun refreshPeerList() {
+ notifyPeerListUpdate()
+ }
/**
* Start periodic cleanup of stale peers
diff --git a/app/src/main/java/com/bitchat/android/mesh/PowerManager.kt b/app/src/main/java/com/bitchat/android/mesh/PowerManager.kt
index 46bc394f..3096828a 100644
--- a/app/src/main/java/com/bitchat/android/mesh/PowerManager.kt
+++ b/app/src/main/java/com/bitchat/android/mesh/PowerManager.kt
@@ -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)
}
diff --git a/app/src/main/java/com/bitchat/android/mesh/SecurityManager.kt b/app/src/main/java/com/bitchat/android/mesh/SecurityManager.kt
index bce69c26..5beb3384 100644
--- a/app/src/main/java/com/bitchat/android/mesh/SecurityManager.kt
+++ b/app/src/main/java/com/bitchat/android/mesh/SecurityManager.kt
@@ -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")
}
+
+ // Add to processed messages
+ processedMessages.add(messageID)
+ messageTimestamps[messageID] = currentTime
- // NEW: Signature verification logging (not rejecting yet)
- verifyPacketSignatureWithLogging(packet, peerID)
+ // 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
}
}
diff --git a/app/src/main/java/com/bitchat/android/mesh/UnifiedMeshService.kt b/app/src/main/java/com/bitchat/android/mesh/UnifiedMeshService.kt
new file mode 100644
index 00000000..46d36a8f
--- /dev/null
+++ b/app/src/main/java/com/bitchat/android/mesh/UnifiedMeshService.kt
@@ -0,0 +1,386 @@
+package com.bitchat.android.mesh
+
+import android.content.Context
+import android.util.Log
+import com.bitchat.android.model.BitchatFilePacket
+import com.bitchat.android.model.BitchatMessage
+import com.bitchat.android.noise.NoiseSession
+import com.bitchat.android.wifiaware.WifiAwareController
+
+/**
+ * Feature-facing mesh service that hides local transport selection from the rest of the app.
+ *
+ * BLE remains the canonical origin for broadcast packets when it is enabled so existing BLE mesh
+ * behavior and bridge semantics stay intact. Addressed Noise traffic is routed over whichever
+ * local transport already has the peer/session, falling back to a connected transport handshake.
+ */
+class UnifiedMeshService(
+ private val context: Context,
+ private val bluetooth: BluetoothMeshService
+) : MeshService, BluetoothMeshDelegate {
+
+ companion object {
+ private const val TAG = "UnifiedMeshService"
+ }
+
+ override val myPeerID: String
+ get() = bluetooth.myPeerID
+
+ override var delegate: MeshDelegate? = null
+ set(value) {
+ field = value
+ refreshDelegates()
+ }
+
+ fun refreshDelegates() {
+ try { bluetooth.delegate = if (delegate != null) this else null } catch (_: Exception) { }
+ try { wifiService()?.delegate = if (delegate != null) this else null } catch (_: Exception) { }
+ }
+
+ override fun startServices() {
+ if (isBleEnabled()) {
+ try { bluetooth.startServices() } catch (e: Exception) {
+ Log.w(TAG, "Failed to start BLE transport: ${e.message}")
+ }
+ } else {
+ try { bluetooth.setBleTransportEnabled(false) } catch (_: Exception) { }
+ }
+ try { WifiAwareController.startIfPossible() } catch (e: Exception) {
+ Log.w(TAG, "Failed to start Wi-Fi Aware transport: ${e.message}")
+ }
+ refreshDelegates()
+ }
+
+ override fun stopServices() {
+ try { bluetooth.stopServices() } catch (_: Exception) { }
+ try { WifiAwareController.stop() } catch (_: Exception) { }
+ }
+
+ override fun sendMessage(content: String, mentions: List, channel: String?) {
+ when {
+ isBleEnabled() -> bluetooth.sendMessage(content, mentions, channel)
+ else -> wifiService()?.sendMessage(content, mentions, channel)
+ }
+ }
+
+ override fun sendPrivateMessage(
+ content: String,
+ recipientPeerID: String,
+ recipientNickname: String,
+ messageID: String?
+ ) {
+ when {
+ isBleReady(recipientPeerID) -> bluetooth.sendPrivateMessage(content, recipientPeerID, recipientNickname, messageID)
+ isWifiReady(recipientPeerID) -> wifiService()?.sendPrivateMessage(content, recipientPeerID, recipientNickname, messageID)
+ isBleConnected(recipientPeerID) || (isBleEnabled() && !isWifiConnected(recipientPeerID)) ->
+ bluetooth.sendPrivateMessage(content, recipientPeerID, recipientNickname, messageID)
+ else -> wifiService()?.sendPrivateMessage(content, recipientPeerID, recipientNickname, messageID)
+ }
+ }
+
+ override fun sendReadReceipt(messageID: String, recipientPeerID: String, readerNickname: String) {
+ when {
+ isBleReady(recipientPeerID) -> bluetooth.sendReadReceipt(messageID, recipientPeerID, readerNickname)
+ isWifiReady(recipientPeerID) -> wifiService()?.sendReadReceipt(messageID, recipientPeerID, readerNickname)
+ }
+ }
+
+ override fun sendFavoriteNotification(peerID: String, isFavorite: Boolean) {
+ val myNpub = try {
+ com.bitchat.android.nostr.NostrIdentityBridge.getCurrentNostrIdentity(context)?.npub
+ } catch (_: Exception) {
+ null
+ }
+ val content = if (isFavorite) "[FAVORITED]:${myNpub ?: ""}" else "[UNFAVORITED]:${myNpub ?: ""}"
+ val nickname = getPeerNicknames()[peerID] ?: peerID
+ if (hasEstablishedSession(peerID)) {
+ sendPrivateMessage(content, peerID, nickname, java.util.UUID.randomUUID().toString())
+ }
+ }
+
+ override fun sendVerifyChallenge(peerID: String, noiseKeyHex: String, nonceA: ByteArray) {
+ when {
+ isBleReady(peerID) -> bluetooth.sendVerifyChallenge(peerID, noiseKeyHex, nonceA)
+ isWifiReady(peerID) -> wifiService()?.sendVerifyChallenge(peerID, noiseKeyHex, nonceA)
+ }
+ }
+
+ override fun sendVerifyResponse(peerID: String, noiseKeyHex: String, nonceA: ByteArray) {
+ when {
+ isBleReady(peerID) -> bluetooth.sendVerifyResponse(peerID, noiseKeyHex, nonceA)
+ isWifiReady(peerID) -> wifiService()?.sendVerifyResponse(peerID, noiseKeyHex, nonceA)
+ }
+ }
+
+ override fun sendFileBroadcast(file: BitchatFilePacket) {
+ when {
+ isBleEnabled() -> bluetooth.sendFileBroadcast(file)
+ else -> wifiService()?.sendFileBroadcast(file)
+ }
+ }
+
+ override fun sendFilePrivate(recipientPeerID: String, file: BitchatFilePacket) {
+ when {
+ isBleReady(recipientPeerID) -> bluetooth.sendFilePrivate(recipientPeerID, file)
+ isWifiReady(recipientPeerID) -> wifiService()?.sendFilePrivate(recipientPeerID, file)
+ isBleConnected(recipientPeerID) || (isBleEnabled() && !isWifiConnected(recipientPeerID)) ->
+ bluetooth.sendFilePrivate(recipientPeerID, file)
+ else -> wifiService()?.sendFilePrivate(recipientPeerID, file)
+ }
+ }
+
+ override fun cancelFileTransfer(transferId: String): Boolean {
+ val bleCancelled = try { bluetooth.cancelFileTransfer(transferId) } catch (_: Exception) { false }
+ val wifiCancelled = try { wifiService()?.cancelFileTransfer(transferId) == true } catch (_: Exception) { false }
+ return bleCancelled || wifiCancelled
+ }
+
+ override fun sendBroadcastAnnounce() {
+ if (isBleEnabled()) {
+ try { bluetooth.sendBroadcastAnnounce() } catch (_: Exception) { }
+ }
+ try { wifiService()?.sendBroadcastAnnounce() } catch (_: Exception) { }
+ }
+
+ override fun sendAnnouncementToPeer(peerID: String) {
+ when {
+ isBleConnected(peerID) || (isBleEnabled() && !isWifiConnected(peerID)) -> bluetooth.sendAnnouncementToPeer(peerID)
+ else -> wifiService()?.sendAnnouncementToPeer(peerID)
+ }
+ }
+
+ override fun getPeerNicknames(): Map {
+ val merged = linkedMapOf()
+ try { merged.putAll(wifiService()?.getPeerNicknames().orEmpty()) } catch (_: Exception) { }
+ try { merged.putAll(bluetooth.getPeerNicknames()) } catch (_: Exception) { }
+ return merged
+ }
+
+ override fun getPeerRSSI(): Map {
+ val merged = linkedMapOf()
+ try { merged.putAll(wifiService()?.getPeerRSSI().orEmpty()) } catch (_: Exception) { }
+ try { merged.putAll(bluetooth.getPeerRSSI()) } catch (_: Exception) { }
+ return merged
+ }
+
+ override fun getActivePeerCount(): Int {
+ return mergedPeerIDs().filter { it != myPeerID }.distinct().size
+ }
+
+ override fun hasEstablishedSession(peerID: String): Boolean {
+ return isBleReady(peerID) || isWifiReady(peerID)
+ }
+
+ override fun getSessionState(peerID: String): NoiseSession.NoiseSessionState {
+ val bleState = try { bluetooth.getSessionState(peerID) } catch (_: Exception) { NoiseSession.NoiseSessionState.Uninitialized }
+ val wifiState = try { wifiService()?.getSessionState(peerID) } catch (_: Exception) { null }
+ return when {
+ bleState is NoiseSession.NoiseSessionState.Established -> bleState
+ wifiState is NoiseSession.NoiseSessionState.Established -> wifiState
+ bleState is NoiseSession.NoiseSessionState.Handshaking -> bleState
+ wifiState is NoiseSession.NoiseSessionState.Handshaking -> wifiState
+ bleState !is NoiseSession.NoiseSessionState.Uninitialized -> bleState
+ wifiState != null -> wifiState
+ else -> bleState
+ }
+ }
+
+ override fun initiateNoiseHandshake(peerID: String) {
+ when {
+ isBleConnected(peerID) -> bluetooth.initiateNoiseHandshake(peerID)
+ isWifiConnected(peerID) -> wifiService()?.initiateNoiseHandshake(peerID)
+ isBleEnabled() -> bluetooth.initiateNoiseHandshake(peerID)
+ else -> wifiService()?.initiateNoiseHandshake(peerID)
+ }
+ }
+
+ override fun getPeerFingerprint(peerID: String): String? {
+ return try { bluetooth.getPeerFingerprint(peerID) } catch (_: Exception) { null }
+ ?: try { wifiService()?.getPeerFingerprint(peerID) } catch (_: Exception) { null }
+ }
+
+ override fun getPeerInfo(peerID: String): PeerInfo? {
+ val ble = try { bluetooth.getPeerInfo(peerID) } catch (_: Exception) { null }
+ val wifi = try { wifiService()?.getPeerInfo(peerID) } catch (_: Exception) { null }
+ return when {
+ ble?.isConnected == true && hasEstablishedSessionOnBluetooth(peerID) -> ble
+ wifi?.isConnected == true && wifiService()?.hasEstablishedSession(peerID) == true -> wifi
+ ble?.isConnected == true -> ble
+ wifi?.isConnected == true -> wifi
+ else -> ble ?: wifi
+ }
+ }
+
+ override fun updatePeerInfo(
+ peerID: String,
+ nickname: String,
+ noisePublicKey: ByteArray,
+ signingPublicKey: ByteArray,
+ isVerified: Boolean
+ ): Boolean {
+ val bleUpdated = try {
+ bluetooth.updatePeerInfo(peerID, nickname, noisePublicKey, signingPublicKey, isVerified)
+ } catch (_: Exception) {
+ false
+ }
+ val wifiUpdated = try {
+ wifiService()?.updatePeerInfo(peerID, nickname, noisePublicKey, signingPublicKey, isVerified) == true
+ } catch (_: Exception) {
+ false
+ }
+ return bleUpdated || wifiUpdated
+ }
+
+ override fun getIdentityFingerprint(): String = bluetooth.getIdentityFingerprint()
+
+ override fun getStaticNoisePublicKey(): ByteArray? {
+ return bluetooth.getStaticNoisePublicKey() ?: wifiService()?.getStaticNoisePublicKey()
+ }
+
+ override fun shouldShowEncryptionIcon(peerID: String): Boolean {
+ return hasEstablishedSession(peerID)
+ }
+
+ override fun getEncryptedPeers(): List {
+ val encrypted = linkedSetOf()
+ try { encrypted.addAll(bluetooth.getEncryptedPeers()) } catch (_: Exception) { }
+ try { encrypted.addAll(wifiService()?.getEncryptedPeers().orEmpty()) } catch (_: Exception) { }
+ mergedPeerIDs().filterTo(encrypted) { hasEstablishedSession(it) }
+ return encrypted.toList()
+ }
+
+ override fun getDeviceAddressForPeer(peerID: String): String? {
+ return try { bluetooth.getDeviceAddressForPeer(peerID) } catch (_: Exception) { null }
+ ?: try { wifiService()?.getDeviceAddressForPeer(peerID) } catch (_: Exception) { null }
+ }
+
+ override fun getDeviceAddressToPeerMapping(): Map {
+ val merged = linkedMapOf()
+ try { merged.putAll(wifiService()?.getDeviceAddressToPeerMapping().orEmpty()) } catch (_: Exception) { }
+ try { merged.putAll(bluetooth.getDeviceAddressToPeerMapping()) } catch (_: Exception) { }
+ return merged
+ }
+
+ override fun printDeviceAddressesForPeers(): String {
+ return buildString {
+ appendLine(bluetooth.printDeviceAddressesForPeers())
+ wifiService()?.let {
+ appendLine()
+ appendLine(it.printDeviceAddressesForPeers())
+ }
+ }
+ }
+
+ override fun getDebugStatus(): String {
+ return buildString {
+ appendLine("=== Unified Mesh Service Debug Status ===")
+ appendLine("My Peer ID: $myPeerID")
+ appendLine("Merged Peers: ${mergedPeerIDs().joinToString(", ")}")
+ appendLine()
+ appendLine(bluetooth.getDebugStatus())
+ wifiService()?.let {
+ appendLine()
+ appendLine(it.getDebugStatus())
+ }
+ }
+ }
+
+ override fun clearAllInternalData() {
+ try { bluetooth.clearAllInternalData() } catch (_: Exception) { }
+ try { wifiService()?.clearAllInternalData() } catch (_: Exception) { }
+ }
+
+ override fun clearAllEncryptionData() {
+ try { bluetooth.clearAllEncryptionData() } catch (_: Exception) { }
+ try { wifiService()?.clearAllEncryptionData() } catch (_: Exception) { }
+ }
+
+ override fun didReceiveMessage(message: BitchatMessage) {
+ delegate?.didReceiveMessage(message)
+ }
+
+ override fun didUpdatePeerList(peers: List) {
+ delegate?.didUpdatePeerList(mergedPeerIDs().ifEmpty { peers.distinct() })
+ }
+
+ override fun didReceiveChannelLeave(channel: String, fromPeer: String) {
+ delegate?.didReceiveChannelLeave(channel, fromPeer)
+ }
+
+ override fun didReceiveDeliveryAck(messageID: String, recipientPeerID: String) {
+ delegate?.didReceiveDeliveryAck(messageID, recipientPeerID)
+ }
+
+ override fun didReceiveReadReceipt(messageID: String, recipientPeerID: String) {
+ delegate?.didReceiveReadReceipt(messageID, recipientPeerID)
+ }
+
+ override fun didReceiveVerifyChallenge(peerID: String, payload: ByteArray, timestampMs: Long) {
+ delegate?.didReceiveVerifyChallenge(peerID, payload, timestampMs)
+ }
+
+ override fun didReceiveVerifyResponse(peerID: String, payload: ByteArray, timestampMs: Long) {
+ delegate?.didReceiveVerifyResponse(peerID, payload, timestampMs)
+ }
+
+ override fun decryptChannelMessage(encryptedContent: ByteArray, channel: String): String? {
+ return delegate?.decryptChannelMessage(encryptedContent, channel)
+ }
+
+ override fun getNickname(): String? = delegate?.getNickname()
+
+ override fun isFavorite(peerID: String): Boolean = delegate?.isFavorite(peerID) ?: false
+
+ private fun mergedPeerIDs(): List {
+ val ids = linkedSetOf()
+ try { ids.addAll(com.bitchat.android.services.AppStateStore.peers.value) } catch (_: Exception) { }
+ try { ids.addAll(bluetooth.getPeerNicknames().keys) } catch (_: Exception) { }
+ try { ids.addAll(wifiService()?.getPeerNicknames()?.keys.orEmpty()) } catch (_: Exception) { }
+ return ids.toList()
+ }
+
+ private fun wifiService(): MeshService? {
+ return try {
+ WifiAwareController.getService()?.also { service ->
+ if (delegate != null && service.delegate !== this) {
+ service.delegate = this
+ }
+ }
+ } catch (_: Exception) {
+ null
+ }
+ }
+
+ private fun isBleEnabled(): Boolean {
+ return try {
+ com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().bleEnabled.value
+ } catch (_: Exception) {
+ try { com.bitchat.android.ui.debug.DebugPreferenceManager.getBleEnabled(true) } catch (_: Exception) { true }
+ }
+ }
+
+ private fun isBleConnected(peerID: String): Boolean {
+ return try { bluetooth.getPeerInfo(peerID)?.isConnected == true } catch (_: Exception) { false }
+ }
+
+ private fun isWifiConnected(peerID: String): Boolean {
+ return try { wifiService()?.getPeerInfo(peerID)?.isConnected == true } catch (_: Exception) { false }
+ }
+
+ private fun isBleReady(peerID: String): Boolean {
+ return isBleConnected(peerID) && hasEstablishedSessionOnBluetooth(peerID)
+ }
+
+ private fun isWifiReady(peerID: String): Boolean {
+ return try {
+ val wifi = wifiService()
+ wifi?.getPeerInfo(peerID)?.isConnected == true && wifi.hasEstablishedSession(peerID)
+ } catch (_: Exception) {
+ false
+ }
+ }
+
+ private fun hasEstablishedSessionOnBluetooth(peerID: String): Boolean {
+ return try { bluetooth.hasEstablishedSession(peerID) } catch (_: Exception) { false }
+ }
+}
diff --git a/app/src/main/java/com/bitchat/android/model/NoiseEncrypted.kt b/app/src/main/java/com/bitchat/android/model/NoiseEncrypted.kt
index 7f691a9c..c46a114f 100644
--- a/app/src/main/java/com/bitchat/android/model/NoiseEncrypted.kt
+++ b/app/src/main/java/com/bitchat/android/model/NoiseEncrypted.kt
@@ -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);
diff --git a/app/src/main/java/com/bitchat/android/noise/NoiseEncryptionService.kt b/app/src/main/java/com/bitchat/android/noise/NoiseEncryptionService.kt
index f9e969d3..5ca8df28 100644
--- a/app/src/main/java/com/bitchat/android/noise/NoiseEncryptionService.kt
+++ b/app/src/main/java/com/bitchat/android/noise/NoiseEncryptionService.kt
@@ -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,16 +116,8 @@ class NoiseEncryptionService(private val context: Context) {
identityStateManager.saveSigningKey(signingPrivateKey, signingPublicKey)
Log.d(TAG, "Generated and saved new Ed25519 signing key")
}
-
- // Initialize session manager
- sessionManager = NoiseSessionManager(staticIdentityPrivateKey, staticIdentityPublicKey)
-
- // Set up session callbacks
- sessionManager.onSessionEstablished = { peerID, remoteStaticKey ->
- handleSessionEstablished(peerID, remoteStaticKey)
- }
}
-
+
// MARK: - Public Interface
/**
@@ -135,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
@@ -478,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
}
diff --git a/app/src/main/java/com/bitchat/android/noise/NoiseSession.kt b/app/src/main/java/com/bitchat/android/noise/NoiseSession.kt
index dc4a3d50..f5803994 100644
--- a/app/src/main/java/com/bitchat/android/noise/NoiseSession.kt
+++ b/app/src/main/java/com/bitchat/android/noise/NoiseSession.kt
@@ -153,6 +153,9 @@ class NoiseSession(
// Session state
private var state: NoiseSessionState = NoiseSessionState.Uninitialized
private val creationTime = System.currentTimeMillis()
+ private var handshakeStartMs: Long? = null
+ private var lastHandshakeActivityMs: Long? = null
+ private var handshakeMessage1: ByteArray? = null
// Session counters
private var currentPattern = 0;
@@ -195,6 +198,15 @@ class NoiseSession(
fun isEstablished(): Boolean = state is NoiseSessionState.Established
fun isHandshaking(): Boolean = state is NoiseSessionState.Handshaking
fun getCreationTime(): Long = creationTime
+ fun isInitiatorRole(): Boolean = isInitiator
+ fun getHandshakeStartMs(): Long? = handshakeStartMs
+ fun getLastHandshakeActivityMs(): Long? = lastHandshakeActivityMs
+
+ internal fun getHandshakeMessage1(): ByteArray? = handshakeMessage1?.clone()
+
+ internal fun setLastHandshakeActivityForTest(timestampMs: Long) {
+ lastHandshakeActivityMs = timestampMs
+ }
init {
try {
@@ -317,19 +329,25 @@ class NoiseSession(
// Initialize handshake as initiator
initializeNoiseHandshake(HandshakeState.INITIATOR)
state = NoiseSessionState.Handshaking
+ if (handshakeStartMs == null) {
+ handshakeStartMs = System.currentTimeMillis()
+ }
+ lastHandshakeActivityMs = System.currentTimeMillis()
val messageBuffer = ByteArray(XX_MESSAGE_1_SIZE)
val handshakeStateLocal = handshakeState ?: throw IllegalStateException("Handshake state is null")
val messageLength = handshakeStateLocal.writeMessage(messageBuffer, 0, null, 0, 0)
currentPattern++
val firstMessage = messageBuffer.copyOf(messageLength)
+ handshakeMessage1 = firstMessage
// Validate message size matches XX pattern expectations
if (firstMessage.size != XX_MESSAGE_1_SIZE) {
Log.w(TAG, "Warning: XX message 1 size ${firstMessage.size} != expected $XX_MESSAGE_1_SIZE")
}
- Log.d(TAG, "Sending XX handshake message 1 to $peerID (${firstMessage.size} bytes) currentPattern: $currentPattern")
+ val ePrefix = firstMessage.take(4).toByteArray().toHexString()
+ Log.d(TAG, "Sending XX handshake message 1 to $peerID (${firstMessage.size} bytes) e_prefix=$ePrefix currentPattern: $currentPattern")
return firstMessage
} catch (e: Exception) {
state = NoiseSessionState.Failed(e)
@@ -344,19 +362,24 @@ class NoiseSession(
*/
@Synchronized
fun processHandshakeMessage(message: ByteArray): ByteArray? {
- Log.d(TAG, "Processing handshake message from $peerID (${message.size} bytes)")
+ val inputPrefix = message.take(4).toByteArray().toHexString()
+ Log.d(TAG, "Processing handshake message from $peerID (${message.size} bytes) prefix=$inputPrefix")
try {
// Initialize as responder if receiving first message
if (state == NoiseSessionState.Uninitialized && !isInitiator) {
initializeNoiseHandshake(HandshakeState.RESPONDER)
state = NoiseSessionState.Handshaking
+ if (handshakeStartMs == null) {
+ handshakeStartMs = System.currentTimeMillis()
+ }
Log.d(TAG, "Initialized as RESPONDER for XX handshake with $peerID")
}
if (state != NoiseSessionState.Handshaking) {
throw IllegalStateException("Invalid state for handshake: $state")
}
+ lastHandshakeActivityMs = System.currentTimeMillis()
val handshakeStateLocal = handshakeState ?: throw IllegalStateException("Handshake state is null")
@@ -366,7 +389,8 @@ class NoiseSession(
// Read the incoming message - the Noise library will handle validation
val payloadLength = handshakeStateLocal.readMessage(message, 0, message.size, payloadBuffer, 0)
currentPattern++
- Log.d(TAG, "Read handshake message, payload length: $payloadLength currentPattern: $currentPattern")
+ val readPrefix = message.take(4).toByteArray().toHexString()
+ Log.d(TAG, "Read handshake message, payload length: $payloadLength prefix=$readPrefix currentPattern: $currentPattern")
// Check what action the handshake state wants us to take next
val action = handshakeStateLocal.getAction()
diff --git a/app/src/main/java/com/bitchat/android/noise/NoiseSessionManager.kt b/app/src/main/java/com/bitchat/android/noise/NoiseSessionManager.kt
index 2d9e06d8..618f26be 100644
--- a/app/src/main/java/com/bitchat/android/noise/NoiseSessionManager.kt
+++ b/app/src/main/java/com/bitchat/android/noise/NoiseSessionManager.kt
@@ -8,11 +8,14 @@ import java.util.concurrent.ConcurrentHashMap
*/
class NoiseSessionManager(
private val localStaticPrivateKey: ByteArray,
- private val localStaticPublicKey: ByteArray
+ private val localStaticPublicKey: ByteArray,
+ private val localPeerID: String
) {
companion object {
private const val TAG = "NoiseSessionManager"
+ private const val HANDSHAKE_TIMEOUT_MS = 20_000L
+ private const val HANDSHAKE_MESSAGE_1_SIZE = 32
}
private val sessions = ConcurrentHashMap()
@@ -51,11 +54,30 @@ class NoiseSessionManager(
/**
* SIMPLIFIED: Initiate handshake - no tie breaker, just start
*/
- fun initiateHandshake(peerID: String): ByteArray {
+ fun initiateHandshake(peerID: String): ByteArray? {
Log.d(TAG, "initiateHandshake($peerID)")
- // Remove any existing session first
- removeSession(peerID)
+ val now = System.currentTimeMillis()
+ val existing = getSession(peerID)
+ if (existing != null) {
+ when {
+ existing.isEstablished() -> {
+ Log.d(TAG, "Handshake already established with $peerID, skipping initiate")
+ return null
+ }
+ existing.isHandshaking() -> {
+ if (!isHandshakeStale(existing, now)) {
+ Log.d(TAG, "Handshake already in progress with $peerID, not restarting")
+ return null
+ }
+ Log.d(TAG, "Handshake with $peerID is stale; restarting")
+ removeSession(peerID)
+ }
+ else -> {
+ removeSession(peerID)
+ }
+ }
+ }
// Create new session as initiator
val session = NoiseSession(
@@ -85,6 +107,23 @@ class NoiseSessionManager(
try {
var session = getSession(peerID)
+
+ // Collision handling: both sides initiated and we received message 1
+ if (session != null &&
+ session.isHandshaking() &&
+ session.isInitiatorRole() &&
+ message.size == HANDSHAKE_MESSAGE_1_SIZE
+ ) {
+ val shouldYield = localPeerID > peerID
+ if (shouldYield) {
+ Log.d(TAG, "Handshake collision with $peerID; yielding to responder role")
+ removeSession(peerID)
+ session = null
+ } else {
+ Log.d(TAG, "Handshake collision with $peerID; keeping initiator role")
+ return null
+ }
+ }
// If no session exists, create one as responder
if (session == null) {
@@ -119,6 +158,12 @@ class NoiseSessionManager(
throw e
}
}
+
+ private fun isHandshakeStale(session: NoiseSession, nowMs: Long): Boolean {
+ val lastActivity = session.getLastHandshakeActivityMs() ?: session.getHandshakeStartMs()
+ if (lastActivity == null) return false
+ return (nowMs - lastActivity) > HANDSHAKE_TIMEOUT_MS
+ }
/**
* SIMPLIFIED: Encrypt data
diff --git a/app/src/main/java/com/bitchat/android/nostr/GeohashAliasRegistry.kt b/app/src/main/java/com/bitchat/android/nostr/GeohashAliasRegistry.kt
index ea2ab0ba..9ead2485 100644
--- a/app/src/main/java/com/bitchat/android/nostr/GeohashAliasRegistry.kt
+++ b/app/src/main/java/com/bitchat/android/nostr/GeohashAliasRegistry.kt
@@ -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_ -> 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 = 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 = HashMap(map)
- fun clear() { map.clear() }
+ fun clear() {
+ map.clear()
+ prefs?.edit()?.clear()?.apply()
+ }
}
diff --git a/app/src/main/java/com/bitchat/android/nostr/GeohashConversationRegistry.kt b/app/src/main/java/com/bitchat/android/nostr/GeohashConversationRegistry.kt
index 0ae2a6db..68c1b29e 100644
--- a/app/src/main/java/com/bitchat/android/nostr/GeohashConversationRegistry.kt
+++ b/app/src/main/java/com/bitchat/android/nostr/GeohashConversationRegistry.kt
@@ -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_") -> 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()
+ 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 = map.toMap()
- fun clear() = map.clear()
+ fun clear() {
+ map.clear()
+ prefs?.edit()?.clear()?.apply()
+ }
}
diff --git a/app/src/main/java/com/bitchat/android/nostr/GeohashMessageHandler.kt b/app/src/main/java/com/bitchat/android/nostr/GeohashMessageHandler.kt
index 0e6e6634..ec3af4e6 100644
--- a/app/src/main/java/com/bitchat/android/nostr/GeohashMessageHandler.kt
+++ b/app/src/main/java/com/bitchat/android/nostr/GeohashMessageHandler.kt
@@ -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
diff --git a/app/src/main/java/com/bitchat/android/nostr/GeohashRepository.kt b/app/src/main/java/com/bitchat/android/nostr/GeohashRepository.kt
index 822606c2..f6fbca78 100644
--- a/app/src/main/java/com/bitchat/android/nostr/GeohashRepository.kt
+++ b/app/src/main/java/com/bitchat/android/nostr/GeohashRepository.kt
@@ -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 = mutableMapOf()
diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrDirectMessageHandler.kt b/app/src/main/java/com/bitchat/android/nostr/NostrDirectMessageHandler.kt
index 3dcb60d6..18c12e4d 100644
--- a/app/src/main/java/com/bitchat/android/nostr/NostrDirectMessageHandler.kt
+++ b/app/src/main/java/com/bitchat/android/nostr/NostrDirectMessageHandler.kt
@@ -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
}
}
diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrEvent.kt b/app/src/main/java/com/bitchat/android/nostr/NostrEvent.kt
index 785b41f3..92752b17 100644
--- a/app/src/main/java/com/bitchat/android/nostr/NostrEvent.kt
+++ b/app/src/main/java/com/bitchat/android/nostr/NostrEvent.kt
@@ -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
}
/**
diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrFilter.kt b/app/src/main/java/com/bitchat/android/nostr/NostrFilter.kt
index b6313ea7..247162a0 100644
--- a/app/src/main/java/com/bitchat/android/nostr/NostrFilter.kt
+++ b/app/src/main/java/com/bitchat/android/nostr/NostrFilter.kt
@@ -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
diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrProtocol.kt b/app/src/main/java/com/bitchat/android/nostr/NostrProtocol.kt
index 0b94bf78..1e0b51f6 100644
--- a/app/src/main/java/com/bitchat/android/nostr/NostrProtocol.kt
+++ b/app/src/main/java/com/bitchat/android/nostr/NostrProtocol.kt
@@ -117,6 +117,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>()
+ 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)
diff --git a/app/src/main/java/com/bitchat/android/onboarding/BackgroundLocationPermissionScreen.kt b/app/src/main/java/com/bitchat/android/onboarding/BackgroundLocationPermissionScreen.kt
new file mode 100644
index 00000000..1346cb23
--- /dev/null
+++ b/app/src/main/java/com/bitchat/android/onboarding/BackgroundLocationPermissionScreen.kt
@@ -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)
+ )
+ }
+}
diff --git a/app/src/main/java/com/bitchat/android/onboarding/BackgroundLocationPreferenceManager.kt b/app/src/main/java/com/bitchat/android/onboarding/BackgroundLocationPreferenceManager.kt
new file mode 100644
index 00000000..0a73e346
--- /dev/null
+++ b/app/src/main/java/com/bitchat/android/onboarding/BackgroundLocationPreferenceManager.kt
@@ -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)
+ }
+}
diff --git a/app/src/main/java/com/bitchat/android/onboarding/OnboardingCoordinator.kt b/app/src/main/java/com/bitchat/android/onboarding/OnboardingCoordinator.kt
index ba4da701..674deb7d 100644
--- a/app/src/main/java/com/bitchat/android/onboarding/OnboardingCoordinator.kt
+++ b/app/src/main/java/com/bitchat/android/onboarding/OnboardingCoordinator.kt
@@ -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>? = null
+ private var backgroundLocationLauncher: ActivityResultLauncher? = 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 {
// 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 Wi‑Fi Devices (for Wi‑Fi Aware)"
permission.contains("NOTIFICATION") -> "Notifications"
diff --git a/app/src/main/java/com/bitchat/android/onboarding/OnboardingState.kt b/app/src/main/java/com/bitchat/android/onboarding/OnboardingState.kt
index f06ddd08..7fa07817 100644
--- a/app/src/main/java/com/bitchat/android/onboarding/OnboardingState.kt
+++ b/app/src/main/java/com/bitchat/android/onboarding/OnboardingState.kt
@@ -6,8 +6,9 @@ enum class OnboardingState {
LOCATION_CHECK,
BATTERY_OPTIMIZATION_CHECK,
PERMISSION_EXPLANATION,
+ BACKGROUND_LOCATION_EXPLANATION,
PERMISSION_REQUESTING,
INITIALIZING,
COMPLETE,
ERROR
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/com/bitchat/android/onboarding/PermissionExplanationScreen.kt b/app/src/main/java/com/bitchat/android/onboarding/PermissionExplanationScreen.kt
index c00f35f3..48138d66 100644
--- a/app/src/main/java/com/bitchat/android/onboarding/PermissionExplanationScreen.kt
+++ b/app/src/main/java/com/bitchat/android/onboarding/PermissionExplanationScreen.kt
@@ -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
diff --git a/app/src/main/java/com/bitchat/android/onboarding/PermissionManager.kt b/app/src/main/java/com/bitchat/android/onboarding/PermissionManager.kt
index aedf85f8..8fcf9c31 100644
--- a/app/src/main/java/com/bitchat/android/onboarding/PermissionManager.kt
+++ b/app/src/main/java/com/bitchat/android/onboarding/PermissionManager.kt
@@ -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
@@ -22,6 +23,22 @@ class PermissionManager(private val context: Context) {
private val sharedPrefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
+ private fun shouldRequireWifiAwarePermission(): Boolean {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return false
+ val enabled = try {
+ com.bitchat.android.ui.debug.DebugPreferenceManager.getWifiAwareEnabled(true)
+ } catch (_: Exception) {
+ true
+ }
+ if (!enabled) return false
+
+ return try {
+ com.bitchat.android.wifiaware.WifiAwareSupport.isSupported(context)
+ } catch (_: Exception) {
+ false
+ }
+ }
+
/**
* Check if this is the first time the user is launching the app
*/
@@ -40,7 +57,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.
*/
@@ -68,7 +86,7 @@ class PermissionManager(private val context: Context) {
))
// Wi‑Fi Aware: Android 13+ requires NEARBY_WIFI_DEVICES runtime permission
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ if (shouldRequireWifiAwarePermission()) {
permissions.add(Manifest.permission.NEARBY_WIFI_DEVICES)
}
@@ -77,6 +95,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 +137,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 +179,11 @@ class PermissionManager(private val context: Context) {
return getRequiredPermissions().filter { !isPermissionGranted(it) }
}
+ fun getMissingBackgroundLocationPermission(): List {
+ val permission = getBackgroundLocationPermission() ?: return emptyList()
+ return if (isPermissionGranted(permission)) emptyList() else listOf(permission)
+ }
+
/**
* Get categorized permission information for display
*/
@@ -183,7 +231,7 @@ class PermissionManager(private val context: Context) {
)
// Wi‑Fi Aware category (Android 13+)
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ if (shouldRequireWifiAwarePermission()) {
val wifiAwarePermissions = listOf(Manifest.permission.NEARBY_WIFI_DEVICES)
categories.add(
PermissionCategory(
@@ -196,6 +244,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 +296,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 +308,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 +340,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("Wi‑Fi Aware"),
diff --git a/app/src/main/java/com/bitchat/android/protocol/BinaryProtocol.kt b/app/src/main/java/com/bitchat/android/protocol/BinaryProtocol.kt
index 2d15b86e..88e5f783 100644
--- a/app/src/main/java/com/bitchat/android/protocol/BinaryProtocol.kt
+++ b/app/src/main/java/com/bitchat/android/protocol/BinaryProtocol.kt
@@ -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? = 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,13 +244,21 @@ 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 {
+ if (payloadDataSize > 0xFFFF || (originalPayloadSize ?: 0) > 0xFFFF) {
+ Log.w("BinaryProtocol", "Cannot encode oversized v1 packet payload: $payloadDataSize bytes")
+ return null
+ }
buffer.putShort(payloadDataSize.toShort()) // 2 bytes for v1
}
@@ -256,12 +277,26 @@ object BinaryProtocol {
buffer.put(ByteArray(RECIPIENT_ID_SIZE - recipientBytes.size))
}
}
+
+ // 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 +359,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 +369,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,15 +407,46 @@ object BinaryProtocol {
recipientBytes
} else null
+ // Route (optional)
+ val route: List? = 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()
+ 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
@@ -383,10 +471,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
}
diff --git a/app/src/main/java/com/bitchat/android/service/AppShutdownCoordinator.kt b/app/src/main/java/com/bitchat/android/service/AppShutdownCoordinator.kt
index 1c3abaf9..f4ca9f74 100644
--- a/app/src/main/java/com/bitchat/android/service/AppShutdownCoordinator.kt
+++ b/app/src/main/java/com/bitchat/android/service/AppShutdownCoordinator.kt
@@ -3,7 +3,7 @@ package com.bitchat.android.service
import android.app.Application
import android.os.Process
import androidx.core.app.NotificationManagerCompat
-import com.bitchat.android.mesh.BluetoothMeshService
+import com.bitchat.android.mesh.MeshService
import com.bitchat.android.net.ArtiTorManager
import com.bitchat.android.net.TorMode
import kotlinx.coroutines.CoroutineScope
@@ -39,7 +39,7 @@ object AppShutdownCoordinator {
fun requestFullShutdownAndKill(
app: Application,
- mesh: BluetoothMeshService?,
+ mesh: MeshService?,
notificationManager: NotificationManagerCompat,
stopForeground: () -> Unit,
stopService: () -> Unit
diff --git a/app/src/main/java/com/bitchat/android/service/MeshForegroundService.kt b/app/src/main/java/com/bitchat/android/service/MeshForegroundService.kt
index e92274f8..218631ea 100644
--- a/app/src/main/java/com/bitchat/android/service/MeshForegroundService.kt
+++ b/app/src/main/java/com/bitchat/android/service/MeshForegroundService.kt
@@ -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,10 @@ 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 unifiedMeshService: com.bitchat.android.mesh.MeshService?
+ get() = MeshServiceHolder.unifiedMeshService
private val serviceJob = Job()
private val scope = CoroutineScope(Dispatchers.Default + serviceJob)
private var isInForeground: Boolean = false
@@ -122,15 +127,16 @@ 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!!)
+ MeshServiceHolder.getUnifiedOrCreate(applicationContext)
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
@@ -144,7 +150,7 @@ class MeshForegroundService : Service() {
when (intent?.action) {
ACTION_STOP -> {
// Stop FGS and mesh cleanly
- try { meshService?.stopServices() } catch (_: Exception) { }
+ try { unifiedMeshService?.stopServices() ?: meshService?.stopServices() } catch (_: Exception) { }
try { MeshServiceHolder.clear() } catch (_: Exception) { }
try { stopForeground(true) } catch (_: Exception) { }
notificationManager.cancel(NOTIFICATION_ID)
@@ -162,7 +168,7 @@ class MeshForegroundService : Service() {
// Fully stop all background activity, stop Tor (without changing setting), then kill the app
AppShutdownCoordinator.requestFullShutdownAndKill(
app = application,
- mesh = meshService,
+ mesh = unifiedMeshService,
notificationManager = notificationManager,
stopForeground = {
try { stopForeground(true) } catch (_: Exception) { }
@@ -175,8 +181,8 @@ class MeshForegroundService : Service() {
ACTION_UPDATE_NOTIFICATION -> {
// 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)
+ val n = buildNotification(getUnifiedActivePeerCount())
+ startForegroundCompat(n)
isInForeground = true
} else {
updateNotification(force = true)
@@ -190,8 +196,8 @@ 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)
+ val notification = buildNotification(getUnifiedActivePeerCount())
+ startForegroundCompat(notification)
isInForeground = true
}
@@ -223,10 +229,26 @@ class MeshForegroundService : Service() {
private fun ensureMeshStarted() {
if (isShuttingDown) return
+ try {
+ com.bitchat.android.wifiaware.WifiAwareController.startIfPossible()
+ } catch (e: Exception) {
+ android.util.Log.e("MeshForegroundService", "Failed to ensure Wi-Fi Aware transport: ${e.message}")
+ }
+
+ val bleEnabled = try {
+ com.bitchat.android.ui.debug.DebugPreferenceManager.getBleEnabled(true)
+ } catch (_: Exception) {
+ true
+ }
+ if (!bleEnabled) {
+ try { meshService?.setBleTransportEnabled(false) } catch (_: Exception) { }
+ return
+ }
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}")
}
@@ -237,7 +259,7 @@ class MeshForegroundService : Service() {
notificationManager.cancel(NOTIFICATION_ID)
return
}
- val count = meshService?.getActivePeerCount() ?: 0
+ val count = getUnifiedActivePeerCount()
val notification = buildNotification(count)
if (MeshServicePreferences.isBackgroundEnabled(true) && hasAllRequiredPermissions()) {
notificationManager.notify(NOTIFICATION_ID, notification)
@@ -257,6 +279,14 @@ class MeshForegroundService : Service() {
return hasBluetoothPermissions() && hasNotificationPermission()
}
+ private fun getUnifiedActivePeerCount(): Int {
+ return try {
+ unifiedMeshService?.getActivePeerCount() ?: meshService?.getActivePeerCount() ?: 0
+ } catch (_: Exception) {
+ 0
+ }
+ }
+
private fun hasBluetoothPermissions(): Boolean {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
androidx.core.content.ContextCompat.checkSelfPermission(this, android.Manifest.permission.BLUETOOTH_ADVERTISE) == android.content.pm.PackageManager.PERMISSION_GRANTED &&
@@ -276,7 +306,7 @@ class MeshForegroundService : Service() {
} else true
}
- private fun buildNotification(activeUsers: Int): Notification {
+ private fun buildNotification(activePeers: Int): Notification {
val openIntent = Intent(this, MainActivity::class.java)
val pendingIntent = PendingIntent.getActivity(
this, 0, openIntent,
@@ -291,7 +321,7 @@ class MeshForegroundService : Service() {
)
val title = getString(R.string.app_name)
- val content = getString(R.string.mesh_service_notification_content, activeUsers)
+ val content = getString(R.string.mesh_service_notification_content, activePeers)
return NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle(title)
@@ -326,6 +356,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
diff --git a/app/src/main/java/com/bitchat/android/service/MeshServiceHolder.kt b/app/src/main/java/com/bitchat/android/service/MeshServiceHolder.kt
index 71dddb66..1ff4ec29 100644
--- a/app/src/main/java/com/bitchat/android/service/MeshServiceHolder.kt
+++ b/app/src/main/java/com/bitchat/android/service/MeshServiceHolder.kt
@@ -2,6 +2,10 @@ package com.bitchat.android.service
import android.content.Context
import com.bitchat.android.mesh.BluetoothMeshService
+import com.bitchat.android.mesh.UnifiedMeshService
+import com.bitchat.android.model.RoutedPacket
+import com.bitchat.android.protocol.BitchatPacket
+import com.bitchat.android.sync.GossipSyncManager
/**
* Process-wide holder to share a single BluetoothMeshService instance
@@ -10,15 +14,68 @@ import com.bitchat.android.mesh.BluetoothMeshService
object MeshServiceHolder {
private const val TAG = "MeshServiceHolder"
@Volatile
- var sharedGossipSyncManager: com.bitchat.android.sync.GossipSyncManager? = null
+ var sharedGossipSyncManager: GossipSyncManager? = null
private set
- fun setGossipManager(mgr: com.bitchat.android.sync.GossipSyncManager) { sharedGossipSyncManager = mgr }
+ private val activeGossipOwners = mutableSetOf()
+
+ @Synchronized
+ fun setGossipManager(
+ mgr: GossipSyncManager,
+ signer: (BitchatPacket) -> BitchatPacket
+ ) {
+ val previous = sharedGossipSyncManager
+ if (previous !== mgr) {
+ try { previous?.stop() } catch (_: Exception) { }
+ }
+ sharedGossipSyncManager = mgr
+ mgr.delegate = TransportGossipDelegate(signer)
+ if (activeGossipOwners.isNotEmpty()) {
+ mgr.start()
+ }
+ }
+
+ @Synchronized
+ fun startSharedGossip(owner: String) {
+ val wasIdle = activeGossipOwners.isEmpty()
+ activeGossipOwners.add(owner)
+ if (wasIdle) {
+ sharedGossipSyncManager?.start()
+ }
+ }
+
+ @Synchronized
+ fun stopSharedGossip(owner: String) {
+ activeGossipOwners.remove(owner)
+ if (activeGossipOwners.isEmpty()) {
+ sharedGossipSyncManager?.stop()
+ }
+ }
+
+ private class TransportGossipDelegate(
+ private val signer: (BitchatPacket) -> BitchatPacket
+ ) : GossipSyncManager.Delegate {
+ override fun sendPacket(packet: BitchatPacket) {
+ TransportBridgeService.broadcastFromLocal(RoutedPacket(packet))
+ }
+
+ override fun sendPacketToPeer(peerID: String, packet: BitchatPacket) {
+ TransportBridgeService.sendToPeerFromLocal(peerID, packet)
+ }
+
+ override fun signPacketForBroadcast(packet: BitchatPacket): BitchatPacket {
+ return signer(packet)
+ }
+ }
@Volatile
var meshService: BluetoothMeshService? = null
private set
+ @Volatile
+ var unifiedMeshService: UnifiedMeshService? = null
+ private set
+
@Synchronized
fun getOrCreate(context: Context): BluetoothMeshService {
val existing = meshService
@@ -37,18 +94,35 @@ object MeshServiceHolder {
val created = BluetoothMeshService(context.applicationContext)
android.util.Log.i(TAG, "Created new BluetoothMeshService (replacement)")
meshService = created
+ unifiedMeshService = null
created
}
} catch (e: Exception) {
android.util.Log.e(TAG, "Error checking service reusability; creating new instance: ${e.message}")
val created = BluetoothMeshService(context.applicationContext)
meshService = created
+ unifiedMeshService = null
created
}
}
val created = BluetoothMeshService(context.applicationContext)
android.util.Log.i(TAG, "Created new BluetoothMeshService (no existing instance)")
meshService = created
+ unifiedMeshService = null
+ return created
+ }
+
+ @Synchronized
+ fun getUnifiedOrCreate(context: Context): UnifiedMeshService {
+ val bluetooth = getOrCreate(context)
+ val existing = unifiedMeshService
+ if (existing != null) {
+ existing.refreshDelegates()
+ return existing
+ }
+ val created = UnifiedMeshService(context.applicationContext, bluetooth)
+ unifiedMeshService = created
+ android.util.Log.i(TAG, "Created new UnifiedMeshService")
return created
}
@@ -56,11 +130,16 @@ object MeshServiceHolder {
fun attach(service: BluetoothMeshService) {
android.util.Log.d(TAG, "Attaching BluetoothMeshService to holder")
meshService = service
+ unifiedMeshService = null
}
@Synchronized
fun clear() {
android.util.Log.d(TAG, "Clearing BluetoothMeshService from holder")
+ try { sharedGossipSyncManager?.stop() } catch (_: Exception) { }
+ sharedGossipSyncManager = null
+ activeGossipOwners.clear()
meshService = null
+ unifiedMeshService = null
}
}
diff --git a/app/src/main/java/com/bitchat/android/service/TransportBridgeService.kt b/app/src/main/java/com/bitchat/android/service/TransportBridgeService.kt
index 61c73c82..9422be0e 100644
--- a/app/src/main/java/com/bitchat/android/service/TransportBridgeService.kt
+++ b/app/src/main/java/com/bitchat/android/service/TransportBridgeService.kt
@@ -2,6 +2,11 @@ package com.bitchat.android.service
import android.util.Log
import com.bitchat.android.model.RoutedPacket
+import com.bitchat.android.protocol.BitchatPacket
+import com.bitchat.android.util.toHexString
+import java.security.MessageDigest
+import java.util.Collections
+import java.util.LinkedHashMap
import java.util.concurrent.ConcurrentHashMap
/**
@@ -13,6 +18,8 @@ import java.util.concurrent.ConcurrentHashMap
*/
object TransportBridgeService {
private const val TAG = "TransportBridgeService"
+ private const val MAX_SEEN_PACKETS = 4096
+ private const val SEEN_PACKET_TTL_MS = 5 * 60 * 1000L
/**
* Interface that any transport layer (BLE, WiFi, Tor, etc.) must implement
@@ -23,9 +30,21 @@ object TransportBridgeService {
* Send a packet out via this transport.
*/
fun send(packet: RoutedPacket)
+
+ /**
+ * Send a packet to a specific peer via this transport (optional).
+ */
+ fun sendToPeer(peerID: String, packet: BitchatPacket) { }
}
private val transports = ConcurrentHashMap()
+ private val seenPackets = Collections.synchronizedMap(
+ object : LinkedHashMap(MAX_SEEN_PACKETS, 0.75f, true) {
+ override fun removeEldestEntry(eldest: MutableMap.MutableEntry?): Boolean {
+ return size > MAX_SEEN_PACKETS
+ }
+ }
+ )
/**
* Register a transport layer to receive bridged packets.
@@ -55,15 +74,111 @@ object TransportBridgeService {
fun broadcast(sourceId: String, packet: RoutedPacket) {
val targets = transports.filterKeys { it != sourceId }
if (targets.isEmpty()) return
+ val forwardedPacket = prepareForwardedPacket("broadcast", packet.packet) ?: return
+ val forwarded = packet.copy(packet = forwardedPacket)
// Log.v(TAG, "Bridging packet type ${packet.packet.type} from $sourceId to ${targets.keys}")
targets.forEach { (id, layer) ->
try {
- layer.send(packet)
+ layer.send(forwarded)
} catch (e: Exception) {
Log.e(TAG, "Failed to bridge packet to $id: ${e.message}")
}
}
}
+
+ /**
+ * Send a packet to a specific peer across all other transports.
+ */
+ fun sendToPeer(sourceId: String, peerID: String, packet: BitchatPacket) {
+ val targets = transports.filterKeys { it != sourceId }
+ if (targets.isEmpty()) return
+ val forwardedPacket = prepareForwardedPacket("peer:$peerID", packet) ?: return
+
+ targets.forEach { (id, layer) ->
+ try {
+ layer.sendToPeer(peerID, forwardedPacket)
+ } catch (e: Exception) {
+ Log.e(TAG, "Failed to bridge unicast packet to $id: ${e.message}")
+ }
+ }
+ }
+
+ /**
+ * Send a locally originated packet to every active transport without applying relay TTL
+ * handling. This is used for neighbor-only packets such as REQUEST_SYNC whose TTL is
+ * intentionally zero on the first radio hop.
+ */
+ fun broadcastFromLocal(packet: RoutedPacket) {
+ val targets = transports.toMap()
+ if (targets.isEmpty()) return
+
+ targets.forEach { (id, layer) ->
+ try {
+ layer.send(packet)
+ } catch (e: Exception) {
+ Log.e(TAG, "Failed to send local packet to $id: ${e.message}")
+ }
+ }
+ }
+
+ /**
+ * Send a locally originated packet directly to a peer on every active transport.
+ */
+ fun sendToPeerFromLocal(peerID: String, packet: BitchatPacket) {
+ val targets = transports.toMap()
+ if (targets.isEmpty()) return
+
+ targets.forEach { (id, layer) ->
+ try {
+ layer.sendToPeer(peerID, packet)
+ } catch (e: Exception) {
+ Log.e(TAG, "Failed to send local peer packet to $id: ${e.message}")
+ }
+ }
+ }
+
+ private fun prepareForwardedPacket(kind: String, packet: BitchatPacket): BitchatPacket? {
+ if (packet.ttl == 0u.toUByte()) {
+ Log.d(TAG, "Dropping bridged packet type ${packet.type}: TTL expired")
+ return null
+ }
+
+ val key = "$kind:${logicalPacketId(packet)}"
+ val now = System.currentTimeMillis()
+ synchronized(seenPackets) {
+ pruneSeen(now)
+ val previous = seenPackets[key]
+ if (previous != null && now - previous < SEEN_PACKET_TTL_MS) {
+ Log.d(TAG, "Dropping duplicate bridged packet type ${packet.type}")
+ return null
+ }
+ seenPackets[key] = now
+ }
+
+ return packet.copy(ttl = (packet.ttl - 1u).toUByte())
+ }
+
+ private fun pruneSeen(now: Long) {
+ val iterator = seenPackets.entries.iterator()
+ while (iterator.hasNext()) {
+ val entry = iterator.next()
+ if (now - entry.value > SEEN_PACKET_TTL_MS) {
+ iterator.remove()
+ }
+ }
+ }
+
+ private fun logicalPacketId(packet: BitchatPacket): String {
+ val digest = MessageDigest.getInstance("SHA-256")
+ digest.update(packet.type.toByte())
+ digest.update(packet.senderID)
+ packet.recipientID?.let { digest.update(it) }
+ digest.update(packet.timestamp.toString().toByteArray(Charsets.UTF_8))
+ digest.update(packet.payload)
+ packet.route?.forEach { digest.update(it) }
+ packet.signature?.let { digest.update(it) }
+ return digest.digest().toHexString()
+ }
}
diff --git a/app/src/main/java/com/bitchat/android/services/AppStateStore.kt b/app/src/main/java/com/bitchat/android/services/AppStateStore.kt
index 07f146bd..7a935396 100644
--- a/app/src/main/java/com/bitchat/android/services/AppStateStore.kt
+++ b/app/src/main/java/com/bitchat/android/services/AppStateStore.kt
@@ -13,6 +13,10 @@ import kotlinx.coroutines.flow.asStateFlow
object AppStateStore {
// Global de-dup set by message id to avoid duplicate keys in Compose lists
private val seenMessageIds = mutableSetOf()
+ private val seenPublicMessageKeys = mutableSetOf()
+ private val peerIdsByTransport = mutableMapOf>()
+ // Direct (single-hop) peer IDs per transport, used to gossip a unified neighbor set.
+ private val directPeerIdsByTransport = mutableMapOf>()
// Connected peer IDs (mesh ephemeral IDs)
private val _peers = MutableStateFlow>(emptyList())
val peers: StateFlow> = _peers.asStateFlow()
@@ -30,13 +34,63 @@ object AppStateStore {
val channelMessages: StateFlow