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>> = _channelMessages.asStateFlow() fun setPeers(ids: List) { - _peers.value = ids + synchronized(this) { + _peers.value = ids.distinct() + } + } + + fun setTransportPeers(transportId: String, ids: List) { + synchronized(this) { + peerIdsByTransport[transportId] = ids.toSet() + publishTransportPeersLocked() + } + } + + fun clearTransportPeers(transportId: String) { + synchronized(this) { + peerIdsByTransport.remove(transportId) + publishTransportPeersLocked() + } + } + + private fun publishTransportPeersLocked() { + _peers.value = peerIdsByTransport.values + .asSequence() + .flatten() + .distinct() + .toList() + } + + /** + * Record the set of direct (single-hop) peers reachable over a given transport. Each transport + * (BLE, Wi-Fi Aware, ...) only knows its own direct peers; [getDirectPeers] unions them so every + * transport can gossip the same complete neighbor list under our shared node identity. + */ + fun setTransportDirectPeers(transportId: String, ids: Collection) { + synchronized(this) { + directPeerIdsByTransport[transportId] = ids.toSet() + } + } + + fun clearTransportDirectPeers(transportId: String) { + synchronized(this) { + directPeerIdsByTransport.remove(transportId) + } + } + + /** Union of direct peers across all transports. */ + fun getDirectPeers(): Set { + synchronized(this) { + return directPeerIdsByTransport.values.flatten().toSet() + } } fun addPublicMessage(msg: BitchatMessage) { synchronized(this) { - if (seenMessageIds.contains(msg.id)) return + val publicKey = publicMessageKey(msg) + if (seenMessageIds.contains(msg.id) || seenPublicMessageKeys.contains(publicKey)) return seenMessageIds.add(msg.id) + seenPublicMessageKeys.add(publicKey) _publicMessages.value = _publicMessages.value + msg } } @@ -100,10 +154,24 @@ object AppStateStore { fun clear() { synchronized(this) { seenMessageIds.clear() + seenPublicMessageKeys.clear() + peerIdsByTransport.clear() + directPeerIdsByTransport.clear() _peers.value = emptyList() _publicMessages.value = emptyList() _privateMessages.value = emptyMap() _channelMessages.value = emptyMap() } } + + private fun publicMessageKey(msg: BitchatMessage): String { + val sender = msg.senderPeerID ?: msg.sender + return listOf( + sender, + msg.timestamp.time.toString(), + msg.type.name, + msg.channel ?: "", + msg.content + ).joinToString("\u001F") + } } diff --git a/app/src/main/java/com/bitchat/android/services/ConversationAliasResolver.kt b/app/src/main/java/com/bitchat/android/services/ConversationAliasResolver.kt index 2c80d17e..d67ff432 100644 --- a/app/src/main/java/com/bitchat/android/services/ConversationAliasResolver.kt +++ b/app/src/main/java/com/bitchat/android/services/ConversationAliasResolver.kt @@ -76,7 +76,12 @@ object ConversationAliasResolver { if (selected != null && keysToMerge.contains(selected)) { state.setSelectedPrivateChatPeer(targetPeerID) } + + // Switch sheet peer if currently viewing an alias that got merged + val sheetPeer = state.getPrivateChatSheetPeerValue() + if (sheetPeer != null && keysToMerge.contains(sheetPeer)) { + state.setPrivateChatSheetPeer(targetPeerID) + } } } } - diff --git a/app/src/main/java/com/bitchat/android/services/MessageRouter.kt b/app/src/main/java/com/bitchat/android/services/MessageRouter.kt index 53db2fa6..77949697 100644 --- a/app/src/main/java/com/bitchat/android/services/MessageRouter.kt +++ b/app/src/main/java/com/bitchat/android/services/MessageRouter.kt @@ -2,39 +2,39 @@ package com.bitchat.android.services import android.content.Context import android.util.Log -import com.bitchat.android.mesh.BluetoothMeshService +import com.bitchat.android.mesh.MeshService import com.bitchat.android.model.ReadReceipt import com.bitchat.android.nostr.NostrTransport /** - * Routes messages between BLE mesh and Nostr transports, matching iOS behavior. + * Routes messages between local mesh transports and Nostr, matching iOS behavior. */ class MessageRouter private constructor( private val context: Context, - private val mesh: BluetoothMeshService, + private var mesh: MeshService, private val nostr: NostrTransport ) { companion object { private const val TAG = "MessageRouter" @Volatile private var INSTANCE: MessageRouter? = null fun tryGetInstance(): MessageRouter? = INSTANCE - fun getInstance(context: Context, mesh: BluetoothMeshService): MessageRouter { - return INSTANCE ?: synchronized(this) { - val nostr = NostrTransport.getInstance(context) - INSTANCE?.also { - // Update mesh reference if needed and keep senderPeerID in sync - it.nostr.senderPeerID = mesh.myPeerID - return it - } - MessageRouter(context.applicationContext, mesh, nostr).also { instance -> - instance.nostr.senderPeerID = mesh.myPeerID - // Register for favorites changes to flush outbox - try { - com.bitchat.android.favorites.FavoritesPersistenceService.shared.addListener(instance.favoriteListener) - } catch (_: Exception) {} - INSTANCE = instance + fun getInstance(context: Context, mesh: MeshService): MessageRouter { + val instance = INSTANCE ?: synchronized(this) { + INSTANCE ?: run { + val nostr = NostrTransport.getInstance(context) + MessageRouter(context.applicationContext, mesh, nostr).also { instance -> + // Register for favorites changes to flush outbox + try { + com.bitchat.android.favorites.FavoritesPersistenceService.shared.addListener(instance.favoriteListener) + } catch (_: Exception) {} + INSTANCE = instance + } } } + // Always update mesh reference and sync peer ID + instance.mesh = mesh + instance.nostr.senderPeerID = mesh.myPeerID + return instance } } @@ -70,17 +70,10 @@ class MessageRouter private constructor( } } - val hasMesh = mesh.getPeerInfo(toPeerID)?.isConnected == true - val hasEstablished = mesh.hasEstablishedSession(toPeerID) - // Check Wi‑Fi Aware availability as a secondary transport - val aware = try { com.bitchat.android.wifiaware.WifiAwareController.getService() } catch (_: Exception) { null } - val hasAware = try { aware?.getPeerInfo(toPeerID)?.isConnected == true && aware.hasEstablishedSession(toPeerID) } catch (_: Exception) { false } - if (hasMesh && hasEstablished) { + val hasMesh = isConnected(mesh, toPeerID) + if (isReady(mesh, toPeerID)) { Log.d(TAG, "Routing PM via mesh to ${toPeerID} msg_id=${messageID.take(8)}…") mesh.sendPrivateMessage(content, toPeerID, recipientNickname, messageID) - } else if (hasAware) { - Log.d(TAG, "Routing PM via Wi‑Fi Aware to ${toPeerID} msg_id=${messageID.take(8)}…") - aware?.sendPrivateMessage(content, toPeerID, recipientNickname, messageID) } else if (canSendViaNostr(toPeerID)) { Log.d(TAG, "Routing PM via Nostr to ${toPeerID.take(32)}… msg_id=${messageID.take(8)}…") nostr.sendPrivateMessage(content, toPeerID, recipientNickname, messageID) @@ -89,21 +82,14 @@ class MessageRouter private constructor( val q = outbox.getOrPut(toPeerID) { mutableListOf() } q.add(Triple(content, recipientNickname, messageID)) Log.d(TAG, "Initiating noise handshake after queueing PM for ${toPeerID.take(8)}…") - if (hasMesh) mesh.initiateNoiseHandshake(toPeerID) else aware?.initiateNoiseHandshake(toPeerID) + if (hasMesh) mesh.initiateNoiseHandshake(toPeerID) } } fun sendReadReceipt(receipt: ReadReceipt, toPeerID: String) { - val aware = try { com.bitchat.android.wifiaware.WifiAwareController.getService() } catch (_: Exception) { null } - val viaMesh = (mesh.getPeerInfo(toPeerID)?.isConnected == true) && mesh.hasEstablishedSession(toPeerID) - val viaAware = try { aware?.getPeerInfo(toPeerID)?.isConnected == true && aware.hasEstablishedSession(toPeerID) } catch (_: Exception) { false } - if (viaMesh) { + if (isReady(mesh, toPeerID)) { Log.d(TAG, "Routing READ via mesh to ${toPeerID.take(8)}… id=${receipt.originalMessageID.take(8)}…") mesh.sendReadReceipt(receipt.originalMessageID, toPeerID, mesh.getPeerNicknames()[toPeerID] ?: mesh.myPeerID) - } else if (viaAware) { - Log.d(TAG, "Routing READ via Wi‑Fi Aware to ${toPeerID.take(8)}… id=${receipt.originalMessageID.take(8)}…") - val me = try { aware?.myPeerID } catch (_: Exception) { null } - aware?.sendReadReceipt(receipt.originalMessageID, toPeerID, me ?: "") } else { Log.d(TAG, "Routing READ via Nostr to ${toPeerID.take(8)}… id=${receipt.originalMessageID.take(8)}…") nostr.sendReadReceipt(receipt, toPeerID) @@ -126,11 +112,11 @@ class MessageRouter private constructor( } fun sendFavoriteNotification(toPeerID: String, isFavorite: Boolean) { - if (mesh.getPeerInfo(toPeerID)?.isConnected == true) { + if (mesh.getPeerInfo(toPeerID)?.isConnected == true && mesh.hasEstablishedSession(toPeerID)) { 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 = mesh.getPeerNicknames()[toPeerID] ?: toPeerID - mesh.sendPrivateMessage(content, toPeerID, nickname) + mesh.sendPrivateMessage(content, toPeerID, nickname, null) } else { nostr.sendFavoriteNotification(toPeerID, isFavorite) } @@ -144,11 +130,11 @@ class MessageRouter private constructor( val iterator = queued.iterator() while (iterator.hasNext()) { val (content, nickname, messageID) = iterator.next() - var hasMesh = mesh.getPeerInfo(peerID)?.isConnected == true && mesh.hasEstablishedSession(peerID) + val hasMesh = isReady(mesh, peerID) // If this is a noiseHex key, see if there is a connected mesh peer for this identity if (!hasMesh && peerID.length == 64 && peerID.matches(Regex("^[0-9a-fA-F]+$"))) { - val meshPeer = resolveMeshPeerForNoiseHex(peerID) - if (meshPeer != null && mesh.getPeerInfo(meshPeer)?.isConnected == true && mesh.hasEstablishedSession(meshPeer)) { + val meshPeer = resolvePeerForNoiseHex(peerID, mesh) + if (meshPeer != null && isReady(mesh, meshPeer)) { mesh.sendPrivateMessage(content, meshPeer, nickname, messageID) iterator.remove() continue @@ -195,10 +181,27 @@ class MessageRouter private constructor( return clean.chunked(2).map { it.toInt(16).toByte() }.toByteArray() } - private fun resolveMeshPeerForNoiseHex(noiseHex: String): String? { + private fun isConnected(service: MeshService, peerID: String): Boolean { return try { - mesh.getPeerNicknames().keys.firstOrNull { pid -> - val info = mesh.getPeerInfo(pid) + service.getPeerInfo(peerID)?.isConnected == true + } catch (_: Exception) { + false + } + } + + private fun isReady(service: MeshService, peerID: String): Boolean { + return try { + service.getPeerInfo(peerID)?.isConnected == true && + service.hasEstablishedSession(peerID) + } catch (_: Exception) { + false + } + } + + private fun resolvePeerForNoiseHex(noiseHex: String, service: MeshService): String? { + return try { + service.getPeerNicknames().keys.firstOrNull { pid -> + val info = service.getPeerInfo(pid) val keyHex = info?.noisePublicKey?.joinToString("") { b -> "%02x".format(b) } keyHex != null && keyHex.equals(noiseHex, ignoreCase = true) } diff --git a/app/src/main/java/com/bitchat/android/services/SeenMessageStore.kt b/app/src/main/java/com/bitchat/android/services/SeenMessageStore.kt index a6cd2378..15e1c7ca 100644 --- a/app/src/main/java/com/bitchat/android/services/SeenMessageStore.kt +++ b/app/src/main/java/com/bitchat/android/services/SeenMessageStore.kt @@ -50,6 +50,12 @@ class SeenMessageStore private constructor(private val context: Context) { persist() } + @Synchronized fun clear() { + delivered.clear() + read.clear() + persist() + } + private fun trim(set: LinkedHashSet) { if (set.size <= MAX_IDS) return val it = set.iterator() diff --git a/app/src/main/java/com/bitchat/android/services/VerificationService.kt b/app/src/main/java/com/bitchat/android/services/VerificationService.kt new file mode 100644 index 00000000..06dcd6f3 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/services/VerificationService.kt @@ -0,0 +1,294 @@ +package com.bitchat.android.services + +import android.net.Uri +import android.util.Base64 +import com.bitchat.android.crypto.EncryptionService +import com.bitchat.android.util.AppConstants +import com.bitchat.android.util.dataFromHexString +import com.bitchat.android.util.hexEncodedString +import java.io.ByteArrayOutputStream +import java.security.SecureRandom +import androidx.core.net.toUri +import java.lang.ref.WeakReference + +/** + * QR verification helpers: schema, signing, and basic challenge/response helpers. + */ +object VerificationService { + private const val CONTEXT = "bitchat-verify-v1" + private const val RESPONSE_CONTEXT = "bitchat-verify-resp-v1" + + private var encryptionServiceRef: WeakReference? = null + + fun configure(encryptionService: EncryptionService) { + this.encryptionServiceRef = WeakReference(encryptionService) + } + + data class VerificationQR( + val v: Int, + val noiseKeyHex: String, + val signKeyHex: String, + val npub: String?, + val nickname: String, + val ts: Long, + val nonceB64: String, + val sigHex: String + ) { + fun canonicalBytes(): ByteArray { + val out = ByteArrayOutputStream() + + fun appendField(value: String) { + val data = value.toByteArray(Charsets.UTF_8) + val len = minOf(data.size, 255) + out.write(len) + out.write(data, 0, len) + } + + appendField(CONTEXT) + appendField(v.toString()) + appendField(noiseKeyHex.lowercase()) + appendField(signKeyHex.lowercase()) + appendField(npub ?: "") + appendField(nickname) + appendField(ts.toString()) + appendField(nonceB64) + return out.toByteArray() + } + + fun toUrlString(): String { + val builder = Uri.Builder() + .scheme("bitchat") + .authority("verify") + .appendQueryParameter("v", v.toString()) + .appendQueryParameter("noise", noiseKeyHex) + .appendQueryParameter("sign", signKeyHex) + .appendQueryParameter("nick", nickname) + .appendQueryParameter("ts", ts.toString()) + .appendQueryParameter("nonce", nonceB64) + .appendQueryParameter("sig", sigHex) + if (npub != null) { + builder.appendQueryParameter("npub", npub) + } + return builder.build().toString() + } + + companion object { + fun fromUrlString(urlString: String): VerificationQR? { + val uri = runCatching { urlString.toUri() }.getOrNull() ?: return null + if (uri.scheme != "bitchat" || uri.host != "verify") return null + + val vStr = uri.getQueryParameter("v") ?: return null + val v = vStr.toIntOrNull() ?: return null + val noise = uri.getQueryParameter("noise") ?: return null + val sign = uri.getQueryParameter("sign") ?: return null + val nick = uri.getQueryParameter("nick") ?: return null + val tsStr = uri.getQueryParameter("ts") ?: return null + val ts = tsStr.toLongOrNull() ?: return null + val nonce = uri.getQueryParameter("nonce") ?: return null + val sig = uri.getQueryParameter("sig") ?: return null + val npub = uri.getQueryParameter("npub") + + return VerificationQR( + v = v, + noiseKeyHex = noise, + signKeyHex = sign, + npub = npub, + nickname = nick, + ts = ts, + nonceB64 = nonce, + sigHex = sig + ) + } + } + } + + fun buildMyQRString(nickname: String, npub: String?): String? { + val service = encryptionServiceRef?.get() ?: return null + val cache = Cache.last + if (cache != null && cache.nickname == nickname && cache.npub == npub) { + if (System.currentTimeMillis() - cache.builtAtMs < 60_000L) { + return cache.value + } + } + + val noiseKey = service.getStaticPublicKey()?.hexEncodedString() ?: return null + val signKey = service.getSigningPublicKey()?.hexEncodedString() ?: return null + val ts = System.currentTimeMillis() / 1000L + val nonce = ByteArray(16) + SecureRandom().nextBytes(nonce) + val nonceB64 = Base64.encodeToString( + nonce, + Base64.URL_SAFE or Base64.NO_WRAP or Base64.NO_PADDING + ) + + val payload = VerificationQR( + v = 1, + noiseKeyHex = noiseKey, + signKeyHex = signKey, + npub = npub, + nickname = nickname, + ts = ts, + nonceB64 = nonceB64, + sigHex = "" + ) + + val signature = service.signData(payload.canonicalBytes()) ?: return null + val signed = payload.copy(sigHex = signature.hexEncodedString()) + val out = signed.toUrlString() + Cache.last = CacheEntry(nickname, npub, System.currentTimeMillis(), out) + return out + } + + fun verifyScannedQR( + urlString: String, + maxAgeSeconds: Long = AppConstants.Verification.QR_MAX_AGE_SECONDS + ): VerificationQR? { + val service = encryptionServiceRef?.get() ?: return null + val qr = VerificationQR.fromUrlString(urlString) ?: return null + val now = System.currentTimeMillis() / 1000L + if (now - qr.ts > maxAgeSeconds) return null + + val sig = qr.sigHex.dataFromHexString() ?: return null + val signKey = qr.signKeyHex.dataFromHexString() ?: return null + val ok = service.verifyEd25519Signature(sig, qr.canonicalBytes(), signKey) + return if (ok) qr else null + } + + fun buildVerifyChallenge(noiseKeyHex: String, nonceA: ByteArray): ByteArray { + val noiseData = noiseKeyHex.toByteArray(Charsets.UTF_8) + val out = ByteArrayOutputStream() + out.write(0x01) + out.write(minOf(noiseData.size, 255)) + out.write(noiseData, 0, minOf(noiseData.size, 255)) + out.write(0x02) + out.write(minOf(nonceA.size, 255)) + out.write(nonceA, 0, minOf(nonceA.size, 255)) + return out.toByteArray() + } + + fun buildVerifyResponse(noiseKeyHex: String, nonceA: ByteArray): ByteArray? { + val service = encryptionServiceRef?.get() ?: return null + val noiseData = noiseKeyHex.toByteArray(Charsets.UTF_8) + val msg = ByteArrayOutputStream() + msg.write(RESPONSE_CONTEXT.toByteArray(Charsets.UTF_8)) + msg.write(minOf(noiseData.size, 255)) + msg.write(noiseData, 0, minOf(noiseData.size, 255)) + msg.write(nonceA) + val sig = service.signData(msg.toByteArray()) ?: return null + + val out = ByteArrayOutputStream() + out.write(0x01) + out.write(minOf(noiseData.size, 255)) + out.write(noiseData, 0, minOf(noiseData.size, 255)) + out.write(0x02) + out.write(minOf(nonceA.size, 255)) + out.write(nonceA, 0, minOf(nonceA.size, 255)) + out.write(0x03) + out.write(minOf(sig.size, 255)) + out.write(sig, 0, minOf(sig.size, 255)) + return out.toByteArray() + } + + fun parseVerifyChallenge(data: ByteArray): Pair? { + var idx = 0 + + fun take(n: Int): ByteArray? { + if (idx + n > data.size) return null + val out = data.copyOfRange(idx, idx + n) + idx += n + return out + } + + val t1 = take(1) ?: return null + if (t1[0].toInt() != 0x01) return null + val l1 = take(1)?.get(0)?.toInt() ?: return null + val noiseBytes = take(l1) ?: return null + val noise = noiseBytes.toString(Charsets.UTF_8) + + val t2 = take(1) ?: return null + if (t2[0].toInt() != 0x02) return null + val l2 = take(1)?.get(0)?.toInt() ?: return null + val nonce = take(l2) ?: return null + + return noise to nonce + } + + data class VerifyResponse(val noiseKeyHex: String, val nonceA: ByteArray, val signature: ByteArray) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as VerifyResponse + + if (noiseKeyHex != other.noiseKeyHex) return false + if (!nonceA.contentEquals(other.nonceA)) return false + if (!signature.contentEquals(other.signature)) return false + + return true + } + + override fun hashCode(): Int { + var result = noiseKeyHex.hashCode() + result = 31 * result + nonceA.contentHashCode() + result = 31 * result + signature.contentHashCode() + return result + } + } + + fun parseVerifyResponse(data: ByteArray): VerifyResponse? { + var idx = 0 + + fun take(n: Int): ByteArray? { + if (idx + n > data.size) return null + val out = data.copyOfRange(idx, idx + n) + idx += n + return out + } + + val t1 = take(1) ?: return null + if (t1[0].toInt() != 0x01) return null + val l1 = take(1)?.get(0)?.toInt() ?: return null + val noiseBytes = take(l1) ?: return null + val noise = noiseBytes.toString(Charsets.UTF_8) + + val t2 = take(1) ?: return null + if (t2[0].toInt() != 0x02) return null + val l2 = take(1)?.get(0)?.toInt() ?: return null + val nonce = take(l2) ?: return null + + val t3 = take(1) ?: return null + if (t3[0].toInt() != 0x03) return null + val l3 = take(1)?.get(0)?.toInt() ?: return null + val sig = take(l3) ?: return null + + return VerifyResponse(noise, nonce, sig) + } + + fun verifyResponseSignature( + noiseKeyHex: String, + nonceA: ByteArray, + signature: ByteArray, + signerPublicKeyHex: String + ): Boolean { + val service = encryptionServiceRef?.get() ?: return false + val noiseData = noiseKeyHex.toByteArray(Charsets.UTF_8) + val msg = ByteArrayOutputStream() + msg.write(RESPONSE_CONTEXT.toByteArray(Charsets.UTF_8)) + msg.write(minOf(noiseData.size, 255)) + msg.write(noiseData, 0, minOf(noiseData.size, 255)) + msg.write(nonceA) + val signerKey = signerPublicKeyHex.dataFromHexString() ?: return false + return service.verifyEd25519Signature(signature, msg.toByteArray(), signerKey) + } + + private data class CacheEntry( + val nickname: String, + val npub: String?, + val builtAtMs: Long, + val value: String + ) + + private object Cache { + var last: CacheEntry? = null + } +} diff --git a/app/src/main/java/com/bitchat/android/services/meshgraph/GossipTLV.kt b/app/src/main/java/com/bitchat/android/services/meshgraph/GossipTLV.kt new file mode 100644 index 00000000..85ea6c9c --- /dev/null +++ b/app/src/main/java/com/bitchat/android/services/meshgraph/GossipTLV.kt @@ -0,0 +1,77 @@ +package com.bitchat.android.services.meshgraph + +import android.util.Log + +/** + * Gossip TLV helpers for embedding direct neighbor peer IDs in ANNOUNCE payloads. + * Uses compact TLV: [type=0x04][len=1 byte][value=N*8 bytes of peerIDs] + */ +object GossipTLV { + // TLV type for a compact list of direct neighbor peerIDs (each 8 bytes) + const val DIRECT_NEIGHBORS_TYPE: UByte = 0x04u + + /** + * Encode up to 10 unique peerIDs (hex string up to 16 chars) as TLV value. + */ + fun encodeNeighbors(peerIDs: List): ByteArray { + val unique = peerIDs.distinct().take(10) + val valueBytes = unique.flatMap { id -> hexStringPeerIdTo8Bytes(id).toList() }.toByteArray() + if (valueBytes.size > 255) { + // Safety check, though 10*8 = 80 bytes, so well under 255 + Log.w("GossipTLV", "Neighbors value exceeds 255, truncating") + } + return byteArrayOf(DIRECT_NEIGHBORS_TYPE.toByte(), valueBytes.size.toByte()) + valueBytes + } + + /** + * Scan a TLV-encoded announce payload and extract neighbor peerIDs. + * Returns null if the TLV is not present at all; returns an empty list if present with length 0. + */ + fun decodeNeighborsFromAnnouncementPayload(payload: ByteArray): List? { + val result = mutableListOf() + var offset = 0 + while (offset + 2 <= payload.size) { + val type = payload[offset].toUByte() + val len = payload[offset + 1].toUByte().toInt() + offset += 2 + if (offset + len > payload.size) break + val value = payload.sliceArray(offset until offset + len) + offset += len + + if (type == DIRECT_NEIGHBORS_TYPE) { + // Value is N*8 bytes of peer IDs + var pos = 0 + while (pos + 8 <= value.size) { + val idBytes = value.sliceArray(pos until pos + 8) + result.add(bytesToPeerIdHex(idBytes)) + pos += 8 + } + return result // present (possibly empty) + } + } + // Not present + return null + } + + private fun hexStringPeerIdTo8Bytes(hexString: String): ByteArray { + val clean = hexString.lowercase().take(16) + val result = ByteArray(8) { 0 } + var idx = 0 + var out = 0 + while (idx + 1 < clean.length && out < 8) { + val byteStr = clean.substring(idx, idx + 2) + val b = byteStr.toIntOrNull(16)?.toByte() ?: 0 + result[out++] = b + idx += 2 + } + return result + } + + private fun bytesToPeerIdHex(bytes: ByteArray): String { + val sb = StringBuilder() + for (b in bytes.take(8)) { + sb.append(String.format("%02x", b)) + } + return sb.toString() + } +} diff --git a/app/src/main/java/com/bitchat/android/services/meshgraph/MeshGraphService.kt b/app/src/main/java/com/bitchat/android/services/meshgraph/MeshGraphService.kt new file mode 100644 index 00000000..c2849b01 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/services/meshgraph/MeshGraphService.kt @@ -0,0 +1,127 @@ +package com.bitchat.android.services.meshgraph + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import java.util.concurrent.ConcurrentHashMap + +/** + * Maintains an internal graph of the mesh based on gossip. + * Nodes are peers (peerID), edges are direct connections. + */ +class MeshGraphService private constructor() { + data class GraphNode(val peerID: String, val nickname: String?) + data class GraphEdge(val a: String, val b: String, val isConfirmed: Boolean, val confirmedBy: String? = null) + data class GraphSnapshot(val nodes: List, val edges: List) + + // Map peerID -> nickname (may be null if unknown) + private val nicknames = ConcurrentHashMap() + // Announcements: peerID -> set of neighbor peerIDs that *this* peer claims to see + private val announcements = ConcurrentHashMap>() + // Latest announcement timestamp per peer (ULong from packet) + private val lastUpdate = ConcurrentHashMap() + + private val _graphState = MutableStateFlow(GraphSnapshot(emptyList(), emptyList())) + val graphState: StateFlow = _graphState.asStateFlow() + + /** + * Update graph from a verified announcement. + * Replaces previous neighbors for origin if this is newer (by timestamp). + */ + fun updateFromAnnouncement(originPeerID: String, originNickname: String?, neighborsOrNull: List?, timestamp: ULong) { + synchronized(this) { + // Always update nickname if provided + if (originNickname != null) nicknames[originPeerID] = originNickname + + // 1. Check timestamp first to ensure this is the latest word from the peer + val prevTs = lastUpdate[originPeerID] + if (prevTs != null && prevTs >= timestamp) { + // Older or equal update: ignore + return + } + lastUpdate[originPeerID] = timestamp + + // 2. Latest announcement determines state. + // If neighborsOrNull is null (TLV omitted), it means the peer is not reporting any neighbors (empty list). + val neighbors = neighborsOrNull ?: emptyList() + + // Filter out self-loops just in case + val newSet = neighbors.distinct().take(10).filter { it != originPeerID }.toSet() + announcements[originPeerID] = newSet + + publishSnapshot() + } + } + + fun updateNickname(peerID: String, nickname: String?) { + if (nickname == null) return + nicknames[peerID] = nickname + publishSnapshot() + } + + /** + * Remove a peer from the graph completely (e.g. when stale/offline). + */ + fun removePeer(peerID: String) { + synchronized(this) { + nicknames.remove(peerID) + announcements.remove(peerID) + lastUpdate.remove(peerID) + publishSnapshot() + } + } + + private fun publishSnapshot() { + // Collect all known nodes from nicknames and announcements + val allNodes = mutableSetOf() + allNodes.addAll(nicknames.keys) + announcements.forEach { (origin, neighbors) -> + allNodes.add(origin) + allNodes.addAll(neighbors) + } + + val nodeList = allNodes.map { GraphNode(it, nicknames[it]) }.sortedBy { it.peerID } + + val edges = mutableListOf() + val processedPairs = mutableSetOf>() + + // We only care about connections that exist in at least one direction. + // So iterating through all entries in `announcements` covers every declared edge. + announcements.forEach { (source, targets) -> + targets.forEach { target -> + val pair = if (source <= target) source to target else target to source + if (processedPairs.add(pair)) { + // This is a new pair we haven't evaluated yet + val (a, b) = pair + val aAnnouncesB = announcements[a]?.contains(b) == true + val bAnnouncesA = announcements[b]?.contains(a) == true + + if (aAnnouncesB && bAnnouncesA) { + edges.add(GraphEdge(a, b, isConfirmed = true)) + } else if (aAnnouncesB) { + edges.add(GraphEdge(a, b, isConfirmed = false, confirmedBy = a)) + } else if (bAnnouncesA) { + edges.add(GraphEdge(a, b, isConfirmed = false, confirmedBy = b)) + } + } + } + } + + val sortedEdges = edges.sortedWith(compareBy({ it.a }, { it.b })) + _graphState.value = GraphSnapshot(nodeList, sortedEdges) + } + + companion object { + @Volatile private var INSTANCE: MeshGraphService? = null + fun getInstance(): MeshGraphService = INSTANCE ?: synchronized(this) { + INSTANCE ?: MeshGraphService().also { INSTANCE = it } + } + + @org.jetbrains.annotations.TestOnly + fun resetForTesting() { + synchronized(this) { + INSTANCE = null + } + } + } +} diff --git a/app/src/main/java/com/bitchat/android/services/meshgraph/RoutePlanner.kt b/app/src/main/java/com/bitchat/android/services/meshgraph/RoutePlanner.kt new file mode 100644 index 00000000..51814848 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/services/meshgraph/RoutePlanner.kt @@ -0,0 +1,68 @@ +package com.bitchat.android.services.meshgraph + +import android.util.Log +import java.util.PriorityQueue + +/** + * Computes shortest paths on the current mesh graph snapshot using Dijkstra. + * Assumes unit edge weights. + */ +object RoutePlanner { + private const val TAG = "RoutePlanner" + + /** + * Return full path [src, ..., dst] if reachable, else null. + */ + fun shortestPath(src: String, dst: String): List? { + if (src == dst) return listOf(src) + val snapshot = MeshGraphService.getInstance().graphState.value + val neighbors = mutableMapOf>() + + // Only consider confirmed edges for routing + snapshot.edges.filter { it.isConfirmed }.forEach { e -> + neighbors.getOrPut(e.a) { mutableSetOf() }.add(e.b) + neighbors.getOrPut(e.b) { mutableSetOf() }.add(e.a) + } + // Ensure nodes known even if isolated + snapshot.nodes.forEach { n -> neighbors.putIfAbsent(n.peerID, mutableSetOf()) } + + if (!neighbors.containsKey(src) || !neighbors.containsKey(dst)) return null + + val dist = mutableMapOf() + val prev = mutableMapOf() + val pq = PriorityQueue>(compareBy { it.second }) + + neighbors.keys.forEach { v -> + dist[v] = if (v == src) 0 else Int.MAX_VALUE + prev[v] = null + } + pq.add(src to 0) + + while (pq.isNotEmpty()) { + val top = pq.poll() ?: break + val (u, d) = top + if (d > (dist[u] ?: Int.MAX_VALUE)) continue + if (u == dst) break + neighbors[u]?.forEach { v -> + val alt = d + 1 + if (alt < (dist[v] ?: Int.MAX_VALUE)) { + dist[v] = alt + prev[v] = u + pq.add(v to alt) + } + } + } + + if ((dist[dst] ?: Int.MAX_VALUE) == Int.MAX_VALUE) return null + + val path = mutableListOf() + var cur: String? = dst + while (cur != null) { + path.add(cur) + cur = prev[cur] + } + path.reverse() + Log.d(TAG, "Computed path $path") + return path + } +} diff --git a/app/src/main/java/com/bitchat/android/sync/GCSFilter.kt b/app/src/main/java/com/bitchat/android/sync/GCSFilter.kt index 3cc64c58..212def6a 100644 --- a/app/src/main/java/com/bitchat/android/sync/GCSFilter.kt +++ b/app/src/main/java/com/bitchat/android/sync/GCSFilter.kt @@ -43,21 +43,29 @@ object GCSFilter { targetFpr: Double ): Params { val p = deriveP(targetFpr) - var nCap = estimateMaxElementsForSize(maxBytes, p) - val n = ids.size.coerceAtMost(nCap) - val selected = ids.take(n) - // Map to [0, M) - val m = (n.toLong() shl p) - val mapped = selected.map { id -> (h64(id) % m) }.sorted() + val nCap = estimateMaxElementsForSize(maxBytes, p) + var trimmedN = ids.size.coerceAtMost(nCap) + + var finalM = (trimmedN.toLong() shl p).coerceAtLeast(1L) + var selected = ids.take(trimmedN) + var mapped = selected.map { id -> + val v = h64(id) % finalM + if (v == 0L) 1L else v + }.distinct().sorted() var encoded = encode(mapped, p) + // If estimate was too optimistic, trim until it fits - var trimmedN = n while (encoded.size > maxBytes && trimmedN > 0) { trimmedN = (trimmedN * 9) / 10 // drop 10% - val mapped2 = mapped.take(trimmedN) - encoded = encode(mapped2, p) + finalM = (trimmedN.toLong() shl p).coerceAtLeast(1L) + selected = ids.take(trimmedN) + mapped = selected.map { id -> + val v = h64(id) % finalM + if (v == 0L) 1L else v + }.distinct().sorted() + encoded = encode(mapped, p) } - val finalM = (trimmedN.toLong() shl p) + return Params(p = p, m = finalM, data = encoded) } @@ -96,7 +104,7 @@ object GCSFilter { return false } - private fun h64(id16: ByteArray): Long { + internal fun h64(id16: ByteArray): Long { val md = MessageDigest.getInstance("SHA-256") md.update(id16) val d = md.digest() diff --git a/app/src/main/java/com/bitchat/android/sync/GossipSyncManager.kt b/app/src/main/java/com/bitchat/android/sync/GossipSyncManager.kt index 6e29aa79..5c786365 100644 --- a/app/src/main/java/com/bitchat/android/sync/GossipSyncManager.kt +++ b/app/src/main/java/com/bitchat/android/sync/GossipSyncManager.kt @@ -169,14 +169,9 @@ class GossipSyncManager( // Decode GCS into sorted set for membership checks val sorted = GCSFilter.decodeToSortedSet(request.p, request.m, request.data) fun mightContain(id: ByteArray): Boolean { - val v = (GCSFilter.run { - // reuse hashing method from GCSFilter - val md = java.security.MessageDigest.getInstance("SHA-256"); - md.update(id); val d = md.digest(); - var x = 0L; for (i in 0 until 8) { x = (x shl 8) or (d[i].toLong() and 0xFF) } - (x and 0x7fff_ffff_ffff_ffffL) % request.m - }) - return GCSFilter.contains(sorted, v) + val v = GCSFilter.h64(id) % request.m + val nonZeroV = if (v == 0L) 1L else v + return GCSFilter.contains(sorted, nonZeroV) } // 1) Announcements: send latest per peerID if remote doesn't have them diff --git a/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt b/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt index 7c5fa7a0..f137ac63 100644 --- a/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt @@ -13,13 +13,10 @@ import androidx.compose.material.icons.filled.Lock import androidx.compose.material.icons.filled.Public import androidx.compose.material.icons.filled.Warning import androidx.compose.material.icons.filled.Security -import androidx.compose.material.icons.filled.NetworkCheck import androidx.compose.material.icons.filled.Speed -import androidx.compose.material.icons.outlined.Info import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment -import kotlinx.coroutines.launch import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector @@ -27,15 +24,14 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.BaselineShift import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.bitchat.android.nostr.NostrProofOfWork import com.bitchat.android.nostr.PoWPreferenceManager import androidx.compose.ui.res.stringResource -import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.bitchat.android.R import com.bitchat.android.core.ui.component.button.CloseButton +import com.bitchat.android.core.ui.component.sheet.BitchatBottomSheet import com.bitchat.android.net.TorMode import com.bitchat.android.net.TorPreferenceManager import com.bitchat.android.net.ArtiTorManager @@ -217,10 +213,6 @@ fun AboutSheet( } } - val sheetState = rememberModalBottomSheetState( - skipPartiallyExpanded = true - ) - val lazyListState = rememberLazyListState() val isScrolled by remember { derivedStateOf { @@ -236,12 +228,9 @@ fun AboutSheet( val isDark = colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f if (isPresented) { - ModalBottomSheet( - modifier = modifier.statusBarsPadding(), + BitchatBottomSheet( + modifier = modifier, onDismissRequest = onDismiss, - sheetState = sheetState, - containerColor = colorScheme.background, - dragHandle = null ) { Box(modifier = Modifier.fillMaxWidth()) { LazyColumn( diff --git a/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt b/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt index f741dafa..7273813b 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt @@ -37,22 +37,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle */ -/** - * Reactive helper to compute favorite state from fingerprint mapping - * This eliminates the need for static isFavorite parameters and makes - * the UI reactive to fingerprint manager changes - */ -@Composable -fun isFavoriteReactive( - peerID: String, - peerFingerprints: Map, - favoritePeers: Set -): Boolean { - return remember(peerID, peerFingerprints, favoritePeers) { - val fingerprint = peerFingerprints[peerID] - fingerprint != null && favoritePeers.contains(fingerprint) - } -} + @Composable fun TorStatusDot( @@ -246,39 +231,6 @@ fun ChatHeaderContent( val colorScheme = MaterialTheme.colorScheme when { - selectedPrivatePeer != null -> { - // Private chat header - Fully reactive state tracking - val favoritePeers by viewModel.favoritePeers.collectAsStateWithLifecycle() - val peerFingerprints by viewModel.peerFingerprints.collectAsStateWithLifecycle() - val peerSessionStates by viewModel.peerSessionStates.collectAsStateWithLifecycle() - val peerNicknames by viewModel.peerNicknames.collectAsStateWithLifecycle() - - // Reactive favorite computation - no more static lookups! - val isFavorite = isFavoriteReactive( - peerID = selectedPrivatePeer, - peerFingerprints = peerFingerprints, - favoritePeers = favoritePeers - ) - val sessionState = peerSessionStates[selectedPrivatePeer] - - Log.d("ChatHeader", "Header recomposing: peer=$selectedPrivatePeer, isFav=$isFavorite, sessionState=$sessionState") - - // Pass geohash context and people for NIP-17 chat title formatting - val selectedLocationChannel by viewModel.selectedLocationChannel.collectAsStateWithLifecycle() - val geohashPeople by viewModel.geohashPeople.collectAsStateWithLifecycle() - - PrivateChatHeader( - peerID = selectedPrivatePeer, - peerNicknames = peerNicknames, - isFavorite = isFavorite, - sessionState = sessionState, - selectedLocationChannel = selectedLocationChannel, - geohashPeople = geohashPeople, - onBackClick = onBackClick, - onToggleFavorite = { viewModel.toggleFavorite(selectedPrivatePeer) }, - viewModel = viewModel - ) - } currentChannel != null -> { // Channel header ChannelHeader( @@ -304,148 +256,7 @@ fun ChatHeaderContent( } } -@Composable -private fun PrivateChatHeader( - peerID: String, - peerNicknames: Map, - isFavorite: Boolean, - sessionState: String?, - selectedLocationChannel: com.bitchat.android.geohash.ChannelID?, - geohashPeople: List, - onBackClick: () -> Unit, - onToggleFavorite: () -> Unit, - viewModel: ChatViewModel -) { - val colorScheme = MaterialTheme.colorScheme - val isNostrDM = peerID.startsWith("nostr_") || peerID.startsWith("nostr:") - // Determine mutual favorite state for this peer (supports mesh ephemeral 16-hex via favorites lookup) - val isMutualFavorite = remember(peerID, peerNicknames) { - try { - if (isNostrDM) return@remember false - if (peerID.length == 64 && peerID.matches(Regex("^[0-9a-fA-F]+$"))) { - val noiseKeyBytes = peerID.chunked(2).map { it.toInt(16).toByte() }.toByteArray() - com.bitchat.android.favorites.FavoritesPersistenceService.shared.getFavoriteStatus(noiseKeyBytes)?.isMutual == true - } else if (peerID.length == 16 && peerID.matches(Regex("^[0-9a-fA-F]+$"))) { - com.bitchat.android.favorites.FavoritesPersistenceService.shared.getFavoriteStatus(peerID)?.isMutual == true - } else false - } catch (_: Exception) { false } - } - // Compute title text: for NIP-17 chats show "#geohash/@username" (iOS parity) - val titleText: String = if (isNostrDM) { - // For geohash DMs, get the actual source geohash and proper display name - val (conversationGeohash, baseName) = try { - val repoField = com.bitchat.android.ui.GeohashViewModel::class.java.getDeclaredField("repo") - repoField.isAccessible = true - val repo = repoField.get(viewModel.geohashViewModel) as com.bitchat.android.nostr.GeohashRepository - val gh = repo.getConversationGeohash(peerID) ?: "geohash" - val fullPubkey = com.bitchat.android.nostr.GeohashAliasRegistry.get(peerID) ?: "" - val displayName = if (fullPubkey.isNotEmpty()) { - repo.displayNameForGeohashConversation(fullPubkey, gh) - } else { - peerNicknames[peerID] ?: "unknown" - } - Pair(gh, displayName) - } catch (e: Exception) { - Pair("geohash", peerNicknames[peerID] ?: "unknown") - } - - "#$conversationGeohash/@$baseName" - } else { - // Prefer live mesh nickname; fallback to favorites nickname (supports 16-hex), finally short key - peerNicknames[peerID] ?: run { - val titleFromFavorites = try { - if (peerID.length == 64 && peerID.matches(Regex("^[0-9a-fA-F]+$"))) { - val noiseKeyBytes = peerID.chunked(2).map { it.toInt(16).toByte() }.toByteArray() - com.bitchat.android.favorites.FavoritesPersistenceService.shared.getFavoriteStatus(noiseKeyBytes)?.peerNickname - } else if (peerID.length == 16 && peerID.matches(Regex("^[0-9a-fA-F]+$"))) { - com.bitchat.android.favorites.FavoritesPersistenceService.shared.getFavoriteStatus(peerID)?.peerNickname - } else null - } catch (_: Exception) { null } - titleFromFavorites ?: peerID.take(12) - } - } - - Box(modifier = Modifier.fillMaxWidth()) { - // Back button - positioned all the way to the left with minimal margin - Button( - onClick = onBackClick, - colors = ButtonDefaults.buttonColors( - containerColor = Color.Transparent, - contentColor = colorScheme.primary - ), - contentPadding = PaddingValues(horizontal = 4.dp, vertical = 4.dp), // Reduced horizontal padding - modifier = Modifier - .align(Alignment.CenterStart) - .offset(x = (-8).dp) // Move even further left to minimize margin - ) { - Row( - verticalAlignment = Alignment.CenterVertically - ) { - Icon( - imageVector = Icons.Filled.ArrowBack, - contentDescription = stringResource(R.string.back), - modifier = Modifier.size(16.dp), - tint = colorScheme.primary - ) - Spacer(modifier = Modifier.width(4.dp)) - Text( - text = stringResource(R.string.chat_back), - style = MaterialTheme.typography.bodyMedium, - color = colorScheme.primary - ) - } - } - - // Title - perfectly centered regardless of other elements - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.align(Alignment.Center) - ) { - - Text( - text = titleText, - style = MaterialTheme.typography.titleMedium, - color = Color(0xFFFF9500) // Orange - ) - - Spacer(modifier = Modifier.width(4.dp)) - - // Show a globe when chatting via Nostr alias, or when mesh session not established but mutual favorite exists - val showGlobe = isNostrDM || (sessionState != "established" && isMutualFavorite) - if (showGlobe) { - Icon( - imageVector = Icons.Outlined.Public, - contentDescription = stringResource(R.string.cd_nostr_reachable), - modifier = Modifier.size(14.dp), - tint = Color(0xFF9B59B6) // Purple like iOS - ) - } else { - NoiseSessionIcon( - sessionState = sessionState, - modifier = Modifier.size(14.dp) - ) - } - - } - - // Favorite button - positioned on the right - IconButton( - onClick = { - Log.d("ChatHeader", "Header toggle favorite: peerID=$peerID, currentFavorite=$isFavorite") - onToggleFavorite() - }, - modifier = Modifier.align(Alignment.CenterEnd) - ) { - Icon( - imageVector = if (isFavorite) Icons.Filled.Star else Icons.Outlined.Star, - contentDescription = if (isFavorite) stringResource(R.string.cd_remove_favorite) else stringResource(R.string.cd_add_favorite), - modifier = Modifier.size(18.dp), // Slightly larger than sidebar icon - tint = if (isFavorite) Color(0xFFFFD700) else Color(0x87878700) // Yellow or grey - ) - } - } -} @Composable private fun ChannelHeader( @@ -633,7 +444,7 @@ private fun MainHeader( ) Spacer(modifier = Modifier.width(2.dp)) PeerCounter( - connectedPeers = connectedPeers.filter { it != viewModel.meshService.myPeerID }, + connectedPeers = connectedPeers.filter { it != viewModel.myPeerID }, joinedChannels = joinedChannels, hasUnreadChannels = hasUnreadChannels, isConnected = isConnected, diff --git a/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt b/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt index d4d33ac0..bc83a9f9 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt @@ -5,7 +5,6 @@ package com.bitchat.android.ui import androidx.compose.animation.* -import androidx.compose.animation.core.* import androidx.compose.foundation.* import androidx.compose.foundation.layout.* import androidx.compose.material3.* @@ -51,12 +50,15 @@ fun ChatScreen(viewModel: ChatViewModel) { val hasUnreadPrivateMessages by viewModel.unreadPrivateMessages.collectAsStateWithLifecycle() val privateChats by viewModel.privateChats.collectAsStateWithLifecycle() val channelMessages by viewModel.channelMessages.collectAsStateWithLifecycle() - val showSidebar by viewModel.showSidebar.collectAsStateWithLifecycle() val showCommandSuggestions by viewModel.showCommandSuggestions.collectAsStateWithLifecycle() val commandSuggestions by viewModel.commandSuggestions.collectAsStateWithLifecycle() val showMentionSuggestions by viewModel.showMentionSuggestions.collectAsStateWithLifecycle() val mentionSuggestions by viewModel.mentionSuggestions.collectAsStateWithLifecycle() val showAppInfo by viewModel.showAppInfo.collectAsStateWithLifecycle() + val showMeshPeerListSheet by viewModel.showMeshPeerList.collectAsStateWithLifecycle() + val privateChatSheetPeer by viewModel.privateChatSheetPeer.collectAsStateWithLifecycle() + val showVerificationSheet by viewModel.showVerificationSheet.collectAsStateWithLifecycle() + val showSecurityVerificationSheet by viewModel.showSecurityVerificationSheet.collectAsStateWithLifecycle() var messageText by remember { mutableStateOf(TextFieldValue("")) } var showPasswordPrompt by remember { mutableStateOf(false) } @@ -85,8 +87,8 @@ fun ChatScreen(viewModel: ChatViewModel) { val selectedLocationChannel by viewModel.selectedLocationChannel.collectAsStateWithLifecycle() // Determine what messages to show based on current context (unified timelines) + // Legacy private chat timeline removed - private chats now exclusively use PrivateChatSheet val displayMessages = when { - selectedPrivatePeer != null -> privateChats[selectedPrivatePeer] ?: emptyList() currentChannel != null -> channelMessages[currentChannel] ?: emptyList() else -> { val locationChannel = selectedLocationChannel @@ -101,7 +103,6 @@ fun ChatScreen(viewModel: ChatViewModel) { // Determine whether to show media buttons (only hide in geohash location chats) val showMediaButtons = when { - selectedPrivatePeer != null -> true currentChannel != null -> true else -> selectedLocationChannel !is com.bitchat.android.geohash.ChannelID.Location } @@ -132,7 +133,7 @@ fun ChatScreen(viewModel: ChatViewModel) { MessagesList( messages = displayMessages, currentUserNickname = nickname, - meshService = viewModel.meshService, + meshService = viewModel.meshServiceFacade, modifier = Modifier.weight(1f), forceScrollToBottom = forceScrollToBottom, onScrolledUpChanged = { isUp -> isScrolledUp = isUp }, @@ -231,7 +232,7 @@ fun ChatScreen(viewModel: ChatViewModel) { selection = TextRange(mentionText.length) ) }, - selectedPrivatePeer = selectedPrivatePeer, + selectedPrivatePeer = null, currentChannel = currentChannel, nickname = nickname, colorScheme = colorScheme, @@ -242,12 +243,12 @@ fun ChatScreen(viewModel: ChatViewModel) { // Floating header - positioned absolutely at top, ignores keyboard ChatFloatingHeader( headerHeight = headerHeight, - selectedPrivatePeer = selectedPrivatePeer, + selectedPrivatePeer = null, currentChannel = currentChannel, nickname = nickname, viewModel = viewModel, colorScheme = colorScheme, - onSidebarToggle = { viewModel.showSidebar() }, + onSidebarToggle = { viewModel.showMeshPeerList() }, onShowAppInfo = { viewModel.showAppInfo() }, onPanicClear = { viewModel.panicClearAllData() }, onLocationChannelsClick = { showLocationChannelsSheet = true }, @@ -264,28 +265,9 @@ fun ChatScreen(viewModel: ChatViewModel) { color = colorScheme.outline.copy(alpha = 0.3f) ) - val alpha by animateFloatAsState( - targetValue = if (showSidebar) 0.5f else 0f, - animationSpec = tween( - durationMillis = 300, - easing = EaseOutCubic - ), label = "overlayAlpha" - ) - - // Only render the background if it's visible - if (alpha > 0f) { - Box( - modifier = Modifier - .fillMaxSize() - .background(Color.Black.copy(alpha = alpha)) - .clickable { viewModel.hideSidebar() } - .zIndex(1f) - ) - } - // Scroll-to-bottom floating button AnimatedVisibility( - visible = isScrolledUp && !showSidebar, + visible = isScrolledUp, enter = slideInVertically(initialOffsetY = { it / 2 }) + fadeIn(), exit = slideOutVertically(targetOffsetY = { it / 2 }) + fadeOut(), modifier = Modifier @@ -311,25 +293,6 @@ fun ChatScreen(viewModel: ChatViewModel) { } } } - - AnimatedVisibility( - visible = showSidebar, - enter = slideInHorizontally( - initialOffsetX = { it }, - animationSpec = tween(300, easing = EaseOutCubic) - ) + fadeIn(animationSpec = tween(300)), - exit = slideOutHorizontally( - targetOffsetX = { it }, - animationSpec = tween(250, easing = EaseInCubic) - ) + fadeOut(animationSpec = tween(250)), - modifier = Modifier.zIndex(2f) - ) { - SidebarOverlay( - viewModel = viewModel, - onDismiss = { viewModel.hideSidebar() }, - modifier = Modifier.fillMaxSize() - ) - } } // Full-screen image viewer - separate from other sheets to allow image browsing without navigation @@ -373,12 +336,18 @@ fun ChatScreen(viewModel: ChatViewModel) { }, selectedUserForSheet = selectedUserForSheet, selectedMessageForSheet = selectedMessageForSheet, - viewModel = viewModel + viewModel = viewModel, + showVerificationSheet = showVerificationSheet, + onVerificationSheetDismiss = viewModel::hideVerificationSheet, + showSecurityVerificationSheet = showSecurityVerificationSheet, + onSecurityVerificationSheetDismiss = viewModel::hideSecurityVerificationSheet, + showMeshPeerListSheet = showMeshPeerListSheet, + onMeshPeerListDismiss = viewModel::hideMeshPeerList, ) } @Composable -private fun ChatInputSection( +fun ChatInputSection( messageText: TextFieldValue, onMessageTextChange: (TextFieldValue) -> Unit, onSend: () -> Unit, @@ -513,8 +482,16 @@ private fun ChatDialogs( onUserSheetDismiss: () -> Unit, selectedUserForSheet: String, selectedMessageForSheet: BitchatMessage?, - viewModel: ChatViewModel + viewModel: ChatViewModel, + showVerificationSheet: Boolean, + onVerificationSheetDismiss: () -> Unit, + showSecurityVerificationSheet: Boolean, + onSecurityVerificationSheetDismiss: () -> Unit, + showMeshPeerListSheet: Boolean, + onMeshPeerListDismiss: () -> Unit, ) { + val privateChatSheetPeer by viewModel.privateChatSheetPeer.collectAsStateWithLifecycle() + // Password dialog PasswordPromptDialog( show = showPasswordDialog, @@ -567,4 +544,44 @@ private fun ChatDialogs( viewModel = viewModel ) } + // MeshPeerList sheet (network view) + if (showMeshPeerListSheet){ + MeshPeerListSheet( + isPresented = showMeshPeerListSheet, + viewModel = viewModel, + onDismiss = onMeshPeerListDismiss, + onShowVerification = { + onMeshPeerListDismiss() + viewModel.showVerificationSheet(fromSidebar = true) + } + ) + } + + if (showVerificationSheet) { + VerificationSheet( + isPresented = showVerificationSheet, + onDismiss = onVerificationSheetDismiss, + viewModel = viewModel + ) + } + + if (showSecurityVerificationSheet) { + SecurityVerificationSheet( + isPresented = showSecurityVerificationSheet, + onDismiss = onSecurityVerificationSheetDismiss, + viewModel = viewModel + ) + } + + if (privateChatSheetPeer != null) { + PrivateChatSheet( + isPresented = true, + peerID = privateChatSheetPeer!!, + viewModel = viewModel, + onDismiss = { + viewModel.hidePrivateChatSheet() + viewModel.endPrivateChat() + } + ) + } } diff --git a/app/src/main/java/com/bitchat/android/ui/ChatState.kt b/app/src/main/java/com/bitchat/android/ui/ChatState.kt index 6d0f2364..53a298ef 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatState.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatState.kt @@ -76,11 +76,7 @@ class ChatState( private val _passwordPromptChannel = MutableStateFlow(null) val passwordPromptChannel: StateFlow = _passwordPromptChannel.asStateFlow() - - // Sidebar state - private val _showSidebar = MutableStateFlow(false) - val showSidebar: StateFlow = _showSidebar.asStateFlow() - + // Command autocomplete private val _showCommandSuggestions = MutableStateFlow(false) val showCommandSuggestions: StateFlow = _showCommandSuggestions.asStateFlow() @@ -122,6 +118,18 @@ class ChatState( // Navigation state private val _showAppInfo = MutableStateFlow(false) val showAppInfo: StateFlow = _showAppInfo.asStateFlow() + + private val _showMeshPeerList = MutableStateFlow(false) + val showMeshPeerList: StateFlow = _showMeshPeerList.asStateFlow() + + private val _privateChatSheetPeer = MutableStateFlow(null) + val privateChatSheetPeer: StateFlow = _privateChatSheetPeer.asStateFlow() + + private val _showVerificationSheet = MutableStateFlow(false) + val showVerificationSheet: StateFlow = _showVerificationSheet.asStateFlow() + + private val _showSecurityVerificationSheet = MutableStateFlow(false) + val showSecurityVerificationSheet: StateFlow = _showSecurityVerificationSheet.asStateFlow() // Location channels state (for Nostr geohash features) private val _selectedLocationChannel = MutableStateFlow(com.bitchat.android.geohash.ChannelID.Mesh) @@ -149,7 +157,7 @@ class ChatState( started = WhileSubscribed(5_000), initialValue = false ) - + val hasUnreadPrivateMessages: StateFlow = _unreadPrivateMessages .map { unreadSet -> unreadSet.isNotEmpty() } .stateIn( @@ -172,7 +180,6 @@ class ChatState( fun getPasswordProtectedChannelsValue() = _passwordProtectedChannels.value fun getShowPasswordPromptValue() = _showPasswordPrompt.value fun getPasswordPromptChannelValue() = _passwordPromptChannel.value - fun getShowSidebarValue() = _showSidebar.value fun getShowCommandSuggestionsValue() = _showCommandSuggestions.value fun getCommandSuggestionsValue() = _commandSuggestions.value fun getShowMentionSuggestionsValue() = _showMentionSuggestions.value @@ -182,6 +189,10 @@ class ChatState( fun getPeerFingerprintsValue() = _peerFingerprints.value fun getShowAppInfoValue() = _showAppInfo.value fun getGeohashPeopleValue() = _geohashPeople.value + + fun getShowMeshPeerListValue() = _showMeshPeerList.value + fun getPrivateChatSheetPeerValue() = _privateChatSheetPeer.value + fun getTeleportedGeoValue() = _teleportedGeo.value fun getGeohashParticipantCountsValue() = _geohashParticipantCounts.value @@ -245,11 +256,7 @@ class ChatState( fun setPasswordPromptChannel(channel: String?) { _passwordPromptChannel.value = channel } - - fun setShowSidebar(show: Boolean) { - _showSidebar.value = show - } - + fun setShowCommandSuggestions(show: Boolean) { _showCommandSuggestions.value = show } @@ -302,6 +309,14 @@ class ChatState( fun setShowAppInfo(show: Boolean) { _showAppInfo.value = show } + + fun setShowVerificationSheet(show: Boolean) { + _showVerificationSheet.value = show + } + + fun setShowSecurityVerificationSheet(show: Boolean) { + _showSecurityVerificationSheet.value = show + } fun setSelectedLocationChannel(channel: com.bitchat.android.geohash.ChannelID?) { _selectedLocationChannel.value = channel @@ -323,4 +338,11 @@ class ChatState( _geohashParticipantCounts.value = counts } + fun setShowMeshPeerList(show: Boolean) { + _showMeshPeerList.value = show + } + + fun setPrivateChatSheetPeer(peerID: String?) { + _privateChatSheetPeer.value = peerID + } } diff --git a/app/src/main/java/com/bitchat/android/ui/ChatUIUtils.kt b/app/src/main/java/com/bitchat/android/ui/ChatUIUtils.kt index 82db6b64..e3097eed 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatUIUtils.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatUIUtils.kt @@ -11,7 +11,7 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Shield import androidx.compose.ui.graphics.vector.ImageVector import com.bitchat.android.model.BitchatMessage -import com.bitchat.android.mesh.BluetoothMeshService +import com.bitchat.android.mesh.MeshService import androidx.compose.material3.ColorScheme import com.bitchat.android.ui.theme.BASE_FONT_SIZE import java.text.SimpleDateFormat @@ -42,7 +42,7 @@ fun getRSSIColor(rssi: Int): Color { fun formatMessageAsAnnotatedString( message: BitchatMessage, currentUserNickname: String, - meshService: BluetoothMeshService, + meshService: MeshService, colorScheme: ColorScheme, timeFormatter: SimpleDateFormat = SimpleDateFormat("HH:mm:ss", Locale.getDefault()) ): AnnotatedString { @@ -162,7 +162,7 @@ fun formatMessageAsAnnotatedString( fun formatMessageHeaderAnnotatedString( message: BitchatMessage, currentUserNickname: String, - meshService: BluetoothMeshService, + meshService: MeshService, colorScheme: ColorScheme, timeFormatter: SimpleDateFormat = SimpleDateFormat("HH:mm:ss", Locale.getDefault()) ): AnnotatedString { diff --git a/app/src/main/java/com/bitchat/android/ui/ChatUserSheet.kt b/app/src/main/java/com/bitchat/android/ui/ChatUserSheet.kt index 54e7aea4..0cfa91ba 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatUserSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatUserSheet.kt @@ -4,7 +4,6 @@ import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.material3.* import androidx.compose.runtime.* -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontFamily @@ -16,7 +15,7 @@ import androidx.compose.ui.res.stringResource import com.bitchat.android.R import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.text.AnnotatedString -import kotlinx.coroutines.launch +import com.bitchat.android.core.ui.component.sheet.BitchatBottomSheet import com.bitchat.android.model.BitchatMessage /** @@ -36,23 +35,18 @@ fun ChatUserSheet( val coroutineScope = rememberCoroutineScope() val clipboardManager = LocalClipboardManager.current - // Bottom sheet state - val sheetState = rememberModalBottomSheetState( - skipPartiallyExpanded = true - ) - // iOS system colors (matches LocationChannelsSheet exactly) val colorScheme = MaterialTheme.colorScheme val isDark = colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f val standardGreen = if (isDark) Color(0xFF32D74B) else Color(0xFF248A3D) // iOS green val standardBlue = Color(0xFF007AFF) // iOS blue + val standardPurple = if (isDark) Color(0xFFBF5AF2) else Color(0xFFAF52DE) // iOS purple val standardRed = Color(0xFFFF3B30) // iOS red val standardGrey = if (isDark) Color(0xFF8E8E93) else Color(0xFF6D6D70) // iOS grey if (isPresented) { - ModalBottomSheet( + BitchatBottomSheet( onDismissRequest = onDismiss, - sheetState = sheetState, modifier = modifier ) { Column( @@ -99,6 +93,33 @@ fun ChatUserSheet( // Only show user actions for other users' messages or when no message is selected if (selectedMessage?.sender != viewModel.nickname.value) { + // Send private message action + item { + UserActionRow( + title = stringResource(R.string.action_private_message_title, targetNickname), + subtitle = stringResource(R.string.action_private_message_subtitle), + titleColor = standardPurple, + onClick = { + val selectedLocationChannel = viewModel.selectedLocationChannel.value + if (selectedLocationChannel is com.bitchat.android.geohash.ChannelID.Location) { + if (selectedMessage?.senderPeerID?.startsWith("nostr:") == true) { + val shortId = selectedMessage.senderPeerID!!.substring(6) + viewModel.startGeohashDMByShortId(shortId) + } else { + viewModel.startGeohashDMByNickname(targetNickname) + } + } else { + // Mesh chat + val peerID = selectedMessage?.senderPeerID ?: viewModel.getPeerIDForNickname(targetNickname) + if (peerID != null) { + viewModel.showPrivateChatSheet(peerID) + } + } + onDismiss() + } + ) + } + // Slap action item { UserActionRow( diff --git a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt index ee31ef74..ed61f567 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt @@ -5,11 +5,17 @@ import android.util.Log import androidx.core.app.NotificationManagerCompat import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope +import com.bitchat.android.favorites.FavoritesPersistenceService import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow import com.bitchat.android.mesh.BluetoothMeshDelegate import com.bitchat.android.mesh.BluetoothMeshService +import com.bitchat.android.mesh.MeshService +import com.bitchat.android.service.MeshServiceHolder import com.bitchat.android.model.BitchatMessage import com.bitchat.android.model.BitchatMessageType +import com.bitchat.android.nostr.NostrIdentityBridge import com.bitchat.android.protocol.BitchatPacket @@ -19,6 +25,13 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.launch import java.util.Date import kotlin.random.Random +import com.bitchat.android.services.VerificationService +import com.bitchat.android.identity.SecureIdentityStateManager +import com.bitchat.android.noise.NoiseSession +import com.bitchat.android.nostr.GeohashAliasRegistry +import com.bitchat.android.util.dataFromHexString +import com.bitchat.android.util.hexEncodedString +import java.security.MessageDigest /** * Refactored ChatViewModel - Main coordinator for bitchat functionality @@ -26,8 +39,16 @@ import kotlin.random.Random */ class ChatViewModel( application: Application, - val meshService: BluetoothMeshService + initialMeshService: BluetoothMeshService, + initialUnifiedMeshService: MeshService ) : AndroidViewModel(application), BluetoothMeshDelegate { + + // Made var to support mesh service replacement after panic clear + var meshService: BluetoothMeshService = initialMeshService + private set + private var unifiedMeshService: MeshService = initialUnifiedMeshService + private val mesh: MeshService + get() = unifiedMeshService private val debugManager by lazy { try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance() } catch (e: Exception) { null } } companion object { @@ -46,6 +67,20 @@ class ChatViewModel( mediaSendingManager.sendImageNote(toPeerIDOrNull, channelOrNull, filePath) } + fun getCurrentNpub(): String? { + return try { + NostrIdentityBridge + .getCurrentNostrIdentity(getApplication()) + ?.npub + } catch (_: Exception) { + null + } + } + + fun buildMyQRString(nickname: String, npub: String?): String { + return VerificationService.buildMyQRString(nickname, npub) ?: "" + } + // MARK: - State management private val state = ChatState( scope = viewModelScope, @@ -57,14 +92,15 @@ class ChatViewModel( // Specialized managers private val dataManager = DataManager(application.applicationContext) + private val identityManager by lazy { SecureIdentityStateManager(getApplication()) } private val messageManager = MessageManager(state) private val channelManager = ChannelManager(state, messageManager, dataManager, viewModelScope) // Create Noise session delegate for clean dependency injection private val noiseSessionDelegate = object : NoiseSessionDelegate { - override fun hasEstablishedSession(peerID: String): Boolean = meshService.hasEstablishedSession(peerID) - override fun initiateHandshake(peerID: String) = meshService.initiateNoiseHandshake(peerID) - override fun getMyPeerID(): String = meshService.myPeerID + override fun hasEstablishedSession(peerID: String): Boolean = hasEstablishedSessionOnAnyLocalTransport(peerID) + override fun initiateHandshake(peerID: String) = initiateNoiseHandshakeOnBestLocalTransport(peerID) + override fun getMyPeerID(): String = mesh.myPeerID } val privateChatManager = PrivateChatManager(state, messageManager, dataManager, noiseSessionDelegate) @@ -75,8 +111,19 @@ class ChatViewModel( NotificationIntervalManager() ) + private val verificationHandler = VerificationHandler( + context = application.applicationContext, + scope = viewModelScope, + getMeshService = { mesh }, + identityManager = identityManager, + state = state, + notificationManager = notificationManager, + messageManager = messageManager + ) + val verifiedFingerprints = verificationHandler.verifiedFingerprints + // Media file sending manager - private val mediaSendingManager = MediaSendingManager(state, messageManager, channelManager, meshService) + private val mediaSendingManager = MediaSendingManager(state, messageManager, channelManager) { mesh } // Delegate handler for mesh callbacks private val meshDelegateHandler = MeshDelegateHandler( @@ -87,8 +134,8 @@ class ChatViewModel( notificationManager = notificationManager, coroutineScope = viewModelScope, onHapticFeedback = { ChatViewModelUtils.triggerHapticFeedback(application.applicationContext) }, - getMyPeerID = { meshService.myPeerID }, - getMeshService = { meshService } + getMyPeerID = { mesh.myPeerID }, + getMeshService = { mesh } ) // New Geohash architecture ViewModel (replaces God object service usage in UI path) @@ -120,7 +167,6 @@ class ChatViewModel( val passwordProtectedChannels: StateFlow> = state.passwordProtectedChannels val showPasswordPrompt: StateFlow = state.showPasswordPrompt val passwordPromptChannel: StateFlow = state.passwordPromptChannel - val showSidebar: StateFlow = state.showSidebar val hasUnreadChannels = state.hasUnreadChannels val hasUnreadPrivateMessages = state.hasUnreadPrivateMessages val showCommandSuggestions: StateFlow = state.showCommandSuggestions @@ -134,11 +180,27 @@ class ChatViewModel( val peerRSSI: StateFlow> = state.peerRSSI val peerDirect: StateFlow> = state.peerDirect val showAppInfo: StateFlow = state.showAppInfo + val showMeshPeerList: StateFlow = state.showMeshPeerList + val privateChatSheetPeer: StateFlow = state.privateChatSheetPeer + val showVerificationSheet: StateFlow = state.showVerificationSheet + val showSecurityVerificationSheet: StateFlow = state.showSecurityVerificationSheet val selectedLocationChannel: StateFlow = state.selectedLocationChannel val isTeleported: StateFlow = state.isTeleported val geohashPeople: StateFlow> = state.geohashPeople val teleportedGeo: StateFlow> = state.teleportedGeo val geohashParticipantCounts: StateFlow> = state.geohashParticipantCounts + val meshServiceFacade: MeshService + get() = mesh + val myPeerID: String + get() = mesh.myPeerID + + fun getMeshPeerFingerprint(peerID: String): String? = mesh.getPeerFingerprint(peerID) + + fun getMeshPeerInfo(peerID: String): com.bitchat.android.mesh.PeerInfo? = mesh.getPeerInfo(peerID) + + fun initiateMeshHandshake(peerID: String) { + mesh.initiateNoiseHandshake(peerID) + } init { // Note: Mesh service delegate is now set by MainActivity @@ -163,7 +225,7 @@ class ChatViewModel( // Recompute unread set using SeenMessageStore for robustness across Activity recreation try { val seen = com.bitchat.android.services.SeenMessageStore.getInstance(getApplication()) - val myNick = state.getNicknameValue() ?: meshService.myPeerID + val myNick = state.getNicknameValue() ?: mesh.myPeerID val unread = mutableSetOf() byPeer.forEach { (peer, list) -> if (list.any { msg -> msg.sender != myNick && !seen.hasRead(msg.id) }) unread.add(peer) @@ -247,11 +309,14 @@ class ChatViewModel( // Initialize favorites persistence service com.bitchat.android.favorites.FavoritesPersistenceService.initialize(getApplication()) + // Load verified fingerprints from secure storage + verificationHandler.loadVerifiedFingerprints() + // Ensure NostrTransport knows our mesh peer ID for embedded packets try { val nostrTransport = com.bitchat.android.nostr.NostrTransport.getInstance(getApplication()) - nostrTransport.senderPeerID = meshService.myPeerID + nostrTransport.senderPeerID = mesh.myPeerID } catch (_: Exception) { } // Note: Mesh service is now started by MainActivity @@ -269,7 +334,7 @@ class ChatViewModel( fun setNickname(newNickname: String) { state.setNickname(newNickname) dataManager.saveNickname(newNickname) - meshService.sendBroadcastAnnounce() + mesh.sendBroadcastAnnounce() } /** @@ -315,7 +380,7 @@ class ChatViewModel( // MARK: - Channel Management (delegated) fun joinChannel(channel: String, password: String? = null): Boolean { - return channelManager.joinChannel(channel, password, meshService.myPeerID) + return channelManager.joinChannel(channel, password, mesh.myPeerID) } fun switchToChannel(channel: String?) { @@ -324,7 +389,7 @@ class ChatViewModel( fun leaveChannel(channel: String) { channelManager.leaveChannel(channel) - meshService.sendMessage("left $channel") + mesh.sendMessage("left $channel", emptyList(), null) } // MARK: - Private Chat Management (delegated) @@ -335,7 +400,7 @@ class ChatViewModel( ensureGeohashDMSubscriptionIfNeeded(peerID) } - val success = privateChatManager.startPrivateChat(peerID, meshService) + val success = privateChatManager.startPrivateChat(peerID, mesh) if (success) { // Notify notification manager about current private chat setCurrentPrivateChatPeer(peerID) @@ -361,6 +426,8 @@ class ChatViewModel( setCurrentPrivateChatPeer(null) // Clear mesh mention notifications since user is now back in mesh chat clearMeshMentionNotifications() + // Ensure sheet is hidden + hidePrivateChatSheet() } // MARK: - Open Latest Unread Private Chat @@ -370,7 +437,7 @@ class ChatViewModel( val unreadKeys = state.getUnreadPrivateMessagesValue() if (unreadKeys.isEmpty()) return - val me = state.getNicknameValue() ?: meshService.myPeerID + val me = state.getNicknameValue() ?: mesh.myPeerID val chats = state.getPrivateChatsValue() // Pick the latest incoming message among unread conversations @@ -401,20 +468,15 @@ class ChatViewModel( val canonical = com.bitchat.android.services.ConversationAliasResolver.resolveCanonicalPeerID( selectedPeerID = targetKey, connectedPeers = state.getConnectedPeersValue(), - meshNoiseKeyForPeer = { pid -> meshService.getPeerInfo(pid)?.noisePublicKey }, - meshHasPeer = { pid -> meshService.getPeerInfo(pid)?.isConnected == true }, + meshNoiseKeyForPeer = { pid -> mesh.getPeerInfo(pid)?.noisePublicKey }, + meshHasPeer = { pid -> mesh.getPeerInfo(pid)?.isConnected == true }, nostrPubHexForAlias = { alias -> com.bitchat.android.nostr.GeohashAliasRegistry.get(alias) }, findNoiseKeyForNostr = { key -> com.bitchat.android.favorites.FavoritesPersistenceService.shared.findNoiseKey(key) } ) canonical ?: targetKey } - startPrivateChat(openPeer) - - // If sidebar visible, hide it to focus on the private chat - if (state.getShowSidebarValue()) { - state.setShowSidebar(false) - } + showPrivateChatSheet(openPeer) } catch (e: Exception) { Log.w(TAG, "openLatestUnreadPrivateChat failed: ${e.message}") } @@ -431,25 +493,23 @@ class ChatViewModel( // Check for commands if (content.startsWith("/")) { val selectedLocationForCommand = state.selectedLocationChannel.value - commandProcessor.processCommand(content, meshService, meshService.myPeerID, { messageContent, mentions, channel -> + commandProcessor.processCommand(content, mesh, mesh.myPeerID, { messageContent, mentions, channel -> if (selectedLocationForCommand is com.bitchat.android.geohash.ChannelID.Location) { // Route command-generated public messages via Nostr in geohash channels geohashViewModel.sendGeohashMessage( messageContent, selectedLocationForCommand.channel, - meshService.myPeerID, + mesh.myPeerID, state.getNicknameValue() ) } else { - // Default: route via mesh + Wi‑Fi Aware - meshService.sendMessage(messageContent, mentions, channel) - try { com.bitchat.android.wifiaware.WifiAwareController.getService()?.sendMessage(messageContent, mentions, channel) } catch (_: Exception) {} + mesh.sendMessage(messageContent, mentions, channel) } - }) + }, this) return } - val mentions = messageManager.parseMentions(content, meshService.getPeerNicknames().values.toSet(), state.getNicknameValue()) + val mentions = messageManager.parseMentions(content, mesh.getPeerNicknames().values.toSet(), state.getNicknameValue()) // REMOVED: Auto-join mentioned channels feature that was incorrectly parsing hashtags from @mentions // This was causing messages like "test @jack#1234 test" to auto-join channel "#1234" @@ -461,26 +521,30 @@ class ChatViewModel( selectedPeer = com.bitchat.android.services.ConversationAliasResolver.resolveCanonicalPeerID( selectedPeerID = selectedPeer, connectedPeers = state.getConnectedPeersValue(), - meshNoiseKeyForPeer = { pid -> meshService.getPeerInfo(pid)?.noisePublicKey }, - meshHasPeer = { pid -> meshService.getPeerInfo(pid)?.isConnected == true }, + meshNoiseKeyForPeer = { pid -> mesh.getPeerInfo(pid)?.noisePublicKey }, + meshHasPeer = { pid -> mesh.getPeerInfo(pid)?.isConnected == true }, nostrPubHexForAlias = { alias -> com.bitchat.android.nostr.GeohashAliasRegistry.get(alias) }, findNoiseKeyForNostr = { key -> com.bitchat.android.favorites.FavoritesPersistenceService.shared.findNoiseKey(key) } ).also { canonical -> if (canonical != state.getSelectedPrivateChatPeerValue()) { - privateChatManager.startPrivateChat(canonical, meshService) + privateChatManager.startPrivateChat(canonical, mesh) + // If we're in the private chat sheet, update its active peer too + if (state.getPrivateChatSheetPeerValue() != null) { + showPrivateChatSheet(canonical) + } } } // Send private message - val recipientNickname = meshService.getPeerNicknames()[selectedPeer] + val recipientNickname = nicknameForPeer(selectedPeer) privateChatManager.sendPrivateMessage( content, selectedPeer, recipientNickname, state.getNicknameValue(), - meshService.myPeerID + mesh.myPeerID ) { messageContent, peerID, recipientNicknameParam, messageId -> // Route via MessageRouter (mesh when connected+established, else Nostr) - val router = com.bitchat.android.services.MessageRouter.getInstance(getApplication(), meshService) + val router = com.bitchat.android.services.MessageRouter.getInstance(getApplication(), mesh) router.sendPrivate(messageContent, peerID, recipientNicknameParam, messageId) } } else { @@ -488,21 +552,21 @@ class ChatViewModel( val selectedLocationChannel = state.selectedLocationChannel.value if (selectedLocationChannel is com.bitchat.android.geohash.ChannelID.Location) { // Send to geohash channel via Nostr ephemeral event - geohashViewModel.sendGeohashMessage(content, selectedLocationChannel.channel, meshService.myPeerID, state.getNicknameValue()) + geohashViewModel.sendGeohashMessage(content, selectedLocationChannel.channel, mesh.myPeerID, state.getNicknameValue()) } else { // Send public/channel message via mesh val message = BitchatMessage( - sender = state.getNicknameValue() ?: meshService.myPeerID, + sender = state.getNicknameValue() ?: mesh.myPeerID, content = content, timestamp = Date(), isRelay = false, - senderPeerID = meshService.myPeerID, + senderPeerID = mesh.myPeerID, mentions = if (mentions.isNotEmpty()) mentions else null, channel = currentChannelValue ) if (currentChannelValue != null) { - channelManager.addChannelMessage(currentChannelValue, message, meshService.myPeerID) + channelManager.addChannelMessage(currentChannelValue, message, mesh.myPeerID) // Check if encrypted channel if (channelManager.hasChannelKey(currentChannelValue)) { @@ -511,25 +575,20 @@ class ChatViewModel( mentions, currentChannelValue, state.getNicknameValue(), - meshService.myPeerID, + mesh.myPeerID, onEncryptedPayload = { encryptedData -> - // Send encrypted payload announcement over both transports for reachability - meshService.sendMessage(content, mentions, currentChannelValue) - try { com.bitchat.android.wifiaware.WifiAwareController.getService()?.sendMessage(content, mentions, currentChannelValue) } catch (_: Exception) {} + mesh.sendMessage(content, mentions, currentChannelValue) }, onFallback = { - meshService.sendMessage(content, mentions, currentChannelValue) - try { com.bitchat.android.wifiaware.WifiAwareController.getService()?.sendMessage(content, mentions, currentChannelValue) } catch (_: Exception) {} + mesh.sendMessage(content, mentions, currentChannelValue) } ) } else { - meshService.sendMessage(content, mentions, currentChannelValue) - try { com.bitchat.android.wifiaware.WifiAwareController.getService()?.sendMessage(content, mentions, currentChannelValue) } catch (_: Exception) {} + mesh.sendMessage(content, mentions, currentChannelValue) } } else { messageManager.addMessage(message) - meshService.sendMessage(content, mentions, null) - try { com.bitchat.android.wifiaware.WifiAwareController.getService()?.sendMessage(content, mentions, null) } catch (_: Exception) {} + mesh.sendMessage(content, mentions, null) } } } @@ -538,7 +597,7 @@ class ChatViewModel( // MARK: - Utility Functions fun getPeerIDForNickname(nickname: String): String? { - return meshService.getPeerNicknames().entries.find { it.value == nickname }?.key + return mesh.getPeerNicknames().entries.find { it.value == nickname }?.key } fun toggleFavorite(peerID: String) { @@ -548,10 +607,10 @@ class ChatViewModel( // Persist relationship in FavoritesPersistenceService try { var noiseKey: ByteArray? = null - var nickname: String = meshService.getPeerNicknames()[peerID] ?: peerID + var nickname: String = mesh.getPeerNicknames()[peerID] ?: peerID // Case 1: Live mesh peer with known info - val peerInfo = meshService.getPeerInfo(peerID) + val peerInfo = mesh.getPeerInfo(peerID) if (peerInfo?.noisePublicKey != null) { noiseKey = peerInfo.noisePublicKey nickname = peerInfo.nickname @@ -581,22 +640,9 @@ class ChatViewModel( // Send favorite notification via mesh or Nostr with our npub if available try { - val myNostr = com.bitchat.android.nostr.NostrIdentityBridge.getCurrentNostrIdentity(getApplication()) - val announcementContent = if (isNowFavorite) "[FAVORITED]:${myNostr?.npub ?: ""}" else "[UNFAVORITED]:${myNostr?.npub ?: ""}" - // Prefer mesh if session established, else try Nostr - if (meshService.hasEstablishedSession(peerID)) { - // Reuse existing private message path for notifications - meshService.sendPrivateMessage( - announcementContent, - peerID, - nickname, - java.util.UUID.randomUUID().toString() - ) - } else { - val nostrTransport = com.bitchat.android.nostr.NostrTransport.getInstance(getApplication()) - nostrTransport.senderPeerID = meshService.myPeerID - nostrTransport.sendFavoriteNotification(peerID, isNowFavorite) - } + com.bitchat.android.services.MessageRouter + .getInstance(getApplication(), mesh) + .sendFavoriteNotification(peerID, isNowFavorite) } catch (_: Exception) { } } } catch (_: Exception) { } @@ -612,6 +658,40 @@ class ChatViewModel( Log.i("ChatViewModel", "Peer fingerprints: ${privateChatManager.getAllPeerFingerprints()}") Log.i("ChatViewModel", "==============================") } + + private fun isConnectedOnMesh(peerID: String): Boolean { + return try { + mesh.getPeerInfo(peerID)?.isConnected == true + } catch (_: Exception) { + false + } + } + + private fun hasEstablishedSessionOnMesh(peerID: String): Boolean { + return try { + mesh.getPeerInfo(peerID)?.isConnected == true && + mesh.hasEstablishedSession(peerID) + } catch (_: Exception) { + false + } + } + + private fun hasEstablishedSessionOnAnyLocalTransport(peerID: String): Boolean { + return hasEstablishedSessionOnMesh(peerID) + } + + private fun initiateNoiseHandshakeOnBestLocalTransport(peerID: String) { + mesh.initiateNoiseHandshake(peerID) + } + + private fun nicknameForPeer(peerID: String): String? { + return state.peerNicknames.value[peerID] + ?: try { mesh.getPeerNicknames()[peerID] } catch (_: Exception) { null } + } + + private fun sessionStateForPeer(peerID: String): NoiseSession.NoiseSessionState { + return try { mesh.getSessionState(peerID) } catch (_: Exception) { NoiseSession.NoiseSessionState.Uninitialized } + } /** * Initialize session state monitoring for reactive UI updates @@ -636,7 +716,7 @@ class ChatViewModel( // Update session states val prevStates = state.getPeerSessionStatesValue() val sessionStates = currentPeers.associateWith { peerID -> - meshService.getSessionState(peerID).toString() + sessionStateForPeer(peerID).toString() } state.setPeerSessionStates(sessionStates) // Detect new established sessions and flush router outbox for them and their noiseHex aliases @@ -644,77 +724,154 @@ class ChatViewModel( val old = prevStates[peerID] if (old != "established" && newState == "established") { com.bitchat.android.services.MessageRouter - .getInstance(getApplication(), meshService) + .getInstance(getApplication(), mesh) .onSessionEstablished(peerID) } } // Update fingerprint mappings from centralized manager val fingerprints = privateChatManager.getAllPeerFingerprints() state.setPeerFingerprints(fingerprints) + fingerprints.forEach { (peerID, fingerprint) -> + identityManager.cachePeerFingerprint(peerID, fingerprint) + val info = try { mesh.getPeerInfo(peerID) } catch (_: Exception) { null } + val noiseKeyHex = info?.noisePublicKey?.hexEncodedString() + if (noiseKeyHex != null) { + identityManager.cachePeerNoiseKey(peerID, noiseKeyHex) + identityManager.cacheNoiseFingerprint(noiseKeyHex, fingerprint) + } + info?.nickname?.takeIf { it.isNotBlank() }?.let { nickname -> + identityManager.cacheFingerprintNickname(fingerprint, nickname) + } + } - // Merge nicknames from BLE and Wi‑Fi Aware to display names for all peers - val bleNick = meshService.getPeerNicknames() - val awareNickRaw = try { com.bitchat.android.wifiaware.WifiAwareController.getService()?.getPeerNicknamesMap() } catch (_: Exception) { null } - val mergedNick = if (awareNickRaw != null) bleNick + awareNickRaw.filter { it.value != null }.mapValues { it.value!! }.filterKeys { it !in bleNick || bleNick[it].isNullOrBlank() } else bleNick - state.setPeerNicknames(mergedNick) + state.setPeerNicknames(mesh.getPeerNicknames()) - val rssiValues = meshService.getPeerRSSI() - val awareRssi = try { com.bitchat.android.wifiaware.WifiAwareController.getService()?.getPeerRSSI() } catch (_: Exception) { null } - val mergedRssi = if (awareRssi != null) rssiValues + awareRssi.filterKeys { it !in rssiValues } else rssiValues - state.setPeerRSSI(mergedRssi) + state.setPeerRSSI(mesh.getPeerRSSI()) // Update directness per peer (driven by PeerManager state) try { val directMap = state.getConnectedPeersValue().associateWith { pid -> - val ble = meshService.getPeerInfo(pid)?.isDirectConnection == true - val aware = try { com.bitchat.android.wifiaware.WifiAwareController.getService()?.getPeerInfo(pid)?.isDirectConnection == true } catch (_: Exception) { false } - ble || aware + mesh.getPeerInfo(pid)?.isDirectConnection == true } state.setPeerDirect(directMap) } catch (_: Exception) { } + + // Flush any pending QR verification once a Noise session is established + currentPeers.forEach { peerID -> + if (sessionStateForPeer(peerID) is NoiseSession.NoiseSessionState.Established) { + verificationHandler.sendPendingVerificationIfNeeded(peerID) + } + } + } + + // MARK: - QR Verification + + fun isPeerVerified(peerID: String, verifiedFingerprints: Set): Boolean { + if (peerID.startsWith("nostr_") || peerID.startsWith("nostr:")) return false + val fingerprint = verificationHandler.getPeerFingerprintForDisplay(peerID) + return fingerprint != null && verifiedFingerprints.contains(fingerprint) + } + + fun isNoisePublicKeyVerified(noisePublicKey: ByteArray, verifiedFingerprints: Set): Boolean { + val fingerprint = verificationHandler.fingerprintFromNoiseBytes(noisePublicKey) + return verifiedFingerprints.contains(fingerprint) + } + + fun unverifyFingerprint(peerID: String) { + verificationHandler.unverifyFingerprint(peerID) + } + + fun beginQRVerification(qr: VerificationService.VerificationQR): Boolean { + return verificationHandler.beginQRVerification(qr) } // MARK: - Debug and Troubleshooting fun getDebugStatus(): String { - return meshService.getDebugStatus() - } - - // Note: Mesh service restart is now handled by MainActivity - // This function is no longer needed - - fun setAppBackgroundState(inBackground: Boolean) { - // Forward to notification manager for notification logic - notificationManager.setAppBackgroundState(inBackground) + return mesh.getDebugStatus() } fun setCurrentPrivateChatPeer(peerID: String?) { - // Update notification manager with current private chat peer notificationManager.setCurrentPrivateChatPeer(peerID) } fun setCurrentGeohash(geohash: String?) { - // Update notification manager with current geohash for notification logic notificationManager.setCurrentGeohash(geohash) } fun clearNotificationsForSender(peerID: String) { - // Clear notifications when user opens a chat notificationManager.clearNotificationsForSender(peerID) } fun clearNotificationsForGeohash(geohash: String) { - // Clear notifications when user opens a geohash chat notificationManager.clearNotificationsForGeohash(geohash) } - /** - * Clear mesh mention notifications when user opens mesh chat - */ fun clearMeshMentionNotifications() { notificationManager.clearMeshMentionNotifications() } + private var reopenSidebarAfterVerification = false + + fun showVerificationSheet(fromSidebar: Boolean = false) { + if (fromSidebar) { + reopenSidebarAfterVerification = true + } + state.setShowVerificationSheet(true) + } + + fun hideVerificationSheet() { + state.setShowVerificationSheet(false) + if (reopenSidebarAfterVerification) { + reopenSidebarAfterVerification = false + state.setShowMeshPeerList(true) + } + } + + fun showSecurityVerificationSheet() { + state.setShowSecurityVerificationSheet(true) + } + + fun hideSecurityVerificationSheet() { + state.setShowSecurityVerificationSheet(false) + } + + fun showMeshPeerList() { + state.setShowMeshPeerList(true) + } + + fun hideMeshPeerList() { + state.setShowMeshPeerList(false) + } + + fun showPrivateChatSheet(peerID: String) { + state.setPrivateChatSheetPeer(peerID) + } + + fun hidePrivateChatSheet() { + state.setPrivateChatSheetPeer(null) + } + + fun getPeerFingerprintForDisplay(peerID: String): String? { + return verificationHandler.getPeerFingerprintForDisplay(peerID) + } + + fun getMyFingerprint(): String { + return verificationHandler.getMyFingerprint() + } + + fun resolvePeerDisplayNameForFingerprint(peerID: String): String { + return verificationHandler.resolvePeerDisplayNameForFingerprint(peerID) + } + + fun verifyFingerprintValue(fingerprint: String) { + verificationHandler.verifyFingerprintValue(fingerprint) + } + + fun unverifyFingerprintValue(fingerprint: String) { + verificationHandler.unverifyFingerprintValue(fingerprint) + } + // MARK: - Command Autocomplete (delegated) fun updateCommandSuggestions(input: String) { @@ -728,7 +885,7 @@ class ChatViewModel( // MARK: - Mention Autocomplete fun updateMentionSuggestions(input: String) { - commandProcessor.updateMentionSuggestions(input, meshService, this) + commandProcessor.updateMentionSuggestions(input, mesh, this) } fun selectMentionSuggestion(nickname: String, currentText: String): String { @@ -745,10 +902,6 @@ class ChatViewModel( meshDelegateHandler.didUpdatePeerList(peers) } - fun onWifiPeersUpdated(peers: List) { - meshDelegateHandler.onWifiPeersUpdated(peers) - } - override fun didReceiveChannelLeave(channel: String, fromPeer: String) { meshDelegateHandler.didReceiveChannelLeave(channel, fromPeer) } @@ -760,6 +913,14 @@ class ChatViewModel( override fun didReceiveReadReceipt(messageID: String, recipientPeerID: String) { meshDelegateHandler.didReceiveReadReceipt(messageID, recipientPeerID) } + + override fun didReceiveVerifyChallenge(peerID: String, payload: ByteArray, timestampMs: Long) { + verificationHandler.didReceiveVerifyChallenge(peerID, payload) + } + + override fun didReceiveVerifyResponse(peerID: String, payload: ByteArray, timestampMs: Long) { + verificationHandler.didReceiveVerifyResponse(peerID, payload) + } override fun decryptChannelMessage(encryptedContent: ByteArray, channel: String): String? { return meshDelegateHandler.decryptChannelMessage(encryptedContent, channel) @@ -786,6 +947,11 @@ class ChatViewModel( privateChatManager.clearAllPrivateChats() dataManager.clearAllData() + // Clear seen message store + try { + com.bitchat.android.services.SeenMessageStore.getInstance(getApplication()).clear() + } catch (_: Exception) { } + // Clear all mesh service data clearAllMeshServiceData() @@ -794,6 +960,9 @@ class ChatViewModel( // Clear all notifications notificationManager.clearAllNotifications() + + // Clear all media files + com.bitchat.android.features.file.FileUtils.clearAllMedia(getApplication()) // Clear Nostr/geohash state, keys, connections, bookmarks, and reinitialize from scratch try { @@ -813,10 +982,39 @@ class ChatViewModel( state.setNickname(newNickname) dataManager.saveNickname(newNickname) - Log.w(TAG, "🚨 PANIC MODE COMPLETED - All sensitive data cleared") - - // Note: Mesh service restart is now handled by MainActivity - // This method now only clears data, not mesh service lifecycle + // Recreate mesh service with fresh identity + recreateMeshServiceAfterPanic() + + Log.w(TAG, "🚨 PANIC MODE COMPLETED - New identity: ${mesh.myPeerID}") + } + + /** + * Recreate the mesh service with a fresh identity after panic clear. + * This ensures the new cryptographic keys are used for a new peer ID. + */ + private fun recreateMeshServiceAfterPanic() { + val oldPeerID = mesh.myPeerID + + // Clear the holder so getOrCreate() returns a fresh instance + MeshServiceHolder.clear() + + // Create fresh mesh service with new identity (keys were regenerated in clearAllCryptographicData) + val freshMeshService = MeshServiceHolder.getOrCreate(getApplication()) + val freshUnifiedMeshService = MeshServiceHolder.getUnifiedOrCreate(getApplication()) + + // Replace our reference and set up the new service + meshService = freshMeshService + unifiedMeshService = freshUnifiedMeshService + mesh.delegate = this + + // Restart mesh operations with new identity + mesh.startServices() + mesh.sendBroadcastAnnounce() + + Log.d( + TAG, + "✅ Mesh service recreated. Old peerID: $oldPeerID, New peerID: ${mesh.myPeerID}" + ) } /** @@ -825,7 +1023,7 @@ class ChatViewModel( private fun clearAllMeshServiceData() { try { // Request mesh service to clear all its internal data - meshService.clearAllInternalData() + mesh.clearAllInternalData() Log.d(TAG, "✅ Cleared all mesh service data") } catch (e: Exception) { @@ -839,11 +1037,11 @@ class ChatViewModel( private fun clearAllCryptographicData() { try { // Clear encryption service persistent identity (Ed25519 signing keys) - meshService.clearAllEncryptionData() + mesh.clearAllEncryptionData() // Clear secure identity state (if used) try { - val identityManager = com.bitchat.android.identity.SecureIdentityStateManager(getApplication()) + val identityManager = SecureIdentityStateManager(getApplication()) identityManager.clearIdentityData() // Also clear secure values used by FavoritesPersistenceService (favorites + peerID index) try { @@ -856,7 +1054,7 @@ class ChatViewModel( // Clear FavoritesPersistenceService persistent relationships try { - com.bitchat.android.favorites.FavoritesPersistenceService.shared.clearAllFavorites() + FavoritesPersistenceService.shared.clearAllFavorites() Log.d(TAG, "✅ Cleared FavoritesPersistenceService relationships") } catch (_: Exception) { } @@ -884,7 +1082,7 @@ class ChatViewModel( * End geohash sampling */ fun endGeohashSampling() { - // No-op in refactored architecture; sampling subscriptions are short-lived + geohashViewModel.endGeohashSampling() } /** @@ -899,7 +1097,19 @@ class ChatViewModel( */ fun startGeohashDM(pubkeyHex: String) { geohashViewModel.startGeohashDM(pubkeyHex) { convKey -> - startPrivateChat(convKey) + showPrivateChatSheet(convKey) + } + } + + fun startGeohashDMByNickname(nickname: String) { + geohashViewModel.startGeohashDMByNickname(nickname) { convKey -> + showPrivateChatSheet(convKey) + } + } + + fun startGeohashDMByShortId(shortId: String) { + geohashViewModel.startGeohashDMByShortId(shortId) { convKey -> + showPrivateChatSheet(convKey) } } @@ -923,15 +1133,7 @@ class ChatViewModel( fun hideAppInfo() { state.setShowAppInfo(false) } - - fun showSidebar() { - state.setShowSidebar(true) - } - - fun hideSidebar() { - state.setShowSidebar(false) - } - + /** * Handle Android back navigation * Returns true if the back press was handled, false if it should be passed to the system @@ -943,11 +1145,6 @@ class ChatViewModel( hideAppInfo() true } - // Close sidebar - state.getShowSidebarValue() -> { - hideSidebar() - true - } // Close password dialog state.getShowPasswordPromptValue() -> { state.setShowPasswordPrompt(false) @@ -955,7 +1152,7 @@ class ChatViewModel( true } // Exit private chat - state.getSelectedPrivateChatPeerValue() != null -> { + state.getSelectedPrivateChatPeerValue() != null || state.getPrivateChatSheetPeerValue() != null -> { endPrivateChat() true } @@ -985,5 +1182,6 @@ class ChatViewModel( */ fun colorForNostrPubkey(pubkeyHex: String, isDark: Boolean): androidx.compose.ui.graphics.Color { return geohashViewModel.colorForNostrPubkey(pubkeyHex, isDark) - } +} + } diff --git a/app/src/main/java/com/bitchat/android/ui/CommandProcessor.kt b/app/src/main/java/com/bitchat/android/ui/CommandProcessor.kt index 499b3926..a92d2d60 100644 --- a/app/src/main/java/com/bitchat/android/ui/CommandProcessor.kt +++ b/app/src/main/java/com/bitchat/android/ui/CommandProcessor.kt @@ -1,6 +1,6 @@ package com.bitchat.android.ui -import com.bitchat.android.mesh.BluetoothMeshService +import com.bitchat.android.mesh.MeshService import com.bitchat.android.model.BitchatMessage import java.util.Date @@ -29,21 +29,21 @@ class CommandProcessor( // MARK: - Command Processing - fun processCommand(command: String, meshService: BluetoothMeshService, myPeerID: String, onSendMessage: (String, List, String?) -> Unit, viewModel: ChatViewModel? = null): Boolean { + fun processCommand(command: String, meshService: MeshService, myPeerID: String, onSendMessage: (String, List, String?) -> Unit, viewModel: ChatViewModel? = null): Boolean { if (!command.startsWith("/")) return false val parts = command.split(" ") val cmd = parts.first().lowercase() when (cmd) { "/j", "/join" -> handleJoinCommand(parts, myPeerID) - "/m", "/msg" -> handleMessageCommand(parts, meshService) + "/m", "/msg" -> handleMessageCommand(parts, meshService, viewModel) "/w" -> handleWhoCommand(meshService, viewModel) "/clear" -> handleClearCommand() "/pass" -> handlePassCommand(parts, myPeerID) "/block" -> handleBlockCommand(parts, meshService) "/unblock" -> handleUnblockCommand(parts, meshService) - "/hug" -> handleActionCommand(parts, "gives", "a warm hug 🫂", meshService, myPeerID, onSendMessage) - "/slap" -> handleActionCommand(parts, "slaps", "around a bit with a large trout 🐟", meshService, myPeerID, onSendMessage) + "/hug" -> handleActionCommand(parts, "gives", "a warm hug 🫂", meshService, myPeerID, onSendMessage, viewModel) + "/slap" -> handleActionCommand(parts, "slaps", "around a bit with a large trout 🐟", meshService, myPeerID, onSendMessage, viewModel) "/channels" -> handleChannelsCommand() else -> handleUnknownCommand(cmd) } @@ -77,7 +77,7 @@ class CommandProcessor( } } - private fun handleMessageCommand(parts: List, meshService: BluetoothMeshService) { + private fun handleMessageCommand(parts: List, meshService: MeshService, viewModel: ChatViewModel?) { if (parts.size > 1) { val targetName = parts[1].removePrefix("@") val peerID = getPeerIDForNickname(targetName, meshService) @@ -96,8 +96,7 @@ class CommandProcessor( state.getNicknameValue(), getMyPeerID(meshService) ) { content, peerIdParam, recipientNicknameParam, messageId -> - // This would trigger the actual mesh service send - sendPrivateMessageVia(meshService, content, peerIdParam, recipientNicknameParam, messageId) + sendPrivateMessageVia(meshService, content, peerIdParam, recipientNicknameParam, messageId, viewModel) } } else { val systemMessage = BitchatMessage( @@ -129,7 +128,7 @@ class CommandProcessor( } } - private fun handleWhoCommand(meshService: BluetoothMeshService, viewModel: ChatViewModel? = null) { + private fun handleWhoCommand(meshService: MeshService, viewModel: ChatViewModel? = null) { // Channel-aware who command (matches iOS behavior) val (peerList, contextDescription) = if (viewModel != null) { when (val selectedChannel = viewModel.selectedLocationChannel.value) { @@ -248,7 +247,7 @@ class CommandProcessor( } } - private fun handleBlockCommand(parts: List, meshService: BluetoothMeshService) { + private fun handleBlockCommand(parts: List, meshService: MeshService) { if (parts.size > 1) { val targetName = parts[1].removePrefix("@") privateChatManager.blockPeerByNickname(targetName, meshService) @@ -265,7 +264,7 @@ class CommandProcessor( } } - private fun handleUnblockCommand(parts: List, meshService: BluetoothMeshService) { + private fun handleUnblockCommand(parts: List, meshService: MeshService) { if (parts.size > 1) { val targetName = parts[1].removePrefix("@") privateChatManager.unblockPeerByNickname(targetName, meshService) @@ -284,9 +283,10 @@ class CommandProcessor( parts: List, verb: String, object_: String, - meshService: BluetoothMeshService, + meshService: MeshService, myPeerID: String, - onSendMessage: (String, List, String?) -> Unit + onSendMessage: (String, List, String?) -> Unit, + viewModel: ChatViewModel? ) { if (parts.size > 1) { val targetName = parts[1].removePrefix("@") @@ -306,7 +306,7 @@ class CommandProcessor( state.getNicknameValue(), myPeerID ) { content, peerIdParam, recipientNicknameParam, messageId -> - sendPrivateMessageVia(meshService, content, peerIdParam, recipientNicknameParam, messageId) + sendPrivateMessageVia(meshService, content, peerIdParam, recipientNicknameParam, messageId, viewModel) } } else if (isInLocationChannel) { // Let the transport layer add the echo; just send it out @@ -423,7 +423,7 @@ class CommandProcessor( // MARK: - Mention Autocomplete - fun updateMentionSuggestions(input: String, meshService: BluetoothMeshService, viewModel: ChatViewModel? = null) { + fun updateMentionSuggestions(input: String, meshService: MeshService, viewModel: ChatViewModel? = null) { // Check if input contains @ and we're at the end of a word or at the end of input val atIndex = input.lastIndexOf('@') if (atIndex == -1) { @@ -503,19 +503,33 @@ class CommandProcessor( // MARK: - Utility Functions - private fun getPeerIDForNickname(nickname: String, meshService: BluetoothMeshService): String? { + private fun getPeerIDForNickname(nickname: String, meshService: MeshService): String? { return meshService.getPeerNicknames().entries.find { it.value == nickname }?.key } - private fun getPeerNickname(peerID: String, meshService: BluetoothMeshService): String { - return meshService.getPeerNicknames()[peerID] ?: peerID + private fun getPeerNickname(peerID: String, meshService: MeshService): String { + return meshService.getPeerNicknames()[peerID] + ?: peerID } - private fun getMyPeerID(meshService: BluetoothMeshService): String { + private fun getMyPeerID(meshService: MeshService): String { return meshService.myPeerID } - private fun sendPrivateMessageVia(meshService: BluetoothMeshService, content: String, peerID: String, recipientNickname: String, messageId: String) { - meshService.sendPrivateMessage(content, peerID, recipientNickname, messageId) + private fun sendPrivateMessageVia( + meshService: MeshService, + content: String, + peerID: String, + recipientNickname: String, + messageId: String, + viewModel: ChatViewModel? + ) { + if (viewModel != null) { + com.bitchat.android.services.MessageRouter + .getInstance(viewModel.getApplication(), meshService) + .sendPrivate(content, peerID, recipientNickname, messageId) + } else { + meshService.sendPrivateMessage(content, peerID, recipientNickname, messageId) + } } } diff --git a/app/src/main/java/com/bitchat/android/ui/GeohashViewModel.kt b/app/src/main/java/com/bitchat/android/ui/GeohashViewModel.kt index 52e00edf..09b32d4f 100644 --- a/app/src/main/java/com/bitchat/android/ui/GeohashViewModel.kt +++ b/app/src/main/java/com/bitchat/android/ui/GeohashViewModel.kt @@ -3,6 +3,10 @@ package com.bitchat.android.ui import android.app.Application import android.util.Log import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.ProcessLifecycleOwner import androidx.lifecycle.viewModelScope import com.bitchat.android.nostr.GeohashMessageHandler import com.bitchat.android.nostr.GeohashRepository @@ -12,11 +16,18 @@ import com.bitchat.android.nostr.NostrProtocol import com.bitchat.android.nostr.NostrRelayManager import com.bitchat.android.nostr.NostrSubscriptionManager import com.bitchat.android.nostr.PoWPreferenceManager +import com.bitchat.android.nostr.GeohashAliasRegistry +import com.bitchat.android.nostr.GeohashConversationRegistry import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch import java.util.Date +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.isActive +import kotlinx.coroutines.Dispatchers +import java.security.SecureRandom +import kotlin.random.asKotlinRandom class GeohashViewModel( application: Application, @@ -26,9 +37,12 @@ class GeohashViewModel( private val meshDelegateHandler: MeshDelegateHandler, private val dataManager: DataManager, private val notificationManager: NotificationManager -) : AndroidViewModel(application) { +) : AndroidViewModel(application), DefaultLifecycleObserver { - companion object { private const val TAG = "GeohashViewModel" } + companion object { + private const val TAG = "GeohashViewModel" + private val secureRandom = SecureRandom().asKotlinRandom() + } private val repo = GeohashRepository(application, state, dataManager) private val subscriptionManager = NostrSubscriptionManager(application, viewModelScope) @@ -53,7 +67,9 @@ class GeohashViewModel( private var currentGeohashSubId: String? = null private var currentDmSubId: String? = null private var geoTimer: Job? = null + private var globalPresenceJob: Job? = null private var locationChannelManager: com.bitchat.android.geohash.LocationChannelManager? = null + private val activeSamplingGeohashes = mutableSetOf() val geohashPeople: StateFlow> = state.geohashPeople val geohashParticipantCounts: StateFlow> = state.geohashParticipantCounts @@ -61,6 +77,10 @@ class GeohashViewModel( fun initialize() { subscriptionManager.connect() + // Observe process lifecycle to manage background sampling + kotlin.runCatching { + ProcessLifecycleOwner.get().lifecycle.addObserver(this) + } val identity = NostrIdentityBridge.getCurrentNostrIdentity(getApplication()) if (identity != null) { // Use global chat-messages only for full account DMs (mesh context). For geohash DMs, subscribe per-geohash below. @@ -84,6 +104,10 @@ class GeohashViewModel( state.setIsTeleported(teleported) } } + + // Start global presence heartbeat loop + startGlobalPresenceHeartbeat() + } catch (e: Exception) { Log.e(TAG, "Failed to initialize location channel state: ${e.message}") state.setSelectedLocationChannel(com.bitchat.android.geohash.ChannelID.Mesh) @@ -91,17 +115,77 @@ class GeohashViewModel( } } + private fun startGlobalPresenceHeartbeat() { + globalPresenceJob?.cancel() + globalPresenceJob = viewModelScope.launch(kotlinx.coroutines.Dispatchers.IO) { + // Reactively restart heartbeat whenever available channels change + locationChannelManager?.availableChannels?.collectLatest { channels -> + // Filter for REGION (2), PROVINCE (4), CITY (5) - precision <= 5 + val targetGeohashes = channels.filter { it.level.precision <= 5 }.map { it.geohash } + + if (targetGeohashes.isNotEmpty()) { + // Enter heartbeat loop for this set of channels + // If channels change (e.g. user moves), collectLatest cancels this loop and starts a new one immediately + while (true) { + // Randomize loop interval (40-80s, average 60s) + val loopInterval = secureRandom.nextLong(40000L, 80000L) + var timeSpent = 0L + + try { + Log.v(TAG, "💓 Broadcasting global presence to ${targetGeohashes.size} channels") + targetGeohashes.forEach { geohash -> + // Decorrelate individual broadcasts with random delay (1s-5s) + val stepDelay = secureRandom.nextLong(1000L, 10000L) + delay(stepDelay) + timeSpent += stepDelay + + broadcastPresence(geohash) + } + } catch (e: Exception) { + Log.w(TAG, "Global presence heartbeat error: ${e.message}") + } + + // Wait remaining time to satisfy target average cadence + val remaining = loopInterval - timeSpent + if (remaining > 0) { + delay(remaining) + } else { + delay(10000L) // Minimum guard delay + } + } + } + } + } + } + fun panicReset() { repo.clearAll() + GeohashAliasRegistry.clear() + GeohashConversationRegistry.clear() subscriptionManager.disconnect() currentGeohashSubId = null currentDmSubId = null geoTimer?.cancel() geoTimer = null + globalPresenceJob?.cancel() + globalPresenceJob = null try { NostrIdentityBridge.clearAllAssociations(getApplication()) } catch (_: Exception) {} initialize() } + private suspend fun broadcastPresence(geohash: String) { + try { + val identity = NostrIdentityBridge.deriveIdentity(geohash, getApplication()) + val event = NostrProtocol.createGeohashPresenceEvent(geohash, identity) + val relayManager = NostrRelayManager.getInstance(getApplication()) + // Presence is lightweight, send to geohash relays + relayManager.sendEventToGeohash(event, geohash, includeDefaults = false, nRelays = 5) + Log.v(TAG, "💓 Sent presence heartbeat for $geohash") + } catch (e: Exception) { + Log.w(TAG, "Failed to send presence for $geohash: ${e.message}") + } + } + fun sendGeohashMessage(content: String, channel: com.bitchat.android.geohash.GeohashChannel, myPeerID: String, nickname: String?) { viewModelScope.launch { try { @@ -141,22 +225,46 @@ class GeohashViewModel( } fun beginGeohashSampling(geohashes: List) { - if (geohashes.isEmpty()) return - Log.d(TAG, "🌍 Beginning geohash sampling for ${geohashes.size} geohashes") - viewModelScope.launch { - geohashes.forEach { geohash -> - subscriptionManager.subscribeGeohash( - geohash = geohash, - sinceMs = System.currentTimeMillis() - 86400000L, - limit = 200, - id = "sampling-$geohash", - handler = { event -> geohashMessageHandler.onEvent(event, geohash) } - ) + if (geohashes.isEmpty()) { + endGeohashSampling() + return + } + + // Diffing logic to avoid redundant REQ and leaks + val currentSet = activeSamplingGeohashes.toSet() + val newSet = geohashes.toSet() + + val toRemove = currentSet - newSet + val toAdd = newSet - currentSet + + if (toAdd.isEmpty() && toRemove.isEmpty()) return + + Log.d(TAG, "🌍 Updating sampling: +${toAdd.size} new, -${toRemove.size} removed") + + // Remove old subscriptions + toRemove.forEach { geohash -> + subscriptionManager.unsubscribe("sampling-$geohash") + activeSamplingGeohashes.remove(geohash) + } + + // Add new subscriptions + activeSamplingGeohashes.addAll(toAdd) + if (isAppInForeground()) { + toAdd.forEach { geohash -> + performSubscribeSampling(geohash) } } } - fun endGeohashSampling() { Log.d(TAG, "🌍 Ending geohash sampling") } + fun endGeohashSampling() { + if (activeSamplingGeohashes.isEmpty()) return + Log.d(TAG, "🌍 Ending geohash sampling (cleaning up ${activeSamplingGeohashes.size} subs)") + + activeSamplingGeohashes.toList().forEach { geohash -> + subscriptionManager.unsubscribe("sampling-$geohash") + } + activeSamplingGeohashes.clear() + } fun geohashParticipantCount(geohash: String): Int = repo.geohashParticipantCount(geohash) fun isPersonTeleported(pubkeyHex: String): Boolean = repo.isPersonTeleported(pubkeyHex) @@ -168,12 +276,31 @@ class GeohashViewModel( val gh = (current as? com.bitchat.android.geohash.ChannelID.Location)?.channel?.geohash if (!gh.isNullOrEmpty()) { repo.setConversationGeohash(convKey, gh) - com.bitchat.android.nostr.GeohashConversationRegistry.set(convKey, gh) + GeohashConversationRegistry.set(convKey, gh) } onStartPrivateChat(convKey) Log.d(TAG, "🗨️ Started geohash DM with ${pubkeyHex} -> ${convKey} (geohash=${gh})") } + fun startGeohashDMByNickname(nickname: String, onStartPrivateChat: (String) -> Unit) { + val pubkey = repo.findPubkeyByNickname(nickname) + if (pubkey != null) { + startGeohashDM(pubkey, onStartPrivateChat) + } else { + Log.w(TAG, "Cannot start geohash DM: nickname '$nickname' not found in repo") + // Optionally notify user + } + } + + fun startGeohashDMByShortId(shortId: String, onStartPrivateChat: (String) -> Unit) { + val pubkey = repo.findPubkeyByShortId(shortId) + if (pubkey != null) { + startGeohashDM(pubkey, onStartPrivateChat) + } else { + Log.w(TAG, "Cannot start geohash DM: shortId '$shortId' not found in repo") + } + } + fun getNostrKeyMapping(): Map = repo.getNostrKeyMapping() fun blockUserInGeohash(targetNickname: String) { @@ -206,6 +333,7 @@ class GeohashViewModel( } fun displayNameForNostrPubkeyUI(pubkeyHex: String): String = repo.displayNameForNostrPubkeyUI(pubkeyHex) + fun displayNameForGeohashConversation(pubkeyHex: String, sourceGeohash: String): String = repo.displayNameForGeohashConversation(pubkeyHex, sourceGeohash) fun colorForNostrPubkey(pubkeyHex: String, isDark: Boolean): androidx.compose.ui.graphics.Color { val seed = "nostr:${pubkeyHex.lowercase()}" @@ -234,7 +362,7 @@ class GeohashViewModel( try { val identity = NostrIdentityBridge.deriveIdentity(channel.channel.geohash, getApplication()) - repo.updateParticipant(channel.channel.geohash, identity.publicKeyHex, Date()) + // We don't update participant here anymore; presence loop handles it via Kind 20001 val teleported = state.isTeleported.value if (teleported) repo.markTeleported(identity.publicKeyHex) } catch (e: Exception) { Log.w(TAG, "Failed identity setup: ${e.message}") } @@ -260,7 +388,7 @@ class GeohashViewModel( handler = { event -> dmHandler.onGiftWrap(event, geohash, dmIdentity) } ) // Also register alias in global registry for routing convenience - com.bitchat.android.nostr.GeohashAliasRegistry.put("nostr_${dmIdentity.publicKeyHex.take(16)}", dmIdentity.publicKeyHex) + GeohashAliasRegistry.put("nostr_${dmIdentity.publicKeyHex.take(16)}", dmIdentity.publicKeyHex) } } null -> { @@ -279,4 +407,35 @@ class GeohashViewModel( } } } + + override fun onCleared() { + super.onCleared() + kotlin.runCatching { + ProcessLifecycleOwner.get().lifecycle.removeObserver(this) + } + } + + override fun onStart(owner: LifecycleOwner) { + Log.d(TAG, "🌍 App foregrounded: Resuming sampling for ${activeSamplingGeohashes.size} geohashes") + activeSamplingGeohashes.forEach { performSubscribeSampling(it) } + } + + override fun onStop(owner: LifecycleOwner) { + Log.d(TAG, "🌍 App backgrounded: Pausing sampling for ${activeSamplingGeohashes.size} geohashes") + activeSamplingGeohashes.forEach { subscriptionManager.unsubscribe("sampling-$it") } + } + + private fun performSubscribeSampling(geohash: String) { + subscriptionManager.subscribeGeohash( + geohash = geohash, + sinceMs = System.currentTimeMillis() - 86400000L, + limit = 200, + id = "sampling-$geohash", + handler = { event -> geohashMessageHandler.onEvent(event, geohash) } + ) + } + + private fun isAppInForeground(): Boolean { + return ProcessLifecycleOwner.get().lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED) + } } diff --git a/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt b/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt index 6f47d68f..99ed10cc 100644 --- a/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt @@ -4,7 +4,6 @@ import android.content.Intent import android.net.Uri import android.provider.Settings import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.foundation.background import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items @@ -39,7 +38,9 @@ import com.bitchat.android.ui.theme.BASE_FONT_SIZE import androidx.compose.ui.res.stringResource import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.bitchat.android.R -import com.bitchat.android.core.ui.component.button.CloseButton +import com.bitchat.android.core.ui.component.sheet.BitchatBottomSheet +import com.bitchat.android.core.ui.component.sheet.BitchatSheetTopBar +import com.bitchat.android.core.ui.component.sheet.BitchatSheetTitle /** * Location Channels Sheet for selecting geohash-based location channels @@ -62,7 +63,9 @@ fun LocationChannelsSheet( val availableChannels by locationManager.availableChannels.collectAsStateWithLifecycle() val selectedChannel by locationManager.selectedChannel.collectAsStateWithLifecycle() val locationNames by locationManager.locationNames.collectAsStateWithLifecycle() - val locationServicesEnabled by locationManager.locationServicesEnabled.collectAsStateWithLifecycle() + val appLocationEnabled by locationManager.locationServicesEnabled.collectAsStateWithLifecycle() + val systemLocationEnabled by locationManager.systemLocationEnabled.collectAsStateWithLifecycle() + val locationServicesEnabled by locationManager.effectiveLocationEnabled.collectAsStateWithLifecycle() // Observe bookmarks state val bookmarks by bookmarksStore.bookmarks.collectAsStateWithLifecycle() @@ -113,43 +116,27 @@ fun LocationChannelsSheet( val standardBlue = Color(0xFF007AFF) // iOS blue if (isPresented) { - ModalBottomSheet( - modifier = modifier.statusBarsPadding(), + BitchatBottomSheet( + modifier = modifier, onDismissRequest = onDismiss, sheetState = sheetState, - containerColor = MaterialTheme.colorScheme.background, - dragHandle = null ) { Box(modifier = Modifier.fillMaxWidth()) { LazyColumn( state = listState, modifier = Modifier.fillMaxSize(), - contentPadding = PaddingValues(top = 48.dp, bottom = 16.dp) + contentPadding = PaddingValues(top = 64.dp, bottom = 16.dp) ) { // Header Section item(key = "header") { - Column( + Text( + text = stringResource(R.string.location_channels_desc), + fontSize = 12.sp, + fontFamily = FontFamily.Monospace, + color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.7f), modifier = Modifier - .fillMaxWidth() .padding(horizontal = 24.dp) - .padding(bottom = 8.dp), - verticalArrangement = Arrangement.spacedBy(4.dp) - ) { - Text( - text = stringResource(R.string.location_channels_title), - style = MaterialTheme.typography.headlineSmall, - fontFamily = FontFamily.Monospace, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onBackground - ) - - Text( - text = stringResource(R.string.location_channels_desc), - fontSize = 12.sp, - fontFamily = FontFamily.Monospace, - color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.7f) - ) - } + ) } // Permission controls if services enabled @@ -163,24 +150,7 @@ fun LocationChannelsSheet( verticalArrangement = Arrangement.spacedBy(4.dp) ) { when (permissionState) { - LocationChannelManager.PermissionState.NOT_DETERMINED -> { - Button( - onClick = { locationManager.enableLocationChannels() }, - colors = ButtonDefaults.buttonColors( - containerColor = standardGreen.copy(alpha = 0.12f), - contentColor = standardGreen - ), - modifier = Modifier.fillMaxWidth() - ) { - Text( - text = stringResource(R.string.grant_location_permission), - fontSize = 12.sp, - fontFamily = FontFamily.Monospace - ) - } - } - LocationChannelManager.PermissionState.DENIED, - LocationChannelManager.PermissionState.RESTRICTED -> { + LocationChannelManager.PermissionState.DENIED -> { Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { Text( text = stringResource(R.string.location_permission_denied), @@ -212,20 +182,6 @@ fun LocationChannelsSheet( color = standardGreen ) } - null -> { - Row( - horizontalArrangement = Arrangement.spacedBy(4.dp), - verticalAlignment = Alignment.CenterVertically - ) { - CircularProgressIndicator(modifier = Modifier.size(12.dp)) - Text( - text = stringResource(R.string.checking_permissions), - fontSize = 11.sp, - fontFamily = FontFamily.Monospace, - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f) - ) - } - } } } } @@ -287,10 +243,17 @@ fun LocationChannelsSheet( } else if (permissionState == LocationChannelManager.PermissionState.AUTHORIZED && locationServicesEnabled) { item { Row( - horizontalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 32.dp, vertical = 12.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically ) { - CircularProgressIndicator(modifier = Modifier.size(16.dp)) + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) + ) Text( text = stringResource(R.string.finding_nearby_channels), fontSize = 12.sp, @@ -545,50 +508,45 @@ fun LocationChannelsSheet( } // TopBar (animated) - Box( - modifier = Modifier - .align(Alignment.TopCenter) - .fillMaxWidth() - .height(56.dp) - .background(MaterialTheme.colorScheme.background.copy(alpha = topBarAlpha)) - ) { - CloseButton( - onClick = onDismiss, - modifier = modifier - .align(Alignment.CenterEnd) - .padding(horizontal = 16.dp), - ) - } + BitchatSheetTopBar( + onClose = onDismiss, + modifier = modifier.align(Alignment.TopCenter), + title = { + BitchatSheetTitle( + text = stringResource(R.string.location_channels_title) + ) + } + ) } } } - // Lifecycle management: when presented, sample both nearby and bookmarked geohashes + // Lifecycle management: when presented, manage location updates + DisposableEffect(isPresented, permissionState, locationServicesEnabled) { + if (isPresented && permissionState == LocationChannelManager.PermissionState.AUTHORIZED && locationServicesEnabled) { + locationManager.refreshChannels() + locationManager.beginLiveRefresh() + } + + onDispose { + locationManager.endLiveRefresh() + } + } + + // Sampling management: update sampling when channels/bookmarks change LaunchedEffect(isPresented, availableChannels, bookmarks) { if (isPresented) { - if (permissionState == LocationChannelManager.PermissionState.AUTHORIZED && locationServicesEnabled) { - locationManager.refreshChannels() - locationManager.beginLiveRefresh() - } val geohashes = (availableChannels.map { it.geohash } + bookmarks).toSet().toList() viewModel.beginGeohashSampling(geohashes) } else { - locationManager.endLiveRefresh() viewModel.endGeohashSampling() } } - // React to permission changes - LaunchedEffect(permissionState) { - if (permissionState == LocationChannelManager.PermissionState.AUTHORIZED && locationServicesEnabled) { - locationManager.refreshChannels() - } - } - - // React to location services enable/disable - LaunchedEffect(locationServicesEnabled) { - if (locationServicesEnabled && permissionState == LocationChannelManager.PermissionState.AUTHORIZED) { - locationManager.refreshChannels() + // Ensure cleanup when the composable is destroyed (e.g. removed from parent composition) + DisposableEffect(Unit) { + onDispose { + viewModel.endGeohashSampling() } } } @@ -698,7 +656,7 @@ private fun meshTitleWithCount(viewModel: ChatViewModel): String { } private fun meshCount(viewModel: ChatViewModel): Int { - val myID = viewModel.meshService.myPeerID + val myID = viewModel.myPeerID return viewModel.connectedPeers.value?.count { peerID -> peerID != myID } ?: 0 @@ -707,7 +665,16 @@ private fun meshCount(viewModel: ChatViewModel): Int { @Composable private fun geohashTitleWithCount(channel: GeohashChannel, participantCount: Int): String { val ctx = androidx.compose.ui.platform.LocalContext.current - val peopleText = ctx.resources.getQuantityString(com.bitchat.android.R.plurals.people_count, participantCount, participantCount) + + // For high precision channels (Neighborhood, Block) where we don't broadcast presence, + // show "? people" instead of "0 people" to avoid misleading "nobody is here" indication. + val isHighPrecision = channel.level.precision > 5 + val peopleText = if (isHighPrecision && participantCount == 0) { + ctx.resources.getQuantityString(com.bitchat.android.R.plurals.people_count, 0, 0).replace("0", "?") + } else { + ctx.resources.getQuantityString(com.bitchat.android.R.plurals.people_count, participantCount, participantCount) + } + val levelName = when (channel.level) { com.bitchat.android.geohash.GeohashChannelLevel.BUILDING -> "Building" // iOS: precision 8 for location notes com.bitchat.android.geohash.GeohashChannelLevel.BLOCK -> stringResource(com.bitchat.android.R.string.location_level_block) @@ -722,7 +689,15 @@ private fun geohashTitleWithCount(channel: GeohashChannel, participantCount: Int @Composable private fun geohashHashTitleWithCount(geohash: String, participantCount: Int): String { val ctx = androidx.compose.ui.platform.LocalContext.current - val peopleText = ctx.resources.getQuantityString(com.bitchat.android.R.plurals.people_count, participantCount, participantCount) + val level = levelForLength(geohash.length) + val isHighPrecision = level.precision > 5 + + val peopleText = if (isHighPrecision && participantCount == 0) { + ctx.resources.getQuantityString(com.bitchat.android.R.plurals.people_count, 0, 0).replace("0", "?") + } else { + ctx.resources.getQuantityString(com.bitchat.android.R.plurals.people_count, participantCount, participantCount) + } + return "#$geohash [$peopleText]" } diff --git a/app/src/main/java/com/bitchat/android/ui/LocationNotesButton.kt b/app/src/main/java/com/bitchat/android/ui/LocationNotesButton.kt index 601d4c03..6ad9bdd8 100644 --- a/app/src/main/java/com/bitchat/android/ui/LocationNotesButton.kt +++ b/app/src/main/java/com/bitchat/android/ui/LocationNotesButton.kt @@ -38,7 +38,7 @@ fun LocationNotesButton( val selectedLocationChannel by viewModel.selectedLocationChannel.collectAsStateWithLifecycle() val locationManager = remember { LocationChannelManager.getInstance(context) } val permissionState by locationManager.permissionState.collectAsStateWithLifecycle() - val locationServicesEnabled by locationManager.locationServicesEnabled.collectAsStateWithLifecycle(false) + val locationServicesEnabled by locationManager.effectiveLocationEnabled.collectAsStateWithLifecycle(false) // Check both permission AND location services enabled val locationPermissionGranted = permissionState == LocationChannelManager.PermissionState.AUTHORIZED diff --git a/app/src/main/java/com/bitchat/android/ui/LocationNotesSheet.kt b/app/src/main/java/com/bitchat/android/ui/LocationNotesSheet.kt index 461796d3..8eea4db7 100644 --- a/app/src/main/java/com/bitchat/android/ui/LocationNotesSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/LocationNotesSheet.kt @@ -1,5 +1,6 @@ package com.bitchat.android.ui +import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.isSystemInDarkTheme @@ -12,9 +13,9 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ArrowUpward import androidx.compose.material3.* import androidx.compose.runtime.* +import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontFamily @@ -25,6 +26,9 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.bitchat.android.core.ui.component.sheet.BitchatBottomSheet +import com.bitchat.android.core.ui.component.sheet.BitchatSheetTopBar +import com.bitchat.android.core.ui.component.sheet.BitchatSheetTitle import com.bitchat.android.geohash.GeohashChannelLevel import com.bitchat.android.geohash.LocationChannelManager import com.bitchat.android.nostr.LocationNotesManager @@ -49,7 +53,6 @@ fun LocationNotesSheet( val isDark = isSystemInDarkTheme() // iOS color scheme - val backgroundColor = if (isDark) Color.Black else Color.White val accentGreen = if (isDark) Color.Green else Color(0xFF008000) // dark: green, light: dark green (0, 0.5, 0) // Managers @@ -76,7 +79,21 @@ fun LocationNotesSheet( // Scroll state val listState = rememberLazyListState() - + val isScrolled by remember { + derivedStateOf { + listState.firstVisibleItemIndex > 0 || listState.firstVisibleItemScrollOffset > 0 + } + } + val topBarAlpha by animateFloatAsState( + targetValue = if (isScrolled) 0.95f else 0f, + label = "topBarAlpha" + ) + + // Refresh location when sheet opens + LaunchedEffect(Unit) { + locationManager.refreshChannels() + } + // Effect to set geohash when sheet opens LaunchedEffect(geohash) { notesManager.setGeohash(geohash) @@ -88,168 +105,133 @@ fun LocationNotesSheet( notesManager.cancel() } } - - ModalBottomSheet( + + BitchatBottomSheet( onDismissRequest = onDismiss, modifier = modifier, - sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), - containerColor = backgroundColor, - contentColor = if (isDark) Color.White else Color.Black ) { - Column( - modifier = Modifier - .fillMaxWidth() - .fillMaxHeight(0.9f) - ) { - // Header section (matches iOS headerSection) - LocationNotesHeader( - geohash = geohash, - count = count, - locationName = displayLocationName, - state = state, - accentGreen = accentGreen, - backgroundColor = backgroundColor, - onClose = onDismiss - ) - - // ScrollView with notes content - Box( - modifier = Modifier - .weight(1f) - .fillMaxWidth() - .background(backgroundColor) + Box(modifier = Modifier.fillMaxWidth()) { + LazyColumn( + state = listState, + modifier = Modifier.fillMaxSize().padding(horizontal = 16.dp), + contentPadding = PaddingValues(top = 64.dp, bottom = 20.dp) ) { - LazyColumn( - state = listState, - modifier = Modifier.fillMaxSize(), - contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp) - ) { - // Notes content (matches iOS notesContent) - when { - state == LocationNotesManager.State.NO_RELAYS -> { - item { - NoRelaysRow( - onRetry = { notesManager.refresh() } - ) - } - } - state == LocationNotesManager.State.LOADING && !initialLoadComplete -> { - item { - LoadingRow() - } - } - notes.isEmpty() -> { - item { - EmptyRow() - } - } - else -> { - items(notes, key = { it.id }) { note -> - NoteRow(note = note) - Spacer(modifier = Modifier.height(12.dp)) - } + item(key = "notes_header") { + LocationNotesHeader( + locationName = displayLocationName, + state = state, + accentGreen = accentGreen, + ) + } + + // Notes content (matches iOS notesContent) + when { + state == LocationNotesManager.State.NO_RELAYS -> { + item { + NoRelaysRow( + onRetry = { notesManager.refresh() } + ) } } - - // Error row (matches iOS errorRow) - errorMessage?.let { error -> - if (state != LocationNotesManager.State.NO_RELAYS) { - item { - ErrorRow( - message = error, - onDismiss = { notesManager.clearError() } - ) - } + state == LocationNotesManager.State.LOADING && !initialLoadComplete -> { + item { + LoadingRow() + } + } + notes.isEmpty() -> { + item { + EmptyRow() + } + } + else -> { + items(notes, key = { it.id }) { note -> + NoteRow(note = note) + Spacer(modifier = Modifier.height(24.dp)) + } + item { + Spacer(modifier = Modifier.height(24.dp)) + } + } + } + + // Error row (matches iOS errorRow) + errorMessage?.let { error -> + if (state != LocationNotesManager.State.NO_RELAYS) { + item { + ErrorRow( + message = error, + onDismiss = { notesManager.clearError() } + ) } } } } - - // Divider before input (matches iOS overlay) - HorizontalDivider( - modifier = Modifier.fillMaxWidth(), - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.2f), - thickness = 1.dp - ) - - // Input section (matches iOS inputSection) - LocationNotesInputSection( - draft = draft, - onDraftChange = { draft = it }, - sendButtonEnabled = sendButtonEnabled, - accentGreen = accentGreen, - backgroundColor = backgroundColor, - onSend = { - val content = draft.trim() - if (content.isNotEmpty()) { - notesManager.send(content, nickname) - draft = "" - } + + // TopBar (animated) + BitchatSheetTopBar( + onClose = onDismiss, + modifier = Modifier.align(Alignment.TopCenter), + title = { + BitchatSheetTitle( + text = pluralStringResource( + id = R.plurals.location_notes_title, + count = count, + geohash, + count + ) + ) } ) + + Box( + modifier = Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth() + ){ + Column { + // Divider before input (matches iOS overlay) + HorizontalDivider( + modifier = Modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.2f), + thickness = 1.dp + ) + + // Input section (matches iOS inputSection) + LocationNotesInputSection( + draft = draft, + onDraftChange = { draft = it }, + sendButtonEnabled = sendButtonEnabled, + accentGreen = accentGreen, + onSend = { + val content = draft.trim() + if (content.isNotEmpty()) { + notesManager.send(content, nickname) + draft = "" + } + } + ) + } + } } } } /** * Header section - matches iOS headerSection exactly - * Shows: "#geohash • X notes", location name, description, and close button + * Shows: "#geohash • X notes", location name, description */ @Composable private fun LocationNotesHeader( - geohash: String, - count: Int, locationName: String?, state: LocationNotesManager.State, accentGreen: Color, - backgroundColor: Color, - onClose: () -> Unit ) { Column( modifier = Modifier .fillMaxWidth() - .background(backgroundColor) .padding(horizontal = 16.dp) - .padding(top = 16.dp, bottom = 12.dp) + .padding(bottom = 12.dp) ) { - // Title row with close button - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - // Localized title with ±1 and note count - Text( - text = pluralStringResource( - id = R.plurals.location_notes_title, - count = count, - geohash, - count - ), - fontFamily = FontFamily.Monospace, - fontSize = 18.sp, - color = MaterialTheme.colorScheme.onSurface - ) - - // Close button - iOS style with xmark icon - Box( - modifier = Modifier - .size(32.dp) - .clickable(onClick = onClose), - contentAlignment = Alignment.Center - ) { - Text( - text = "✕", - fontFamily = FontFamily.Monospace, - fontSize = 13.sp, - fontWeight = FontWeight.SemiBold, - color = MaterialTheme.colorScheme.onSurface - ) - } - } - - Spacer(modifier = Modifier.height(8.dp)) - // Location name in green (building or block) locationName?.let { name -> if (name.isNotEmpty()) { @@ -469,7 +451,6 @@ private fun LocationNotesInputSection( onDraftChange: (String) -> Unit, sendButtonEnabled: Boolean, accentGreen: Color, - backgroundColor: Color, onSend: () -> Unit ) { val isDark = isSystemInDarkTheme() @@ -478,7 +459,7 @@ private fun LocationNotesInputSection( Row( modifier = Modifier .fillMaxWidth() - .background(backgroundColor) + .background(color = colorScheme.background) .padding(horizontal = 12.dp, vertical = 8.dp), // Match main chat padding verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp) // Match main chat spacing diff --git a/app/src/main/java/com/bitchat/android/ui/LocationNotesSheetPresenter.kt b/app/src/main/java/com/bitchat/android/ui/LocationNotesSheetPresenter.kt index d17b6d7c..9a8cf9e7 100644 --- a/app/src/main/java/com/bitchat/android/ui/LocationNotesSheetPresenter.kt +++ b/app/src/main/java/com/bitchat/android/ui/LocationNotesSheetPresenter.kt @@ -9,9 +9,14 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp +import androidx.compose.ui.res.stringResource import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.bitchat.android.core.ui.component.sheet.BitchatBottomSheet +import com.bitchat.android.core.ui.component.sheet.BitchatSheetTopBar +import com.bitchat.android.core.ui.component.sheet.BitchatSheetTitle import com.bitchat.android.geohash.GeohashChannelLevel import com.bitchat.android.geohash.LocationChannelManager +import com.bitchat.android.R /** * Presenter component for LocationNotesSheet @@ -27,6 +32,8 @@ fun LocationNotesSheetPresenter( val context = LocalContext.current val locationManager = remember { LocationChannelManager.getInstance(context) } val availableChannels by locationManager.availableChannels.collectAsStateWithLifecycle() + val permissionState by locationManager.permissionState.collectAsStateWithLifecycle() + val isLoadingLocation by locationManager.isLoadingLocation.collectAsStateWithLifecycle() val nickname by viewModel.nickname.collectAsStateWithLifecycle() // iOS pattern: notesGeohash ?? LocationChannelManager.shared.availableChannels.first(where: { $0.level == .building })?.geohash @@ -44,6 +51,8 @@ fun LocationNotesSheetPresenter( nickname = nickname, onDismiss = onDismiss ) + } else if (permissionState == LocationChannelManager.PermissionState.AUTHORIZED && isLoadingLocation) { + LocationNotesAcquiringSheet(onDismiss = onDismiss) } else { // No building geohash available - show error state (matches iOS) LocationNotesErrorSheet( @@ -53,6 +62,43 @@ fun LocationNotesSheetPresenter( } } +/** + * Loading sheet when location is being acquired + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun LocationNotesAcquiringSheet( + onDismiss: () -> Unit +) { + BitchatBottomSheet( + onDismissRequest = onDismiss, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = "Acquiring Location", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface + ) + Spacer(modifier = Modifier.height(24.dp)) + CircularProgressIndicator( + modifier = Modifier.size(48.dp), + color = MaterialTheme.colorScheme.primary + ) + Spacer(modifier = Modifier.height(24.dp)) + Text( + text = "Please wait while your location is being determined", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } +} + /** * Error sheet when location is unavailable */ @@ -62,39 +108,50 @@ private fun LocationNotesErrorSheet( onDismiss: () -> Unit, locationManager: LocationChannelManager ) { - val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) - ModalBottomSheet( + BitchatBottomSheet( onDismissRequest = onDismiss, - sheetState = sheetState, - containerColor = MaterialTheme.colorScheme.surface ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(24.dp), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Text( - text = "Location Unavailable", - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurface - ) - Spacer(modifier = Modifier.height(16.dp)) - Text( - text = "Location permission is required for notes", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - Spacer(modifier = Modifier.height(24.dp)) - Button(onClick = { - // UNIFIED FIX: Enable location services first (user toggle) - locationManager.enableLocationServices() - // Then request location channels (which will also request permission if needed) - locationManager.enableLocationChannels() - locationManager.refreshChannels() - }) { - Text("Enable Location") + Box(modifier = Modifier.fillMaxWidth()) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp) + .padding(top = 80.dp, bottom = 24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Text( + text = "Location Unavailable", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface + ) + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = "Location permission is required for notes", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.height(24.dp)) + Button(onClick = { + // UNIFIED FIX: Enable location services first (user toggle) + locationManager.enableLocationServices() + // Then request location channels (which will also request permission if needed) + locationManager.enableLocationChannels() + locationManager.refreshChannels() + }) { + Text("Enable Location") + } } + + BitchatSheetTopBar( + onClose = onDismiss, + modifier = Modifier.align(Alignment.TopCenter), + title = { + BitchatSheetTitle( + text = stringResource(R.string.cd_location_notes).uppercase() + ) + } + ) } } } diff --git a/app/src/main/java/com/bitchat/android/ui/MatrixEncryptionAnimation.kt b/app/src/main/java/com/bitchat/android/ui/MatrixEncryptionAnimation.kt index 9c7d776f..045ea127 100644 --- a/app/src/main/java/com/bitchat/android/ui/MatrixEncryptionAnimation.kt +++ b/app/src/main/java/com/bitchat/android/ui/MatrixEncryptionAnimation.kt @@ -77,7 +77,7 @@ fun MessageWithMatrixAnimation( message: com.bitchat.android.model.BitchatMessage, messages: List = emptyList(), currentUserNickname: String, - meshService: com.bitchat.android.mesh.BluetoothMeshService, + meshService: com.bitchat.android.mesh.MeshService, colorScheme: androidx.compose.material3.ColorScheme, timeFormatter: java.text.SimpleDateFormat, onNicknameClick: ((String) -> Unit)?, @@ -124,7 +124,7 @@ fun MessageWithMatrixAnimation( private fun AnimatedMessageDisplay( message: com.bitchat.android.model.BitchatMessage, currentUserNickname: String, - meshService: com.bitchat.android.mesh.BluetoothMeshService, + meshService: com.bitchat.android.mesh.MeshService, colorScheme: androidx.compose.material3.ColorScheme, timeFormatter: java.text.SimpleDateFormat, modifier: Modifier = Modifier @@ -241,7 +241,7 @@ private fun AnimatedMessageDisplay( private fun formatMessageAsAnnotatedStringWithoutTimestamp( message: com.bitchat.android.model.BitchatMessage, currentUserNickname: String, - meshService: com.bitchat.android.mesh.BluetoothMeshService, + meshService: com.bitchat.android.mesh.MeshService, colorScheme: androidx.compose.material3.ColorScheme ): AnnotatedString { // Get the full formatted text first diff --git a/app/src/main/java/com/bitchat/android/ui/MediaSendingManager.kt b/app/src/main/java/com/bitchat/android/ui/MediaSendingManager.kt index fc134a1b..b7e87f91 100644 --- a/app/src/main/java/com/bitchat/android/ui/MediaSendingManager.kt +++ b/app/src/main/java/com/bitchat/android/ui/MediaSendingManager.kt @@ -1,7 +1,7 @@ package com.bitchat.android.ui import android.util.Log -import com.bitchat.android.mesh.BluetoothMeshService +import com.bitchat.android.mesh.MeshService import com.bitchat.android.model.BitchatFilePacket import com.bitchat.android.model.BitchatMessage import com.bitchat.android.model.BitchatMessageType @@ -16,8 +16,11 @@ class MediaSendingManager( private val state: ChatState, private val messageManager: MessageManager, private val channelManager: ChannelManager, - private val meshService: BluetoothMeshService + private val getMeshService: () -> MeshService ) { + // Helper to get current mesh service (may change after panic clear) + private val meshService: MeshService + get() = getMeshService() companion object { private const val TAG = "MediaSendingManager" private const val MAX_FILE_SIZE = com.bitchat.android.util.AppConstants.Media.MAX_FILE_SIZE_BYTES // 50MB limit @@ -210,7 +213,6 @@ class MediaSendingManager( Log.d(TAG, "📤 Calling meshService.sendFilePrivate to $toPeerID") meshService.sendFilePrivate(toPeerID, filePacket) - try { com.bitchat.android.wifiaware.WifiAwareController.getService()?.sendFilePrivate(toPeerID, filePacket) } catch (_: Exception) {} Log.d(TAG, "✅ File send completed successfully") } @@ -265,7 +267,6 @@ class MediaSendingManager( Log.d(TAG, "📤 Calling meshService.sendFileBroadcast") meshService.sendFileBroadcast(filePacket) - try { com.bitchat.android.wifiaware.WifiAwareController.getService()?.sendFileBroadcast(filePacket) } catch (_: Exception) {} Log.d(TAG, "✅ File broadcast completed successfully") } diff --git a/app/src/main/java/com/bitchat/android/ui/MeshDelegateHandler.kt b/app/src/main/java/com/bitchat/android/ui/MeshDelegateHandler.kt index 559ef11a..f77e146d 100644 --- a/app/src/main/java/com/bitchat/android/ui/MeshDelegateHandler.kt +++ b/app/src/main/java/com/bitchat/android/ui/MeshDelegateHandler.kt @@ -2,7 +2,7 @@ package com.bitchat.android.ui import com.bitchat.android.mesh.BluetoothMeshDelegate import com.bitchat.android.ui.NotificationTextUtils -import com.bitchat.android.mesh.BluetoothMeshService +import com.bitchat.android.mesh.MeshService import com.bitchat.android.model.BitchatMessage import com.bitchat.android.model.DeliveryStatus import kotlinx.coroutines.CoroutineScope @@ -21,7 +21,7 @@ class MeshDelegateHandler( private val coroutineScope: CoroutineScope, private val onHapticFeedback: () -> Unit, private val getMyPeerID: () -> String, - private val getMeshService: () -> BluetoothMeshService + private val getMeshService: () -> MeshService ) : BluetoothMeshDelegate { override fun didReceiveMessage(message: BitchatMessage) { @@ -94,30 +94,13 @@ class MeshDelegateHandler( } } - private var blePeers: Set = emptySet() - private var wifiPeers: Set = emptySet() - override fun didUpdatePeerList(peers: List) { coroutineScope.launch { - blePeers = peers.toSet() - processPeerUpdate() + processPeerUpdate(peers.distinct()) } } - fun onWifiPeersUpdated(peers: List) { - coroutineScope.launch { - wifiPeers = peers.toSet() - processPeerUpdate() - } - } - - private suspend fun processPeerUpdate() { - // Merge peers from multiple transports - val mergedPeers = (blePeers + wifiPeers).toList() - - // Update process-wide state as source of truth - try { com.bitchat.android.services.AppStateStore.setPeers(mergedPeers) } catch (_: Exception) { } - + private suspend fun processPeerUpdate(mergedPeers: List) { state.setConnectedPeers(mergedPeers) state.setIsConnected(mergedPeers.isNotEmpty()) notificationManager.showActiveUserNotification(mergedPeers) @@ -238,6 +221,14 @@ class MeshDelegateHandler( messageManager.updateMessageDeliveryStatus(messageID, DeliveryStatus.Read(recipientPeerID, Date())) } } + + override fun didReceiveVerifyChallenge(peerID: String, payload: ByteArray, timestampMs: Long) { + // Handled by ChatViewModel for verification flow + } + + override fun didReceiveVerifyResponse(peerID: String, payload: ByteArray, timestampMs: Long) { + // Handled by ChatViewModel for verification flow + } override fun decryptChannelMessage(encryptedContent: ByteArray, channel: String): String? { return channelManager.decryptChannelMessage(encryptedContent, channel) @@ -308,15 +299,27 @@ class MeshDelegateHandler( if (shouldSendReadReceipt) { android.util.Log.d("MeshDelegateHandler", "Sending reactive read receipt for focused chat with $senderPeerID (message=${message.id})") val nickname = state.getNicknameValue() ?: "unknown" - // Send directly for this message to avoid relying on unread queues - getMeshService().sendReadReceipt(message.id, senderPeerID!!, nickname) - // Ensure unread badge is cleared for this peer immediately - try { - val current = state.getUnreadPrivateMessagesValue().toMutableSet() - if (current.remove(senderPeerID)) { - state.setUnreadPrivateMessages(current) + val mesh = getMeshService() + val sent = try { + val hasMesh = mesh.getPeerInfo(senderPeerID!!)?.isConnected == true && mesh.hasEstablishedSession(senderPeerID) + if (hasMesh) { + mesh.sendReadReceipt(message.id, senderPeerID, nickname) + true + } else { + false } - } catch (_: Exception) { } + } catch (_: Exception) { + false + } + if (sent) { + // Ensure unread badge is cleared for this peer immediately + try { + val current = state.getUnreadPrivateMessagesValue().toMutableSet() + if (current.remove(senderPeerID)) { + state.setUnreadPrivateMessages(current) + } + } catch (_: Exception) { } + } } else { android.util.Log.d("MeshDelegateHandler", "Skipping read receipt - chat not focused (background: $isAppInBackground, current peer: $currentPrivateChatPeer, sender: $senderPeerID)") } diff --git a/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt b/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt new file mode 100644 index 00000000..f2bccdb6 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt @@ -0,0 +1,1026 @@ +package com.bitchat.android.ui + +import com.bitchat.android.R +import android.util.Log +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.* +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.* +import androidx.compose.material.icons.outlined.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.text.style.TextOverflow +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.bitchat.android.core.ui.component.button.CloseButton +import com.bitchat.android.core.ui.component.sheet.BitchatBottomSheet +import com.bitchat.android.core.ui.component.sheet.BitchatSheetCenterTopBar +import com.bitchat.android.core.ui.component.sheet.BitchatSheetTitle +import com.bitchat.android.core.ui.component.sheet.BitchatSheetTopBar +import com.bitchat.android.geohash.ChannelID +import com.bitchat.android.ui.theme.BASE_FONT_SIZE +import com.bitchat.android.nostr.GeohashAliasRegistry +import com.bitchat.android.nostr.GeohashConversationRegistry + + +/** + * Sheet components for ChatScreen + * Extracted from ChatScreen.kt for better organization + */ + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun MeshPeerListSheet( + isPresented: Boolean, + viewModel: ChatViewModel, + onDismiss: () -> Unit, + onShowVerification: () -> Unit, + modifier: Modifier = Modifier +) { + val colorScheme = MaterialTheme.colorScheme + + val connectedPeers by viewModel.connectedPeers.collectAsStateWithLifecycle() + val joinedChannels by viewModel.joinedChannels.collectAsStateWithLifecycle() + val currentChannel by viewModel.currentChannel.collectAsStateWithLifecycle() + val selectedPrivatePeer by viewModel.selectedPrivateChatPeer.collectAsStateWithLifecycle() + val nickname by viewModel.nickname.collectAsStateWithLifecycle() + val unreadChannelMessages by viewModel.unreadChannelMessages.collectAsStateWithLifecycle() + val peerNicknames by viewModel.peerNicknames.collectAsStateWithLifecycle() + val peerRSSI by viewModel.peerRSSI.collectAsStateWithLifecycle() + val selectedLocationChannel by viewModel.selectedLocationChannel.collectAsStateWithLifecycle() + val wifiAwareConnected by com.bitchat.android.wifiaware.WifiAwareController.connectedPeers.collectAsStateWithLifecycle() + val wifiAwarePeerIDs = remember(wifiAwareConnected) { wifiAwareConnected.keys.toSet() } + + // Bottom sheet state + val sheetState = rememberModalBottomSheetState( + skipPartiallyExpanded = true + ) + + // Scroll state for animated top bar + val listState = rememberLazyListState() + val isScrolled by remember { + derivedStateOf { + listState.firstVisibleItemIndex > 0 || listState.firstVisibleItemScrollOffset > 0 + } + } + val topBarAlpha by animateFloatAsState( + targetValue = if (isScrolled) 0.95f else 0f, + label = "topBarAlpha" + ) + + if (isPresented) { + BitchatBottomSheet( + modifier = modifier, + onDismissRequest = onDismiss, + sheetState = sheetState, + ) { + Box(modifier = Modifier.fillMaxWidth()) { + LazyColumn( + state = listState, + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(top = 64.dp, bottom = 20.dp) + ) { + // Channels section + if (joinedChannels.isNotEmpty()) { + item(key = "channels_header") { + Text( + text = stringResource(id = R.string.channels).uppercase(), + style = MaterialTheme.typography.labelLarge, + color = colorScheme.onSurface.copy(alpha = 0.7f), + fontWeight = FontWeight.Bold, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp) + .padding(top = 8.dp, bottom = 4.dp) + ) + } + + items( + items = joinedChannels.toList(), + key = { "channel_$it" } + ) { channel -> + val isSelected = channel == currentChannel + val unreadCount = unreadChannelMessages[channel] ?: 0 + + ChannelRow( + channel = channel, + isSelected = isSelected, + unreadCount = unreadCount, + colorScheme = colorScheme, + onChannelClick = { + // Check if this is a DM channel (starts with @) + if (channel.startsWith("@")) { + // Extract peer name and find the peer ID + val peerName = channel.removePrefix("@") + val peerID = + peerNicknames.entries.firstOrNull { it.value == peerName }?.key + if (peerID != null) { + viewModel.showPrivateChatSheet(peerID) + onDismiss() + } + } else { + // Regular channel switch + viewModel.switchToChannel(channel) + onDismiss() + } + }, + onLeaveChannel = { + viewModel.leaveChannel(channel) + }, + ) + } + } + + // People section - switch between mesh and geohash lists (iOS-compatible) + item(key = "people_section") { + when (selectedLocationChannel) { + is ChannelID.Location -> { + // Show geohash people list when in location channel + GeohashPeopleList( + viewModel = viewModel, + onTapPerson = onDismiss + ) + } + + else -> { + // Show mesh peer list when in mesh channel (default) + PeopleSection( + modifier = Modifier.padding(top = if (joinedChannels.isNotEmpty()) 16.dp else 0.dp), + connectedPeers = connectedPeers, + peerNicknames = peerNicknames, + peerRSSI = peerRSSI, + nickname = nickname, + colorScheme = colorScheme, + selectedPrivatePeer = selectedPrivatePeer, + wifiAwarePeerIDs = wifiAwarePeerIDs, + viewModel = viewModel, + onPrivateChatStart = { peerID -> + viewModel.showPrivateChatSheet(peerID) + onDismiss() + } + ) + } + } + } + } + + // TopBar (animated) + BitchatSheetTopBar( + title = { + BitchatSheetTitle(text = stringResource(id = R.string.your_network)) + }, + backgroundAlpha = topBarAlpha, + actions = { + if (selectedLocationChannel !is ChannelID.Location) { + IconButton( + onClick = onShowVerification, + modifier = Modifier.size(24.dp) + ) { + Icon( + imageVector = Icons.Outlined.QrCode, + contentDescription = stringResource(R.string.verify_title), + tint = colorScheme.onSurface.copy(alpha = 0.8f), + modifier = Modifier.size(18.dp) + ) + } + } + }, + onClose = onDismiss, + ) + } + } + + } +} + +@Composable +private fun ChannelRow( + channel: String, + isSelected: Boolean, + unreadCount: Int, + colorScheme: ColorScheme, + onChannelClick: () -> Unit, + onLeaveChannel: () -> Unit, +) { + Surface( + onClick = onChannelClick, + color = if (isSelected) { + colorScheme.primaryContainer.copy(alpha = 0.15f) + } else { + Color.Transparent + }, + shape = MaterialTheme.shapes.medium, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp, vertical = 2.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Row( + modifier = Modifier.weight(1f), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + // Unread badge + if (unreadCount > 0) { + UnreadBadge( + count = unreadCount, + colorScheme = colorScheme + ) + } + + Text( + text = channel, + style = MaterialTheme.typography.bodyMedium.copy( + fontFamily = FontFamily.Monospace, + fontSize = BASE_FONT_SIZE.sp + ), + color = if (isSelected) colorScheme.primary else colorScheme.onSurface, + fontWeight = if (isSelected) FontWeight.Medium else FontWeight.Normal + ) + } + + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + // Leave channel button + CloseButton( + onClick = onLeaveChannel, + ) + } + } + } +} + + + +@Composable +fun PeopleSection( + modifier: Modifier = Modifier, + connectedPeers: List, + peerNicknames: Map, + peerRSSI: Map, + nickname: String, + colorScheme: ColorScheme, + selectedPrivatePeer: String?, + wifiAwarePeerIDs: Set = emptySet(), + viewModel: ChatViewModel, + onPrivateChatStart: (String) -> Unit +) { + Column(modifier = modifier) { + Text( + text = stringResource(id = R.string.people).uppercase(), + style = MaterialTheme.typography.labelLarge, + color = colorScheme.onSurface.copy(alpha = 0.7f), + fontWeight = FontWeight.Bold, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp) + .padding(top = 8.dp, bottom = 4.dp) + ) + + if (connectedPeers.isEmpty()) { + Text( + text = stringResource(id = R.string.no_one_connected), + style = MaterialTheme.typography.bodyMedium.copy( + fontFamily = FontFamily.Monospace, + fontSize = 12.sp + ), + color = colorScheme.onSurface.copy(alpha = 0.5f), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 40.dp, vertical = 12.dp) + ) + } + + // Observe reactive state for favorites and fingerprints + val hasUnreadPrivateMessages by viewModel.unreadPrivateMessages.collectAsStateWithLifecycle() + val privateChats by viewModel.privateChats.collectAsStateWithLifecycle() + val favoritePeers by viewModel.favoritePeers.collectAsStateWithLifecycle() + val peerFingerprints by viewModel.peerFingerprints.collectAsStateWithLifecycle() + val verifiedFingerprints by viewModel.verifiedFingerprints.collectAsStateWithLifecycle() + + // Reactive favorite computation for all peers + val peerFavoriteStates = remember(favoritePeers, peerFingerprints, connectedPeers) { + connectedPeers.associateWith { peerID -> + // Reactive favorite computation - same as ChatHeader + val fingerprint = peerFingerprints[peerID] + fingerprint != null && favoritePeers.contains(fingerprint) + } + } + + val peerVerifiedStates = remember(verifiedFingerprints, peerFingerprints, connectedPeers) { + connectedPeers.associateWith { peerID -> + viewModel.isPeerVerified(peerID, verifiedFingerprints) + } + } + + // Build mapping of connected peerID -> noise key hex to unify with offline favorites + val noiseHexByPeerID: Map = connectedPeers.associateWith { pid -> + try { + viewModel.getMeshPeerInfo(pid)?.noisePublicKey?.joinToString("") { b -> "%02x".format(b) } + } catch (_: Exception) { null } + }.filterValues { it != null }.mapValues { it.value!! } + + Log.d("SidebarComponents", "Recomposing with ${favoritePeers.size} favorites, peer states: $peerFavoriteStates") + + // Smart sorting: unread DMs first, then by most recent DM, then favorites, then alphabetical + val sortedPeers = connectedPeers.sortedWith( + compareBy { !hasUnreadPrivateMessages.contains(it) } // Unread DM senders first + .thenByDescending { privateChats[it]?.maxByOrNull { msg -> msg.timestamp }?.timestamp?.time ?: 0L } // Most recent DM (convert Date to Long) + .thenBy { !(peerFavoriteStates[it] ?: false) } // Favorites first + .thenBy { (if (it == nickname) "You" else (peerNicknames[it] ?: it)).lowercase() } // Alphabetical + ) + + // Build a map of base name counts across all people shown in the list (connected + offline + nostr) + val hex64Regex = Regex("^[0-9a-fA-F]{64}$") + + // Helper to compute display name used for a given key + fun computeDisplayNameForPeerId(key: String): String { + return if (key == nickname) "You" else (peerNicknames[key] ?: (privateChats[key]?.lastOrNull()?.sender ?: key.take(12))) + } + + val baseNameCounts = mutableMapOf() + + // Connected peers + sortedPeers.forEach { pid -> + val dn = computeDisplayNameForPeerId(pid) + val (b, _) = splitSuffix(dn) + if (b != "You") baseNameCounts[b] = (baseNameCounts[b] ?: 0) + 1 + } + + // Offline favorites (exclude ones mapped to connected) + val offlineFavorites = com.bitchat.android.favorites.FavoritesPersistenceService.shared.getOurFavorites() + offlineFavorites.forEach { fav -> + val favPeerID = fav.peerNoisePublicKey.joinToString("") { b -> "%02x".format(b) } + val isMappedToConnected = noiseHexByPeerID.values.any { it.equals(favPeerID, ignoreCase = true) } + if (!isMappedToConnected) { + val dn = peerNicknames[favPeerID] ?: fav.peerNickname + val (b, _) = splitSuffix(dn) + if (b != "You") baseNameCounts[b] = (baseNameCounts[b] ?: 0) + 1 + } + } + + // Nostr-only conversations + val connectedIds = sortedPeers.toSet() + val appendedOfflineIds = mutableSetOf() + privateChats.keys + .filter { key -> + (key.startsWith("nostr_") || hex64Regex.matches(key)) && + !connectedIds.contains(key) && + !noiseHexByPeerID.values.any { it.equals(key, ignoreCase = true) } + } + .forEach { convKey -> + val dn = peerNicknames[convKey] ?: (privateChats[convKey]?.lastOrNull()?.sender ?: convKey.take(12)) + val (b, _) = splitSuffix(dn) + if (b != "You") baseNameCounts[b] = (baseNameCounts[b] ?: 0) + 1 + } + + sortedPeers.forEach { peerID -> + val isFavorite = peerFavoriteStates[peerID] ?: false + val isVerified = peerVerifiedStates[peerID] ?: false + // fingerprint and favorite relationship resolution not needed here; UI will show Nostr globe for appended offline favorites below + + val noiseHex = noiseHexByPeerID[peerID] + val meshUnread = hasUnreadPrivateMessages.contains(peerID) + val nostrUnread = if (noiseHex != null) hasUnreadPrivateMessages.contains(noiseHex) else false + val combinedHasUnread = meshUnread || nostrUnread + val combinedUnreadCount = ( + privateChats[peerID]?.count { msg -> msg.sender != nickname && meshUnread } ?: 0 + ) + ( + if (noiseHex != null) privateChats[noiseHex]?.count { msg -> msg.sender != nickname && nostrUnread } ?: 0 else 0 + ) + + val displayName = if (peerID == nickname) "You" else (peerNicknames[peerID] ?: (privateChats[peerID]?.lastOrNull()?.sender ?: peerID.take(12))) + val (bName, _) = splitSuffix(displayName) + val showHash = (baseNameCounts[bName] ?: 0) > 1 + + val directMap by viewModel.peerDirect.collectAsStateWithLifecycle() + val isDirectLive = directMap[peerID] ?: try { viewModel.getMeshPeerInfo(peerID)?.isDirectConnection == true } catch (_: Exception) { false } + PeerItem( + peerID = peerID, + displayName = displayName, + isDirect = isDirectLive, + isWifiAware = peerID in wifiAwarePeerIDs, + isSelected = peerID == selectedPrivatePeer, + isFavorite = isFavorite, + isVerified = isVerified, + hasUnreadDM = combinedHasUnread, + colorScheme = colorScheme, + viewModel = viewModel, + onItemClick = { onPrivateChatStart(peerID) }, + onToggleFavorite = { + Log.d("SidebarComponents", "Sidebar toggle favorite: peerID=$peerID, currentFavorite=$isFavorite") + viewModel.toggleFavorite(peerID) + }, + unreadCount = if (combinedUnreadCount > 0) combinedUnreadCount else if (combinedHasUnread) 1 else 0, + showNostrGlobe = false, + showHashSuffix = showHash + ) + } + + // Append offline favorites we actively favorite (and not currently connected) + offlineFavorites.forEach { fav -> + val favPeerID = fav.peerNoisePublicKey.joinToString("") { b -> "%02x".format(b) } + // If any connected peer maps to this noise key, skip showing the offline entry + val isMappedToConnected = noiseHexByPeerID.values.any { it.equals(favPeerID, ignoreCase = true) } + if (isMappedToConnected) return@forEach + + // Resolve potential Nostr conversation key for this favorite (for unread detection) + val nostrConvKey: String? = try { + val npubOrHex = com.bitchat.android.favorites.FavoritesPersistenceService.shared.findNostrPubkey(fav.peerNoisePublicKey) + if (npubOrHex != null) { + val hex = if (npubOrHex.startsWith("npub")) { + val (hrp, data) = com.bitchat.android.nostr.Bech32.decode(npubOrHex) + if (hrp == "npub") data.joinToString("") { "%02x".format(it) } else null + } else { + npubOrHex.lowercase() + } + hex?.let { "nostr_${it.take(16)}" } + } else null + } catch (_: Exception) { null } + + val hasUnread = hasUnreadPrivateMessages.contains(favPeerID) || (nostrConvKey != null && hasUnreadPrivateMessages.contains(nostrConvKey)) + + // If user clicks an offline favorite and the mapped peer is currently connected under a different ID, + // open chat with the connected peerID instead of the noise hex for a seamless window + val mappedConnectedPeerID = noiseHexByPeerID.entries.firstOrNull { it.value.equals(favPeerID, ignoreCase = true) }?.key + val dn = peerNicknames[favPeerID] ?: fav.peerNickname + val (bName, _) = splitSuffix(dn) + val showHash = (baseNameCounts[bName] ?: 0) > 1 + + val isVerified = viewModel.isNoisePublicKeyVerified(fav.peerNoisePublicKey, verifiedFingerprints) + + // Compute unreadCount from either noise conversation or Nostr conversation + val unreadCount = ( + privateChats[favPeerID]?.count { msg -> msg.sender != nickname && hasUnreadPrivateMessages.contains(favPeerID) } ?: 0 + ) + ( + if (nostrConvKey != null) privateChats[nostrConvKey]?.count { msg -> msg.sender != nickname && hasUnreadPrivateMessages.contains(nostrConvKey) } ?: 0 else 0 + ) + + PeerItem( + peerID = favPeerID, + displayName = dn, + isDirect = false, + isSelected = (mappedConnectedPeerID ?: favPeerID) == selectedPrivatePeer, + isFavorite = true, + isVerified = isVerified, + hasUnreadDM = hasUnread, + colorScheme = colorScheme, + viewModel = viewModel, + onItemClick = { onPrivateChatStart(mappedConnectedPeerID ?: favPeerID) }, + onToggleFavorite = { + Log.d("SidebarComponents", "Sidebar toggle favorite (offline): peerID=$favPeerID") + viewModel.toggleFavorite(favPeerID) + }, + unreadCount = if (unreadCount > 0) unreadCount else if (hasUnread) 1 else 0, + showNostrGlobe = (fav.isMutual && fav.peerNostrPublicKey != null), + showHashSuffix = showHash + ) + appendedOfflineIds.add(favPeerID) + } + + // NOTE: Do NOT append Nostr-only (nostr_*) conversations to the mesh people list. + // Geohash DMs should appear in the GeohashPeople list for the active geohash, not in mesh offline contacts. + // We intentionally remove previously-added behavior that mixed geohash DMs into mesh sidebar. + // If you need to surface non-geohash offline mesh conversations in the future, do it here for 64-hex noise IDs only. + /* + val alreadyShownIds = connectedIds + appendedOfflineIds + privateChats.keys + .filter { key -> + // Only include 64-hex noise IDs (mesh identities); exclude any nostr_* aliases + hex64Regex.matches(key) && + !alreadyShownIds.contains(key) && + // Skip if this key maps to a connected peer via noiseHex mapping + !noiseHexByPeerID.values.any { it.equals(key, ignoreCase = true) } + } + .sortedBy { key -> privateChats[key]?.lastOrNull()?.timestamp } + .forEach { convKey -> + val lastSender = privateChats[convKey]?.lastOrNull()?.sender + val dn = peerNicknames[convKey] ?: (lastSender ?: convKey.take(12)) + val (bName, _) = splitSuffix(dn) + val showHash = (baseNameCounts[bName] ?: 0) > 1 + + PeerItem( + peerID = convKey, + displayName = dn, + isDirect = false, + isSelected = convKey == selectedPrivatePeer, + isFavorite = false, + hasUnreadDM = hasUnreadPrivateMessages.contains(convKey), + colorScheme = colorScheme, + viewModel = viewModel, + onItemClick = { onPrivateChatStart(convKey) }, + onToggleFavorite = { viewModel.toggleFavorite(convKey) }, + unreadCount = privateChats[convKey]?.count { msg -> + msg.sender != nickname && hasUnreadPrivateMessages.contains(convKey) + } ?: if (hasUnreadPrivateMessages.contains(convKey)) 1 else 0, + showNostrGlobe = false, + showHashSuffix = showHash + ) + } + */ + // End intentional removal + + } +} + +@Composable +private fun PeerItem( + peerID: String, + displayName: String, + isDirect: Boolean, + isWifiAware: Boolean = false, + isSelected: Boolean, + isFavorite: Boolean, + isVerified: Boolean, + hasUnreadDM: Boolean, + colorScheme: ColorScheme, + viewModel: ChatViewModel, + onItemClick: () -> Unit, + onToggleFavorite: () -> Unit, + unreadCount: Int = 0, + showNostrGlobe: Boolean = false, + showHashSuffix: Boolean = true +) { + val currentNickname by viewModel.nickname.collectAsStateWithLifecycle() + // Split display name for hashtag suffix support (iOS-compatible) + val (baseNameRaw, suffixRaw) = splitSuffix(displayName) + val baseName = truncateNickname(baseNameRaw) + val suffix = if (showHashSuffix) suffixRaw else "" + val isMe = displayName == "You" || peerID == currentNickname + + // Get consistent peer color (iOS-compatible) + val isDark = colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f + val assignedColor = viewModel.colorForMeshPeer(peerID, isDark) + val baseColor = if (isMe) Color(0xFFFF9500) else assignedColor + + Surface( + onClick = onItemClick, + color = if (isSelected) { + colorScheme.primaryContainer.copy(alpha = 0.15f) + } else { + Color.Transparent + }, + shape = MaterialTheme.shapes.medium, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp, vertical = 2.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Row( + modifier = Modifier.weight(1f), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + // Connection/status indicator + if (hasUnreadDM) { + // Show mail icon for unread DMs (iOS orange) + Icon( + imageVector = Icons.Filled.Email, + contentDescription = stringResource(R.string.cd_unread_message), + modifier = Modifier.size(16.dp), + tint = Color(0xFFFF9500) // iOS orange + ) + } else if (showNostrGlobe) { + // Purple globe to indicate Nostr availability + Icon( + imageVector = Icons.Filled.Public, + contentDescription = stringResource(R.string.cd_reachable_via_nostr), + modifier = Modifier.size(16.dp), + tint = Color(0xFF9C27B0) // Purple + ) + } else if (!isDirect && isFavorite) { + // Offline favorited user: show outlined circle icon + Icon( + imageVector = Icons.Outlined.Circle, + contentDescription = stringResource(R.string.cd_offline_favorite), + modifier = Modifier.size(16.dp), + tint = Color.Gray + ) + } else { + Icon( + imageVector = when { + isWifiAware -> Icons.Filled.Wifi + isDirect -> Icons.Outlined.Bluetooth + else -> Icons.Filled.Route + }, + contentDescription = when { + isWifiAware -> "Direct Wi-Fi Aware" + isDirect -> "Direct Bluetooth" + else -> "Routed" + }, + modifier = Modifier.size(16.dp), + tint = colorScheme.onSurface.copy(alpha = 0.6f) + ) + } + + // Display name with iOS-style color and hashtag suffix support + Row(verticalAlignment = Alignment.CenterVertically) { + // Base name with peer-specific color + Text( + text = baseName, + style = MaterialTheme.typography.bodyMedium.copy( + fontFamily = FontFamily.Monospace, + fontSize = BASE_FONT_SIZE.sp, + fontWeight = if (isMe) FontWeight.Bold else FontWeight.Normal + ), + color = baseColor, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + + // Hashtag suffix in lighter shade (iOS-style) + if (suffix.isNotEmpty()) { + Text( + text = suffix, + style = MaterialTheme.typography.bodyMedium.copy( + fontFamily = FontFamily.Monospace, + fontSize = BASE_FONT_SIZE.sp + ), + color = baseColor.copy(alpha = 0.6f) + ) + } + + if (isWifiAware && hasUnreadDM) { + Spacer(modifier = Modifier.width(4.dp)) + Icon( + imageVector = Icons.Filled.Wifi, + contentDescription = "Direct Wi-Fi Aware", + modifier = Modifier.size(13.dp), + tint = colorScheme.onSurface.copy(alpha = 0.8f) + ) + } + } + } + + if (isVerified) { + Spacer(modifier = Modifier.width(4.dp)) + Icon( + imageVector = Icons.Filled.Verified, + contentDescription = null, + modifier = Modifier.size(14.dp), + tint = Color(0xFF32D74B) // iOS Green + ) + } + + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + // Favorite star with proper filled/outlined states + IconButton( + onClick = onToggleFavorite, + modifier = Modifier.size(32.dp) + ) { + Icon( + imageVector = if (isFavorite) Icons.Filled.Star else Icons.Outlined.Star, + contentDescription = if (isFavorite) "Remove from favorites" else "Add to favorites", + modifier = Modifier.size(16.dp), + tint = if (isFavorite) Color(0xFFFFD700) else Color(0xFF4CAF50) + ) + } + } + } + } +} + +/** + * Reusable unread badge component for both channels and private messages + */ +@Composable +private fun UnreadBadge( + count: Int, + colorScheme: ColorScheme, + modifier: Modifier = Modifier +) { + if (count > 0) { + Box( + modifier = modifier + .background( + color = Color(0xFFFFD700), // Yellow color + shape = RoundedCornerShape(10.dp) + ) + .padding(horizontal = 6.dp, vertical = 2.dp) + .defaultMinSize(minWidth = 18.dp, minHeight = 18.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = if (count > 99) "99+" else count.toString(), + style = MaterialTheme.typography.labelSmall.copy( + fontSize = 10.sp, + fontWeight = FontWeight.Bold, + fontFamily = FontFamily.Monospace + ), + color = Color.Black // Black text on yellow background + ) + } + } +} + +/** + * Convert RSSI value (dBm) to signal strength percentage (0-100) + * RSSI typically ranges from -30 (excellent) to -100 (very poor) + * Maps to 0-100 scale where: + * - 0-32: No signal (0 bars) + * - 33-65: Weak (1 bar) + * - 66-98: Good (2 bars) + * - 99-100: Excellent (3 bars) + */ +private fun convertRSSIToSignalStrength(rssi: Int?): Int { + if (rssi == null) return 0 + + return when { + rssi >= -40 -> 100 // Excellent signal + rssi >= -55 -> 85 // Very good signal + rssi >= -70 -> 70 // Good signal + rssi >= -85 -> 50 // Fair signal + rssi >= -100 -> 25 // Poor signal + else -> 0 // Very poor or no signal + } +} + +/** + * Nested Private Chat Sheet - iOS-style nested bottom sheet + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun PrivateChatSheet( + isPresented: Boolean, + peerID: String, + viewModel: ChatViewModel, + onDismiss: () -> Unit +) { + val colorScheme = MaterialTheme.colorScheme + val privateChats by viewModel.privateChats.collectAsStateWithLifecycle() + val peerNicknames by viewModel.peerNicknames.collectAsStateWithLifecycle() + val nickname by viewModel.nickname.collectAsStateWithLifecycle() + val connectedPeers by viewModel.connectedPeers.collectAsStateWithLifecycle() + val peerDirectMap by viewModel.peerDirect.collectAsStateWithLifecycle() + val peerSessionStates by viewModel.peerSessionStates.collectAsStateWithLifecycle() + val favoritePeers by viewModel.favoritePeers.collectAsStateWithLifecycle() + val peerFingerprints by viewModel.peerFingerprints.collectAsStateWithLifecycle() + + val verifiedFingerprints by viewModel.verifiedFingerprints.collectAsStateWithLifecycle() + val wifiAwareConnected by com.bitchat.android.wifiaware.WifiAwareController.connectedPeers.collectAsStateWithLifecycle() + val isWifiAware = peerID in wifiAwareConnected.keys + + // Start private chat when screen opens + LaunchedEffect(peerID) { + viewModel.startPrivateChat(peerID) + } + + val isNostrPeer = peerID.startsWith("nostr_") || peerID.startsWith("nostr:") + + // Compute display name and title text reactively + val displayName = peerNicknames[peerID] ?: peerID.take(12) + val titleText = remember(peerID, peerNicknames) { + if (isNostrPeer) { + val gh = GeohashConversationRegistry.get(peerID) ?: "geohash" + val fullPubkey = GeohashAliasRegistry.get(peerID) ?: "" + val name = if (fullPubkey.isNotEmpty()) { + viewModel.geohashViewModel.displayNameForGeohashConversation(fullPubkey, gh) + } else { + peerNicknames[peerID] ?: "unknown" + } + "#$gh/@$name" + } else { + peerNicknames[peerID] ?: peerID.take(12) + } + } + + val messages = privateChats[peerID] ?: emptyList() + val isDirect = peerDirectMap[peerID] == true + val isConnected = connectedPeers.contains(peerID) || isDirect + val sessionState = peerSessionStates[peerID] + val fingerprint = peerFingerprints[peerID] + val isFavorite = remember(favoritePeers, fingerprint) { + if (fingerprint != null) favoritePeers.contains(fingerprint) else viewModel.isFavorite(peerID) + } + + val isVerified = remember(peerID, verifiedFingerprints) { + viewModel.isPeerVerified(peerID, verifiedFingerprints) + } + + val securityModifier = if (!isNostrPeer) { + Modifier.clickable { viewModel.showSecurityVerificationSheet() } + } else { + Modifier + } + + val sheetState = rememberModalBottomSheetState( + skipPartiallyExpanded = true + ) + + if (isPresented) { + BitchatBottomSheet( + onDismissRequest = onDismiss, + sheetState = sheetState, + ) { + Box(modifier = Modifier.fillMaxSize()) { + Column( + modifier = Modifier.fillMaxSize() + ) { + Spacer(modifier = Modifier.height(64.dp)) + + HorizontalDivider(color = colorScheme.outline.copy(alpha = 0.3f)) + + // Messages list + var forceScrollToBottom by remember { mutableStateOf(false) } + var isScrolledUp by remember { mutableStateOf(false) } + + MessagesList( + messages = messages, + currentUserNickname = nickname, + meshService = viewModel.meshServiceFacade, + modifier = Modifier.weight(1f), + forceScrollToBottom = forceScrollToBottom, + onScrolledUpChanged = { isUp -> isScrolledUp = isUp }, + onNicknameClick = { /* handle mention */ }, + onMessageLongPress = { /* handle long press */ }, + onCancelTransfer = { msg -> viewModel.cancelMediaSend(msg.id) }, + onImageClick = { _, _, _ -> /* handle image click */ } + ) + + HorizontalDivider(color = colorScheme.outline.copy(alpha = 0.3f)) + + // Input section + var messageText by remember { + mutableStateOf( + androidx.compose.ui.text.input.TextFieldValue( + "" + ) + ) + } + + ChatInputSection( + messageText = messageText, + onMessageTextChange = { newText -> + messageText = newText + viewModel.updateMentionSuggestions(newText.text) + }, + onSend = { + if (messageText.text.trim().isNotEmpty()) { + viewModel.sendMessage(messageText.text.trim()) + messageText = androidx.compose.ui.text.input.TextFieldValue("") + forceScrollToBottom = !forceScrollToBottom + } + }, + onSendVoiceNote = { peer, channel, path -> + viewModel.sendVoiceNote(peer, channel, path) + }, + onSendImageNote = { peer, channel, path -> + viewModel.sendImageNote(peer, channel, path) + }, + onSendFileNote = { peer, channel, path -> + viewModel.sendFileNote(peer, channel, path) + }, + showCommandSuggestions = false, + commandSuggestions = emptyList(), + showMentionSuggestions = false, + mentionSuggestions = emptyList(), + onCommandSuggestionClick = { }, + onMentionSuggestionClick = { }, + selectedPrivatePeer = peerID, + currentChannel = null, + nickname = nickname, + colorScheme = colorScheme, + showMediaButtons = true + ) + } + + // TopBar (fixed at top, iOS-style) + BitchatSheetCenterTopBar( + onClose = onDismiss, + modifier = Modifier.align(Alignment.TopCenter), + navigationIcon = { + IconButton( + onClick = onDismiss, + modifier = Modifier + .align(Alignment.CenterStart) + .padding(start = 16.dp) + .size(32.dp) + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(R.string.chat_back), + tint = colorScheme.onSurface + ) + } + }, + title = { + // Center content: connection status + name + encryption + Row( + modifier = Modifier.align(Alignment.Center), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp) + ) { + when { + isNostrPeer -> { + Icon( + imageVector = Icons.Filled.Public, + contentDescription = stringResource(R.string.cd_nostr_reachable), + modifier = Modifier.size(14.dp), + tint = Color(0xFF9C27B0) + ) + } + isWifiAware -> { + Icon( + imageVector = Icons.Filled.Wifi, + contentDescription = "Direct Wi-Fi Aware", + modifier = Modifier.size(14.dp), + tint = colorScheme.onSurface.copy(alpha = 0.6f) + ) + } + isDirect -> { + Icon( + imageVector = Icons.Outlined.Bluetooth, + contentDescription = "Direct Bluetooth", + modifier = Modifier.size(14.dp), + tint = colorScheme.onSurface.copy(alpha = 0.6f) + ) + } + isConnected -> { + Icon( + imageVector = Icons.Filled.Route, + contentDescription = "Routed", + modifier = Modifier.size(14.dp), + tint = colorScheme.onSurface.copy(alpha = 0.6f) + ) + } + } + + Text( + text = titleText, + style = MaterialTheme.typography.titleMedium.copy( + fontWeight = FontWeight.Bold, + fontFamily = FontFamily.Monospace + ), + color = if (isNostrPeer) Color(0xFFFF9500) else colorScheme.onSurface + ) + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.then(securityModifier) + ) { + if (!isNostrPeer) { + NoiseSessionIcon( + sessionState = sessionState, + modifier = Modifier.size(14.dp) + ) + } + + if (isVerified) { + Spacer(modifier = Modifier.width(4.dp)) + Icon( + imageVector = Icons.Filled.Verified, + contentDescription = stringResource(R.string.verify_title), + modifier = Modifier.size(14.dp), + tint = Color(0xFF32D74B) // iOS Green + ) + } + } + + IconButton( + onClick = { viewModel.toggleFavorite(peerID) }, + modifier = Modifier.size(28.dp) + ) { + Icon( + imageVector = if (isFavorite) Icons.Filled.Star else Icons.Outlined.Star, + contentDescription = if (isFavorite) stringResource(R.string.cd_remove_favorite) else stringResource(R.string.cd_add_favorite), + modifier = Modifier.size(16.dp), + tint = if (isFavorite) Color(0xFFFFD700) else colorScheme.onSurface.copy(alpha = 0.6f) + ) + } + } + } + ) + } + } + } +} diff --git a/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt b/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt index e155b56f..88b24015 100644 --- a/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt +++ b/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt @@ -31,7 +31,7 @@ import android.content.Intent import android.net.Uri import com.bitchat.android.model.BitchatMessage import com.bitchat.android.model.DeliveryStatus -import com.bitchat.android.mesh.BluetoothMeshService +import com.bitchat.android.mesh.MeshService import java.text.SimpleDateFormat import java.util.* import com.bitchat.android.ui.media.VoiceNotePlayer @@ -58,7 +58,7 @@ import androidx.compose.ui.res.stringResource fun MessagesList( messages: List, currentUserNickname: String, - meshService: BluetoothMeshService, + meshService: MeshService, modifier: Modifier = Modifier, forceScrollToBottom: Boolean = false, onScrolledUpChanged: ((Boolean) -> Unit)? = null, @@ -71,19 +71,14 @@ fun MessagesList( // Track if this is the first time messages are being loaded var hasScrolledToInitialPosition by remember { mutableStateOf(false) } + var followIncomingMessages by remember { mutableStateOf(true) } - // Smart scroll: auto-scroll to bottom for initial load, then only when user is at or near the bottom + // Smart scroll: auto-scroll to bottom for initial load, then follow unless user scrolls away LaunchedEffect(messages.size) { if (messages.isNotEmpty()) { - val layoutInfo = listState.layoutInfo - val firstVisibleIndex = layoutInfo.visibleItemsInfo.firstOrNull()?.index ?: -1 - - // With reverseLayout=true and reversed data, index 0 is the latest message at the bottom val isFirstLoad = !hasScrolledToInitialPosition - val isNearLatest = firstVisibleIndex <= 2 - - if (isFirstLoad || isNearLatest) { - listState.animateScrollToItem(0) + if (isFirstLoad || followIncomingMessages) { + listState.scrollToItem(0) if (isFirstLoad) { hasScrolledToInitialPosition = true } @@ -99,6 +94,7 @@ fun MessagesList( } } LaunchedEffect(isAtLatest) { + followIncomingMessages = isAtLatest onScrolledUpChanged?.invoke(!isAtLatest) } @@ -106,7 +102,8 @@ fun MessagesList( LaunchedEffect(forceScrollToBottom) { if (messages.isNotEmpty()) { // With reverseLayout=true and reversed data, latest is at index 0 - listState.animateScrollToItem(0) + followIncomingMessages = true + listState.scrollToItem(0) } } @@ -140,7 +137,7 @@ fun MessagesList( fun MessageItem( message: BitchatMessage, currentUserNickname: String, - meshService: BluetoothMeshService, + meshService: MeshService, messages: List = emptyList(), onNicknameClick: ((String) -> Unit)? = null, onMessageLongPress: ((BitchatMessage) -> Unit)? = null, @@ -204,7 +201,7 @@ fun MessageItem( message: BitchatMessage, messages: List, currentUserNickname: String, - meshService: BluetoothMeshService, + meshService: MeshService, colorScheme: ColorScheme, timeFormatter: SimpleDateFormat, onNicknameClick: ((String) -> Unit)?, diff --git a/app/src/main/java/com/bitchat/android/ui/NotificationManager.kt b/app/src/main/java/com/bitchat/android/ui/NotificationManager.kt index 622800ca..9eb72b48 100644 --- a/app/src/main/java/com/bitchat/android/ui/NotificationManager.kt +++ b/app/src/main/java/com/bitchat/android/ui/NotificationManager.kt @@ -280,6 +280,37 @@ class NotificationManager( Log.d(TAG, "Displayed notification for $contentTitle with ID $notificationId") } + fun showVerificationNotification(title: String, body: String, peerID: String? = null) { + val intent = Intent(context, MainActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP + if (peerID != null) { + putExtra(EXTRA_OPEN_PRIVATE_CHAT, true) + putExtra(EXTRA_PEER_ID, peerID) + putExtra(EXTRA_SENDER_NICKNAME, body) + } + } + + val pendingIntent = PendingIntent.getActivity( + context, + (System.currentTimeMillis() and 0x7FFFFFFF).toInt(), + intent, + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT + ) + + val builder = NotificationCompat.Builder(context, CHANNEL_ID) + .setSmallIcon(R.drawable.ic_notification) + .setContentTitle(title) + .setContentText(body) + .setContentIntent(pendingIntent) + .setAutoCancel(true) + .setPriority(NotificationCompat.PRIORITY_HIGH) + .setCategory(NotificationCompat.CATEGORY_STATUS) + .setShowWhen(true) + .setWhen(System.currentTimeMillis()) + + notificationManager.notify((System.currentTimeMillis() and 0x7FFFFFFF).toInt(), builder.build()) + } + private fun showNotificationForActivePeers(peersSize: Int) { // Create intent to open the app val intent = Intent(context, MainActivity::class.java).apply { diff --git a/app/src/main/java/com/bitchat/android/ui/PrivateChatManager.kt b/app/src/main/java/com/bitchat/android/ui/PrivateChatManager.kt index 098a83d7..1ddf8cbf 100644 --- a/app/src/main/java/com/bitchat/android/ui/PrivateChatManager.kt +++ b/app/src/main/java/com/bitchat/android/ui/PrivateChatManager.kt @@ -3,9 +3,9 @@ package com.bitchat.android.ui import com.bitchat.android.model.BitchatMessage import com.bitchat.android.model.DeliveryStatus import com.bitchat.android.mesh.PeerFingerprintManager +import com.bitchat.android.mesh.MeshService import java.security.MessageDigest -import com.bitchat.android.mesh.BluetoothMeshService import java.util.* import android.util.Log @@ -42,7 +42,7 @@ class PrivateChatManager( // MARK: - Private Chat Lifecycle - fun startPrivateChat(peerID: String, meshService: BluetoothMeshService): Boolean { + fun startPrivateChat(peerID: String, meshService: MeshService): Boolean { if (isPeerBlocked(peerID)) { val peerNickname = getPeerNickname(peerID, meshService) val systemMessage = BitchatMessage( @@ -188,7 +188,7 @@ class PrivateChatManager( // MARK: - Block/Unblock Operations - fun blockPeer(peerID: String, meshService: BluetoothMeshService): Boolean { + fun blockPeer(peerID: String, meshService: MeshService): Boolean { val fingerprint = fingerprintManager.getFingerprintForPeer(peerID) if (fingerprint != null) { dataManager.addBlockedUser(fingerprint) @@ -212,7 +212,7 @@ class PrivateChatManager( return false } - fun unblockPeer(peerID: String, meshService: BluetoothMeshService): Boolean { + fun unblockPeer(peerID: String, meshService: MeshService): Boolean { val fingerprint = fingerprintManager.getFingerprintForPeer(peerID) if (fingerprint != null && dataManager.isUserBlocked(fingerprint)) { dataManager.removeBlockedUser(fingerprint) @@ -230,7 +230,7 @@ class PrivateChatManager( return false } - fun blockPeerByNickname(targetName: String, meshService: BluetoothMeshService): Boolean { + fun blockPeerByNickname(targetName: String, meshService: MeshService): Boolean { val peerID = getPeerIDForNickname(targetName, meshService) if (peerID != null) { @@ -247,7 +247,7 @@ class PrivateChatManager( } } - fun unblockPeerByNickname(targetName: String, meshService: BluetoothMeshService): Boolean { + fun unblockPeerByNickname(targetName: String, meshService: MeshService): Boolean { val peerID = getPeerIDForNickname(targetName, meshService) if (peerID != null) { @@ -298,6 +298,16 @@ class PrivateChatManager( if (!isPeerBlocked(senderPeerID)) { // Ensure chat exists messageManager.initializePrivateChat(senderPeerID) + + // Exception: Nostr messages (nostr_ prefix) originate in Kotlin layer and MUST be added here. + if (senderPeerID.startsWith("nostr_")) { + if (suppressUnread) { + messageManager.addPrivateMessageNoUnread(senderPeerID, message) + } else { + messageManager.addPrivateMessage(senderPeerID, message) + } + } + // Track as unread for read receipt purposes if not focused if (!suppressUnread && state.getSelectedPrivateChatPeerValue() != senderPeerID) { val unreadList = unreadReceivedMessages.getOrPut(senderPeerID) { mutableListOf() } @@ -323,7 +333,7 @@ class PrivateChatManager( * Send read receipts for all unread messages from a specific peer * Called when the user focuses on a private chat */ - fun sendReadReceiptsForPeer(peerID: String, meshService: BluetoothMeshService) { + fun sendReadReceiptsForPeer(peerID: String, meshService: MeshService) { // Collect candidate messages: all incoming messages from this peer in the conversation val chats = try { state.getPrivateChatsValue() } catch (_: Exception) { emptyMap>() } val messages = chats[peerID].orEmpty() @@ -333,13 +343,16 @@ class PrivateChatManager( } val myNickname = state.getNicknameValue() ?: "unknown" + val hasMesh = try { meshService.getPeerInfo(peerID)?.isConnected == true && meshService.hasEstablishedSession(peerID) } catch (_: Exception) { false } var sentCount = 0 messages.forEach { msg -> // Only for incoming messages from this peer if (msg.senderPeerID == peerID) { try { - meshService.sendReadReceipt(msg.id, peerID, myNickname) - sentCount += 1 + if (hasMesh) { + meshService.sendReadReceipt(msg.id, peerID, myNickname) + sentCount += 1 + } } catch (e: Exception) { Log.w(TAG, "Failed to send read receipt for message ${msg.id}: ${e.message}") } @@ -370,7 +383,7 @@ class PrivateChatManager( * Establish Noise session if needed before starting private chat * Uses same lexicographical logic as MessageHandler.handleNoiseIdentityAnnouncement */ - private fun establishNoiseSessionIfNeeded(peerID: String, meshService: BluetoothMeshService) { + private fun establishNoiseSessionIfNeeded(peerID: String, meshService: MeshService) { if (noiseSessionDelegate.hasEstablishedSession(peerID)) { Log.d(TAG, "Noise session already established with $peerID") return @@ -403,11 +416,11 @@ class PrivateChatManager( // MARK: - Utility Functions - private fun getPeerIDForNickname(nickname: String, meshService: BluetoothMeshService): String? { + private fun getPeerIDForNickname(nickname: String, meshService: MeshService): String? { return meshService.getPeerNicknames().entries.find { it.value == nickname }?.key } - private fun getPeerNickname(peerID: String, meshService: BluetoothMeshService): String { + private fun getPeerNickname(peerID: String, meshService: MeshService): String { return meshService.getPeerNicknames()[peerID] ?: peerID } @@ -466,6 +479,12 @@ class PrivateChatManager( unread.add(targetPeerID) state.setUnreadPrivateMessages(unread) } + + // If we're currently viewing one of the temp aliases in the sheet, switch to the permanent ID + val sheetPeer = state.getPrivateChatSheetPeerValue() + if (sheetPeer != null && tryMergeKeys.contains(sheetPeer)) { + state.setPrivateChatSheetPeer(targetPeerID) + } } } diff --git a/app/src/main/java/com/bitchat/android/ui/SecurityVerificationSheet.kt b/app/src/main/java/com/bitchat/android/ui/SecurityVerificationSheet.kt new file mode 100644 index 00000000..2633f888 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/ui/SecurityVerificationSheet.kt @@ -0,0 +1,438 @@ +package com.bitchat.android.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Lock +import androidx.compose.material.icons.filled.Verified +import androidx.compose.material.icons.filled.Warning +import androidx.compose.material.icons.outlined.NoEncryption +import androidx.compose.material.icons.outlined.Sync +import androidx.compose.material.icons.outlined.Warning as OutlinedWarning +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.bitchat.android.R +import com.bitchat.android.core.ui.component.button.CloseButton +import com.bitchat.android.core.ui.component.sheet.BitchatBottomSheet + +private data class SecurityStatusInfo( + val text: String, + val icon: ImageVector, + val tint: Color +) + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SecurityVerificationSheet( + isPresented: Boolean, + onDismiss: () -> Unit, + viewModel: ChatViewModel, + modifier: Modifier = Modifier +) { + if (!isPresented) return + + val peerID by viewModel.selectedPrivateChatPeer.collectAsStateWithLifecycle() + val verifiedFingerprints by viewModel.verifiedFingerprints.collectAsStateWithLifecycle() + val peerSessionStates by viewModel.peerSessionStates.collectAsStateWithLifecycle() + + val isDark = isSystemInDarkTheme() + val accent = if (isDark) Color.Green else Color(0xFF008000) + val boxColor = if (isDark) Color.White.copy(alpha = 0.06f) else Color.Black.copy(alpha = 0.06f) + val peerHexRegex = remember { Regex("^[0-9a-fA-F]{16}$") } + + BitchatBottomSheet( + modifier = modifier, + onDismissRequest = onDismiss, + ) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + SecurityVerificationHeader( + accent = accent, + onClose = onDismiss + ) + + if (peerID == null) { + Text( + text = stringResource(R.string.fingerprint_no_peer), + style = MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace), + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f) + ) + } else { + val selectedPeerID = peerID!! + val displayName = viewModel.resolvePeerDisplayNameForFingerprint(selectedPeerID) + val fingerprint = viewModel.getPeerFingerprintForDisplay(selectedPeerID) + val isVerified = fingerprint != null && verifiedFingerprints.contains(fingerprint) + val sessionState = peerSessionStates[selectedPeerID] + val statusInfo = buildStatusInfo( + isVerified = isVerified, + sessionState = sessionState, + accent = accent + ) + + SecurityStatusCard( + displayName = displayName, + accent = accent, + boxColor = boxColor, + statusInfo = statusInfo + ) + + FingerprintBlock( + title = stringResource(R.string.fingerprint_their), + fingerprint = fingerprint, + boxColor = boxColor, + accent = accent + ) + + FingerprintBlock( + title = stringResource(R.string.fingerprint_yours), + fingerprint = viewModel.getMyFingerprint(), + boxColor = boxColor, + accent = accent + ) + + SecurityVerificationActions( + isVerified = isVerified, + fingerprint = fingerprint, + displayName = displayName, + accent = accent, + canStartHandshake = fingerprint == null && selectedPeerID.matches(peerHexRegex), + onStartHandshake = { viewModel.initiateMeshHandshake(selectedPeerID) }, + onVerify = { fp -> viewModel.verifyFingerprintValue(fp) }, + onUnverify = { fp -> viewModel.unverifyFingerprintValue(fp) } + ) + } + } + } +} + +@Composable +private fun SecurityVerificationHeader( + accent: Color, + onClose: () -> Unit +) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = stringResource(R.string.security_verification_title), + style = MaterialTheme.typography.titleSmall.copy( + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.Bold + ), + color = accent + ) + Spacer(modifier = Modifier.weight(1f)) + CloseButton(onClick = onClose) + } +} + +@Composable +private fun buildStatusInfo( + isVerified: Boolean, + sessionState: String?, + accent: Color +): SecurityStatusInfo { + val text = when { + isVerified -> stringResource(R.string.fingerprint_status_verified) + sessionState == "established" -> stringResource(R.string.fingerprint_status_encrypted) + sessionState == "handshaking" -> stringResource(R.string.fingerprint_status_handshaking) + sessionState == "failed" -> stringResource(R.string.fingerprint_status_failed) + else -> stringResource(R.string.fingerprint_status_uninitialized) + } + val icon = when { + isVerified -> Icons.Filled.Verified + sessionState == "handshaking" -> Icons.Outlined.Sync + sessionState == "failed" -> Icons.Outlined.OutlinedWarning + sessionState == "established" -> Icons.Filled.Lock + else -> Icons.Outlined.NoEncryption + } + val tint = when { + isVerified -> Color(0xFF32D74B) + sessionState == "failed" -> Color(0xFFFF3B30) + sessionState == "handshaking" -> Color(0xFFFF9500) + sessionState == "established" -> Color(0xFF32D74B) + else -> accent.copy(alpha = 0.6f) + } + return SecurityStatusInfo(text, icon, tint) +} + +@Composable +private fun SecurityStatusCard( + displayName: String, + accent: Color, + boxColor: Color, + statusInfo: SecurityStatusInfo +) { + Row( + modifier = Modifier + .fillMaxWidth() + .background(boxColor, shape = MaterialTheme.shapes.medium) + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = statusInfo.icon, + contentDescription = null, + tint = statusInfo.tint + ) + Spacer(modifier = Modifier.width(12.dp)) + Column { + Text( + text = displayName, + style = MaterialTheme.typography.titleMedium.copy( + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.Bold + ), + color = accent + ) + Text( + text = statusInfo.text, + style = MaterialTheme.typography.bodySmall.copy( + fontFamily = FontFamily.Monospace + ), + color = accent.copy(alpha = 0.8f) + ) + } + } +} + +@Composable +private fun SecurityVerificationActions( + isVerified: Boolean, + fingerprint: String?, + displayName: String, + accent: Color, + canStartHandshake: Boolean, + onStartHandshake: () -> Unit, + onVerify: (String) -> Unit, + onUnverify: (String) -> Unit +) { + if (canStartHandshake) { + Button( + onClick = onStartHandshake, + colors = ButtonDefaults.buttonColors( + containerColor = accent, + contentColor = Color.White + ), + modifier = Modifier.fillMaxWidth() + ) { + Text( + text = stringResource(R.string.fingerprint_start_handshake), + fontFamily = FontFamily.Monospace, + fontSize = 12.sp + ) + } + } + + if (isVerified) { + VerificationStatusRow( + icon = Icons.Filled.Verified, + iconTint = Color(0xFF32D74B), + text = stringResource(R.string.fingerprint_verified_label), + textTint = Color(0xFF32D74B) + ) + Text( + text = stringResource(R.string.fingerprint_verified_message), + style = MaterialTheme.typography.bodySmall.copy( + fontFamily = FontFamily.Monospace + ), + color = accent.copy(alpha = 0.7f), + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center + ) + Button( + onClick = { fingerprint?.let(onUnverify) }, + colors = ButtonDefaults.buttonColors( + containerColor = Color(0xFFFF3B30), + contentColor = Color.White + ), + modifier = Modifier.fillMaxWidth() + ) { + Text( + text = stringResource(R.string.verify_remove), + fontFamily = FontFamily.Monospace, + fontSize = 12.sp + ) + } + } else { + VerificationStatusRow( + icon = Icons.Filled.Warning, + iconTint = Color(0xFFFF9500), + text = stringResource(R.string.fingerprint_not_verified_label), + textTint = Color(0xFFFF9500) + ) + Text( + text = stringResource(R.string.fingerprint_not_verified_message_fmt, displayName), + style = MaterialTheme.typography.bodySmall.copy( + fontFamily = FontFamily.Monospace + ), + color = accent.copy(alpha = 0.7f), + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center + ) + if (fingerprint != null) { + Button( + onClick = { onVerify(fingerprint) }, + colors = ButtonDefaults.buttonColors( + containerColor = Color(0xFF34C759), + contentColor = Color.White + ), + modifier = Modifier.fillMaxWidth() + ) { + Text( + text = stringResource(R.string.fingerprint_mark_verified), + fontFamily = FontFamily.Monospace, + fontSize = 12.sp + ) + } + } + } +} + +@Composable +private fun VerificationStatusRow( + icon: ImageVector, + iconTint: Color, + text: String, + textTint: Color +) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = iconTint + ) + Spacer(modifier = Modifier.width(6.dp)) + Text( + text = text, + style = MaterialTheme.typography.bodyMedium.copy( + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.Bold + ), + color = textTint + ) + } +} + +@Composable +private fun FingerprintBlock( + title: String, + fingerprint: String?, + boxColor: Color, + accent: Color +) { + val clipboardManager = LocalClipboardManager.current + var showMenu by remember(fingerprint) { mutableStateOf(false) } + val interactionSource = remember { MutableInteractionSource() } + + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + text = title, + style = MaterialTheme.typography.labelSmall.copy( + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.Bold + ), + color = accent.copy(alpha = 0.8f) + ) + if (fingerprint != null) { + Column { + Text( + text = formatFingerprint(fingerprint), + style = MaterialTheme.typography.bodyMedium.copy( + fontFamily = FontFamily.Monospace, + fontSize = 14.sp + ), + color = accent, + textAlign = TextAlign.Center, + modifier = Modifier + .fillMaxWidth() + .combinedClickable( + interactionSource = interactionSource, + indication = null, + onClick = {}, + onLongClick = { showMenu = true } + ) + .background(boxColor, shape = MaterialTheme.shapes.small) + .padding(16.dp), + ) + DropdownMenu( + expanded = showMenu, + onDismissRequest = { showMenu = false } + ) { + DropdownMenuItem( + text = { Text(text = stringResource(R.string.fingerprint_copy)) }, + onClick = { + clipboardManager.setText(AnnotatedString(fingerprint)) + showMenu = false + } + ) + } + } + } else { + Text( + text = stringResource(R.string.fingerprint_pending), + style = MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace), + color = Color(0xFFFF9500), + modifier = Modifier.padding(16.dp) + ) + } + } +} + +private fun formatFingerprint(fingerprint: String): String { + val upper = fingerprint.uppercase() + val sb = StringBuilder() + upper.forEachIndexed { index, c -> + if (index > 0 && index % 4 == 0) { + if (index % 16 == 0) sb.append('\n') else sb.append(' ') + } + sb.append(c) + } + return sb.toString() +} diff --git a/app/src/main/java/com/bitchat/android/ui/SidebarComponents.kt b/app/src/main/java/com/bitchat/android/ui/SidebarComponents.kt deleted file mode 100644 index c504b9f9..00000000 --- a/app/src/main/java/com/bitchat/android/ui/SidebarComponents.kt +++ /dev/null @@ -1,731 +0,0 @@ -package com.bitchat.android.ui - -import com.bitchat.android.R -import android.util.Log -import androidx.compose.foundation.* -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.* -import androidx.compose.material.icons.outlined.* -import androidx.compose.material3.* -import androidx.compose.runtime.* -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import androidx.compose.ui.text.style.TextOverflow -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.bitchat.android.ui.theme.BASE_FONT_SIZE - - -/** - * Sidebar components for ChatScreen - * Extracted from ChatScreen.kt for better organization - */ - -@Composable -fun SidebarOverlay( - viewModel: ChatViewModel, - onDismiss: () -> Unit, - modifier: Modifier = Modifier -) { - val colorScheme = MaterialTheme.colorScheme - val interactionSource = remember { MutableInteractionSource() } - - val connectedPeers by viewModel.connectedPeers.collectAsStateWithLifecycle() - val joinedChannels by viewModel.joinedChannels.collectAsStateWithLifecycle() - val currentChannel by viewModel.currentChannel.collectAsStateWithLifecycle() - val selectedPrivatePeer by viewModel.selectedPrivateChatPeer.collectAsStateWithLifecycle() - val nickname by viewModel.nickname.collectAsStateWithLifecycle() - val unreadChannelMessages by viewModel.unreadChannelMessages.collectAsStateWithLifecycle() - val peerNicknames by viewModel.peerNicknames.collectAsStateWithLifecycle() - val peerRSSI by viewModel.peerRSSI.collectAsStateWithLifecycle() - - Box( - modifier = modifier - .background(Color.Black.copy(alpha = 0.5f)) - .clickable(indication = null, interactionSource = interactionSource) { onDismiss() } - ) { - Row( - modifier = Modifier - .fillMaxHeight() - .width(280.dp) - .align(Alignment.CenterEnd) - .clickable { /* Prevent dismissing when clicking sidebar */ } - ) { - // Grey vertical bar for visual continuity (matches iOS) - Box( - modifier = Modifier - .fillMaxHeight() - .width(1.dp) - .background(Color.Gray.copy(alpha = 0.3f)) - ) - - Column( - modifier = Modifier - .fillMaxHeight() - .weight(1f) - .background(colorScheme.background.copy(alpha = 0.95f)) - .windowInsetsPadding(WindowInsets.statusBars) // Add status bar padding - ) { - SidebarHeader() - - HorizontalDivider() - - // Scrollable content - LazyColumn( - modifier = Modifier.fillMaxSize(), - contentPadding = PaddingValues(vertical = 8.dp), - verticalArrangement = Arrangement.spacedBy(12.dp) - ) { - // Channels section - if (joinedChannels.isNotEmpty()) { - item { - ChannelsSection( - channels = joinedChannels.toList(), // Convert Set to List - currentChannel = currentChannel, - colorScheme = colorScheme, - onChannelClick = { channel -> - viewModel.switchToChannel(channel) - onDismiss() - }, - onLeaveChannel = { channel -> - viewModel.leaveChannel(channel) - }, - unreadChannelMessages = unreadChannelMessages - ) - } - - item { - HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp)) - } - } - - // People section - switch between mesh and geohash lists (iOS-compatible) - item { - val selectedLocationChannel by viewModel.selectedLocationChannel.collectAsState() - - when (selectedLocationChannel) { - is com.bitchat.android.geohash.ChannelID.Location -> { - // Show geohash people list when in location channel - GeohashPeopleList( - viewModel = viewModel, - onTapPerson = onDismiss - ) - } - else -> { - // Show mesh peer list when in mesh channel (default) - PeopleSection( - modifier = modifier.padding(bottom = 16.dp), - connectedPeers = connectedPeers, - peerNicknames = peerNicknames, - peerRSSI = peerRSSI, - nickname = nickname, - colorScheme = colorScheme, - selectedPrivatePeer = selectedPrivatePeer, - viewModel = viewModel, - onPrivateChatStart = { peerID -> - viewModel.startPrivateChat(peerID) - onDismiss() - } - ) - } - } - } - } - } - } - } -} - -@Composable -private fun SidebarHeader() { - val colorScheme = MaterialTheme.colorScheme - - Row( - modifier = Modifier - .height(42.dp) // Match reduced main header height - .fillMaxWidth() - .background(colorScheme.background.copy(alpha = 0.95f)) - .padding(horizontal = 12.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = stringResource(id = R.string.your_network).uppercase(), - style = MaterialTheme.typography.titleMedium.copy( - fontWeight = FontWeight.Bold, - fontFamily = FontFamily.Monospace - ), - color = colorScheme.onSurface - ) - Spacer(modifier = Modifier.weight(1f)) - } -} - -@Composable -fun ChannelsSection( - channels: List, - currentChannel: String?, - colorScheme: ColorScheme, - onChannelClick: (String) -> Unit, - onLeaveChannel: (String) -> Unit, - unreadChannelMessages: Map = emptyMap() -) { - Column { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Icon( - imageVector = Icons.Default.Person, // Using Person icon as placeholder - contentDescription = null, - modifier = Modifier.size(10.dp), - tint = colorScheme.onSurface.copy(alpha = 0.6f) - ) - Spacer(modifier = Modifier.width(6.dp)) - Text( - text = stringResource(id = R.string.channels).uppercase(), - style = MaterialTheme.typography.labelSmall, - color = colorScheme.onSurface.copy(alpha = 0.6f), - fontWeight = FontWeight.Bold - ) - } - - channels.forEach { channel -> - val isSelected = channel == currentChannel - val unreadCount = unreadChannelMessages[channel] ?: 0 - - Row( - modifier = Modifier - .fillMaxWidth() - .clickable { onChannelClick(channel) } - .background( - if (isSelected) colorScheme.primaryContainer.copy(alpha = 0.3f) - else Color.Transparent - ) - .padding(horizontal = 24.dp, vertical = 8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - // Unread badge for channels - UnreadBadge( - count = unreadCount, - colorScheme = colorScheme, - modifier = Modifier.padding(end = 8.dp) - ) - - Text( - text = channel, // Channel already contains the # prefix - style = MaterialTheme.typography.bodyMedium, - color = if (isSelected) colorScheme.primary else colorScheme.onSurface, - fontWeight = if (isSelected) FontWeight.Medium else FontWeight.Normal, - modifier = Modifier.weight(1f) - ) - - // Leave channel button - IconButton( - onClick = { onLeaveChannel(channel) }, - modifier = Modifier.size(24.dp) - ) { - Icon( - imageVector = Icons.Default.Close, - contentDescription = stringResource(com.bitchat.android.R.string.cd_leave_channel), - modifier = Modifier.size(14.dp), - tint = colorScheme.onSurface.copy(alpha = 0.5f) - ) - } - } - } - } -} - -@Composable -fun PeopleSection( - modifier: Modifier = Modifier, - connectedPeers: List, - peerNicknames: Map, - peerRSSI: Map, - nickname: String, - colorScheme: ColorScheme, - selectedPrivatePeer: String?, - viewModel: ChatViewModel, - onPrivateChatStart: (String) -> Unit -) { - Column(modifier = modifier) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Icon( - imageVector = Icons.Default.Group, // Using Person icon for people - contentDescription = null, - modifier = Modifier.size(12.dp), - tint = colorScheme.onSurface.copy(alpha = 0.6f) - ) - Spacer(modifier = Modifier.width(6.dp)) - Text( - text = stringResource(id = R.string.people).uppercase(), - style = MaterialTheme.typography.labelSmall, - color = colorScheme.onSurface.copy(alpha = 0.6f), - fontWeight = FontWeight.Bold - ) - } - - if (connectedPeers.isEmpty()) { - Text( - text = stringResource(id = R.string.no_one_connected), - style = MaterialTheme.typography.bodyMedium, - color = colorScheme.onSurface.copy(alpha = 0.5f), - modifier = Modifier.padding(horizontal = 24.dp, vertical = 8.dp) - ) - } - - // Observe reactive state for favorites and fingerprints - val hasUnreadPrivateMessages by viewModel.unreadPrivateMessages.collectAsStateWithLifecycle() - val privateChats by viewModel.privateChats.collectAsStateWithLifecycle() - val favoritePeers by viewModel.favoritePeers.collectAsStateWithLifecycle() - val peerFingerprints by viewModel.peerFingerprints.collectAsStateWithLifecycle() - - // Reactive favorite computation for all peers - val peerFavoriteStates = remember(favoritePeers, peerFingerprints, connectedPeers) { - connectedPeers.associateWith { peerID -> - // Reactive favorite computation - same as ChatHeader - val fingerprint = peerFingerprints[peerID] - fingerprint != null && favoritePeers.contains(fingerprint) - } - } - - // Build mapping of connected peerID -> noise key hex to unify with offline favorites - val noiseHexByPeerID: Map = connectedPeers.associateWith { pid -> - try { - viewModel.meshService.getPeerInfo(pid)?.noisePublicKey?.joinToString("") { b -> "%02x".format(b) } - } catch (_: Exception) { null } - }.filterValues { it != null }.mapValues { it.value!! } - - Log.d("SidebarComponents", "Recomposing with ${favoritePeers.size} favorites, peer states: $peerFavoriteStates") - - // Smart sorting: unread DMs first, then by most recent DM, then favorites, then alphabetical - val sortedPeers = connectedPeers.sortedWith( - compareBy { !hasUnreadPrivateMessages.contains(it) } // Unread DM senders first - .thenByDescending { privateChats[it]?.maxByOrNull { msg -> msg.timestamp }?.timestamp?.time ?: 0L } // Most recent DM (convert Date to Long) - .thenBy { !(peerFavoriteStates[it] ?: false) } // Favorites first - .thenBy { (if (it == nickname) "You" else (peerNicknames[it] ?: it)).lowercase() } // Alphabetical - ) - - // Build a map of base name counts across all people shown in the list (connected + offline + nostr) - val hex64Regex = Regex("^[0-9a-fA-F]{64}$") - - // Helper to compute display name used for a given key - fun computeDisplayNameForPeerId(key: String): String { - return if (key == nickname) "You" else (peerNicknames[key] ?: (privateChats[key]?.lastOrNull()?.sender ?: key.take(12))) - } - - val baseNameCounts = mutableMapOf() - - // Connected peers - sortedPeers.forEach { pid -> - val dn = computeDisplayNameForPeerId(pid) - val (b, _) = com.bitchat.android.ui.splitSuffix(dn) - if (b != "You") baseNameCounts[b] = (baseNameCounts[b] ?: 0) + 1 - } - - // Offline favorites (exclude ones mapped to connected) - val offlineFavorites = com.bitchat.android.favorites.FavoritesPersistenceService.shared.getOurFavorites() - offlineFavorites.forEach { fav -> - val favPeerID = fav.peerNoisePublicKey.joinToString("") { b -> "%02x".format(b) } - val isMappedToConnected = noiseHexByPeerID.values.any { it.equals(favPeerID, ignoreCase = true) } - if (!isMappedToConnected) { - val dn = peerNicknames[favPeerID] ?: fav.peerNickname - val (b, _) = com.bitchat.android.ui.splitSuffix(dn) - if (b != "You") baseNameCounts[b] = (baseNameCounts[b] ?: 0) + 1 - } - } - - // Nostr-only conversations - val connectedIds = sortedPeers.toSet() - val appendedOfflineIds = mutableSetOf() - privateChats.keys - .filter { key -> - (key.startsWith("nostr_") || hex64Regex.matches(key)) && - !connectedIds.contains(key) && - !noiseHexByPeerID.values.any { it.equals(key, ignoreCase = true) } - } - .forEach { convKey -> - val dn = peerNicknames[convKey] ?: (privateChats[convKey]?.lastOrNull()?.sender ?: convKey.take(12)) - val (b, _) = com.bitchat.android.ui.splitSuffix(dn) - if (b != "You") baseNameCounts[b] = (baseNameCounts[b] ?: 0) + 1 - } - - sortedPeers.forEach { peerID -> - val isFavorite = peerFavoriteStates[peerID] ?: false - // fingerprint and favorite relationship resolution not needed here; UI will show Nostr globe for appended offline favorites below - - val noiseHex = noiseHexByPeerID[peerID] - val meshUnread = hasUnreadPrivateMessages.contains(peerID) - val nostrUnread = if (noiseHex != null) hasUnreadPrivateMessages.contains(noiseHex) else false - val combinedHasUnread = meshUnread || nostrUnread - val combinedUnreadCount = ( - privateChats[peerID]?.count { msg -> msg.sender != nickname && meshUnread } ?: 0 - ) + ( - if (noiseHex != null) privateChats[noiseHex]?.count { msg -> msg.sender != nickname && nostrUnread } ?: 0 else 0 - ) - - val displayName = if (peerID == nickname) "You" else (peerNicknames[peerID] ?: (privateChats[peerID]?.lastOrNull()?.sender ?: peerID.take(12))) - val (bName, _) = com.bitchat.android.ui.splitSuffix(displayName) - val showHash = (baseNameCounts[bName] ?: 0) > 1 - - val directMap by viewModel.peerDirect.collectAsStateWithLifecycle() - val isDirectLive = directMap[peerID] ?: try { viewModel.meshService.getPeerInfo(peerID)?.isDirectConnection == true } catch (_: Exception) { false } - PeerItem( - peerID = peerID, - displayName = displayName, - isDirect = isDirectLive, - isSelected = peerID == selectedPrivatePeer, - isFavorite = isFavorite, - hasUnreadDM = combinedHasUnread, - colorScheme = colorScheme, - viewModel = viewModel, - onItemClick = { onPrivateChatStart(peerID) }, - onToggleFavorite = { - Log.d("SidebarComponents", "Sidebar toggle favorite: peerID=$peerID, currentFavorite=$isFavorite") - viewModel.toggleFavorite(peerID) - }, - unreadCount = if (combinedUnreadCount > 0) combinedUnreadCount else if (combinedHasUnread) 1 else 0, - showNostrGlobe = false, - showHashSuffix = showHash - ) - } - - // Append offline favorites we actively favorite (and not currently connected) - offlineFavorites.forEach { fav -> - val favPeerID = fav.peerNoisePublicKey.joinToString("") { b -> "%02x".format(b) } - // If any connected peer maps to this noise key, skip showing the offline entry - val isMappedToConnected = noiseHexByPeerID.values.any { it.equals(favPeerID, ignoreCase = true) } - if (isMappedToConnected) return@forEach - - // Resolve potential Nostr conversation key for this favorite (for unread detection) - val nostrConvKey: String? = try { - val npubOrHex = com.bitchat.android.favorites.FavoritesPersistenceService.shared.findNostrPubkey(fav.peerNoisePublicKey) - if (npubOrHex != null) { - val hex = if (npubOrHex.startsWith("npub")) { - val (hrp, data) = com.bitchat.android.nostr.Bech32.decode(npubOrHex) - if (hrp == "npub") data.joinToString("") { "%02x".format(it) } else null - } else { - npubOrHex.lowercase() - } - hex?.let { "nostr_${it.take(16)}" } - } else null - } catch (_: Exception) { null } - - val hasUnread = hasUnreadPrivateMessages.contains(favPeerID) || (nostrConvKey != null && hasUnreadPrivateMessages.contains(nostrConvKey)) - - // If user clicks an offline favorite and the mapped peer is currently connected under a different ID, - // open chat with the connected peerID instead of the noise hex for a seamless window - val mappedConnectedPeerID = noiseHexByPeerID.entries.firstOrNull { it.value.equals(favPeerID, ignoreCase = true) }?.key - val dn = peerNicknames[favPeerID] ?: fav.peerNickname - val (bName, _) = com.bitchat.android.ui.splitSuffix(dn) - val showHash = (baseNameCounts[bName] ?: 0) > 1 - - // Compute unreadCount from either noise conversation or Nostr conversation - val unreadCount = ( - privateChats[favPeerID]?.count { msg -> msg.sender != nickname && hasUnreadPrivateMessages.contains(favPeerID) } ?: 0 - ) + ( - if (nostrConvKey != null) privateChats[nostrConvKey]?.count { msg -> msg.sender != nickname && hasUnreadPrivateMessages.contains(nostrConvKey) } ?: 0 else 0 - ) - - PeerItem( - peerID = favPeerID, - displayName = dn, - isDirect = false, - isSelected = (mappedConnectedPeerID ?: favPeerID) == selectedPrivatePeer, - isFavorite = true, - hasUnreadDM = hasUnread, - colorScheme = colorScheme, - viewModel = viewModel, - onItemClick = { onPrivateChatStart(mappedConnectedPeerID ?: favPeerID) }, - onToggleFavorite = { - Log.d("SidebarComponents", "Sidebar toggle favorite (offline): peerID=$favPeerID") - viewModel.toggleFavorite(favPeerID) - }, - unreadCount = if (unreadCount > 0) unreadCount else if (hasUnread) 1 else 0, - showNostrGlobe = (fav.isMutual && fav.peerNostrPublicKey != null), - showHashSuffix = showHash - ) - appendedOfflineIds.add(favPeerID) - } - - // NOTE: Do NOT append Nostr-only (nostr_*) conversations to the mesh people list. - // Geohash DMs should appear in the GeohashPeople list for the active geohash, not in mesh offline contacts. - // We intentionally remove previously-added behavior that mixed geohash DMs into mesh sidebar. - // If you need to surface non-geohash offline mesh conversations in the future, do it here for 64-hex noise IDs only. - /* - val alreadyShownIds = connectedIds + appendedOfflineIds - privateChats.keys - .filter { key -> - // Only include 64-hex noise IDs (mesh identities); exclude any nostr_* aliases - hex64Regex.matches(key) && - !alreadyShownIds.contains(key) && - // Skip if this key maps to a connected peer via noiseHex mapping - !noiseHexByPeerID.values.any { it.equals(key, ignoreCase = true) } - } - .sortedBy { key -> privateChats[key]?.lastOrNull()?.timestamp } - .forEach { convKey -> - val lastSender = privateChats[convKey]?.lastOrNull()?.sender - val dn = peerNicknames[convKey] ?: (lastSender ?: convKey.take(12)) - val (bName, _) = com.bitchat.android.ui.splitSuffix(dn) - val showHash = (baseNameCounts[bName] ?: 0) > 1 - - PeerItem( - peerID = convKey, - displayName = dn, - isDirect = false, - isSelected = convKey == selectedPrivatePeer, - isFavorite = false, - hasUnreadDM = hasUnreadPrivateMessages.contains(convKey), - colorScheme = colorScheme, - viewModel = viewModel, - onItemClick = { onPrivateChatStart(convKey) }, - onToggleFavorite = { viewModel.toggleFavorite(convKey) }, - unreadCount = privateChats[convKey]?.count { msg -> - msg.sender != nickname && hasUnreadPrivateMessages.contains(convKey) - } ?: if (hasUnreadPrivateMessages.contains(convKey)) 1 else 0, - showNostrGlobe = false, - showHashSuffix = showHash - ) - } - */ - // End intentional removal - - } -} - -@Composable -private fun PeerItem( - peerID: String, - displayName: String, - isDirect: Boolean, - isSelected: Boolean, - isFavorite: Boolean, - hasUnreadDM: Boolean, - colorScheme: ColorScheme, - viewModel: ChatViewModel, - onItemClick: () -> Unit, - onToggleFavorite: () -> Unit, - unreadCount: Int = 0, - showNostrGlobe: Boolean = false, - showHashSuffix: Boolean = true -) { - // Split display name for hashtag suffix support (iOS-compatible) - val (baseNameRaw, suffixRaw) = com.bitchat.android.ui.splitSuffix(displayName) - val baseName = truncateNickname(baseNameRaw) - val suffix = if (showHashSuffix) suffixRaw else "" - val isMe = displayName == "You" || peerID == viewModel.nickname.value - - // Get consistent peer color (iOS-compatible) - val isDark = colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f - val assignedColor = viewModel.colorForMeshPeer(peerID, isDark) - val baseColor = if (isMe) Color(0xFFFF9500) else assignedColor - - Row( - modifier = Modifier - .fillMaxWidth() - .clickable { onItemClick() } - .background( - if (isSelected) colorScheme.primaryContainer.copy(alpha = 0.3f) - else Color.Transparent - ) - .padding(horizontal = 24.dp, vertical = 8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - // Show unread badge or signal strength - if (hasUnreadDM) { - // Show mail icon for unread DMs (iOS orange) - Icon( - imageVector = Icons.Filled.Email, - contentDescription = stringResource(com.bitchat.android.R.string.cd_unread_message), - modifier = Modifier.size(16.dp), - tint = Color(0xFFFF9500) // iOS orange - ) - } else { - // Connection indicator icons - if (showNostrGlobe) { - // Purple globe to indicate Nostr availability - Icon( - imageVector = Icons.Filled.Public, - contentDescription = stringResource(com.bitchat.android.R.string.cd_reachable_via_nostr), - modifier = Modifier.size(16.dp), - tint = Color(0xFF9C27B0) // Purple - ) - } else if (!isDirect && isFavorite) { - // Offline favorited user: show outlined circle icon - Icon( - //painter = androidx.compose.ui.res.painterResource(id = R.drawable.ic_offline_favorite), - imageVector = Icons.Outlined.Circle, - contentDescription = stringResource(com.bitchat.android.R.string.cd_offline_favorite), - modifier = Modifier.size(16.dp), - tint = Color.Gray - ) - } else { - val awareConnected by com.bitchat.android.wifiaware.WifiAwareController.connectedPeers.collectAsState() - val awareDiscovered by com.bitchat.android.wifiaware.WifiAwareController.discoveredPeers.collectAsState() - val isWifiDirect = awareConnected.containsKey(peerID) - val isBleDirect = isDirect - val icon = when { - isWifiDirect -> Icons.Filled.Wifi - isBleDirect -> Icons.Outlined.SettingsInputAntenna - // Routed: show Route icon; optionally prefer Wi‑Fi Aware if discovered there - awareDiscovered.contains(peerID) -> Icons.Filled.WifiTethering - else -> Icons.Filled.Route - } - val cd = when { - isWifiDirect -> "Direct Wi‑Fi Aware" - isBleDirect -> "Direct Bluetooth" - awareDiscovered.contains(peerID) -> "Routed over Wi‑Fi" - else -> "Routed" - } - Icon( - imageVector = icon, - contentDescription = cd, - modifier = Modifier.size(16.dp), - tint = colorScheme.onSurface.copy(alpha = 0.8f) - ) - } - } - - Spacer(modifier = Modifier.width(8.dp)) - - // Display name with iOS-style color and hashtag suffix support - Row( - modifier = Modifier.weight(1f), - verticalAlignment = Alignment.CenterVertically - ) { - // Base name with peer-specific color - Text( - text = baseName, - style = MaterialTheme.typography.bodyMedium.copy( - fontFamily = FontFamily.Monospace, - fontSize = BASE_FONT_SIZE.sp, - fontWeight = if (isMe) FontWeight.Bold else FontWeight.Normal - ), - color = baseColor, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - - // Hashtag suffix in lighter shade (iOS-style) - if (suffix.isNotEmpty()) { - Text( - text = suffix, - style = MaterialTheme.typography.bodyMedium.copy( - fontFamily = FontFamily.Monospace, - fontSize = BASE_FONT_SIZE.sp - ), - color = baseColor.copy(alpha = 0.6f) - ) - } - } - - // Favorite star with proper filled/outlined states - IconButton( - onClick = onToggleFavorite, - modifier = Modifier.size(24.dp) - ) { - Icon( - imageVector = if (isFavorite) Icons.Filled.Star else Icons.Outlined.Star, - contentDescription = if (isFavorite) "Remove from favorites" else "Add to favorites", - modifier = Modifier.size(16.dp), - tint = if (isFavorite) Color(0xFFFFD700) else Color(0xFF4CAF50) - ) - } - } -} - - - -@Composable -private fun SignalStrengthIndicator( - signalStrength: Int, - colorScheme: ColorScheme -) { - Row(modifier = Modifier.width(24.dp)) { - repeat(3) { index -> - val opacity = when { - signalStrength >= (index + 1) * 33 -> 1f - else -> 0.2f - } - Box( - modifier = Modifier - .size(width = 3.dp, height = (4 + index * 2).dp) - .background( - colorScheme.onSurface.copy(alpha = opacity), - RoundedCornerShape(1.dp) - ) - ) - if (index < 2) Spacer(modifier = Modifier.width(2.dp)) - } - } -} - -/** - * Reusable unread badge component for both channels and private messages - */ -@Composable -private fun UnreadBadge( - count: Int, - colorScheme: ColorScheme, - modifier: Modifier = Modifier -) { - if (count > 0) { - Box( - modifier = modifier - .background( - color = Color(0xFFFFD700), // Yellow color - shape = RoundedCornerShape(10.dp) - ) - .padding(horizontal = 2.dp, vertical = 0.dp) - .defaultMinSize(minWidth = 14.dp, minHeight = 14.dp), - contentAlignment = Alignment.Center - ) { - Text( - text = if (count > 99) "99+" else count.toString(), - style = MaterialTheme.typography.labelSmall.copy( - fontSize = 10.sp, - fontWeight = FontWeight.Bold - ), - color = Color.Black // Black text on yellow background - ) - } - } -} - -/** - * Convert RSSI value (dBm) to signal strength percentage (0-100) - * RSSI typically ranges from -30 (excellent) to -100 (very poor) - * Maps to 0-100 scale where: - * - 0-32: No signal (0 bars) - * - 33-65: Weak (1 bar) - * - 66-98: Good (2 bars) - * - 99-100: Excellent (3 bars) - */ -private fun convertRSSIToSignalStrength(rssi: Int?): Int { - if (rssi == null) return 0 - - return when { - rssi >= -40 -> 100 // Excellent signal - rssi >= -55 -> 85 // Very good signal - rssi >= -70 -> 70 // Good signal - rssi >= -85 -> 50 // Fair signal - rssi >= -100 -> 25 // Poor signal - else -> 0 // Very poor or no signal - } -} diff --git a/app/src/main/java/com/bitchat/android/ui/VerificationHandler.kt b/app/src/main/java/com/bitchat/android/ui/VerificationHandler.kt new file mode 100644 index 00000000..28c051bc --- /dev/null +++ b/app/src/main/java/com/bitchat/android/ui/VerificationHandler.kt @@ -0,0 +1,371 @@ +package com.bitchat.android.ui + +import android.content.Context +import com.bitchat.android.R +import com.bitchat.android.favorites.FavoritesPersistenceService +import com.bitchat.android.identity.SecureIdentityStateManager +import com.bitchat.android.mesh.MeshService +import com.bitchat.android.model.BitchatMessage +import com.bitchat.android.noise.NoiseSession +import com.bitchat.android.nostr.GeohashAliasRegistry +import com.bitchat.android.services.VerificationService +import com.bitchat.android.util.dataFromHexString +import com.bitchat.android.util.hexEncodedString +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import java.security.MessageDigest +import java.util.Date +import java.util.concurrent.ConcurrentHashMap + +/** + * Handles QR verification logic and state, extracted from ChatViewModel. + */ +class VerificationHandler( + private val context: Context, + private val scope: CoroutineScope, + private val getMeshService: () -> MeshService, + private val identityManager: SecureIdentityStateManager, + private val state: ChatState, + private val notificationManager: NotificationManager, + private val messageManager: MessageManager +) { + // Helper to get current mesh service (may change after panic clear) + private val meshService: MeshService + get() = getMeshService() + + private val _verifiedFingerprints = MutableStateFlow>(emptySet()) + val verifiedFingerprints: StateFlow> = _verifiedFingerprints.asStateFlow() + + private val pendingQRVerifications = ConcurrentHashMap() + private val lastVerifyNonceByPeer = ConcurrentHashMap() + private val lastInboundVerifyChallengeAt = ConcurrentHashMap() + private val lastMutualToastAt = ConcurrentHashMap() + + fun loadVerifiedFingerprints() { + _verifiedFingerprints.value = identityManager.getVerifiedFingerprints() + } + + fun isPeerVerified(peerID: String): Boolean { + if (peerID.startsWith("nostr_") || peerID.startsWith("nostr:")) return false + val fingerprint = getPeerFingerprintForDisplay(peerID) + return fingerprint != null && _verifiedFingerprints.value.contains(fingerprint) + } + + fun isNoisePublicKeyVerified(noisePublicKey: ByteArray): Boolean { + val fingerprint = fingerprintFromNoiseBytes(noisePublicKey) + return _verifiedFingerprints.value.contains(fingerprint) + } + + fun unverifyFingerprint(peerID: String) { + val fingerprint = meshService.getPeerFingerprint(peerID) ?: return + identityManager.setVerifiedFingerprint(fingerprint, false) + val current = _verifiedFingerprints.value.toMutableSet() + current.remove(fingerprint) + _verifiedFingerprints.value = current + } + + fun beginQRVerification(qr: VerificationService.VerificationQR): Boolean { + val targetNoise = qr.noiseKeyHex.lowercase() + val peerID = state.getConnectedPeersValue().firstOrNull { pid -> + val noiseKeyHex = meshService.getPeerInfo(pid)?.noisePublicKey?.hexEncodedString()?.lowercase() + noiseKeyHex == targetNoise + } ?: return false + + if (pendingQRVerifications.containsKey(peerID)) return true + val nonce = ByteArray(16) + java.security.SecureRandom().nextBytes(nonce) + val pending = PendingVerification(qr.noiseKeyHex, qr.signKeyHex, nonce, System.currentTimeMillis(), false) + pendingQRVerifications[peerID] = pending + + if (meshService.getSessionState(peerID) is NoiseSession.NoiseSessionState.Established) { + meshService.sendVerifyChallenge(peerID, qr.noiseKeyHex, nonce) + pendingQRVerifications[peerID] = pending.copy(sent = true) + } else { + meshService.initiateNoiseHandshake(peerID) + } + fingerprintFromNoiseHex(qr.noiseKeyHex)?.let { fp -> + identityManager.cacheFingerprintNickname(fp, qr.nickname) + identityManager.cacheNoiseFingerprint(qr.noiseKeyHex, fp) + identityManager.cachePeerNoiseKey(peerID, qr.noiseKeyHex) + } + return true + } + + fun sendPendingVerificationIfNeeded(peerID: String) { + val pending = pendingQRVerifications[peerID] ?: return + if (pending.sent) return + meshService.sendVerifyChallenge(peerID, pending.noiseKeyHex, pending.nonceA) + pendingQRVerifications[peerID] = pending.copy(sent = true) + } + + fun didReceiveVerifyChallenge(peerID: String, payload: ByteArray) { + scope.launch { + val parsed = VerificationService.parseVerifyChallenge(payload) ?: return@launch + val myNoiseHex = meshService.getStaticNoisePublicKey()?.hexEncodedString()?.lowercase() ?: return@launch + if (parsed.first.lowercase() != myNoiseHex) return@launch + + val lastNonce = lastVerifyNonceByPeer[peerID] + if (lastNonce != null && lastNonce.contentEquals(parsed.second)) return@launch + lastVerifyNonceByPeer[peerID] = parsed.second + + val fp = meshService.getPeerFingerprint(peerID) + if (fp != null) { + lastInboundVerifyChallengeAt[fp] = System.currentTimeMillis() + if (_verifiedFingerprints.value.contains(fp)) { + val lastToast = lastMutualToastAt[fp] ?: 0L + if (System.currentTimeMillis() - lastToast > 60_000L) { + lastMutualToastAt[fp] = System.currentTimeMillis() + val name = resolvePeerDisplayName(peerID) + val body = context.getString(R.string.verify_mutual_match_body, name) + addVerificationSystemMessage(peerID, context.getString(R.string.verify_mutual_system_message, name)) + sendVerificationNotification(context.getString(R.string.verify_mutual_match_title), body, peerID) + } + } + } + + meshService.sendVerifyResponse(peerID, parsed.first, parsed.second) + } + } + + fun didReceiveVerifyResponse(peerID: String, payload: ByteArray) { + scope.launch { + val resp = VerificationService.parseVerifyResponse(payload) ?: return@launch + val pending = pendingQRVerifications[peerID] ?: return@launch + if (!resp.noiseKeyHex.equals(pending.noiseKeyHex, ignoreCase = true)) return@launch + if (!resp.nonceA.contentEquals(pending.nonceA)) return@launch + + val ok = VerificationService.verifyResponseSignature( + noiseKeyHex = resp.noiseKeyHex, + nonceA = resp.nonceA, + signature = resp.signature, + signerPublicKeyHex = pending.signKeyHex + ) + if (!ok) return@launch + + pendingQRVerifications.remove(peerID) + val fp = meshService.getPeerFingerprint(peerID) ?: return@launch + identityManager.setVerifiedFingerprint(fp, true) + val current = _verifiedFingerprints.value.toMutableSet() + current.add(fp) + _verifiedFingerprints.value = current + + val name = resolvePeerDisplayName(peerID) + identityManager.cacheFingerprintNickname(fp, name) + val noiseKeyHex = try { + meshService.getPeerInfo(peerID)?.noisePublicKey?.hexEncodedString() + } catch (_: Exception) { + null + } + if (noiseKeyHex != null) { + identityManager.cachePeerNoiseKey(peerID, noiseKeyHex) + identityManager.cacheNoiseFingerprint(noiseKeyHex, fp) + } + addVerificationSystemMessage(peerID, context.getString(R.string.verify_success_system_message, name)) + sendVerificationNotification(context.getString(R.string.verify_success_title), context.getString(R.string.verify_success_body, name), peerID) + + val lastChallenge = lastInboundVerifyChallengeAt[fp] ?: 0L + if (System.currentTimeMillis() - lastChallenge < 600_000L) { + val lastToast = lastMutualToastAt[fp] ?: 0L + if (System.currentTimeMillis() - lastToast > 60_000L) { + lastMutualToastAt[fp] = System.currentTimeMillis() + val body = context.getString(R.string.verify_mutual_match_body, name) + addVerificationSystemMessage(peerID, context.getString(R.string.verify_mutual_system_message, name)) + sendVerificationNotification(context.getString(R.string.verify_mutual_match_title), body, peerID) + } + } + } + } + + fun getPeerFingerprintForDisplay(peerID: String): String? { + val fromMap = state.getPeerFingerprintsValue()[peerID] + if (fromMap != null) return fromMap + val hexRegex = Regex("^[0-9a-fA-F]+$") + return try { + when { + peerID.length == 64 && peerID.matches(hexRegex) -> { + identityManager.getCachedNoiseFingerprint(peerID)?.let { return it } + fingerprintFromNoiseHex(peerID)?.also { identityManager.cacheNoiseFingerprint(peerID, it) } + } + peerID.length == 16 && peerID.matches(hexRegex) -> { + val meshFp = meshService.getPeerFingerprint(peerID) + if (meshFp != null) return meshFp + identityManager.getCachedPeerFingerprint(peerID)?.let { return it } + identityManager.getCachedNoiseKey(peerID)?.let { noiseHex -> + identityManager.getCachedNoiseFingerprint(noiseHex)?.let { return it } + return fingerprintFromNoiseHex(noiseHex)?.also { identityManager.cacheNoiseFingerprint(noiseHex, it) } + } + val favorite = try { + FavoritesPersistenceService.shared.getFavoriteStatus(peerID) + } catch (_: Exception) { + null + } + favorite?.peerNoisePublicKey?.let { fingerprintFromNoiseBytes(it) } + } + peerID.startsWith("nostr_") -> { + val pubHex = GeohashAliasRegistry.get(peerID) + val noiseKey = pubHex?.let { + FavoritesPersistenceService.shared.findNoiseKey(it) + } + noiseKey?.let { + val noiseHex = it.hexEncodedString() + identityManager.getCachedNoiseFingerprint(noiseHex) ?: fingerprintFromNoiseBytes(it) + } + } + peerID.startsWith("nostr:") -> { + val prefix = peerID.removePrefix("nostr:").lowercase() + val pubHex = GeohashAliasRegistry + .snapshot() + .values + .firstOrNull { it.lowercase().startsWith(prefix) } + val noiseKey = pubHex?.let { + FavoritesPersistenceService.shared.findNoiseKey(it) + } + noiseKey?.let { + val noiseHex = it.hexEncodedString() + identityManager.getCachedNoiseFingerprint(noiseHex) ?: fingerprintFromNoiseBytes(it) + } + } + else -> { + val meshFp = meshService.getPeerFingerprint(peerID) + if (meshFp != null) return meshFp + identityManager.getCachedPeerFingerprint(peerID)?.let { return it } + identityManager.getCachedNoiseKey(peerID)?.let { noiseHex -> + identityManager.getCachedNoiseFingerprint(noiseHex)?.let { return it } + return fingerprintFromNoiseHex(noiseHex)?.also { identityManager.cacheNoiseFingerprint(noiseHex, it) } + } + val favorite = try { + FavoritesPersistenceService.shared.getFavoriteStatus(peerID) + } catch (_: Exception) { + null + } + favorite?.peerNoisePublicKey?.let { fingerprintFromNoiseBytes(it) } + } + } + } catch (_: Exception) { + null + } + } + + fun resolvePeerDisplayNameForFingerprint(peerID: String): String { + val nicknameMap = state.peerNicknames.value + nicknameMap[peerID]?.let { return it } + try { + meshService.getPeerInfo(peerID)?.nickname?.let { return it } + } catch (_: Exception) { } + + val fingerprint = getPeerFingerprintForDisplay(peerID) + fingerprint?.let { fp -> + identityManager.getCachedFingerprintNickname(fp)?.let { cached -> + if (cached.isNotBlank()) return cached + } + } + + val hexRegex = Regex("^[0-9a-fA-F]+$") + if (peerID.length == 64 && peerID.matches(hexRegex)) { + val noiseKeyBytes = try { + peerID.chunked(2).map { it.toInt(16).toByte() }.toByteArray() + } catch (_: Exception) { null } + val favorite = noiseKeyBytes?.let { + FavoritesPersistenceService.shared.getFavoriteStatus(it) + } + favorite?.peerNickname?.takeIf { it.isNotBlank() }?.let { return it } + } + + if (peerID.length == 16 && peerID.matches(hexRegex)) { + val favorite = try { + FavoritesPersistenceService.shared.getFavoriteStatus(peerID) + } catch (_: Exception) { + null + } + favorite?.peerNickname?.takeIf { it.isNotBlank() }?.let { return it } + } + + return peerID.take(8) + } + + fun getMyFingerprint(): String { + return meshService.getIdentityFingerprint() + } + + fun verifyFingerprintValue(fingerprint: String) { + if (fingerprint.isBlank()) return + identityManager.setVerifiedFingerprint(fingerprint, true) + val current = _verifiedFingerprints.value.toMutableSet() + current.add(fingerprint) + _verifiedFingerprints.value = current + } + + fun unverifyFingerprintValue(fingerprint: String) { + if (fingerprint.isBlank()) return + identityManager.setVerifiedFingerprint(fingerprint, false) + val current = _verifiedFingerprints.value.toMutableSet() + current.remove(fingerprint) + _verifiedFingerprints.value = current + } + + private fun addVerificationSystemMessage(peerID: String, text: String) { + val msg = BitchatMessage( + sender = "system", + content = text, + timestamp = Date(), + isRelay = false, + isPrivate = true, + senderPeerID = peerID + ) + messageManager.addPrivateMessageNoUnread(peerID, msg) + } + + private fun resolvePeerDisplayName(peerID: String): String { + val nick = try { meshService.getPeerInfo(peerID)?.nickname } catch (_: Exception) { null } + return nick ?: peerID.take(8) + } + + private fun sendVerificationNotification(title: String, body: String, peerID: String) { + notificationManager.showVerificationNotification(title, body, peerID) + } + + private fun fingerprintFromNoiseHex(noiseHex: String): String? { + val bytes = noiseHex.dataFromHexString() ?: return null + return fingerprintFromNoiseBytes(bytes) + } + + fun fingerprintFromNoiseBytes(bytes: ByteArray): String { + val hash = MessageDigest.getInstance("SHA-256").digest(bytes) + return hash.hexEncodedString() + } + + private data class PendingVerification( + val noiseKeyHex: String, + val signKeyHex: String, + val nonceA: ByteArray, + val startedAtMs: Long, + val sent: Boolean + ) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as PendingVerification + + if (startedAtMs != other.startedAtMs) return false + if (sent != other.sent) return false + if (noiseKeyHex != other.noiseKeyHex) return false + if (signKeyHex != other.signKeyHex) return false + if (!nonceA.contentEquals(other.nonceA)) return false + + return true + } + + override fun hashCode(): Int { + var result = startedAtMs.hashCode() + result = 31 * result + sent.hashCode() + result = 31 * result + noiseKeyHex.hashCode() + result = 31 * result + signKeyHex.hashCode() + result = 31 * result + nonceA.contentHashCode() + return result + } + } +} diff --git a/app/src/main/java/com/bitchat/android/ui/VerificationSheet.kt b/app/src/main/java/com/bitchat/android/ui/VerificationSheet.kt new file mode 100644 index 00000000..97392eae --- /dev/null +++ b/app/src/main/java/com/bitchat/android/ui/VerificationSheet.kt @@ -0,0 +1,538 @@ +package com.bitchat.android.ui + +import android.graphics.Bitmap +import android.os.Handler +import android.os.Looper +import android.util.Log +import androidx.camera.compose.CameraXViewfinder +import androidx.camera.core.CameraSelector +import androidx.camera.core.ExperimentalGetImage +import androidx.camera.core.ImageAnalysis +import androidx.camera.core.ImageProxy +import androidx.camera.core.Preview +import androidx.camera.core.SurfaceRequest +import androidx.camera.lifecycle.ProcessCameraProvider +import androidx.camera.viewfinder.core.ImplementationMode +import androidx.compose.animation.Crossfade +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.QrCodeScanner +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Tab +import androidx.compose.material3.TabRow +import androidx.compose.material3.TabRowDefaults +import androidx.compose.material3.TabRowDefaults.tabIndicatorOffset +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.core.content.ContextCompat +import androidx.core.graphics.createBitmap +import androidx.core.graphics.set +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.bitchat.android.R +import com.bitchat.android.core.ui.component.button.CloseButton +import com.bitchat.android.core.ui.component.sheet.BitchatBottomSheet +import com.bitchat.android.services.VerificationService +import com.google.accompanist.permissions.ExperimentalPermissionsApi +import com.google.accompanist.permissions.isGranted +import com.google.accompanist.permissions.rememberPermissionState +import com.google.mlkit.vision.barcode.BarcodeScannerOptions +import com.google.mlkit.vision.barcode.BarcodeScanning +import com.google.mlkit.vision.barcode.common.Barcode +import com.google.mlkit.vision.common.InputImage +import com.google.zxing.BarcodeFormat +import com.google.zxing.common.BitMatrix +import com.google.zxing.qrcode.QRCodeWriter +import kotlinx.coroutines.flow.MutableStateFlow +import java.util.concurrent.ExecutorService +import java.util.concurrent.Executors + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun VerificationSheet( + isPresented: Boolean, + onDismiss: () -> Unit, + viewModel: ChatViewModel, + modifier: Modifier = Modifier +) { + if (!isPresented) return + + val isDark = isSystemInDarkTheme() + val accent = if (isDark) Color.Green else Color(0xFF008000) + + var selectedTab by remember { mutableStateOf(0) } // 0 = My Code, 1 = Scan + val nickname by viewModel.nickname.collectAsStateWithLifecycle() + val npub = remember { viewModel.getCurrentNpub() } + + val qrString = remember(nickname, npub) { + viewModel.buildMyQRString(nickname, npub) + } + + BitchatBottomSheet( + modifier = modifier, + onDismissRequest = onDismiss, + ) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(bottom = 16.dp), + verticalArrangement = Arrangement.Top + ) { + // Header + VerificationHeader( + accent = accent, + onClose = onDismiss, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp) + ) + + // Tabs + TabRow( + selectedTabIndex = selectedTab, + containerColor = Color.Transparent, + contentColor = accent, + indicator = { tabPositions -> + TabRowDefaults.Indicator( + Modifier.tabIndicatorOffset(tabPositions[selectedTab]), + color = accent + ) + } + ) { + Tab( + selected = selectedTab == 0, + onClick = { selectedTab = 0 }, + text = { + Text( + text = "My QR", + fontFamily = FontFamily.Monospace, + fontSize = 14.sp + ) + } + ) + Tab( + selected = selectedTab == 1, + onClick = { selectedTab = 1 }, + text = { + Text( + text = "Scan", + fontFamily = FontFamily.Monospace, + fontSize = 14.sp + ) + } + ) + } + + Spacer(modifier = Modifier.height(24.dp)) + + // Content + Crossfade( + targetState = selectedTab, + label = "VerificationTabCrossfade", + modifier = Modifier.weight(1f) + ) { tab -> + when (tab) { + 0 -> MyQrTabContent( + qrString = qrString, + nickname = nickname, + accent = accent + ) + 1 -> ScanTabContent( + accent = accent, + onScan = { code -> + val qr = VerificationService.verifyScannedQR(code) + if (qr != null && viewModel.beginQRVerification(qr)) { + selectedTab = 0 + } + } + ) + } + } + + // Unverify Action + val peerID by viewModel.selectedPrivateChatPeer.collectAsStateWithLifecycle() + val fingerprints by viewModel.verifiedFingerprints.collectAsStateWithLifecycle() + + if (peerID != null) { + val fingerprint = viewModel.getMeshPeerFingerprint(peerID!!) + if (fingerprint != null && fingerprints.contains(fingerprint)) { + Spacer(modifier = Modifier.height(16.dp)) + Button( + onClick = { viewModel.unverifyFingerprint(peerID!!) }, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.errorContainer, + contentColor = MaterialTheme.colorScheme.onErrorContainer + ), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + ) { + Text( + text = stringResource(R.string.verify_remove), + fontFamily = FontFamily.Monospace, + fontSize = 12.sp + ) + } + } + } + } + } +} + +@Composable +private fun VerificationHeader( + accent: Color, + onClose: () -> Unit, + modifier: Modifier = Modifier +) { + Row( + modifier = modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = stringResource(R.string.verify_title).uppercase(), + fontSize = 14.sp, + fontFamily = FontFamily.Monospace, + color = accent + ) + CloseButton(onClick = onClose) + } +} + +@Composable +private fun MyQrTabContent( + qrString: String, + nickname: String, + accent: Color +) { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Top + ) { + Spacer(modifier = Modifier.height(24.dp)) + + Text( + text = stringResource(R.string.verify_my_qr_title), + style = MaterialTheme.typography.titleMedium, + fontFamily = FontFamily.Monospace, + color = accent + ) + + Spacer(modifier = Modifier.height(32.dp)) + + if (qrString.isNotBlank()) { + Box( + modifier = Modifier + .clip(RoundedCornerShape(24.dp)) + .background(Color.White) + .padding(20.dp) // Quiet zone + ) { + QRCodeImage(data = qrString, size = 260.dp) + } + } else { + Box( + modifier = Modifier + .size(260.dp) + .clip(RoundedCornerShape(24.dp)) + .background(Color.White.copy(alpha = 0.5f)), + contentAlignment = Alignment.Center + ) { + Text( + text = stringResource(R.string.verify_qr_unavailable), + fontFamily = FontFamily.Monospace, + fontSize = 12.sp, + color = Color.Black.copy(alpha = 0.6f) + ) + } + } + + Spacer(modifier = Modifier.height(32.dp)) + + // User Nickname + Text( + text = nickname, + style = MaterialTheme.typography.headlineSmall, + fontFamily = FontFamily.Monospace, + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Center + ) + + Spacer(modifier = Modifier.height(8.dp)) + + // Helper text + Text( + text = stringResource(R.string.app_name).lowercase(), + style = MaterialTheme.typography.bodyMedium, + fontFamily = FontFamily.Monospace, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f), + textAlign = TextAlign.Center + ) + } +} + +@OptIn(ExperimentalPermissionsApi::class) +@Composable +private fun ScanTabContent( + accent: Color, + onScan: (String) -> Unit +) { + val permissionState = rememberPermissionState(android.Manifest.permission.CAMERA) + + Column( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + if (permissionState.status.isGranted) { + Box( + modifier = Modifier + .weight(1f) + .fillMaxWidth() + .clip(RoundedCornerShape(24.dp)) + .background(Color.Black), + contentAlignment = Alignment.Center + ) { + ScannerView(onScan = onScan) + + // Overlay border + Box( + modifier = Modifier + .size(280.dp) + .border(2.dp, accent.copy(alpha = 0.8f), RoundedCornerShape(16.dp)) + ) + + // Corner accents for the overlay + Box(modifier = Modifier.size(260.dp)) { + // This could be drawn with Canvas for cooler effect, but simple border is cleaner for now + } + + Text( + text = stringResource(R.string.verify_scan_prompt_friend), + color = Color.White, + fontFamily = FontFamily.Monospace, + fontSize = 12.sp, + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(bottom = 32.dp) + .background(Color.Black.copy(alpha = 0.6f), RoundedCornerShape(8.dp)) + .padding(horizontal = 12.dp, vertical = 8.dp) + ) + } + } else { + Column( + modifier = Modifier + .fillMaxWidth() + .weight(1f) + .background( + MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f), + RoundedCornerShape(24.dp) + ) + .padding(24.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Icon( + imageVector = Icons.Outlined.QrCodeScanner, + contentDescription = null, + modifier = Modifier.size(64.dp), + tint = accent + ) + Spacer(modifier = Modifier.height(24.dp)) + Text( + text = stringResource(R.string.verify_camera_permission), + fontFamily = FontFamily.Monospace, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSurface + ) + Spacer(modifier = Modifier.height(32.dp)) + Button( + onClick = { permissionState.launchPermissionRequest() }, + colors = ButtonDefaults.buttonColors(containerColor = accent) + ) { + Text( + text = stringResource(R.string.verify_request_camera), + fontFamily = FontFamily.Monospace + ) + } + } + } + } +} + +@Composable +private fun ScannerView( + onScan: (String) -> Unit +) { + val context = LocalContext.current + val lifecycleOwner = LocalLifecycleOwner.current + var lastValid by remember { mutableStateOf(null) } + val cameraProviderFuture = remember { ProcessCameraProvider.getInstance(context) } + val cameraExecutor: ExecutorService = remember { Executors.newSingleThreadExecutor() } + val surfaceRequests = remember { MutableStateFlow(null) } + val surfaceRequest by surfaceRequests.collectAsState(initial = null) + val mainHandler = remember { Handler(Looper.getMainLooper()) } + + val onCodeState = rememberUpdatedState(onScan) + val analyzer = remember { + QRCodeAnalyzer { text -> + mainHandler.post { + if (text == lastValid) return@post + lastValid = text + onCodeState.value(text) + } + } + } + + DisposableEffect(Unit) { + val executor = ContextCompat.getMainExecutor(context) + var cameraProvider: ProcessCameraProvider? = null + + cameraProviderFuture.addListener( + { + val provider = cameraProviderFuture.get() + cameraProvider = provider + val preview = Preview.Builder().build().also { + it.setSurfaceProvider { request -> surfaceRequests.value = request } + } + val analysis = ImageAnalysis.Builder() + .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) + .build() + .also { it.setAnalyzer(cameraExecutor, analyzer) } + + runCatching { + provider.unbindAll() + provider.bindToLifecycle( + lifecycleOwner, + CameraSelector.DEFAULT_BACK_CAMERA, + preview, + analysis + ) + }.onFailure { + Log.w("VerificationSheet", "Failed to bind camera: ${it.message}") + } + }, + executor + ) + + onDispose { + surfaceRequests.value = null + runCatching { cameraProvider?.unbindAll() } + cameraExecutor.shutdown() + } + } + + surfaceRequest?.let { request -> + CameraXViewfinder( + surfaceRequest = request, + implementationMode = ImplementationMode.EMBEDDED, + modifier = Modifier.fillMaxSize() + ) + } +} + +@Composable +private fun QRCodeImage(data: String, size: Dp) { + val sizePx = with(LocalDensity.current) { size.toPx().toInt() } + val bitmap = remember(data, sizePx) { generateQrBitmap(data, sizePx) } + if (bitmap != null) { + Image( + bitmap = bitmap.asImageBitmap(), + contentDescription = null, + modifier = Modifier.size(size) + ) + } +} + +private fun generateQrBitmap(data: String, sizePx: Int): Bitmap? { + if (data.isBlank() || sizePx <= 0) return null + return try { + val matrix = QRCodeWriter().encode(data, BarcodeFormat.QR_CODE, sizePx, sizePx) + bitmapFromMatrix(matrix) + } catch (_: Exception) { + null + } +} + +private fun bitmapFromMatrix(matrix: BitMatrix): Bitmap { + val width = matrix.width + val height = matrix.height + val bitmap = createBitmap(width, height) + for (x in 0 until width) { + for (y in 0 until height) { + bitmap[x, y] = + if (matrix[x, y]) android.graphics.Color.BLACK else android.graphics.Color.WHITE + } + } + return bitmap +} + +private class QRCodeAnalyzer( + private val onCode: (String) -> Unit +) : ImageAnalysis.Analyzer { + private val scanner = BarcodeScanning.getClient( + BarcodeScannerOptions.Builder() + .setBarcodeFormats(Barcode.FORMAT_QR_CODE) + .build() + ) + + @ExperimentalGetImage + override fun analyze(imageProxy: ImageProxy) { + val mediaImage = imageProxy.image ?: run { + imageProxy.close() + return + } + val input = InputImage.fromMediaImage(mediaImage, imageProxy.imageInfo.rotationDegrees) + scanner.process(input) + .addOnSuccessListener { barcodes -> + val text = barcodes.firstOrNull()?.rawValue + if (!text.isNullOrBlank()) onCode(text) + } + .addOnCompleteListener { imageProxy.close() } + } +} diff --git a/app/src/main/java/com/bitchat/android/ui/debug/DebugPreferenceManager.kt b/app/src/main/java/com/bitchat/android/ui/debug/DebugPreferenceManager.kt index 2d734c14..f03df3da 100644 --- a/app/src/main/java/com/bitchat/android/ui/debug/DebugPreferenceManager.kt +++ b/app/src/main/java/com/bitchat/android/ui/debug/DebugPreferenceManager.kt @@ -113,7 +113,7 @@ object DebugPreferenceManager { if (ready()) prefs.edit().putBoolean(KEY_BLE_ENABLED, value).apply() } - fun getWifiAwareEnabled(default: Boolean = false): Boolean = + fun getWifiAwareEnabled(default: Boolean = true): Boolean = if (ready()) prefs.getBoolean(KEY_WIFI_AWARE_ENABLED, default) else default fun setWifiAwareEnabled(value: Boolean) { diff --git a/app/src/main/java/com/bitchat/android/ui/debug/DebugSettingsManager.kt b/app/src/main/java/com/bitchat/android/ui/debug/DebugSettingsManager.kt index 669331e6..82bc7fc2 100644 --- a/app/src/main/java/com/bitchat/android/ui/debug/DebugSettingsManager.kt +++ b/app/src/main/java/com/bitchat/android/ui/debug/DebugSettingsManager.kt @@ -3,8 +3,12 @@ package com.bitchat.android.ui.debug import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow import java.util.Date import java.util.concurrent.ConcurrentLinkedQueue +import com.bitchat.android.protocol.BitchatPacket +import com.bitchat.android.util.toHexString /** * Debug settings manager for controlling debug features and collecting debug data @@ -40,7 +44,7 @@ class DebugSettingsManager private constructor() { private val _bleEnabled = MutableStateFlow(true) val bleEnabled: StateFlow = _bleEnabled.asStateFlow() - private val _wifiAwareEnabled = MutableStateFlow(false) + private val _wifiAwareEnabled = MutableStateFlow(true) val wifiAwareEnabled: StateFlow = _wifiAwareEnabled.asStateFlow() // Master transport toggles @@ -72,7 +76,7 @@ class DebugSettingsManager private constructor() { _maxClientConnections.value = DebugPreferenceManager.getMaxConnectionsClient(8) // Transport toggles _bleEnabled.value = DebugPreferenceManager.getBleEnabled(true) - _wifiAwareEnabled.value = DebugPreferenceManager.getWifiAwareEnabled(false) + _wifiAwareEnabled.value = DebugPreferenceManager.getWifiAwareEnabled(true) _wifiAwareVerbose.value = DebugPreferenceManager.getWifiAwareVerbose(false) } catch (_: Exception) { // Preferences not ready yet; keep defaults. They will be applied on first change. @@ -281,6 +285,9 @@ class DebugSettingsManager private constructor() { DebugPreferenceManager.setBleEnabled(enabled) _bleEnabled.value = enabled addDebugMessage(DebugMessage.SystemMessage(if (enabled) "🟢 BLE enabled" else "🔴 BLE disabled")) + try { + com.bitchat.android.service.MeshServiceHolder.meshService?.setBleTransportEnabled(enabled) + } catch (_: Exception) { } } fun setWifiAwareEnabled(enabled: Boolean) { @@ -464,7 +471,9 @@ class DebugSettingsManager private constructor() { toNickname: String?, toDeviceAddress: String?, ttl: UByte?, - isRelay: Boolean = true + isRelay: Boolean = true, + packetVersion: UByte = 1u, + routeInfo: String? = null ) { // Build message only if verbose logging is enabled, but always update stats val senderLabel = when { @@ -487,18 +496,20 @@ class DebugSettingsManager private constructor() { val fromAddr = fromDeviceAddress ?: "?" val toAddr = toDeviceAddress ?: "?" val ttlStr = ttl?.toString() ?: "?" + val routeStr = if (routeInfo != null) " $routeInfo" else "" if (verboseLoggingEnabled.value) { if (isRelay) { + // Relay: show [previousPeer] -> [nextPeer] addDebugMessage( DebugMessage.RelayEvent( - "♻️ Relayed $packetType by $senderLabel from $fromName (${fromPeerID ?: "?"}, $fromAddr) to $toName (${toPeerID ?: "?"}, $toAddr) with TTL $ttlStr" + "♻️ Relayed v$packetVersion $packetType by $senderLabel from $fromName (${fromPeerID ?: "?"}, $fromAddr) to $toName (${toPeerID ?: "?"}, $toAddr) with TTL $ttlStr$routeStr" ) ) } else { addDebugMessage( DebugMessage.PacketEvent( - "📤 Sent $packetType by $senderLabel to $toName (${toPeerID ?: "?"}, $toAddr) with TTL $ttlStr" + "📤 Sent v$packetVersion $packetType by $senderLabel to $toName (${toPeerID ?: "?"}, $toAddr) with TTL $ttlStr$routeStr" ) ) } @@ -507,12 +518,52 @@ class DebugSettingsManager private constructor() { // Do not update counters here; this path is for readable logs only. } - // Explicit incoming/outgoing logging to avoid double counting - fun logIncoming(packetType: String, fromPeerID: String?, fromNickname: String?, fromDeviceAddress: String?) { - if (verboseLoggingEnabled.value) { - val who = fromNickname ?: fromPeerID ?: "unknown" - addDebugMessage(DebugMessage.PacketEvent("📥 Incoming $packetType from $who (${fromPeerID ?: "?"}, ${fromDeviceAddress ?: "?"})")) + // MARK: - Debug Events for Animation + sealed class MeshVisualEvent { + data class PacketActivity(val peerID: String) : MeshVisualEvent() + data class RouteActivity(val route: List) : MeshVisualEvent() + } + + private val _meshVisualEvents = kotlinx.coroutines.flow.MutableSharedFlow( + extraBufferCapacity = 64, + onBufferOverflow = kotlinx.coroutines.channels.BufferOverflow.DROP_OLDEST + ) + val meshVisualEvents: kotlinx.coroutines.flow.SharedFlow = _meshVisualEvents.asSharedFlow() + + fun emitVisualEvent(event: MeshVisualEvent) { + if (_debugSheetVisible.value) { + _meshVisualEvents.tryEmit(event) } + } + + // Peer nickname resolver + private var nicknameResolver: ((String) -> String?)? = null + fun setNicknameResolver(resolver: (String) -> String?) { nicknameResolver = resolver } + + // Explicit incoming/outgoing logging to avoid double counting + fun logIncoming(packet: BitchatPacket, fromPeerID: String, fromNickname: String?, fromDeviceAddress: String?, myPeerID: String) { + val packetType = packet.type.toString() + val packetVersion = packet.version + val route = packet.route + val routeInfo = if (!route.isNullOrEmpty()) "routed: ${route.size} hops" else null + + if (verboseLoggingEnabled.value) { + val resolvedNick = fromNickname ?: nicknameResolver?.invoke(fromPeerID) ?: "unknown" + val who = if (resolvedNick != "unknown") "$resolvedNick ($fromPeerID)" else fromPeerID + val routeStr = if (routeInfo != null) " $routeInfo" else "" + addDebugMessage(DebugMessage.PacketEvent("📥 Incoming v$packetVersion $packetType from $who (${fromDeviceAddress ?: "?"})$routeStr")) + } + + emitVisualEvent(MeshVisualEvent.PacketActivity(fromPeerID)) + + if (!route.isNullOrEmpty()) { + val fullRoute = mutableListOf() + fullRoute.add(packet.senderID.toHexString()) + route.forEach { fullRoute.add(it.toHexString()) } + packet.recipientID?.let { fullRoute.add(it.toHexString()) } + emitVisualEvent(MeshVisualEvent.RouteActivity(fullRoute)) + } + val now = System.currentTimeMillis() val visible = _debugSheetVisible.value if (visible) incomingTimestamps.offer(now) @@ -521,11 +572,11 @@ class DebugSettingsManager private constructor() { deviceIncomingTotalsMap[it] = (deviceIncomingTotalsMap[it] ?: 0L) + 1L _perDeviceIncomingTotalsFlow.value = deviceIncomingTotalsMap.toMap() } - fromPeerID?.let { - perPeerIncoming.getOrPut(it) { ConcurrentLinkedQueue() }.offer(now) - peerIncomingTotalsMap[it] = (peerIncomingTotalsMap[it] ?: 0L) + 1L - _perPeerIncomingTotalsFlow.value = peerIncomingTotalsMap.toMap() - } + + perPeerIncoming.getOrPut(fromPeerID) { ConcurrentLinkedQueue() }.offer(now) + peerIncomingTotalsMap[fromPeerID] = (peerIncomingTotalsMap[fromPeerID] ?: 0L) + 1L + _perPeerIncomingTotalsFlow.value = peerIncomingTotalsMap.toMap() + // bump totals val cur = _relayStats.value _relayStats.value = cur.copy( @@ -535,10 +586,11 @@ class DebugSettingsManager private constructor() { if (visible) updateRelayStatsFromTimestamps() } - fun logOutgoing(packetType: String, toPeerID: String?, toNickname: String?, toDeviceAddress: String?, previousHopPeerID: String? = null) { + fun logOutgoing(packetType: String, toPeerID: String?, toNickname: String?, toDeviceAddress: String?, previousHopPeerID: String? = null, packetVersion: UByte = 1u, routeInfo: String? = null) { if (verboseLoggingEnabled.value) { val who = toNickname ?: toPeerID ?: "unknown" - addDebugMessage(DebugMessage.PacketEvent("📤 Outgoing $packetType to $who (${toPeerID ?: "?"}, ${toDeviceAddress ?: "?"})")) + val routeStr = if (routeInfo != null) " $routeInfo" else "" + addDebugMessage(DebugMessage.PacketEvent("📤 Outgoing v$packetVersion $packetType to $who (${toPeerID ?: "?"}, ${toDeviceAddress ?: "?"})$routeStr")) } val now = System.currentTimeMillis() val visible = _debugSheetVisible.value diff --git a/app/src/main/java/com/bitchat/android/ui/debug/DebugSettingsSheet.kt b/app/src/main/java/com/bitchat/android/ui/debug/DebugSettingsSheet.kt index 94781b31..65b19182 100644 --- a/app/src/main/java/com/bitchat/android/ui/debug/DebugSettingsSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/debug/DebugSettingsSheet.kt @@ -5,15 +5,15 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.animation.core.animateFloatAsState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Bluetooth import androidx.compose.material.icons.filled.Wifi import androidx.compose.material.icons.filled.WifiTethering import androidx.compose.material.icons.filled.BugReport -import androidx.compose.material.icons.filled.Cancel import androidx.compose.material.icons.filled.Devices import androidx.compose.material.icons.filled.PowerSettingsNew import androidx.compose.material.icons.filled.SettingsEthernet @@ -29,12 +29,73 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.draw.rotate import com.bitchat.android.mesh.BluetoothMeshService +import com.bitchat.android.services.meshgraph.MeshGraphService import kotlinx.coroutines.launch +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.graphics.drawscope.drawIntoCanvas +import androidx.compose.ui.graphics.nativeCanvas import androidx.compose.ui.res.stringResource import com.bitchat.android.R import androidx.compose.ui.platform.LocalContext -import com.bitchat.android.service.MeshServicePreferences -import com.bitchat.android.service.MeshForegroundService +import com.bitchat.android.core.ui.component.sheet.BitchatBottomSheet +import com.bitchat.android.core.ui.component.sheet.BitchatSheetTopBar +import com.bitchat.android.core.ui.component.sheet.BitchatSheetTitle + +@Composable +fun MeshTopologySection( + localPeerID: String? = null, + blePeerIDs: Set = emptySet(), +) { + val colorScheme = MaterialTheme.colorScheme + val graphService = remember { MeshGraphService.getInstance() } + val snapshot by graphService.graphState.collectAsState() + val wifiAwareConnected by com.bitchat.android.wifiaware.WifiAwareController.connectedPeers.collectAsState() + val wifiAwarePeerIDs = remember(wifiAwareConnected) { wifiAwareConnected.keys.toSet() } + + Surface(shape = RoundedCornerShape(12.dp), color = colorScheme.surfaceVariant.copy(alpha = 0.2f)) { + Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Icon(Icons.Filled.SettingsEthernet, contentDescription = null, tint = Color(0xFF8E8E93)) + Text("mesh topology", fontFamily = FontFamily.Monospace, fontSize = 14.sp, fontWeight = FontWeight.Medium) + } + val nodes = snapshot.nodes + val edges = snapshot.edges + val empty = nodes.isEmpty() + if (empty) { + Text("no gossip yet", fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.6f)) + } else { + ForceDirectedMeshGraph( + nodes = nodes, + edges = edges, + wifiAwarePeerIDs = wifiAwarePeerIDs, + blePeerIDs = blePeerIDs, + localPeerID = localPeerID, + modifier = Modifier + .fillMaxWidth() + .height(300.dp) + .background(colorScheme.surface.copy(alpha = 0.4f)) + ) + + // Flexible peer list + FlowRow( + modifier = Modifier.fillMaxWidth().padding(top = 8.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + nodes.forEach { node -> + val label = "${node.peerID.take(8)} • ${node.nickname ?: "unknown"}" + Text( + text = label, + fontFamily = FontFamily.Monospace, + fontSize = 11.sp, + color = colorScheme.onSurface.copy(alpha = 0.85f) + ) + } + } + } + } + } +} private enum class GraphMode { OVERALL, PER_DEVICE, PER_PEER } @@ -45,7 +106,6 @@ fun DebugSettingsSheet( onDismiss: () -> Unit, meshService: BluetoothMeshService ) { - val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = false) val colorScheme = MaterialTheme.colorScheme val manager = remember { DebugSettingsManager.getInstance() } @@ -70,7 +130,20 @@ fun DebugSettingsSheet( val wifiAwareVerbose by manager.wifiAwareVerbose.collectAsState() val wifiAwareDiscovered by manager.wifiAwareDiscovered.collectAsState() val wifiAwareConnected by manager.wifiAwareConnected.collectAsState() + val wifiAwareSupported by com.bitchat.android.wifiaware.WifiAwareController.supported.collectAsState() + val wifiAwareAvailable by com.bitchat.android.wifiaware.WifiAwareController.available.collectAsState() + val wifiAwareSupportStatus by com.bitchat.android.wifiaware.WifiAwareController.supportStatus.collectAsState() // Persistent notification is now controlled solely by MeshServicePreferences.isBackgroundEnabled + val listState = rememberLazyListState() + val isScrolled by remember { + derivedStateOf { + listState.firstVisibleItemIndex > 0 || listState.firstVisibleItemScrollOffset > 0 + } + } + val topBarAlpha by animateFloatAsState( + targetValue = if (isScrolled) 0.95f else 0f, + label = "topBarAlpha" + ) // Push live connected devices from mesh service whenever sheet is visible LaunchedEffect(isPresented) { @@ -112,35 +185,31 @@ fun DebugSettingsSheet( if (!isPresented) return - ModalBottomSheet( + BitchatBottomSheet( onDismissRequest = onDismiss, - sheetState = sheetState ) { // Mark debug sheet visible/invisible to gate heavy work LaunchedEffect(Unit) { DebugSettingsManager.getInstance().setDebugSheetVisible(true) } DisposableEffect(Unit) { onDispose { DebugSettingsManager.getInstance().setDebugSheetVisible(false) } } - LazyColumn( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp) - .padding(bottom = 24.dp), - verticalArrangement = Arrangement.spacedBy(16.dp) - ) { - item { - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { - Icon(Icons.Filled.BugReport, contentDescription = null, tint = Color(0xFFFF9500)) - Text(stringResource(R.string.debug_tools), fontFamily = FontFamily.Monospace, fontSize = 18.sp, fontWeight = FontWeight.Medium) + Box(modifier = Modifier.fillMaxWidth()) { + LazyColumn( + state = listState, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + contentPadding = PaddingValues(top = 80.dp, bottom = 24.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + item { + Text( + text = stringResource(R.string.debug_tools_desc), + fontFamily = FontFamily.Monospace, + fontSize = 12.sp, + color = colorScheme.onSurface.copy(alpha = 0.7f) + ) } - Text( - text = stringResource(R.string.debug_tools_desc), - fontFamily = FontFamily.Monospace, - fontSize = 12.sp, - color = colorScheme.onSurface.copy(alpha = 0.7f) - ) - } - // Verbose logging toggle item { Surface(shape = RoundedCornerShape(12.dp), color = colorScheme.surfaceVariant.copy(alpha = 0.2f)) { @@ -161,6 +230,17 @@ fun DebugSettingsSheet( } } + // Mesh topology visualization (moved below verbose logging) + item { + val blePeerIDs = remember(connectedDevices) { + connectedDevices.mapNotNull { it.peerID }.toSet() + } + MeshTopologySection( + localPeerID = meshService.myPeerID, + blePeerIDs = blePeerIDs, + ) + } + // GATT controls item { Surface(shape = RoundedCornerShape(12.dp), color = colorScheme.surfaceVariant.copy(alpha = 0.2f)) { @@ -244,22 +324,29 @@ fun DebugSettingsSheet( Text("BLE", fontFamily = FontFamily.Monospace, modifier = Modifier.weight(1f)) Switch(checked = bleEnabled, onCheckedChange = { manager.setBleEnabled(it) - scope.launch { - if (it) { - if (gattServerEnabled) meshService.connectionManager.startServer() - if (gattClientEnabled) meshService.connectionManager.startClient() - } else { - meshService.connectionManager.stopServer() - meshService.connectionManager.stopClient() - } - } }) } Row(verticalAlignment = Alignment.CenterVertically) { Icon(Icons.Filled.Wifi, contentDescription = null, tint = Color(0xFF9C27B0)) Spacer(Modifier.width(8.dp)) Text("Wi‑Fi Aware", fontFamily = FontFamily.Monospace, modifier = Modifier.weight(1f)) - Switch(checked = wifiAwareEnabled, onCheckedChange = { manager.setWifiAwareEnabled(it) }) + val wifiSwitchEnabled = wifiAwareSupported + Text( + when { + !wifiAwareSupported -> "unsupported" + wifiAwareAvailable -> "available" + else -> "unavailable" + }, + fontFamily = FontFamily.Monospace, + fontSize = 11.sp, + color = colorScheme.onSurface.copy(alpha = 0.6f) + ) + Spacer(Modifier.width(8.dp)) + Switch( + checked = wifiAwareEnabled && wifiAwareSupported, + enabled = wifiSwitchEnabled, + onCheckedChange = { manager.setWifiAwareEnabled(it) } + ) } Row(verticalAlignment = Alignment.CenterVertically) { Spacer(Modifier.width(24.dp)) @@ -410,7 +497,7 @@ fun DebugSettingsSheet( kotlinx.coroutines.delay(1000) } } - + // Helper functions moved to top-level composable below to avoid scope issues // Render two blocks: Incoming and Outgoing @@ -533,17 +620,39 @@ fun DebugSettingsSheet( item { Surface(shape = RoundedCornerShape(12.dp), color = colorScheme.surfaceVariant.copy(alpha = 0.2f)) { Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { + val running by com.bitchat.android.wifiaware.WifiAwareController.running.collectAsState() Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { Icon(Icons.Filled.WifiTethering, contentDescription = null, tint = Color(0xFF9C27B0)) Text("Wi‑Fi Aware", fontFamily = FontFamily.Monospace, fontSize = 14.sp, fontWeight = FontWeight.Medium) Spacer(Modifier.weight(1f)) - val running by com.bitchat.android.wifiaware.WifiAwareController.running.collectAsState() - Text(if (running) "running" else "stopped", fontFamily = FontFamily.Monospace, fontSize = 12.sp, color = colorScheme.onSurface.copy(alpha = 0.7f)) + val wifiStatusText = when { + !wifiAwareSupported -> "unsupported" + running -> "running" + !wifiAwareAvailable -> "unavailable" + else -> "stopped" + } + Text(wifiStatusText, fontFamily = FontFamily.Monospace, fontSize = 12.sp, color = colorScheme.onSurface.copy(alpha = 0.7f)) + } + if (!wifiAwareSupported) { + Text( + wifiAwareSupportStatus?.reason ?: "Wi-Fi Aware is not supported on this device", + fontFamily = FontFamily.Monospace, + fontSize = 11.sp, + color = colorScheme.onSurface.copy(alpha = 0.6f) + ) } Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { - AssistChip(onClick = { com.bitchat.android.wifiaware.WifiAwareController.startIfPossible() }, label = { Text("Start") }) - AssistChip(onClick = { com.bitchat.android.wifiaware.WifiAwareController.stop() }, label = { Text("Stop") }) - AssistChip(onClick = { com.bitchat.android.wifiaware.WifiAwareController.getService()?.sendBroadcastAnnounce() }, label = { Text("Announce") }) + AssistChip( + onClick = { manager.setWifiAwareEnabled(true) }, + enabled = wifiAwareSupported, + label = { Text("Start") } + ) + AssistChip(onClick = { manager.setWifiAwareEnabled(false) }, label = { Text("Stop") }) + AssistChip( + onClick = { com.bitchat.android.wifiaware.WifiAwareController.getService()?.sendBroadcastAnnounce() }, + enabled = running, + label = { Text("Announce") } + ) } Text("Discovered: ${wifiAwareDiscovered.size}", fontFamily = FontFamily.Monospace, fontSize = 12.sp) if (wifiAwareDiscovered.isEmpty()) { @@ -587,9 +696,6 @@ fun DebugSettingsSheet( } } - - - // Connected devices item { Surface(shape = RoundedCornerShape(12.dp), color = colorScheme.surfaceVariant.copy(alpha = 0.2f)) { @@ -672,7 +778,29 @@ fun DebugSettingsSheet( } } - item { Spacer(Modifier.height(16.dp)) } + item { Spacer(Modifier.height(16.dp)) } + } + + BitchatSheetTopBar( + onClose = onDismiss, + modifier = Modifier.align(Alignment.TopCenter), + backgroundAlpha = topBarAlpha, + title = { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Icon( + imageVector = Icons.Filled.BugReport, + contentDescription = null, + tint = Color(0xFFFF9500) + ) + BitchatSheetTitle( + text = stringResource(R.string.debug_tools) + ) + } + } + ) } } } diff --git a/app/src/main/java/com/bitchat/android/ui/debug/MeshGraph.kt b/app/src/main/java/com/bitchat/android/ui/debug/MeshGraph.kt new file mode 100644 index 00000000..44c7ef55 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/ui/debug/MeshGraph.kt @@ -0,0 +1,506 @@ +package com.bitchat.android.ui.debug + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.gestures.detectDragGestures +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Wifi +import androidx.compose.material.icons.outlined.Bluetooth +import androidx.compose.material3.Icon +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.PathEffect +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.nativeCanvas +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.bitchat.android.services.meshgraph.MeshGraphService +import kotlin.math.* +import kotlin.random.Random +import androidx.compose.material3.MaterialTheme +import com.bitchat.android.ui.debug.DebugSettingsManager.MeshVisualEvent + +// Physics constants +private const val REPULSION_FORCE = 100000f +private const val SPRING_LENGTH = 150f +private const val SPRING_STRENGTH = 0.02f +private const val CENTER_GRAVITY = 0.02f +private const val DAMPING = 0.85f +private const val MAX_VELOCITY = 30f +private const val PULSE_DECAY = 0.05f +private const val ROUTE_DECAY = 0.02f +private val EDGE_ICON_OFFSET = 14.dp + +private enum class EdgeTransport { WIFI, BLE } + +private data class EdgeMarker(val x: Float, val y: Float, val transport: EdgeTransport) + +private fun shouldMarkDirectEdge( + edge: MeshGraphService.GraphEdge, + transportPeerIDs: Set, + localID: String? +): Boolean { + if (transportPeerIDs.isEmpty()) return false + return if (localID != null) { + (edge.a == localID && edge.b in transportPeerIDs) || + (edge.b == localID && edge.a in transportPeerIDs) + } else { + edge.a in transportPeerIDs || edge.b in transportPeerIDs + } +} + +private fun edgeMarkerPosition( + x1: Float, + y1: Float, + x2: Float, + y2: Float, + offsetPx: Float, + sideSign: Float +): Pair { + val midX = (x1 + x2) / 2f + val midY = (y1 + y2) / 2f + val dx = x2 - x1 + val dy = y2 - y1 + val len = sqrt(dx * dx + dy * dy) + if (len < 0.1f) return midX to midY + val px = -dy / len * offsetPx * sideSign + val py = dx / len * offsetPx * sideSign + return (midX + px) to (midY + py) +} + +private class GraphNodeState( + val id: String, + var label: String, + var x: Float, + var y: Float +) { + var vx: Float = 0f + var vy: Float = 0f + var isDragged: Boolean = false + var pulseLevel: Float = 0f // 0f to 1f, used for size/glow animation +} + +private class Simulation { + val nodes = mutableMapOf() + // Storing edges as pairs of IDs + val edges = mutableListOf() + // Active routes being animated: List of peerIDs in order -> intensity (1.0..0.0) + val activeRoutes = mutableListOf, Float>>() + + // Bounds for initial placement and centering + var width: Float = 1000f + var height: Float = 1000f + + fun updateTopology( + newNodes: List, + newEdges: List + ) { + // Remove stale nodes + val newIds = newNodes.map { it.peerID }.toSet() + nodes.keys.toList().forEach { id -> + if (id !in newIds) nodes.remove(id) + } + + // Add/Update nodes + newNodes.forEach { n -> + val existing = nodes[n.peerID] + val displayLabel = n.nickname ?: n.peerID.take(8) + if (existing != null) { + existing.label = displayLabel + } else { + // Spawn near center with random jitter + val angle = Random.nextFloat() * 2 * PI + val radius = 50f + Random.nextFloat() * 50f + nodes[n.peerID] = GraphNodeState( + id = n.peerID, + label = displayLabel, + x = (width / 2f) + (cos(angle) * radius).toFloat(), + y = (height / 2f) + (sin(angle) * radius).toFloat() + ) + } + } + + // Update edges + edges.clear() + edges.addAll(newEdges) + } + + fun triggerNodePulse(peerID: String) { + nodes[peerID]?.pulseLevel = 1f + } + + fun triggerRouteAnimation(route: List) { + if (route.size > 1) { + activeRoutes.add(route to 1f) + } + } + + fun step() { + val nodeList = nodes.values.toList() + val cx = width / 2f + val cy = height / 2f + + // 1. Repulsion (Node-Node) + for (i in nodeList.indices) { + val n1 = nodeList[i] + for (j in i + 1 until nodeList.size) { + val n2 = nodeList[j] + val dx = n1.x - n2.x + val dy = n1.y - n2.y + val distSq = dx * dx + dy * dy + if (distSq > 0.1f) { + val dist = sqrt(distSq) + val force = REPULSION_FORCE / distSq + val fx = (dx / dist) * force + val fy = (dy / dist) * force + + if (!n1.isDragged) { + n1.vx += fx + n1.vy += fy + } + if (!n2.isDragged) { + n2.vx -= fx + n2.vy -= fy + } + } + } + } + + // 2. Attraction (Edges) + edges.forEach { edge -> + val n1 = nodes[edge.a] + val n2 = nodes[edge.b] + if (n1 != null && n2 != null) { + val dx = n1.x - n2.x + val dy = n1.y - n2.y + val dist = sqrt(dx * dx + dy * dy) + if (dist > 0.1f) { + val force = (dist - SPRING_LENGTH) * SPRING_STRENGTH + val fx = (dx / dist) * force + val fy = (dy / dist) * force + + if (!n1.isDragged) { + n1.vx -= fx + n1.vy -= fy + } + if (!n2.isDragged) { + n2.vx += fx + n2.vy += fy + } + } + } + } + + // 3. Center Gravity & Integration & Animation Decay + nodeList.forEach { n -> + if (!n.isDragged) { + // Pull to center + val dx = n.x - cx + val dy = n.y - cy + n.vx -= dx * CENTER_GRAVITY + n.vy -= dy * CENTER_GRAVITY + + // Apply velocity + val vMag = sqrt(n.vx * n.vx + n.vy * n.vy) + if (vMag > MAX_VELOCITY) { + n.vx = (n.vx / vMag) * MAX_VELOCITY + n.vy = (n.vy / vMag) * MAX_VELOCITY + } + + n.x += n.vx + n.y += n.vy + + // Damping + n.vx *= DAMPING + n.vy *= DAMPING + } else { + n.vx = 0f + n.vy = 0f + } + + // Decay pulse + if (n.pulseLevel > 0f) { + n.pulseLevel = (n.pulseLevel - PULSE_DECAY).coerceAtLeast(0f) + } + } + + // Decay active routes + val iter = activeRoutes.iterator() + while (iter.hasNext()) { + val (route, intensity) = iter.next() + val newIntensity = intensity - ROUTE_DECAY + if (newIntensity <= 0f) { + iter.remove() + } else { + // Ugly mutation but efficient for simulation loop + // We need to replace the pair since pairs are immutable + // Finding index is O(N) but N is small + val idx = activeRoutes.indexOfFirst { it.first === route && it.second == intensity } + if (idx >= 0) { + activeRoutes[idx] = route to newIntensity + } + } + } + } +} + +@Composable +fun ForceDirectedMeshGraph( + nodes: List, + edges: List, + wifiAwarePeerIDs: Set = emptySet(), + blePeerIDs: Set = emptySet(), + localPeerID: String? = null, + modifier: Modifier = Modifier +) { + val density = LocalDensity.current + val simulation = remember { Simulation() } + val colorScheme = MaterialTheme.colorScheme + + // Listen for visual events + val debugManager = remember { DebugSettingsManager.getInstance() } + LaunchedEffect(Unit) { + debugManager.meshVisualEvents.collect { event -> + when (event) { + is MeshVisualEvent.PacketActivity -> simulation.triggerNodePulse(event.peerID) + is MeshVisualEvent.RouteActivity -> simulation.triggerRouteAnimation(event.route) + } + } + } + + // We need a state that changes on every tick to trigger redraw + var tick by remember { mutableLongStateOf(0L) } + + // Update topology when input data changes + LaunchedEffect(nodes, edges) { + simulation.updateTopology(nodes, edges) + } + + // Animation Loop + LaunchedEffect(Unit) { + while (true) { + withFrameNanos { + simulation.step() + tick++ + } + } + } + + BoxWithConstraints(modifier = modifier) { + val w = maxWidth.value * density.density + val h = maxHeight.value * density.density + + // Update simulation bounds if size changes + SideEffect { + simulation.width = w + simulation.height = h + } + + Canvas( + modifier = Modifier + .fillMaxSize() + .pointerInput(Unit) { + detectDragGestures( + onDragStart = { offset -> + // Find closest node + val closest = simulation.nodes.values.minByOrNull { + val dx = it.x - offset.x + val dy = it.y - offset.y + dx*dx + dy*dy + } + if (closest != null) { + val dist = sqrt((closest.x - offset.x).pow(2) + (closest.y - offset.y).pow(2)) + if (dist < 80f) { // Touch radius + closest.isDragged = true + } + } + }, + onDragEnd = { + simulation.nodes.values.forEach { it.isDragged = false } + }, + onDragCancel = { + simulation.nodes.values.forEach { it.isDragged = false } + }, + onDrag = { change, dragAmount -> + change.consume() + val dragged = simulation.nodes.values.find { it.isDragged } + if (dragged != null) { + dragged.x += dragAmount.x + dragged.y += dragAmount.y + } + } + ) + } + ) { + // Read tick to ensure recomposition + val t = tick + + val nodeMap = simulation.nodes + + // Draw Edges + simulation.edges.forEach { edge -> + val n1 = nodeMap[edge.a] + val n2 = nodeMap[edge.b] + + if (n1 != null && n2 != null) { + val start = Offset(n1.x, n1.y) + val end = Offset(n2.x, n2.y) + val baseColor = Color(0xFF4A90E2) + + if (edge.isConfirmed) { + drawLine( + color = baseColor, + start = start, + end = end, + strokeWidth = 5f + ) + } else { + // Unconfirmed: draw "solid" from declarer, "dashed" from other + val isA = (edge.confirmedBy == edge.a) + val solidStart = if (isA) start else end + val solidEnd = if (isA) end else start + + val midX = (start.x + end.x) / 2 + val midY = (start.y + end.y) / 2 + val mid = Offset(midX, midY) + + drawLine( + color = baseColor, + start = solidStart, + end = mid, + strokeWidth = 4f + ) + + drawLine( + color = baseColor.copy(alpha = 0.6f), + start = mid, + end = solidEnd, + strokeWidth = 4f, + pathEffect = PathEffect.dashPathEffect(floatArrayOf(10f, 10f), 0f) + ) + } + } + } + + // Draw Active Routes (overlay) + simulation.activeRoutes.forEach { (route, intensity) -> + val routeColor = Color(0xFFFFD700).copy(alpha = intensity) // Gold + val strokeW = 4f * intensity + 2f + + for (i in 0 until route.size - 1) { + val p1 = nodeMap[route[i]] + val p2 = nodeMap[route[i+1]] + if (p1 != null && p2 != null) { + drawLine( + color = routeColor, + start = Offset(p1.x, p1.y), + end = Offset(p2.x, p2.y), + strokeWidth = strokeW, + cap = androidx.compose.ui.graphics.StrokeCap.Round + ) + } + } + } + + // Draw Nodes + val labelColor = colorScheme.onSurface.toArgb() + val textPaint = android.graphics.Paint().apply { + isAntiAlias = true + textSize = 12.sp.toPx() + this.color = labelColor + } + + nodeMap.values.forEach { node -> + val center = Offset(node.x, node.y) + val pulse = node.pulseLevel + + // Pulse glow + if (pulse > 0.05f) { + drawCircle( + color = Color(0xFF00FF00).copy(alpha = pulse * 0.6f), + radius = 16f + (pulse * 20f), + center = center + ) + } + + drawCircle( + color = Color(0xFF00C851), + radius = 16f + (pulse * 4f), // Slight scale up + center = center + ) + drawCircle( + color = Color.White, + radius = 12f + (pulse * 3f), + center = center, + style = Stroke(width = 2f) + ) + + // Label + drawContext.canvas.nativeCanvas.drawText( + node.label, + node.x + 22f + (pulse * 5f), + node.y + 4f, + textPaint + ) + } + } + + val iconSize = 16.dp + val iconSizePx = with(density) { iconSize.toPx() } + val halfIconSizePx = iconSizePx / 2f + val iconOffsetPx = with(density) { EDGE_ICON_OFFSET.toPx() } + val localID = localPeerID + + val edgeMarkers = tick.let { + simulation.edges.flatMap { edge -> + val n1 = simulation.nodes[edge.a] + val n2 = simulation.nodes[edge.b] + if (n1 == null || n2 == null) return@flatMap emptyList() + + val isWifi = shouldMarkDirectEdge(edge, wifiAwarePeerIDs, localID) + val isBle = shouldMarkDirectEdge(edge, blePeerIDs, localID) + if (!isWifi && !isBle) return@flatMap emptyList() + + val markers = mutableListOf() + if (isWifi) { + val (x, y) = edgeMarkerPosition(n1.x, n1.y, n2.x, n2.y, iconOffsetPx, sideSign = 1f) + markers.add(EdgeMarker(x, y, EdgeTransport.WIFI)) + } + if (isBle) { + val side = if (isWifi) -1f else 1f + val (x, y) = edgeMarkerPosition(n1.x, n1.y, n2.x, n2.y, iconOffsetPx, sideSign = side) + markers.add(EdgeMarker(x, y, EdgeTransport.BLE)) + } + markers + } + } + + edgeMarkers.forEach { marker -> + Icon( + imageVector = when (marker.transport) { + EdgeTransport.WIFI -> Icons.Filled.Wifi + EdgeTransport.BLE -> Icons.Outlined.Bluetooth + }, + contentDescription = null, + tint = colorScheme.onSurface.copy(alpha = 0.82f), + modifier = Modifier + .offset { + IntOffset( + x = (marker.x - halfIconSizePx).roundToInt(), + y = (marker.y - halfIconSizePx).roundToInt() + ) + } + .size(iconSize) + ) + } + } +} diff --git a/app/src/main/java/com/bitchat/android/ui/media/AudioMessageItem.kt b/app/src/main/java/com/bitchat/android/ui/media/AudioMessageItem.kt index 264d9d6c..6af2cc51 100644 --- a/app/src/main/java/com/bitchat/android/ui/media/AudioMessageItem.kt +++ b/app/src/main/java/com/bitchat/android/ui/media/AudioMessageItem.kt @@ -21,7 +21,7 @@ import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.unit.dp import androidx.compose.ui.res.stringResource import com.bitchat.android.R -import com.bitchat.android.mesh.BluetoothMeshService +import com.bitchat.android.mesh.MeshService import com.bitchat.android.model.BitchatMessage import androidx.compose.material3.ColorScheme import java.text.SimpleDateFormat @@ -30,7 +30,7 @@ import java.text.SimpleDateFormat fun AudioMessageItem( message: BitchatMessage, currentUserNickname: String, - meshService: BluetoothMeshService, + meshService: MeshService, colorScheme: ColorScheme, timeFormatter: SimpleDateFormat, onNicknameClick: ((String) -> Unit)?, diff --git a/app/src/main/java/com/bitchat/android/ui/media/ImageMessageItem.kt b/app/src/main/java/com/bitchat/android/ui/media/ImageMessageItem.kt index 2310cd5b..43ae84f9 100644 --- a/app/src/main/java/com/bitchat/android/ui/media/ImageMessageItem.kt +++ b/app/src/main/java/com/bitchat/android/ui/media/ImageMessageItem.kt @@ -27,7 +27,7 @@ import androidx.compose.ui.text.TextLayoutResult import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog import androidx.compose.ui.text.font.FontFamily -import com.bitchat.android.mesh.BluetoothMeshService +import com.bitchat.android.mesh.MeshService import com.bitchat.android.model.BitchatMessage import com.bitchat.android.model.BitchatMessageType import androidx.compose.material3.ColorScheme @@ -39,7 +39,7 @@ fun ImageMessageItem( message: BitchatMessage, messages: List, currentUserNickname: String, - meshService: BluetoothMeshService, + meshService: MeshService, colorScheme: ColorScheme, timeFormatter: SimpleDateFormat, onNicknameClick: ((String) -> Unit)?, diff --git a/app/src/main/java/com/bitchat/android/ui/media/ImagePickerButton.kt b/app/src/main/java/com/bitchat/android/ui/media/ImagePickerButton.kt index 78c9f786..eb1f5d86 100644 --- a/app/src/main/java/com/bitchat/android/ui/media/ImagePickerButton.kt +++ b/app/src/main/java/com/bitchat/android/ui/media/ImagePickerButton.kt @@ -1,5 +1,7 @@ package com.bitchat.android.ui.media +import android.Manifest +import android.content.pm.PackageManager import android.net.Uri import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts @@ -19,6 +21,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp +import androidx.core.content.ContextCompat import androidx.core.content.FileProvider import com.bitchat.android.features.media.ImageUtils import java.io.File @@ -70,8 +73,16 @@ fun ImagePickerButton( ) capturedImagePath = file.absolutePath takePictureLauncher.launch(uri) - } catch (_: Exception) { - // Ignore errors; no-op + } catch (e: Exception) { + android.util.Log.e("ImagePickerButton", "Camera capture failed", e) + } + } + + val permissionLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.RequestPermission() + ) { isGranted -> + if (isGranted) { + startCameraCapture() } } @@ -80,7 +91,13 @@ fun ImagePickerButton( .size(32.dp) .combinedClickable( onClick = { imagePicker.launch("image/*") }, - onLongClick = { startCameraCapture() } + onLongClick = { + if (ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED) { + startCameraCapture() + } else { + permissionLauncher.launch(Manifest.permission.CAMERA) + } + } ), contentAlignment = Alignment.Center ) { diff --git a/app/src/main/java/com/bitchat/android/util/AppConstants.kt b/app/src/main/java/com/bitchat/android/util/AppConstants.kt index ca031df9..11df5b08 100644 --- a/app/src/main/java/com/bitchat/android/util/AppConstants.kt +++ b/app/src/main/java/com/bitchat/android/util/AppConstants.kt @@ -41,6 +41,10 @@ object AppConstants { const val MAX_FRAGMENT_SIZE: Int = 469 const val FRAGMENT_TIMEOUT_MS: Long = 30_000L const val CLEANUP_INTERVAL_MS: Long = 10_000L + const val MAX_FRAGMENTS_PER_ID: Int = 256 + const val MAX_FRAGMENT_TOTAL_BYTES: Int = 1_048_576 + const val MAX_ACTIVE_FRAGMENT_SETS: Int = 64 + const val MAX_GLOBAL_FRAGMENT_TOTAL_BYTES: Long = 4L * 1_048_576L } object Security { @@ -58,8 +62,13 @@ object AppConstants { const val HIGH_NONCE_WARNING_THRESHOLD: Long = 1_000_000_000L } + object Verification { + const val QR_MAX_AGE_SECONDS: Long = 300L // 5 minutes + } + object Protocol { const val COMPRESSION_THRESHOLD_BYTES: Int = 100 + const val MAX_PAYLOAD_LENGTH: Int = 10_485_760 } object StoreForward { @@ -76,9 +85,9 @@ object AppConstants { const val SCAN_ON_DURATION_NORMAL_MS: Long = 8_000L const val SCAN_OFF_DURATION_NORMAL_MS: Long = 2_000L const val SCAN_ON_DURATION_POWER_SAVE_MS: Long = 2_000L - const val SCAN_OFF_DURATION_POWER_SAVE_MS: Long = 8_000L + const val SCAN_OFF_DURATION_POWER_SAVE_MS: Long = 28_000L const val SCAN_ON_DURATION_ULTRA_LOW_MS: Long = 1_000L - const val SCAN_OFF_DURATION_ULTRA_LOW_MS: Long = 10_000L + const val SCAN_OFF_DURATION_ULTRA_LOW_MS: Long = 29_000L const val MAX_CONNECTIONS_NORMAL: Int = 8 const val MAX_CONNECTIONS_POWER_SAVE: Int = 8 const val MAX_CONNECTIONS_ULTRA_LOW: Int = 4 diff --git a/app/src/main/java/com/bitchat/android/wifi-aware/SyncedSocket.kt b/app/src/main/java/com/bitchat/android/wifi-aware/SyncedSocket.kt new file mode 100644 index 00000000..c3039327 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/wifi-aware/SyncedSocket.kt @@ -0,0 +1,99 @@ +package com.bitchat.android.wifiaware + +import java.io.DataInputStream +import java.io.DataOutputStream +import java.io.IOException +import java.net.Socket +import java.util.concurrent.locks.ReentrantLock +import kotlin.concurrent.withLock +import android.util.Log + +/** + * A synchronized wrapper around a raw Socket that implements a framed protocol: + * [4 bytes length][N bytes payload] + */ +class SyncedSocket( + val rawSocket: Socket, + readTimeoutMs: Int = DEFAULT_READ_TIMEOUT_MS +) { + private val TAG = "SyncedSocket" + private val writeLock = ReentrantLock() + private val readLock = ReentrantLock() + + private val inputStream: DataInputStream + private val outputStream: DataOutputStream + + companion object { + // Both peers exchange keep-alive frames every ~2s while connected, so a read that + // stalls well beyond that means the link is dead (half-open). Time out so the read + // loop can detect it and trigger disconnection instead of blocking forever. + const val DEFAULT_READ_TIMEOUT_MS = 15_000 + } + + init { + // A read timeout converts dead/half-open connections into a SocketTimeoutException + // (an IOException) so read() returns null and the peer is cleaned up. + try { rawSocket.soTimeout = readTimeoutMs } catch (_: Exception) {} + // We wrap streams to create DataInput/Output helpers + inputStream = DataInputStream(rawSocket.getInputStream()) + outputStream = DataOutputStream(rawSocket.getOutputStream()) + } + + /** + * Writes a framed message to the socket. + * Thread-safe. + */ + fun write(data: ByteArray) { + writeLock.withLock { + Log.v(TAG, "Writing frame of size: ${data.size}") + outputStream.writeInt(data.size) + if (data.isNotEmpty()) { + outputStream.write(data) + } + outputStream.flush() + } + } + + /** + * Reads a framed message from the socket. + * Blocks until a full frame is available. + * Returns null if socket is closed or EOF. + * Returns empty byte array for keep-alive (0 length frame). + */ + fun read(): ByteArray? { + readLock.withLock { + try { + // Read length prefix + val length = try { + inputStream.readInt() + } catch (e: java.io.EOFException) { + return null + } + Log.v(TAG, "Reading frame of size: $length") + + if (length < 0) throw IOException("Negative frame length: $length") + if (length > 64 * 1024) throw IOException("Frame length exceeds 64KB limit: $length") + + if (length == 0) { + return ByteArray(0) + } + + val buf = ByteArray(length) + inputStream.readFully(buf) + return buf + } catch (e: IOException) { + Log.e(TAG, "Socket read failed: ${e.message}") + // Socket closed or error + return null + } + } + } + + fun close() { + try { rawSocket.close() } catch (_: Exception) {} + } + + fun isClosed() = rawSocket.isClosed + fun isConnected() = rawSocket.isConnected + val inetAddress get() = rawSocket.inetAddress +} diff --git a/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareConnectionTracker.kt b/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareConnectionTracker.kt index b9964472..115dbb05 100644 --- a/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareConnectionTracker.kt +++ b/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareConnectionTracker.kt @@ -21,31 +21,47 @@ class WifiAwareConnectionTracker( } // Active resources per peer - val peerSockets = ConcurrentHashMap() + val peerSockets = ConcurrentHashMap() + private val socketAliases = ConcurrentHashMap() val serverSockets = ConcurrentHashMap() val networkCallbacks = ConcurrentHashMap() override fun isConnected(id: String): Boolean { // We consider it connected if we have a client socket to them - return peerSockets.containsKey(id) + return getSocketForPeer(id) != null } override fun disconnect(id: String) { Log.d(TAG, "Disconnecting peer $id") + val canonicalId = resolveCanonicalPeerId(id) // 1. Close client socket - peerSockets.remove(id)?.let { + peerSockets.remove(canonicalId)?.let { try { it.close() } catch (e: Exception) { Log.w(TAG, "Error closing socket for $id: ${e.message}") } } + socketAliases.entries.removeIf { it.key == id || it.key == canonicalId || it.value == canonicalId } // 2. Close server socket - serverSockets.remove(id)?.let { + serverSockets.remove(canonicalId)?.let { try { it.close() } catch (e: Exception) { Log.w(TAG, "Error closing server socket for $id: ${e.message}") } } - // 3. Unregister network callback - networkCallbacks.remove(id)?.let { - try { cm.unregisterNetworkCallback(it) } catch (e: Exception) { Log.w(TAG, "Error unregistering callback for $id: ${e.message}") } + // Ensure any pending/active network request is explicitly released + releaseNetworkRequest(canonicalId) + removePendingConnection(id) + removePendingConnection(canonicalId) + } + + fun releaseNetworkRequest(id: String) { + val canonicalId = resolveCanonicalPeerId(id) + if (!networkCallbacks.containsKey(canonicalId)) return + + // 3. Unregister network callback properly from ConnectivityManager + networkCallbacks.remove(canonicalId)?.let { + try { + Log.d(TAG, "Unregistering network callback for $canonicalId") + cm.unregisterNetworkCallback(it) + } catch (e: Exception) { Log.w(TAG, "Error unregistering callback for $canonicalId: ${e.message}") } } } @@ -54,17 +70,139 @@ class WifiAwareConnectionTracker( /** * Successfully established a client connection */ - fun onClientConnected(peerId: String, socket: Socket) { + fun onClientConnected(peerId: String, socket: SyncedSocket) { + // Close previous socket if one exists to prevent zombie readers + peerSockets[peerId]?.let { + try { it.close() } catch (_: Exception) {} + } peerSockets[peerId] = socket removePendingConnection(peerId) // Clear retry state on success } + fun getSocketForPeer(peerId: String): SyncedSocket? { + val canonicalId = resolveCanonicalPeerId(peerId) + return peerSockets[canonicalId] + } + + fun canonicalPeerId(peerId: String): String = resolveCanonicalPeerId(peerId) + + fun rebindPeerId(previousPeerId: String, resolvedPeerId: String, socket: SyncedSocket): String { + if (previousPeerId == resolvedPeerId) { + peerSockets[resolvedPeerId] = socket + return resolvedPeerId + } + + val previousCanonical = resolveCanonicalPeerId(previousPeerId) + val existing = peerSockets[previousCanonical] + if (existing === socket) { + peerSockets.remove(previousCanonical) + } + + peerSockets[resolvedPeerId]?.let { current -> + if (current !== socket) { + try { current.close() } catch (_: Exception) { } + } + } + peerSockets[resolvedPeerId] = socket + serverSockets.remove(previousCanonical)?.let { serverSockets[resolvedPeerId] = it } + networkCallbacks.remove(previousCanonical)?.let { networkCallbacks[resolvedPeerId] = it } + socketAliases[previousPeerId] = resolvedPeerId + if (previousCanonical != previousPeerId) { + socketAliases[previousCanonical] = resolvedPeerId + } + removePendingConnection(previousPeerId) + removePendingConnection(resolvedPeerId) + + Log.i(TAG, "Rebound Wi-Fi Aware socket ${previousPeerId.take(8)} -> ${resolvedPeerId.take(8)}") + return resolvedPeerId + } + + private fun resolveCanonicalPeerId(peerId: String): String { + var current = peerId + val visited = mutableSetOf() + while (visited.add(current)) { + val next = socketAliases[current] ?: return current + current = next + } + return current + } + fun addServerSocket(peerId: String, socket: ServerSocket) { - serverSockets[peerId] = socket + val canonicalId = resolveCanonicalPeerId(peerId) + serverSockets.put(canonicalId, socket)?.let { + try { it.close() } catch (e: Exception) { Log.w(TAG, "Error closing replaced server socket for $peerId: ${e.message}") } + } + } + + fun hasOpenServerSocket(peerId: String): Boolean { + val canonicalId = resolveCanonicalPeerId(peerId) + val socket = serverSockets[canonicalId] ?: return false + if (!socket.isClosed) return true + serverSockets.remove(canonicalId) + return false + } + + fun closeServerSocket(peerId: String) { + val canonicalId = resolveCanonicalPeerId(peerId) + serverSockets.remove(canonicalId)?.let { + try { it.close() } catch (e: Exception) { Log.w(TAG, "Error closing server socket for $peerId: ${e.message}") } + } + } + + fun hasPendingDataPathRequest(exceptPeerId: String? = null): Boolean { + val exceptCanonical = exceptPeerId?.let { resolveCanonicalPeerId(it) } + return pendingDataPathPeerIds(exceptCanonical).isNotEmpty() + } + + fun pendingDataPathPeerIds(exceptPeerId: String? = null): Set { + val exceptCanonical = exceptPeerId?.let { resolveCanonicalPeerId(it) } + val pendingIds = linkedSetOf() + + pendingConnections.keys.forEach { peerId -> + val canonicalId = resolveCanonicalPeerId(peerId) + if (canonicalId != exceptCanonical && !isConnected(canonicalId)) { + pendingIds.add(canonicalId) + } + } + + networkCallbacks.keys.forEach { peerId -> + val canonicalId = resolveCanonicalPeerId(peerId) + if (canonicalId != exceptCanonical && !isConnected(canonicalId)) { + pendingIds.add(canonicalId) + } + } + + return pendingIds + } + + fun pendingServerDataPathPeerIds(exceptPeerId: String? = null): Set { + val exceptCanonical = exceptPeerId?.let { resolveCanonicalPeerId(it) } + return serverSockets.keys.map { resolveCanonicalPeerId(it) } + .filter { peerId -> + peerId != exceptCanonical && + !isConnected(peerId) && + networkCallbacks.containsKey(peerId) && + serverSockets[peerId]?.isClosed == false + } + .toSet() + } + + fun cancelPendingServerDataPaths(exceptPeerId: String? = null): Set { + val cancelled = pendingServerDataPathPeerIds(exceptPeerId) + cancelled.forEach { disconnect(it) } + return cancelled } fun addNetworkCallback(peerId: String, callback: ConnectivityManager.NetworkCallback) { - networkCallbacks[peerId] = callback + val canonicalId = resolveCanonicalPeerId(peerId) + networkCallbacks.put(canonicalId, callback)?.let { + try { + Log.d(TAG, "Replacing network callback for $canonicalId") + cm.unregisterNetworkCallback(it) + } catch (e: Exception) { + Log.w(TAG, "Error unregistering replaced callback for $canonicalId: ${e.message}") + } + } } /** @@ -82,6 +220,12 @@ class WifiAwareConnectionTracker( peerSockets.keys.forEach { pid -> appendLine(" - $pid (Socket)") } + if (socketAliases.isNotEmpty()) { + appendLine("Socket aliases:") + socketAliases.forEach { (alias, canonical) -> + appendLine(" - $alias -> $canonical") + } + } appendLine("Server Sockets: ${serverSockets.size}") serverSockets.keys.forEach { pid -> appendLine(" - $pid (Listening)") diff --git a/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareController.kt b/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareController.kt index acdb3c91..f2ec4cfa 100644 --- a/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareController.kt +++ b/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareController.kt @@ -13,6 +13,7 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.isActive import kotlinx.coroutines.launch +import java.util.concurrent.atomic.AtomicBoolean /** * WifiAwareController manages lifecycle and debug surfacing for the WifiAwareMeshService. @@ -20,15 +21,31 @@ import kotlinx.coroutines.launch */ object WifiAwareController { private const val TAG = "WifiAwareController" + private const val MAX_RESTART_ATTEMPTS = 15 + private const val RESTART_RETRY_DELAY_MS = 2_000L private var service: WifiAwareMeshService? = null private var appContext: Context? = null + private val lifecycleLock = Any() + private var starting = false + private val restartInFlight = AtomicBoolean(false) + private var awareReceiverRegistered = false + private var lastBlockedReason: String? = null private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) private val _enabled = MutableStateFlow(false) val enabled: StateFlow = _enabled.asStateFlow() + private val _supported = MutableStateFlow(false) + val supported: StateFlow = _supported.asStateFlow() + + private val _available = MutableStateFlow(false) + val available: StateFlow = _available.asStateFlow() + + private val _supportStatus = MutableStateFlow(null) + val supportStatus: StateFlow = _supportStatus.asStateFlow() + private val _running = MutableStateFlow(false) val running: StateFlow = _running.asStateFlow() @@ -44,6 +61,12 @@ object WifiAwareController { fun initialize(context: Context, enabledByDefault: Boolean) { appContext = context.applicationContext + val status = refreshSupportStatus(appContext!!) + if (status.supported) { + registerAwareStateReceiver(appContext!!) + } else { + Log.i(TAG, "Wi-Fi Aware unsupported: ${status.reason}") + } setEnabled(enabledByDefault) // Start background poller for debug surfacing scope.launch { @@ -71,41 +94,212 @@ object WifiAwareController { } fun startIfPossible() { - if (_running.value) return - val ctx = appContext ?: return - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { - Log.w(TAG, "Wi‑Fi Aware requires Android 10 (Q)+; disabled.") - try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().addDebugMessage(com.bitchat.android.ui.debug.DebugMessage.SystemMessage("Wi‑Fi Aware not supported on this device (requires Android 10+)")) } catch (_: Exception) {} + val reusableService = synchronized(lifecycleLock) { + if (!_enabled.value) return + val existing = service + if (existing?.isRunning() == true) { + _running.value = true + return + } + if (starting) return + starting = true + existing + } + + val ctx = appContext ?: run { + synchronized(lifecycleLock) { starting = false } return } + + val status = refreshSupportStatus(ctx) + if (!status.supported) { + val reason = status.reason ?: "not supported" + Log.w(TAG, "Wi‑Fi Aware unsupported; not starting ($reason)") + addBlockedDebugMessage("unsupported:$reason", "Wi-Fi Aware not supported on this device ($reason)") + synchronized(lifecycleLock) { starting = false } + return + } + + val awareManager = WifiAwareSupport.getManager(ctx) + if (awareManager == null || !status.available) { + Log.w(TAG, "Wi-Fi Aware is not currently available; not starting") + addBlockedDebugMessage("unavailable", "Wi-Fi Aware is not available right now") + synchronized(lifecycleLock) { starting = false } + return + } + + // Check system location setting: WifiAwareManager.attach() throws SecurityException if disabled + val lm = ctx.getSystemService(Context.LOCATION_SERVICE) as? android.location.LocationManager + val locationEnabled = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + lm?.isLocationEnabled == true + } else { + @Suppress("DEPRECATION") + lm?.isProviderEnabled(android.location.LocationManager.GPS_PROVIDER) == true || + lm?.isProviderEnabled(android.location.LocationManager.NETWORK_PROVIDER) == true + } + + if (!locationEnabled) { + Log.w(TAG, "Location services are disabled; Wi-Fi Aware cannot start.") + addBlockedDebugMessage("location-disabled", "Enable Location Services to start Wi-Fi Aware") + synchronized(lifecycleLock) { starting = false } + return + } + // Android 13+: require NEARBY_WIFI_DEVICES runtime permission if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { val granted = androidx.core.content.ContextCompat.checkSelfPermission(ctx, android.Manifest.permission.NEARBY_WIFI_DEVICES) == android.content.pm.PackageManager.PERMISSION_GRANTED if (!granted) { Log.w(TAG, "Missing NEARBY_WIFI_DEVICES permission; not starting Wi‑Fi Aware") - try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().addDebugMessage(com.bitchat.android.ui.debug.DebugMessage.SystemMessage("Grant Nearby Wi‑Fi Devices to start Wi‑Fi Aware")) } catch (_: Exception) {} + addBlockedDebugMessage("missing-nearby-wifi", "Grant Nearby Wi-Fi Devices to start Wi-Fi Aware") + synchronized(lifecycleLock) { starting = false } return } } + if (!_enabled.value) { + synchronized(lifecycleLock) { starting = false } + return + } try { - service = WifiAwareMeshService(ctx).also { - it.startServices() - _running.value = true + val startedService = reusableService ?: run { + Log.i(TAG, "Instantiating WifiAwareMeshService...") + WifiAwareMeshService(ctx) + } + startedService.startServices() + if (startedService.isRunning()) { + synchronized(lifecycleLock) { + service = startedService + _running.value = true + } + try { com.bitchat.android.service.MeshServiceHolder.unifiedMeshService?.refreshDelegates() } catch (_: Exception) { } + clearBlockedDebugMessage() try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().addDebugMessage(com.bitchat.android.ui.debug.DebugMessage.SystemMessage("Wi‑Fi Aware started")) } catch (_: Exception) {} + } else { + if (reusableService == null) { + try { startedService.stopServices() } catch (_: Exception) { } + } + synchronized(lifecycleLock) { + if (service === startedService) service = null + _running.value = false + } + try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().addDebugMessage(com.bitchat.android.ui.debug.DebugMessage.SystemMessage("Wi‑Fi Aware did not start")) } catch (_: Exception) {} } } catch (e: Throwable) { Log.e(TAG, "Failed to start WifiAwareMeshService", e) _running.value = false try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().addDebugMessage(com.bitchat.android.ui.debug.DebugMessage.SystemMessage("Wi‑Fi Aware failed to start: ${e.message}")) } catch (_: Exception) {} + } finally { + synchronized(lifecycleLock) { starting = false } } } fun stop() { - try { service?.stopServices() } catch (_: Exception) { } - service = null - _running.value = false + val stopped = synchronized(lifecycleLock) { + val current = service + service = null + starting = false + _running.value = false + current + } + try { stopped?.stopServices() } catch (_: Exception) { } + try { com.bitchat.android.services.AppStateStore.clearTransportPeers("WIFI") } catch (_: Exception) { } + _connectedPeers.value = emptyMap() + _knownPeers.value = emptyMap() + _discoveredPeers.value = emptySet() try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().addDebugMessage(com.bitchat.android.ui.debug.DebugMessage.SystemMessage("Wi‑Fi Aware stopped")) } catch (_: Exception) {} } + internal fun onServiceStopped(stoppedService: WifiAwareMeshService) { + synchronized(lifecycleLock) { + if (service !== stoppedService) return + service = null + _running.value = false + try { com.bitchat.android.services.AppStateStore.clearTransportPeers("WIFI") } catch (_: Exception) { } + _connectedPeers.value = emptyMap() + _knownPeers.value = emptyMap() + _discoveredPeers.value = emptySet() + } + } + + /** + * Schedules a restart of the Wi-Fi Aware transport. Concurrent requests are coalesced into + * a single in-flight loop that retries with backoff. This is important because a single fixed + * delay can land while the service is still tearing down (recoveryInProgress), in which case + * startServices() defers and we must try again rather than give up. + */ + internal fun restartIfStillEnabled(delayMs: Long = 0L) { + if (!restartInFlight.compareAndSet(false, true)) { + Log.d(TAG, "Restart already in flight; coalescing request") + return + } + scope.launch { + try { + if (delayMs > 0L) delay(delayMs) + var attempt = 0 + while (_enabled.value && !_running.value && attempt < MAX_RESTART_ATTEMPTS) { + val ctx = appContext + if (ctx != null && !refreshSupportStatus(ctx).supported) break + startIfPossible() + if (_running.value) break + attempt++ + delay(RESTART_RETRY_DELAY_MS) + } + } finally { + restartInFlight.set(false) + } + } + } + + /** + * Listens for system Wi-Fi Aware availability changes. Aware can flip off/on at runtime + * (Wi-Fi toggling, hotspot/SoftAP, location changes); without this we would only recover on + * an unrelated trigger. + */ + private fun registerAwareStateReceiver(ctx: Context) { + if (awareReceiverRegistered) return + if (!refreshSupportStatus(ctx).supported) return + try { + val filter = android.content.IntentFilter( + android.net.wifi.aware.WifiAwareManager.ACTION_WIFI_AWARE_STATE_CHANGED + ) + ctx.registerReceiver(object : android.content.BroadcastReceiver() { + override fun onReceive(c: Context?, intent: android.content.Intent?) { + val status = refreshSupportStatus(ctx) + Log.i(TAG, "Wi-Fi Aware availability changed: supported=${status.supported} available=${status.available} enabled=${_enabled.value} running=${_running.value}") + if (status.available) { + if (_enabled.value) restartIfStillEnabled(500) + } else if (_running.value) { + // Aware went away; tear down cleanly so we can re-attach when it returns. + // Note: this does not change the enabled preference. + stop() + } + } + }, filter) + awareReceiverRegistered = true + } catch (e: Exception) { + Log.w(TAG, "Failed to register Wi-Fi Aware state receiver: ${e.message}") + } + } + + private fun refreshSupportStatus(ctx: Context): WifiAwareSupport.Status { + val status = WifiAwareSupport.evaluate(ctx) + _supported.value = status.supported + _available.value = status.available + _supportStatus.value = status + return status + } + + private fun addBlockedDebugMessage(key: String, message: String) { + if (lastBlockedReason == key) return + lastBlockedReason = key + try { + com.bitchat.android.ui.debug.DebugSettingsManager.getInstance() + .addDebugMessage(com.bitchat.android.ui.debug.DebugMessage.SystemMessage(message)) + } catch (_: Exception) { } + } + + private fun clearBlockedDebugMessage() { + lastBlockedReason = null + } + fun getService(): WifiAwareMeshService? = service } diff --git a/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareMeshDelegate.kt b/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareMeshDelegate.kt new file mode 100644 index 00000000..066a8b82 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareMeshDelegate.kt @@ -0,0 +1,3 @@ +package com.bitchat.android.wifiaware + +typealias WifiAwareMeshDelegate = com.bitchat.android.mesh.MeshDelegate diff --git a/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareMeshService.kt b/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareMeshService.kt index f357d715..36eaea1c 100644 --- a/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareMeshService.kt +++ b/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareMeshService.kt @@ -8,38 +8,45 @@ import android.net.wifi.aware.* import android.os.Build import android.os.Handler import android.os.Looper +import android.system.OsConstants import android.util.Log import androidx.annotation.RequiresApi import androidx.annotation.RequiresPermission import com.bitchat.android.crypto.EncryptionService -import com.bitchat.android.model.* -import com.bitchat.android.protocol.* +import com.bitchat.android.mesh.FragmentingPacketSender +import com.bitchat.android.mesh.MeshCore +import com.bitchat.android.mesh.MeshService +import com.bitchat.android.mesh.MeshTransport +import com.bitchat.android.mesh.PeerInfo +import com.bitchat.android.model.BitchatFilePacket +import com.bitchat.android.model.BitchatMessage +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 -// Mesh-layer components are reused from the existing Bluetooth stack -import com.bitchat.android.mesh.PeerManager -import com.bitchat.android.mesh.PeerManagerDelegate -import com.bitchat.android.mesh.PeerInfo -import com.bitchat.android.mesh.FragmentManager -import com.bitchat.android.mesh.SecurityManager -import com.bitchat.android.mesh.SecurityManagerDelegate -import com.bitchat.android.mesh.StoreForwardManager -import com.bitchat.android.mesh.StoreForwardManagerDelegate -import com.bitchat.android.mesh.MessageHandler -import com.bitchat.android.mesh.MessageHandlerDelegate -import com.bitchat.android.mesh.PacketProcessor -import com.bitchat.android.mesh.PacketProcessorDelegate -import kotlinx.coroutines.* +import java.io.InterruptedIOException +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch import java.io.IOException import java.net.Inet6Address import java.net.ServerSocket import java.net.Socket import java.nio.ByteBuffer import java.nio.ByteOrder -import java.util.UUID import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.Executors +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicInteger +import java.util.concurrent.atomic.AtomicLong /** * WifiAware mesh service - LATEST @@ -52,109 +59,154 @@ import java.util.concurrent.Executors * - MessageHandler: Message type processing and relay logic * - PacketProcessor: Incoming packet routing */ -class WifiAwareMeshService(private val context: Context) : TransportBridgeService.TransportLayer { +class WifiAwareMeshService(private val context: Context) : MeshService, TransportBridgeService.TransportLayer { companion object { private const val TAG = "WifiAwareMeshService" private const val MAX_TTL: UByte = 7u private const val SERVICE_NAME = "bitchat" private const val PSK = "bitchat_secret" + // Network request / socket timeouts + private const val NETWORK_REQUEST_TIMEOUT_MS = 30_000 + private const val ACCEPT_TIMEOUT_MS = 30_000 + private const val CLIENT_CONNECT_TIMEOUT_MS = 7_000 + private const val CLIENT_SOCKET_READY_DELAY_MS = 750L + private const val CLIENT_SOCKET_RETRY_DELAY_MS = 750L + private const val CLIENT_SOCKET_ATTEMPTS = 3 + private const val CLIENT_ROLE_REVERSAL_FAILURES = 3 + // Discovery freshness window for reconnection maintenance + private const val DISCOVERY_STALE_MS = 5L * 60 * 1000 + private const val DISCOVERY_IDLE_REFRESH_MS = 2L * 60 * 1000 + private const val DISCOVERY_SESSION_REFRESH_MIN_INTERVAL_MS = 90L * 1000 + private const val ROLE_REVERSAL_PREFIX = "ROLE_SERVER:" } // Core crypto/services private val encryptionService = EncryptionService(context) // Peer ID must match BluetoothMeshService: first 16 hex chars of identity fingerprint (8 bytes) - val myPeerID: String = encryptionService.getIdentityFingerprint().take(16) + override val myPeerID: String = encryptionService.getIdentityFingerprint().take(16) + private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + private val wifiTransport = WifiAwareTransport() + private lateinit var meshCore: MeshCore + private lateinit var fragmentingSender: FragmentingPacketSender - // Core components - private val peerManager = PeerManager() - private 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) - - // Gossip sync - private val gossipSyncManager: GossipSyncManager + // Service-level notification manager for background (no-UI) DMs + private val serviceNotificationManager = com.bitchat.android.ui.NotificationManager( + context.applicationContext, + androidx.core.app.NotificationManagerCompat.from(context.applicationContext), + com.bitchat.android.util.NotificationIntervalManager() + ) // Wi-Fi Aware transport private val awareManager = context.getSystemService(WifiAwareManager::class.java) - private var wifiAwareSession: WifiAwareSession? = null - private var publishSession: PublishDiscoverySession? = null - private var subscribeSession: SubscribeDiscoverySession? = null + @Volatile private var wifiAwareSession: WifiAwareSession? = null + @Volatile private var publishSession: PublishDiscoverySession? = null + @Volatile private var subscribeSession: SubscribeDiscoverySession? = null private val listenerExec = Executors.newCachedThreadPool() - private var isActive = false + @Volatile private var isActive = false + @Volatile private var recoveryInProgress = false + private val sessionGeneration = AtomicInteger(0) // Delegate - var delegate: WifiAwareMeshDelegate? = null - - // Coroutines - must be initialized before tracker - private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + override var delegate: WifiAwareMeshDelegate? = null + set(value) { + field = value + if (::meshCore.isInitialized) { + meshCore.delegate = value + meshCore.refreshPeerList() + } + } private val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager // Transport state private val connectionTracker = WifiAwareConnectionTracker(serviceScope, cm) private val handleToPeerId = ConcurrentHashMap() // discovery mapping private val discoveredTimestamps = ConcurrentHashMap() // peerID -> last seen time + // Subscribe-session-scoped handles only. PeerHandles are session-scoped, so a handle obtained + // from the publish session is NOT valid for subscribeSession.sendMessage(). Maintenance re-pings + // (subscriber -> publisher) must use a handle that originated from the subscribe session. + private val subscribeHandles = ConcurrentHashMap() // peerID -> latest subscribe handle + private val publishHandles = ConcurrentHashMap() // peerID -> latest publish handle + private val forcedServerPeers = ConcurrentHashMap.newKeySet() + private val forcedClientPeers = ConcurrentHashMap.newKeySet() + private val clientSocketFailures = ConcurrentHashMap() + private val lastDiscoveryActivityAt = AtomicLong(0L) + private val lastDiscoveryRefreshAt = AtomicLong(0L) - // Timestamp dedupe - private val lastTimestamps = ConcurrentHashMap() + fun isRunning(): Boolean = isActive init { - setupDelegates() - messageHandler.packetProcessor = packetProcessor - - // Use shared GossipSyncManager from MeshServiceHolder if available (minimal refactor) + // Ensure BluetoothMeshService is initialized so we share its GossipSyncManager + // This avoids race conditions and ensures a single gossip source/delegate + com.bitchat.android.service.MeshServiceHolder.getOrCreate(context) val shared = com.bitchat.android.service.MeshServiceHolder.sharedGossipSyncManager - if (shared != null) { - gossipSyncManager = shared - } else { - gossipSyncManager = GossipSyncManager( - myPeerID = myPeerID, - scope = serviceScope, - configProvider = object : GossipSyncManager.ConfigProvider { - override fun seenCapacity(): Int = 500 - override fun gcsMaxBytes(): Int = 400 - override fun gcsTargetFpr(): Double = 0.01 + encryptionService.onSessionEstablished = { peerID -> + Log.d(TAG, "Wi-Fi Aware Noise session established with ${peerID.take(8)}") + try { + com.bitchat.android.services.MessageRouter + .tryGetInstance() + ?.onSessionEstablished(peerID) + } catch (_: Exception) { } + } + meshCore = MeshCore( + context = context.applicationContext, + scope = serviceScope, + transport = wifiTransport, + encryptionService = encryptionService, + myPeerID = myPeerID, + maxTtl = MAX_TTL, + sharedGossipManager = shared, + gossipConfigProvider = object : GossipSyncManager.ConfigProvider { + override fun seenCapacity(): Int = 500 + override fun gcsMaxBytes(): Int = 400 + override fun gcsTargetFpr(): Double = 0.01 + }, + hooks = MeshCore.Hooks( + onMessageReceived = { message -> handleMessageReceived(message) }, + onAnnounceProcessed = { routed, _ -> + routed.peerID?.let { pid -> + try { meshCore.gossipSyncManager.scheduleInitialSyncToPeer(pid, 1_000) } catch (_: Exception) { } + } + }, + announcementNicknameProvider = { + try { com.bitchat.android.services.NicknameProvider.getNickname(context, myPeerID) } catch (_: Exception) { null } + }, + leavePayloadProvider = { + (delegate?.getNickname() ?: myPeerID).toByteArray(Charsets.UTF_8) } ) - gossipSyncManager.delegate = object : GossipSyncManager.Delegate { - override fun sendPacketToPeer(peerID: String, packet: BitchatPacket) { - this@WifiAwareMeshService.sendPacketToPeer(peerID, packet) + ) + fragmentingSender = FragmentingPacketSender(serviceScope, meshCore.fragmentManager, TAG) + } + + private fun handleMessageReceived(message: BitchatMessage) { + try { + when { + message.isPrivate -> { + val peer = message.senderPeerID ?: "" + if (peer.isNotEmpty()) com.bitchat.android.services.AppStateStore.addPrivateMessage(peer, message) } - override fun sendPacket(packet: BitchatPacket) { - dispatchGlobal(RoutedPacket(packet)) + message.channel != null -> { + com.bitchat.android.services.AppStateStore.addChannelMessage(message.channel!!, message) } - override fun signPacketForBroadcast(packet: BitchatPacket): BitchatPacket { - return signPacketBeforeBroadcast(packet) + else -> { + com.bitchat.android.services.AppStateStore.addPublicMessage(message) } } - } - } + } catch (_: Exception) { } - /** - * Helper method hexToBa. - */ - private fun hexStringToByteArray(hex: String): ByteArray { - val out = ByteArray(8) - var idx = 0 - var s = hex - while (s.length >= 2 && idx < 8) { - val b = s.substring(0, 2).toIntOrNull(16)?.toByte() ?: 0 - out[idx++] = b - s = s.drop(2) + if (delegate == null && message.isPrivate) { + try { + val senderPeerID = message.senderPeerID + if (senderPeerID != null) { + val nick = try { meshCore.getPeerNickname(senderPeerID) } catch (_: Exception) { null } ?: senderPeerID + val preview = com.bitchat.android.ui.NotificationTextUtils.buildPrivateMessagePreview(message) + serviceNotificationManager.setAppBackgroundState(true) + serviceNotificationManager.showPrivateMessageNotification(senderPeerID, nick, preview) + } + } catch (_: Exception) { } } - return out - } - - /** - * Sign packet before broadcasting. - */ - private fun signPacketBeforeBroadcast(packet: BitchatPacket): BitchatPacket { - val data = packet.toBinaryDataForSigning() ?: return packet - val sig = encryptionService.signData(data) ?: return packet - return packet.copy(signature = sig) } /** @@ -164,7 +216,7 @@ class WifiAwareMeshService(private val context: Context) : TransportBridgeServic var sent = 0 connectionTracker.peerSockets.forEach { (pid, sock) -> try { - sock.getOutputStream().write(bytes) + sock.write(bytes) sent++ } catch (e: IOException) { Log.e(TAG, "TX: write failed to ${pid.take(8)}: ${e.message}") @@ -177,17 +229,11 @@ class WifiAwareMeshService(private val context: Context) : TransportBridgeServic override fun send(packet: RoutedPacket) { // Received from bridge (e.g. BLE) -> Send via Wi-Fi // Direct injection prevents routing loops (bridge handles source check) - broadcastPacket(packet) + meshCore.sendFromBridge(packet) } - /** - * unified dispatch: Send to local Wi-Fi and bridge to other transports - */ - private fun dispatchGlobal(routed: RoutedPacket) { - // 1. Send to local Wi-Fi transport - broadcastPacket(routed) - // 2. Bridge to other transports (e.g. BLE) - TransportBridgeService.broadcast("WIFI", routed) + override fun sendToPeer(peerID: String, packet: BitchatPacket) { + sendPacketToPeer(peerID, packet) } /** @@ -195,9 +241,28 @@ class WifiAwareMeshService(private val context: Context) : TransportBridgeServic */ private fun broadcastPacket(routed: RoutedPacket) { Log.d(TAG, "TX: packet type=${routed.packet.type} broadcast (ttl=${routed.packet.ttl})") - // Wi-Fi Aware uses full packets; no fragmentation - val data = routed.packet.toBinaryData() ?: return - serviceScope.launch { broadcastRaw(data) } + + val packet = routed.packet + if (packet.senderID.toHexString() == myPeerID && !packet.route.isNullOrEmpty()) { + val firstHop = packet.route!![0].toHexString() + if (sendRoutedPacketToPeer(firstHop, routed)) { + Log.d(TAG, "TX: source-routed packet sent only to first Wi-Fi hop ${firstHop.take(8)}") + return + } + Log.w(TAG, "TX: first Wi-Fi source-route hop ${firstHop.take(8)} unavailable; falling back to broadcast") + } + + val recipientId = packet.recipientID?.toHexString() + if (recipientId != null && !packet.recipientID.contentEquals(SpecialRecipients.BROADCAST)) { + if (sendRoutedPacketToPeer(recipientId, routed)) { + Log.d(TAG, "TX: addressed packet sent directly to Wi-Fi peer ${recipientId.take(8)}") + return + } + } + + fragmentingSender.send(routed, "Wi-Fi Aware broadcast") { single -> + broadcastSinglePacket(single) + } } // Expose a public method so BLE can forward relays to Wi-Fi Aware @@ -208,214 +273,45 @@ class WifiAwareMeshService(private val context: Context) : TransportBridgeServic /** * Send packet to connected peer. */ - private fun sendPacketToPeer(peerID: String, packet: BitchatPacket) { - // Wi-Fi Aware uses full packets; no fragmentation - val data = packet.toBinaryData() ?: return - serviceScope.launch { - val sock = connectionTracker.peerSockets[peerID] - if (sock == null) { - Log.w(TAG, "TX: no socket for ${peerID.take(8)}") - return@launch - } - try { - sock.getOutputStream().write(data) - Log.d(TAG, "TX: packet type=${packet.type} to ${peerID.take(8)} (bytes=${data.size})") - } catch (e: IOException) { - Log.e(TAG, "TX: write to ${peerID.take(8)} failed: ${e.message}") - } + private fun sendPacketToPeer(peerID: String, packet: BitchatPacket): Boolean { + return sendRoutedPacketToPeer(peerID, RoutedPacket(packet)) + } + + private fun sendRoutedPacketToPeer(peerID: String, routed: RoutedPacket): Boolean { + if (connectionTracker.getSocketForPeer(peerID) == null) { + Log.w(TAG, "TX: no socket for ${peerID.take(8)}") + return false + } + return fragmentingSender.send(routed, "Wi-Fi Aware peer ${peerID.take(8)}") { single -> + sendSinglePacketToPeer(peerID, single.packet) } } - /** - * Configures delegates for internal components so that events are routed back - * through this service and ultimately to the {@link WifiAwareMeshDelegate}. - */ - private fun setupDelegates() { - peerManager.delegate = object : PeerManagerDelegate { - override fun onPeerListUpdated(peerIDs: List) { - delegate?.didUpdatePeerList(peerIDs) - } - override fun onPeerRemoved(peerID: String) { - try { gossipSyncManager.removeAnnouncementForPeer(peerID) } catch (_: Exception) { } - try { encryptionService.removePeer(peerID) } catch (_: Exception) { } - } + private fun broadcastSinglePacket(routed: RoutedPacket): Boolean { + val data = routed.packet.toBinaryData() ?: return false + broadcastRaw(data) + return true + } + + private fun sendSinglePacketToPeer(peerID: String, packet: BitchatPacket): Boolean { + val data = packet.toBinaryData() ?: return false + val sock = connectionTracker.getSocketForPeer(peerID) + if (sock == null) { + Log.w(TAG, "TX: no socket for ${peerID.take(8)}") + return false } - - securityManager.delegate = object : SecurityManagerDelegate { - override fun onKeyExchangeCompleted(peerID: String, peerPublicKeyData: ByteArray) { - serviceScope.launch { - delay(100) - sendAnnouncementToPeer(peerID) - delay(1000) - storeForwardManager.sendCachedMessages(peerID) - } - } - override fun sendHandshakeResponse(peerID: String, response: ByteArray) { - val packet = BitchatPacket( - version = 1u, - type = MessageType.NOISE_HANDSHAKE.value, - senderID = hexStringToByteArray(myPeerID), - recipientID = hexStringToByteArray(peerID), - timestamp = System.currentTimeMillis().toULong(), - payload = response, - ttl = MAX_TTL - ) - dispatchGlobal(RoutedPacket(signPacketBeforeBroadcast(packet))) - } - override fun getPeerInfo(peerID: String): PeerInfo? { - return peerManager.getPeerInfo(peerID) - } - } - - storeForwardManager.delegate = object : StoreForwardManagerDelegate { - override fun isFavorite(peerID: String) = delegate?.isFavorite(peerID) ?: false - override fun isPeerOnline(peerID: String) = peerManager.isPeerActive(peerID) - override fun sendPacket(packet: BitchatPacket) { - dispatchGlobal(RoutedPacket(packet)) - } - } - - messageHandler.delegate = object : MessageHandlerDelegate { - override fun addOrUpdatePeer(peerID: String, nickname: String) = - 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) = - peerManager.getPeerNickname(peerID) - override fun getNetworkSize() = peerManager.getActivePeerCount() - override fun getMyNickname() = delegate?.getNickname() - override fun getPeerInfo(peerID: String): PeerInfo? = peerManager.getPeerInfo(peerID) - override fun updatePeerInfo( - peerID: String, - nickname: String, - noisePublicKey: ByteArray, - signingPublicKey: ByteArray, - isVerified: Boolean - ): Boolean = peerManager.updatePeerInfo(peerID, nickname, noisePublicKey, signingPublicKey, isVerified) - - override fun sendPacket(packet: BitchatPacket) { - dispatchGlobal(RoutedPacket(signPacketBeforeBroadcast(packet))) - } - override fun relayPacket(routed: RoutedPacket) { dispatchGlobal(routed) } - override fun getBroadcastRecipient() = SpecialRecipients.BROADCAST - - override fun verifySignature(packet: BitchatPacket, peerID: String) = - securityManager.verifySignature(packet, peerID) - override fun encryptForPeer(data: ByteArray, recipientPeerID: String) = - securityManager.encryptForPeer(data, recipientPeerID) - override fun decryptFromPeer(encryptedData: ByteArray, senderPeerID: String) = - securityManager.decryptFromPeer(encryptedData, senderPeerID) - override fun verifyEd25519Signature(signature: ByteArray, data: ByteArray, publicKey: ByteArray): Boolean = - encryptionService.verifyEd25519Signature(signature, data, publicKey) - - override fun hasNoiseSession(peerID: String) = - encryptionService.hasEstablishedSession(peerID) - override fun initiateNoiseHandshake(peerID: String) { - serviceScope.launch { - val hs = encryptionService.initiateHandshake(peerID) ?: return@launch - val packet = BitchatPacket( - version = 1u, - type = MessageType.NOISE_HANDSHAKE.value, - senderID = hexStringToByteArray(myPeerID), - recipientID = hexStringToByteArray(peerID), - timestamp = System.currentTimeMillis().toULong(), - payload = hs, - ttl = MAX_TTL - ) - dispatchGlobal(RoutedPacket(signPacketBeforeBroadcast(packet))) - } - } - override fun processNoiseHandshakeMessage(payload: ByteArray, peerID: String): ByteArray? = - 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(TAG, "Updated peer binding to $newPeerID, fp=${fingerprint.take(16)}") - } - - override fun decryptChannelMessage(encryptedContent: ByteArray, channel: String) = - delegate?.decryptChannelMessage(encryptedContent, channel) - - override fun onMessageReceived(message: BitchatMessage) { - 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) - } - } - - packetProcessor.delegate = object : PacketProcessorDelegate { - override fun validatePacketSecurity(packet: BitchatPacket, peerID: String) = - securityManager.validatePacket(packet, peerID) - override fun updatePeerLastSeen(peerID: String) = peerManager.updatePeerLastSeen(peerID) - override fun getPeerNickname(peerID: String): String? = peerManager.getPeerNickname(peerID) - override fun getNetworkSize(): Int = peerManager.getActivePeerCount() - override fun getBroadcastRecipient(): ByteArray = SpecialRecipients.BROADCAST - - override fun handleNoiseHandshake(routed: RoutedPacket): Boolean = - runBlocking { securityManager.handleNoiseHandshake(routed) } - - override fun handleNoiseEncrypted(routed: RoutedPacket) { - serviceScope.launch { messageHandler.handleNoiseEncrypted(routed) } - } - - override fun handleAnnounce(routed: RoutedPacket) { - serviceScope.launch { - val isFirst = messageHandler.handleAnnounce(routed) - routed.peerID?.let { pid -> - try { gossipSyncManager.scheduleInitialSyncToPeer(pid, 1_000) } catch (_: Exception) { } - } - try { gossipSyncManager.onPublicPacketSeen(routed.packet) } catch (_: Exception) { } - } - } - - override fun handleMessage(routed: RoutedPacket) { - serviceScope.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) { - serviceScope.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@WifiAwareMeshService.sendAnnouncementToPeer(peerID) - override fun sendCachedMessages(peerID: String) = storeForwardManager.sendCachedMessages(peerID) - override fun relayPacket(routed: RoutedPacket) = dispatchGlobal(routed) - - override fun handleRequestSync(routed: RoutedPacket) { - val fromPeer = routed.peerID ?: return - val req = RequestSyncPacket.decode(routed.packet.payload) ?: return - gossipSyncManager.handleRequestSync(fromPeer, req) - } + try { + sock.write(data) + Log.d(TAG, "TX: packet type=${packet.type} to ${peerID.take(8)} (bytes=${data.size})") + return true + } catch (e: IOException) { + Log.e(TAG, "TX: write to ${peerID.take(8)} failed: ${e.message}") + return false } } + + /** * Starts Wi-Fi Aware services (publish + subscribe). * @@ -428,18 +324,48 @@ class WifiAwareMeshService(private val context: Context) : TransportBridgeServic Manifest.permission.ACCESS_WIFI_STATE, Manifest.permission.CHANGE_WIFI_STATE ]) - fun startServices() { + override fun startServices() { if (isActive) return + if (!com.bitchat.android.wifiaware.WifiAwareController.enabled.value) { + Log.i(TAG, "Wi-Fi Aware transport disabled by debug settings; not starting") + return + } + val supportStatus = com.bitchat.android.wifiaware.WifiAwareSupport.evaluate(context) + if (!supportStatus.supported) { + Log.i(TAG, "Wi-Fi Aware unsupported on this device; not starting (${supportStatus.reason})") + return + } + if (!supportStatus.available) { + Log.i(TAG, "Wi-Fi Aware unavailable right now; not starting (${supportStatus.reason})") + return + } + if (recoveryInProgress) { + Log.i(TAG, "Wi-Fi Aware recovery cleanup still in progress; deferring start") + return + } + val manager = awareManager + if (manager == null || !manager.isAvailable) { + Log.w(TAG, "Wi-Fi Aware manager unavailable; not starting") + return + } isActive = true + val startTime = System.currentTimeMillis() + lastDiscoveryActivityAt.set(startTime) + lastDiscoveryRefreshAt.set(startTime) + val generation = sessionGeneration.incrementAndGet() Log.i(TAG, "Starting Wi-Fi Aware mesh with peer ID: $myPeerID") - awareManager?.attach(object : AttachCallback() { + manager.attach(object : AttachCallback() { @SuppressLint("MissingPermission") @RequiresPermission(allOf = [ Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.NEARBY_WIFI_DEVICES ]) override fun onAttached(session: WifiAwareSession) { + if (!isCurrentSession(generation)) { + session.close() + return + } wifiAwareSession = session Log.i(TAG, "Wi-Fi Aware attached; starting publish & subscribe (peerID=$myPeerID)") @@ -451,17 +377,30 @@ class WifiAwareMeshService(private val context: Context) : TransportBridgeServic .build(), object : DiscoverySessionCallback() { override fun onPublishStarted(pub: PublishDiscoverySession) { + if (!isCurrentSession(generation)) { + pub.close() + return + } publishSession = pub Log.d(TAG, "PUBLISH: onPublishStarted()") + try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().addDebugMessage(com.bitchat.android.ui.debug.DebugMessage.SystemMessage("Wi-Fi Aware Publish Started")) } catch (_: Exception) {} } override fun onServiceDiscovered( peerHandle: PeerHandle, serviceSpecificInfo: ByteArray, matchFilter: List ) { + if (!isCurrentSession(generation)) return val peerId = try { String(serviceSpecificInfo) } catch (_: Exception) { "" } handleToPeerId[peerHandle] = peerId - if (peerId.isNotBlank()) discoveredTimestamps[peerId] = System.currentTimeMillis() + if (peerId.isNotBlank()) { + rememberDiscoveredPeer(peerId) + publishHandles[peerId] = peerHandle + Log.i(TAG, "PUBLISH: Discovered subscriber '$peerId' via Aware") + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + offerServerPathIfAppropriate(peerId, peerHandle, "publish discovery") + } + } Log.d(TAG, "PUBLISH: onServiceDiscovered ssi='${peerId.take(16)}' len=${serviceSpecificInfo.size}") } @@ -470,15 +409,36 @@ class WifiAwareMeshService(private val context: Context) : TransportBridgeServic peerHandle: PeerHandle, message: ByteArray ) { + if (!isCurrentSession(generation)) return if (message.isEmpty()) return val subscriberId = try { String(message) } catch (_: Exception) { "" } + if (subscriberId.startsWith(ROLE_REVERSAL_PREFIX)) { + val requesterId = subscriberId.removePrefix(ROLE_REVERSAL_PREFIX) + handleRoleReversalRequest(peerHandle, requesterId) + return + } if (subscriberId == myPeerID) return handleToPeerId[peerHandle] = subscriberId - if (subscriberId.isNotBlank()) discoveredTimestamps[subscriberId] = System.currentTimeMillis() - Log.d(TAG, "PUBLISH: got ping from $subscriberId; spinning up server") + if (subscriberId.isNotBlank()) { + rememberDiscoveredPeer(subscriberId) + publishHandles[subscriberId] = peerHandle + } + Log.i(TAG, "PUBLISH: Received discovery ping from subscriber '$subscriberId'") handleSubscriberPing(publishSession!!, peerHandle) } + + override fun onSessionTerminated() { + if (!isCurrentSession(generation)) return + Log.e(TAG, "PUBLISH: onSessionTerminated()") + publishSession = null + val shouldRestart = isActive && com.bitchat.android.wifiaware.WifiAwareController.enabled.value + handleUnexpectedStop(generation) + if (shouldRestart) { + Log.i(TAG, "PUBLISH: Scheduling Wi-Fi Aware restart") + com.bitchat.android.wifiaware.WifiAwareController.restartIfStillEnabled(2000) + } + } }, Handler(Looper.getMainLooper()) ) @@ -487,23 +447,31 @@ class WifiAwareMeshService(private val context: Context) : TransportBridgeServic session.subscribe( SubscribeConfig.Builder() .setServiceName(SERVICE_NAME) + .setServiceSpecificInfo(myPeerID.toByteArray(Charsets.UTF_8)) .build(), object : DiscoverySessionCallback() { override fun onSubscribeStarted(sub: SubscribeDiscoverySession) { + if (!isCurrentSession(generation)) { + sub.close() + return + } subscribeSession = sub Log.d(TAG, "SUBSCRIBE: onSubscribeStarted()") + try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().addDebugMessage(com.bitchat.android.ui.debug.DebugMessage.SystemMessage("Wi-Fi Aware Subscribe Started")) } catch (_: Exception) {} } override fun onServiceDiscovered( peerHandle: PeerHandle, serviceSpecificInfo: ByteArray, matchFilter: List ) { + if (!isCurrentSession(generation)) return val peerId = try { String(serviceSpecificInfo) } catch (_: Exception) { "" } handleToPeerId[peerHandle] = peerId - val msgId = (System.nanoTime() and 0x7fffffff).toInt() - subscribeSession?.sendMessage(peerHandle, msgId, myPeerID.toByteArray()) - if (peerId.isNotBlank()) discoveredTimestamps[peerId] = System.currentTimeMillis() - Log.d(TAG, "SUBSCRIBE: sent ping to '${peerId.take(16)}' (msgId=$msgId)") + // This handle came from the subscribe session, so it is valid for + // subscribeSession.sendMessage() (used by maintenance reconnection). + if (peerId.isNotBlank()) subscribeHandles[peerId] = peerHandle + sendSubscribePing(peerId, peerHandle, "discovery") + if (peerId.isNotBlank()) rememberDiscoveredPeer(peerId) } @RequiresApi(Build.VERSION_CODES.Q) @@ -511,48 +479,79 @@ class WifiAwareMeshService(private val context: Context) : TransportBridgeServic peerHandle: PeerHandle, message: ByteArray ) { + if (!isCurrentSession(generation)) return if (message.isEmpty()) return - val peerId = handleToPeerId[peerHandle] ?: return - if (peerId == myPeerID) return - - Log.d(TAG, "SUBSCRIBE: onMessageReceived() → server-ready from ${peerId.take(8)} payload=${message.size}B") handleServerReady(peerHandle, message) } + + override fun onSessionTerminated() { + if (!isCurrentSession(generation)) return + Log.e(TAG, "SUBSCRIBE: onSessionTerminated()") + subscribeSession = null + val shouldRestart = isActive && com.bitchat.android.wifiaware.WifiAwareController.enabled.value + handleUnexpectedStop(generation) + if (shouldRestart) { + Log.i(TAG, "SUBSCRIBE: Scheduling Wi-Fi Aware restart") + com.bitchat.android.wifiaware.WifiAwareController.restartIfStillEnabled(2000) + } + } }, Handler(Looper.getMainLooper()) ) } override fun onAttachFailed() { + if (!isCurrentSession(generation)) return Log.e(TAG, "Wi-Fi Aware attach failed") + handleUnexpectedStop(generation) + if (com.bitchat.android.wifiaware.WifiAwareController.enabled.value) { + com.bitchat.android.wifiaware.WifiAwareController.restartIfStillEnabled(3000) + } + } + + override fun onAwareSessionTerminated() { + if (!isCurrentSession(generation)) return + Log.e(TAG, "Aware Session Terminated unexpectedly") + wifiAwareSession = null + val shouldRestart = com.bitchat.android.wifiaware.WifiAwareController.enabled.value + handleUnexpectedStop(generation) + if (shouldRestart) { + com.bitchat.android.wifiaware.WifiAwareController.restartIfStillEnabled(3000) + } } }, Handler(Looper.getMainLooper())) - sendPeriodicBroadcastAnnounce() - startPeriodicConnectionMaintenance() - connectionTracker.start() - gossipSyncManager.start() - // Register with cross-layer transport bridge TransportBridgeService.register("WIFI", this) + + meshCore.startCore() + com.bitchat.android.service.MeshServiceHolder.startSharedGossip("WIFI") + startPeriodicConnectionMaintenance() + connectionTracker.start() } /** * Stops the Wi-Fi Aware mesh services and cleans up sockets and sessions. */ - fun stopServices() { - if (!isActive) return + override fun stopServices() { + val wasActive = isActive isActive = false + sessionGeneration.incrementAndGet() Log.i(TAG, "Stopping Wi-Fi Aware mesh") // Unregister from bridge TransportBridgeService.unregister("WIFI") + com.bitchat.android.service.MeshServiceHolder.stopSharedGossip("WIFI") + try { com.bitchat.android.services.AppStateStore.clearTransportPeers("WIFI") } catch (_: Exception) { } + try { com.bitchat.android.services.AppStateStore.clearTransportDirectPeers("WIFI") } catch (_: Exception) { } - sendLeaveAnnouncement() + if (wasActive) { + meshCore.sendLeaveAnnouncement() + } serviceScope.launch { delay(200) - gossipSyncManager.stop() + meshCore.stopCore() connectionTracker.stop() // Handles socket closing and callback unregistration publishSession?.close(); publishSession = null @@ -560,30 +559,95 @@ class WifiAwareMeshService(private val context: Context) : TransportBridgeServic wifiAwareSession?.close(); wifiAwareSession = null handleToPeerId.clear() + subscribeHandles.clear() + publishHandles.clear() + discoveredTimestamps.clear() - peerManager.shutdown() - fragmentManager.shutdown() - securityManager.shutdown() - storeForwardManager.shutdown() - messageHandler.shutdown() - packetProcessor.shutdown() + meshCore.shutdown() + // Tear down listener threads; this instance is discarded after a full stop. + try { listenerExec.shutdownNow() } catch (_: Exception) { } + + com.bitchat.android.wifiaware.WifiAwareController.onServiceStopped(this@WifiAwareMeshService) serviceScope.cancel() } } - /** - * Periodically broadcasts an ANNOUNCE packet (every ~30s) while the service is active, - * so new/idle peers can discover us without user action. - */ - private fun sendPeriodicBroadcastAnnounce() { + private fun isCurrentSession(generation: Int): Boolean { + return generation == sessionGeneration.get() && isActive + } + + private fun handleUnexpectedStop(generation: Int) { + if (generation != sessionGeneration.get()) return + if (!isActive) { + return + } + recoveryInProgress = true + isActive = false + TransportBridgeService.unregister("WIFI") + com.bitchat.android.service.MeshServiceHolder.stopSharedGossip("WIFI") + try { com.bitchat.android.services.AppStateStore.clearTransportPeers("WIFI") } catch (_: Exception) { } + try { com.bitchat.android.services.AppStateStore.clearTransportDirectPeers("WIFI") } catch (_: Exception) { } + val oldPublishSession = publishSession + val oldSubscribeSession = subscribeSession + val oldWifiAwareSession = wifiAwareSession serviceScope.launch { - while (isActive) { - try { delay(30_000); sendBroadcastAnnounce() } catch (_: Exception) { } + try { + try { meshCore.stopCore() } catch (_: Exception) { } + try { connectionTracker.stop() } catch (_: Exception) { } + try { oldPublishSession?.close() } catch (_: Exception) { } + try { oldSubscribeSession?.close() } catch (_: Exception) { } + try { oldWifiAwareSession?.close() } catch (_: Exception) { } + if (generation == sessionGeneration.get() && !isActive) { + if (publishSession === oldPublishSession) publishSession = null + if (subscribeSession === oldSubscribeSession) subscribeSession = null + if (wifiAwareSession === oldWifiAwareSession) wifiAwareSession = null + handleToPeerId.clear() + subscribeHandles.clear() + publishHandles.clear() + discoveredTimestamps.clear() + } + } finally { + recoveryInProgress = false + // Recovery cleanup is done; nudge a restart now that startServices() will no + // longer be deferred by recoveryInProgress. The controller coalesces requests. + if (com.bitchat.android.wifiaware.WifiAwareController.enabled.value) { + com.bitchat.android.wifiaware.WifiAwareController.restartIfStillEnabled(500) + } } } } + private fun rememberDiscoveredPeer(peerId: String) { + if (peerId.isBlank() || peerId == myPeerID) return + val now = System.currentTimeMillis() + discoveredTimestamps[peerId] = now + lastDiscoveryActivityAt.set(now) + } + + @RequiresApi(Build.VERSION_CODES.Q) + private fun offerServerPathIfAppropriate(peerId: String, peerHandle: PeerHandle, reason: String) { + val pubSession = publishSession ?: return + if (peerId.isBlank() || peerId == myPeerID || !amIServerFor(peerId)) return + if (!connectionTracker.isConnectionAttemptAllowed(peerId)) return + + Log.d(TAG, "PUBLISH: offering server path to ${peerId.take(8)} after $reason") + handleSubscriberPing(pubSession, peerHandle) + } + + private fun refreshDiscoverySessions(reason: String, now: Long = System.currentTimeMillis()): Boolean { + if (!isActive || recoveryInProgress) return false + if (!com.bitchat.android.wifiaware.WifiAwareController.enabled.value) return false + + val lastRefresh = lastDiscoveryRefreshAt.get() + if ((now - lastRefresh) < DISCOVERY_SESSION_REFRESH_MIN_INTERVAL_MS) return false + if (!lastDiscoveryRefreshAt.compareAndSet(lastRefresh, now)) return false + + Log.i(TAG, "Maintenance: refreshing Wi-Fi Aware discovery sessions ($reason)") + handleUnexpectedStop(sessionGeneration.get()) + return true + } + /** * Periodic active maintenance: retries connections to discovered but unconnected peers. */ @@ -596,9 +660,23 @@ class WifiAwareMeshService(private val context: Context) : TransportBridgeServic if (!isActive) break val now = System.currentTimeMillis() + + // 0. Prune stale discovery entries. PeerHandles become invalid when the + // discovery sessions restart, so we must not keep pinging old handles forever. + val staleIds = discoveredTimestamps.filter { (id, ts) -> + (now - ts) >= DISCOVERY_STALE_MS && !connectionTracker.isConnected(id) + }.keys.toSet() + if (staleIds.isNotEmpty()) { + staleIds.forEach { discoveredTimestamps.remove(it) } + handleToPeerId.entries.removeIf { it.value in staleIds } + staleIds.forEach { subscribeHandles.remove(it) } + staleIds.forEach { publishHandles.remove(it) } + Log.d(TAG, "Maintenance: pruned ${staleIds.size} stale discovery entries") + } + // 1. Identify peers that are discovered (recently seen) but not currently connected val recentDiscovered = discoveredTimestamps.filter { (id, ts) -> - (now - ts) < 5 * 60 * 1000 // Seen in last 5 minutes + (now - ts) < DISCOVERY_STALE_MS // Seen in last 5 minutes }.keys // 2. Filter out those who are already connected @@ -606,25 +684,57 @@ class WifiAwareMeshService(private val context: Context) : TransportBridgeServic !connectionTracker.isConnected(peerId) } - // 3. Attempt reconnection + // 3. Attempt reconnection. Aware discovery is not always symmetrical: + // subscribe handles can disappear while publish handles still see the peer. + var attemptedReconnect = false + var missingUsableHandle = false for (peerId in disconnectedPeers) { - // Find the PeerHandle for this peerId - val handle = handleToPeerId.entries.find { it.value == peerId }?.key ?: continue - + if (amIServerFor(peerId)) { + val handle = publishHandles[peerId] + if (handle == null) { + missingUsableHandle = true + continue + } + if (!connectionTracker.isConnectionAttemptAllowed(peerId)) continue + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + Log.i(TAG, "Maintenance: offering Wi-Fi Aware server path to ${peerId.take(8)}") + offerServerPathIfAppropriate(peerId, handle, "maintenance") + attemptedReconnect = true + } + continue + } + + // Use a subscribe-session-scoped handle. A publish-scoped handle would be + // invalid for subscribeSession.sendMessage() and silently fail. + val handle = subscribeHandles[peerId] + if (handle == null) { + missingUsableHandle = true + continue + } + // Check tracker policy if (!connectionTracker.isConnectionAttemptAllowed(peerId)) continue - Log.i(TAG, "🔄 Maintenance: attempting reconnect to ${peerId.take(8)}") - if (connectionTracker.addPendingConnection(peerId)) { - // Resend ping to trigger handshake - val msgId = (System.nanoTime() and 0x7fffffff).toInt() - try { - subscribeSession?.sendMessage(handle, msgId, myPeerID.toByteArray()) - } catch (e: Exception) { - Log.w(TAG, "Failed to send maintenance ping to ${peerId.take(8)}: ${e.message}") + Log.i(TAG, "Maintenance: attempting Wi-Fi Aware reconnect to ${peerId.take(8)}") + sendSubscribePing(peerId, handle, "maintenance") + attemptedReconnect = true + } + + val noActiveDataPath = connectionTracker.getConnectionCount() == 0 && + !connectionTracker.hasPendingDataPathRequest() + if (noActiveDataPath) { + val idleFor = now - lastDiscoveryActivityAt.get() + when { + disconnectedPeers.isNotEmpty() && missingUsableHandle && !attemptedReconnect -> { + refreshDiscoverySessions("missing peer handle", now) + } + recentDiscovered.isEmpty() && idleFor >= DISCOVERY_IDLE_REFRESH_MS -> { + refreshDiscoverySessions("idle discovery", now) } } } + } catch (e: CancellationException) { + break } catch (e: Exception) { Log.e(TAG, "Error in connection maintenance: ${e.message}") } @@ -632,6 +742,66 @@ class WifiAwareMeshService(private val context: Context) : TransportBridgeServic } } + private fun sendSubscribePing(peerId: String, peerHandle: PeerHandle, reason: String) { + if (peerId.isBlank()) return + val msgId = (System.nanoTime() and 0x7fffffff).toInt() + try { + subscribeSession?.sendMessage(peerHandle, msgId, myPeerID.toByteArray()) + Log.d(TAG, "SUBSCRIBE: sent $reason ping to '${peerId.take(16)}' (msgId=$msgId)") + } catch (e: Exception) { + Log.w(TAG, "Failed to send $reason ping to ${peerId.take(8)}: ${e.message}") + } + } + + private fun requestRoleReversal(peerId: String, allowForcedClientOverride: Boolean = false) { + if (peerId.isBlank()) return + if (forcedClientPeers.contains(peerId) && !allowForcedClientOverride) return + forcedServerPeers.add(peerId) + forcedClientPeers.remove(peerId) + + val handle = subscribeHandles[peerId] + if (handle == null) { + Log.i(TAG, "CLIENT: role reversal queued for ${peerId.take(8)} until subscribe handle is available") + return + } + + val msgId = (System.nanoTime() and 0x7fffffff).toInt() + val payload = "$ROLE_REVERSAL_PREFIX$myPeerID".toByteArray() + try { + subscribeSession?.sendMessage(handle, msgId, payload) + Log.i(TAG, "CLIENT: requested Wi-Fi Aware role reversal with ${peerId.take(8)} (msgId=$msgId)") + } catch (e: Exception) { + Log.w(TAG, "CLIENT: failed to request role reversal with ${peerId.take(8)}: ${e.message}") + } + } + + private fun shouldRequestRoleReversalAfterClientFailure(peerId: String): Boolean { + val failures = clientSocketFailures + .computeIfAbsent(peerId) { AtomicInteger(0) } + .incrementAndGet() + val shouldReverse = failures >= CLIENT_ROLE_REVERSAL_FAILURES + if (shouldReverse) { + clientSocketFailures.remove(peerId) + Log.i(TAG, "CLIENT: ${peerId.take(8)} failed $failures client socket attempts; requesting role reversal") + } else { + Log.d(TAG, "CLIENT: ${peerId.take(8)} failed client socket attempt $failures/$CLIENT_ROLE_REVERSAL_FAILURES; retrying same role") + } + return shouldReverse + } + + private fun handleRoleReversalRequest(peerHandle: PeerHandle, requesterId: String) { + if (requesterId.isBlank() || requesterId == myPeerID) return + handleToPeerId[peerHandle] = requesterId + discoveredTimestamps[requesterId] = System.currentTimeMillis() + forcedClientPeers.add(requesterId) + forcedServerPeers.remove(requesterId) + Log.i(TAG, "PUBLISH: role reversal requested by ${requesterId.take(8)}; switching to client role") + + subscribeHandles[requesterId]?.let { handle -> + sendSubscribePing(requesterId, handle, "role-reversal") + } + } + /** * Handles subscriber ping: spawns a server socket and responds with connection info. * @@ -646,25 +816,43 @@ class WifiAwareMeshService(private val context: Context) : TransportBridgeServic val peerId = handleToPeerId[peerHandle] ?: return if (!amIServerFor(peerId)) return - if (connectionTracker.serverSockets.containsKey(peerId)) { + if (connectionTracker.isConnected(peerId)) { + Log.v(TAG, "↪ already connected to $peerId, skipping serve") + return + } + if (connectionTracker.hasOpenServerSocket(peerId)) { Log.v(TAG, "↪ already serving $peerId, skipping") return } + if (connectionTracker.hasPendingDataPathRequest(peerId)) { + val pending = connectionTracker.pendingDataPathPeerIds(peerId).joinToString(", ") { it.take(8) } + Log.d(TAG, "SERVER: deferring serve for ${peerId.take(8)}; pending Aware data path(s): $pending") + return + } + if (!connectionTracker.addPendingConnection(peerId)) { + return + } - val ss = ServerSocket(0) - connectionTracker.addServerSocket(peerId, ss) - val port = ss.localPort - - // Ensure port is set to reuse if connection was recently closed (TIME_WAIT) + val ss = ServerSocket() try { ss.reuseAddress = true - } catch (_: Exception) {} + val anyIpv6 = Inet6Address.getByAddress(ByteArray(16)) + ss.bind(java.net.InetSocketAddress(anyIpv6, 0)) + } catch (e: Exception) { + Log.e(TAG, "Failed to bind server socket", e) + handleNetworkFailure(peerId) + return + } - Log.d(TAG, "SERVER: listening for ${peerId.take(8)} on port $port") + connectionTracker.addServerSocket(peerId, ss) + val port = ss.localPort + + Log.d(TAG, "SERVER: listening for ${peerId.take(8)} on ${ss.localSocketAddress}") val spec = WifiAwareNetworkSpecifier.Builder(pubSession, peerHandle) .setPskPassphrase(PSK) .setPort(port) + .setTransportProtocol(OsConstants.IPPROTO_TCP) .build() // Default capabilities include NET_CAPABILITY_NOT_VPN. // Keeping defaults for hardware interface handle acquisition compatibility with global VPNs. @@ -674,46 +862,80 @@ class WifiAwareMeshService(private val context: Context) : TransportBridgeServic .build() val cb = object : ConnectivityManager.NetworkCallback() { + @Volatile private var activeSocket: SyncedSocket? = null + private val acceptStarted = AtomicBoolean(false) + override fun onAvailable(network: Network) { - try { - val client = ss.accept() - try { network.bindSocket(client) } catch (e: Exception) { Log.w(TAG, "Server bindSocket EPERM: ${e.message}") } - client.keepAlive = true - Log.d(TAG, "SERVER: accepted TCP from ${peerId.take(8)} addr=${client.inetAddress?.hostAddress}") - connectionTracker.onClientConnected(peerId, client) - try { peerManager.setDirectConnection(peerId, true) } catch (_: Exception) {} - try { peerManager.addOrUpdatePeer(peerId, peerId) } catch (_: Exception) {} - listenerExec.execute { listenToPeer(client, peerId) } - handleSubscriberKeepAlive(client, peerId, pubSession, peerHandle) - // Kick off Noise handshake for this logical peer - if (myPeerID < peerId) { - messageHandler.delegate?.initiateNoiseHandshake(peerId) - Log.d(TAG, "SERVER: initiating Noise handshake to ${peerId.take(8)} (lower ID)") + Log.i(TAG, "SERVER: onAvailable() - Aware network is ready for ${peerId.take(8)}") + // Only accept once per network request + if (!acceptStarted.compareAndSet(false, true)) return + // Offload the blocking accept() off the callback thread so we never stall + // the (main-thread) ConnectivityManager callback dispatcher. + listenerExec.execute { + try { + try { ss.soTimeout = ACCEPT_TIMEOUT_MS } catch (_: Exception) {} + val client = ss.accept() + Log.i(TAG, "SERVER: Accepted raw TCP connection from ${peerId.take(8)}") + try { network.bindSocket(client) } catch (e: Exception) { Log.w(TAG, "Server bindSocket EPERM: ${e.message}") } + client.keepAlive = true + Log.i(TAG, "SERVER: Bound and established TCP with ${peerId.take(8)} addr=${client.inetAddress?.hostAddress}") + val synced = SyncedSocket(client) + activeSocket = synced + connectionTracker.onClientConnected(peerId, synced) + // We only ever accept a single data socket per server request. Close the + // listening ServerSocket now so it can't block a future re-serve (its + // presence makes hasOpenServerSocket() true for the life of the process) + // and so we free the fd/port promptly. + connectionTracker.closeServerSocket(peerId) + try { meshCore.setDirectConnection(peerId, true) } catch (_: Exception) {} + try { meshCore.addOrUpdatePeer(peerId, peerId) } catch (_: Exception) {} + listenerExec.execute { listenToPeer(synced, peerId) } + handleSubscriberKeepAlive(synced, peerId, pubSession, peerHandle) + + // Kick off Noise handshake for this logical peer + if (myPeerID < peerId) { + meshCore.initiateNoiseHandshake(peerId) + Log.i(TAG, "SERVER: Initiating Noise handshake to ${peerId.take(8)}") + } + // Ensure fast presence even before handshake settles + serviceScope.launch { delay(150); sendBroadcastAnnounce() } + } catch (ioe: IOException) { + if (ss.isClosed || !isActive) { + Log.d(TAG, "SERVER: accept stopped for ${peerId.take(8)} after socket cleanup") + } else { + Log.e(TAG, "SERVER: accept failed for ${peerId.take(8)}", ioe) + handleNetworkFailure(peerId) + } } - // Ensure fast presence even before handshake settles - serviceScope.launch { delay(150); sendBroadcastAnnounce() } - } catch (ioe: IOException) { - Log.e(TAG, "SERVER: accept failed for ${peerId.take(8)}", ioe) } } + + override fun onUnavailable() { + Log.e(TAG, "SERVER: onUnavailable() - Failed to acquire Aware network for ${peerId.take(8)} (timeout or refused)") + handleNetworkFailure(peerId) + } + override fun onLost(network: Network) { - connectionTracker.networkCallbacks.remove(peerId) - Log.d(TAG, "SERVER: network lost for ${peerId.take(8)}") + handlePeerDisconnection(peerId, activeSocket) + Log.i(TAG, "SERVER: WiFi Aware network lost for ${peerId.take(8)}") } } connectionTracker.addNetworkCallback(peerId, cb) - Log.d(TAG, "SERVER: requesting Aware network for ${peerId.take(8)}") - cm.requestNetwork(req, cb) + Log.i(TAG, "SERVER: [Calling requestNetwork] for ${peerId.take(8)} with port $port") + try { + // use requestNetwork with a timeout to trigger onUnavailable if it fails + cm.requestNetwork(req, cb, NETWORK_REQUEST_TIMEOUT_MS) + } catch (e: Exception) { + Log.e(TAG, "SERVER: ConnectivityManager.requestNetwork threw exception", e) + connectionTracker.disconnect(peerId) + } val readyId = (System.nanoTime() and 0x7fffffff).toInt() - val portBytes = ByteBuffer.allocate(4) - .order(ByteOrder.BIG_ENDIAN) - .putInt(port) - .array() + val readyPayload = buildServerReadyPayload(port) Handler(Looper.getMainLooper()).post { try { - val sent = pubSession.sendMessage(peerHandle, readyId, portBytes) + val sent = pubSession.sendMessage(peerHandle, readyId, readyPayload) Log.d(TAG, "PUBLISH: server-ready sent=$sent (msgId=$readyId, port=$port)") } catch (e: Exception) { Log.e(TAG, "PUBLISH: Exception sending server-ready to $peerHandle", e) @@ -728,7 +950,7 @@ class WifiAwareMeshService(private val context: Context) : TransportBridgeServic * @param peerId ID of the connected peer */ private fun handleSubscriberKeepAlive( - client: Socket, + client: SyncedSocket, peerId: String, pubSession: PublishDiscoverySession, peerHandle: PeerHandle @@ -736,9 +958,16 @@ class WifiAwareMeshService(private val context: Context) : TransportBridgeServic // TCP keep-alive pings serviceScope.launch { try { - val os = client.getOutputStream() while (connectionTracker.isConnected(peerId)) { - try { os.write(0) } catch (_: IOException) { break } + // write empty byte array effectively sends [4 bytes length=0] which is our ping + try { + client.write(ByteArray(0)) + } catch (_: IOException) { + // The write side is dead. Don't just stop pinging: actively tear down so the + // half-open socket stops counting as "connected" and maintenance can retry. + handlePeerDisconnection(peerId, client) + break + } delay(2_000) } } catch (_: Exception) {} @@ -753,6 +982,85 @@ class WifiAwareMeshService(private val context: Context) : TransportBridgeServic } } + private fun connectAwareClientSocket( + network: Network, + scopedAddr: Inet6Address, + port: Int, + peerId: String + ): Socket { + var lastFailure: IOException? = null + for (attempt in 1..CLIENT_SOCKET_ATTEMPTS) { + val delayMs = if (attempt == 1) CLIENT_SOCKET_READY_DELAY_MS else CLIENT_SOCKET_RETRY_DELAY_MS + if (delayMs > 0) { + try { + Thread.sleep(delayMs) + } catch (e: InterruptedException) { + Thread.currentThread().interrupt() + throw InterruptedIOException("Interrupted before Wi-Fi Aware socket connect") + } + } + + var sock: Socket? = null + try { + sock = network.socketFactory.createSocket() + sock.tcpNoDelay = true + sock.keepAlive = true + sock.connect(java.net.InetSocketAddress(scopedAddr, port), CLIENT_CONNECT_TIMEOUT_MS) + if (attempt > 1) { + Log.i(TAG, "CLIENT: socket connect succeeded for ${peerId.take(8)} on attempt $attempt") + } + return sock + } catch (e: IOException) { + lastFailure = e + try { sock?.close() } catch (_: Exception) { } + if (attempt < CLIENT_SOCKET_ATTEMPTS) { + Log.w(TAG, "CLIENT: socket attempt $attempt/$CLIENT_SOCKET_ATTEMPTS failed for ${peerId.take(8)}: ${e.message}; retrying") + } + } + } + + throw lastFailure ?: IOException("Wi-Fi Aware socket connect failed without an exception") + } + + private fun buildServerReadyPayload(port: Int): ByteArray { + val peerIdBytes = myPeerID.toByteArray(Charsets.UTF_8) + return ByteBuffer.allocate(Int.SIZE_BYTES + peerIdBytes.size) + .order(ByteOrder.BIG_ENDIAN) + .putInt(port) + .put(peerIdBytes) + .array() + } + + private fun peerIdFromServerReadyPayload(payload: ByteArray): String? { + if (payload.size <= Int.SIZE_BYTES) return null + val peerId = try { + String(payload.copyOfRange(Int.SIZE_BYTES, payload.size), Charsets.UTF_8).trim() + } catch (_: Exception) { + return null + } + return peerId.takeIf { id -> + id.length == 16 && id.all { ch -> ch in '0'..'9' || ch in 'a'..'f' || ch in 'A'..'F' } + }?.lowercase() + } + + private fun resolveServerReadyPeerId(peerHandle: PeerHandle, payload: ByteArray): String? { + val advertisedPeerId = peerIdFromServerReadyPayload(payload) + val mappedPeerId = handleToPeerId[peerHandle]?.takeIf { it.isNotBlank() } + val peerId = advertisedPeerId ?: mappedPeerId + if (peerId == null) { + Log.w(TAG, "SUBSCRIBE: dropped server-ready with no peer mapping and no peer ID payload (payload=${payload.size}B)") + return null + } + + handleToPeerId[peerHandle] = peerId + subscribeHandles[peerId] = peerHandle + rememberDiscoveredPeer(peerId) + if (advertisedPeerId != null && mappedPeerId != null && advertisedPeerId != mappedPeerId) { + Log.d(TAG, "SUBSCRIBE: server-ready remapped handle ${mappedPeerId.take(8)} -> ${advertisedPeerId.take(8)}") + } + return peerId + } + /** * Handles a "server ready" message from a publishing peer and initiates a client connection. */ @@ -766,17 +1074,36 @@ class WifiAwareMeshService(private val context: Context) : TransportBridgeServic return } - val peerId = handleToPeerId[peerHandle] ?: return + val peerId = resolveServerReadyPeerId(peerHandle, payload) ?: return + if (peerId == myPeerID) return if (amIServerFor(peerId)) return if (connectionTracker.peerSockets.containsKey(peerId)) { Log.v(TAG, "↪ already client-connected to $peerId, skipping") return } + val cancelledServerOffers = connectionTracker.cancelPendingServerDataPaths(peerId) + if (cancelledServerOffers.isNotEmpty()) { + val cancelled = cancelledServerOffers.joinToString(", ") { it.take(8) } + Log.i(TAG, "CLIENT: preempted pending server offer(s) for $cancelled to connect ${peerId.take(8)}") + } + if (connectionTracker.hasPendingDataPathRequest(peerId)) { + val pending = connectionTracker.pendingDataPathPeerIds(peerId).joinToString(", ") { it.take(8) } + Log.d(TAG, "CLIENT: deferring server-ready for ${peerId.take(8)}; pending Aware data path(s): $pending") + return + } + if (!connectionTracker.addPendingConnection(peerId)) { + return + } - val port = ByteBuffer.wrap(payload).order(ByteOrder.BIG_ENDIAN).int - Log.d(TAG, "CLIENT: connecting to ${peerId.take(8)} port=$port") + val port = ByteBuffer.wrap(payload, 0, Int.SIZE_BYTES).order(ByteOrder.BIG_ENDIAN).int + Log.i(TAG, "CLIENT: Received server-ready from ${peerId.take(8)} on port $port (payload=${payload.size}B). Requesting network...") - val spec = WifiAwareNetworkSpecifier.Builder(subscribeSession!!, peerHandle) + val subSession = subscribeSession ?: run { + Log.w(TAG, "CLIENT: subscribe session missing for server-ready from ${peerId.take(8)}") + connectionTracker.removePendingConnection(peerId) + return + } + val spec = WifiAwareNetworkSpecifier.Builder(subSession, peerHandle) .setPskPassphrase(PSK) .build() val req = NetworkRequest.Builder() @@ -785,78 +1112,112 @@ class WifiAwareMeshService(private val context: Context) : TransportBridgeServic .build() val cb = object : ConnectivityManager.NetworkCallback() { + @Volatile private var activeSocket: SyncedSocket? = null + private val connectStarted = AtomicBoolean(false) + override fun onAvailable(network: Network) { + Log.i(TAG, "CLIENT: onAvailable() - Aware network is ready for ${peerId.take(8)}") // Do not bind process for Aware; use per-socket binding instead } + + override fun onUnavailable() { + Log.e(TAG, "CLIENT: onUnavailable() - Failed to acquire Aware network for ${peerId.take(8)}") + if (shouldRequestRoleReversalAfterClientFailure(peerId)) { + requestRoleReversal(peerId, allowForcedClientOverride = true) + } + handleNetworkFailure(peerId) + } + override fun onCapabilitiesChanged(network: Network, nc: NetworkCapabilities) { if (connectionTracker.peerSockets.containsKey(peerId)) return val info = (nc.transportInfo as? WifiAwareNetworkInfo) ?: return val addr = info.peerIpv6Addr as? Inet6Address ?: return + val connectPort = if (info.port > 0) info.port else port + // onCapabilitiesChanged can fire multiple times; only connect once + if (!connectStarted.compareAndSet(false, true)) return + Log.i(TAG, "CLIENT: onCapabilitiesChanged() - Peer IPv6 discovered: $addr port=$connectPort") val lp = cm.getLinkProperties(network) val iface = lp?.interfaceName - try { - val sock = Socket() - try { network.bindSocket(sock) } catch (e: Exception) { Log.w(TAG, "Client bindSocket EPERM: ${e.message}") } - sock.tcpNoDelay = true - sock.keepAlive = true - - // Use scoped IPv6 if interface name is available - val scopedAddr = if (iface != null && addr.scopeId == 0) { - try { - Inet6Address.getByAddress(null, addr.address, java.net.NetworkInterface.getByName(iface)) - } catch (e: Exception) { + // Offload the blocking connect() off the callback thread. + listenerExec.execute { + try { + // Use scoped IPv6 if interface name is available + val scopedAddr = if (iface != null && addr.scopeId == 0) { + try { + Inet6Address.getByAddress(null, addr.address, java.net.NetworkInterface.getByName(iface)) + } catch (e: Exception) { + addr + } + } else { addr } - } else { - addr - } - sock.connect(java.net.InetSocketAddress(scopedAddr, port), 7000) - Log.d(TAG, "CLIENT: TCP connected to ${peerId.take(8)} addr=$scopedAddr:$port (iface=$iface)") + val sock = connectAwareClientSocket(network, scopedAddr, connectPort, peerId) + Log.i(TAG, "CLIENT: TCP connected to ${peerId.take(8)} at $scopedAddr:$connectPort") - connectionTracker.onClientConnected(peerId, sock) - try { peerManager.setDirectConnection(peerId, true) } catch (_: Exception) {} - try { peerManager.addOrUpdatePeer(peerId, peerId) } catch (_: Exception) {} - listenerExec.execute { listenToPeer(sock, peerId) } - handleServerKeepAlive(sock, peerId, peerHandle) - // Kick off Noise handshake for this logical peer - if (myPeerID < peerId) { - messageHandler.delegate?.initiateNoiseHandshake(peerId) - Log.d(TAG, "CLIENT: initiating Noise handshake to ${peerId.take(8)} (lower ID)") + val synced = SyncedSocket(sock) + activeSocket = synced + connectionTracker.onClientConnected(peerId, synced) + clientSocketFailures.remove(peerId) + try { meshCore.setDirectConnection(peerId, true) } catch (_: Exception) {} + try { meshCore.addOrUpdatePeer(peerId, peerId) } catch (_: Exception) {} + listenerExec.execute { listenToPeer(synced, peerId) } + handleServerKeepAlive(synced, peerId, peerHandle) + + // Kick off Noise handshake for this logical peer + if (myPeerID < peerId) { + meshCore.initiateNoiseHandshake(peerId) + Log.i(TAG, "CLIENT: Initiating Noise handshake to ${peerId.take(8)}") + } + // Ensure fast presence even before handshake settles + serviceScope.launch { delay(150); sendBroadcastAnnounce() } + } catch (ioe: IOException) { + Log.e(TAG, "CLIENT: socket connect failed to ${peerId.take(8)}", ioe) + if (shouldRequestRoleReversalAfterClientFailure(peerId)) { + requestRoleReversal(peerId, allowForcedClientOverride = true) + } + handleNetworkFailure(peerId) } - // Ensure fast presence even before handshake settles - serviceScope.launch { delay(150); sendBroadcastAnnounce() } - } catch (ioe: IOException) { - Log.e(TAG, "CLIENT: socket connect failed to ${peerId.take(8)}", ioe) } } override fun onLost(network: Network) { - connectionTracker.networkCallbacks.remove(peerId) - Log.d(TAG, "CLIENT: network lost for ${peerId.take(8)}") + handlePeerDisconnection(peerId, activeSocket) + Log.i(TAG, "CLIENT: WiFi Aware network lost for ${peerId.take(8)}") } } connectionTracker.addNetworkCallback(peerId, cb) - Log.d(TAG, "CLIENT: requesting Aware network for ${peerId.take(8)}") - cm.requestNetwork(req, cb) + Log.i(TAG, "CLIENT: [Calling requestNetwork] for ${peerId.take(8)}") + try { + cm.requestNetwork(req, cb, NETWORK_REQUEST_TIMEOUT_MS) + } catch (e: Exception) { + Log.e(TAG, "CLIENT: ConnectivityManager.requestNetwork threw exception", e) + connectionTracker.disconnect(peerId) + } } /** * Sends periodic TCP and discovery keep-alive messages for server connections. */ private fun handleServerKeepAlive( - sock: Socket, + sock: SyncedSocket, peerId: String, peerHandle: PeerHandle ) { // TCP keep-alive serviceScope.launch { try { - val os = sock.getOutputStream() while (connectionTracker.isConnected(peerId)) { - try { os.write(0) } catch (_: IOException) { break } + try { + sock.write(ByteArray(0)) + } catch (_: IOException) { + // The write side is dead. Tear down so the half-open socket stops counting + // as "connected" and maintenance can retry instead of silently stalling. + handlePeerDisconnection(peerId, sock) + break + } delay(2_000) } } catch (_: Exception) {} @@ -874,7 +1235,11 @@ class WifiAwareMeshService(private val context: Context) : TransportBridgeServic /** * Determines whether this device should act as the server in a given peer relationship. */ - private fun amIServerFor(peerId: String) = myPeerID < peerId + private fun amIServerFor(peerId: String): Boolean = when { + forcedClientPeers.contains(peerId) -> false + forcedServerPeers.contains(peerId) -> true + else -> myPeerID < peerId + } /** * Listens for incoming packets from a connected peer and dispatches them through @@ -883,46 +1248,91 @@ class WifiAwareMeshService(private val context: Context) : TransportBridgeServic * @param socket Socket connected to the peer * @param initialLogicalPeerId Temporary identifier before peer ID resolution */ - private fun listenToPeer(socket: Socket, initialLogicalPeerId: String) { - val inStream = socket.getInputStream() - val buf = ByteArray(64 * 1024) - + private fun listenToPeer(socket: SyncedSocket, initialLogicalPeerId: String) { + var logicalPeerId = initialLogicalPeerId while (isActive) { - val len = try { inStream.read(buf) } catch (_: IOException) { break } - if (len <= 0) break - - val raw = buf.copyOf(len) - val pkt = BitchatPacket.fromBinaryData(raw) ?: continue - - val senderPeerHex = pkt.senderID?.toHexString()?.take(16) ?: continue + val raw = socket.read() ?: break - // Deduplicate based on timestamp + sender (standard flood fill protection) - val ts = pkt.timestamp - if (lastTimestamps.put(senderPeerHex, ts) == ts) { + if (raw.isEmpty()) { + // Keep-alive (0 length frame) continue } + val pkt = BitchatPacket.fromBinaryData(raw) ?: continue + + val senderPeerHex = pkt.senderID?.toHexString()?.take(16) ?: continue + + if (pkt.type == MessageType.ANNOUNCE.value && pkt.ttl >= MAX_TTL && senderPeerHex != logicalPeerId) { + val previousPeerId = logicalPeerId + logicalPeerId = connectionTracker.rebindPeerId(previousPeerId, senderPeerHex, socket) + handleToPeerId.forEach { (handle, peerId) -> + if (peerId == previousPeerId) { + handleToPeerId[handle] = senderPeerHex + } + } + subscribeHandles.remove(previousPeerId)?.let { subscribeHandles[senderPeerHex] = it } + discoveredTimestamps.remove(previousPeerId) + discoveredTimestamps[senderPeerHex] = System.currentTimeMillis() + try { meshCore.setDirectConnection(previousPeerId, false) } catch (_: Exception) { } + try { meshCore.removePeer(previousPeerId) } catch (_: Exception) { } + try { meshCore.setDirectConnection(senderPeerHex, true) } catch (_: Exception) { } + publishHandles.remove(previousPeerId)?.let { publishHandles[senderPeerHex] = it } + Log.i(TAG, "RX: rebound Wi-Fi direct peer ${previousPeerId.take(8)} -> ${senderPeerHex.take(8)}") + } + // Route the packet: // - peerID = Originator (who signed it) // - relayAddress = Neighbor (who sent it to us over this socket) - // Note: We do NOT update peerSockets mapping based on senderPeerHex. - // The socket belongs to initialLogicalPeerId effectively serving as the "MAC address" layer. - Log.d(TAG, "RX: packet type=${pkt.type} from ${senderPeerHex.take(8)} via ${initialLogicalPeerId.take(8)} (bytes=${raw.size})") - packetProcessor.processPacket(RoutedPacket(pkt, senderPeerHex, initialLogicalPeerId)) + Log.d(TAG, "RX: packet type=${pkt.type} from ${senderPeerHex.take(8)} via ${logicalPeerId.take(8)} (bytes=${raw.size})") + meshCore.processIncoming(pkt, senderPeerHex, logicalPeerId) } // Breaking out of the loop means the socket is dead or service is stopping. - Log.i(TAG, "Socket loop terminated for ${initialLogicalPeerId.take(8)} removing peer.") - handlePeerDisconnection(initialLogicalPeerId) - socket.closeQuietly() + Log.i(TAG, "Socket loop terminated for ${logicalPeerId.take(8)} removing peer.") + handlePeerDisconnection(logicalPeerId, socket) + socket.close() } - private fun handlePeerDisconnection(initialId: String) { + private fun handleNetworkFailure(peerId: String) { + serviceScope.launch { + Log.d(TAG, "Network failure cleanup for: $peerId") + if (!connectionTracker.isConnected(peerId)) { + val canonicalPeerId = connectionTracker.canonicalPeerId(peerId) + connectionTracker.disconnect(peerId) + meshCore.removePeer(canonicalPeerId) + if (canonicalPeerId != peerId) { + meshCore.removePeer(peerId) + } + } else { + Log.d(TAG, "Network failure ignored for $peerId - another socket is active") + } + } + } + + private fun handlePeerDisconnection(initialId: String, socket: SyncedSocket? = null) { serviceScope.launch { - Log.d(TAG, "Cleaning up peer: $initialId") - - connectionTracker.disconnect(initialId) - peerManager.removePeer(initialId) + // Check if this socket is the current active one before nuking the session + val currentSocket = connectionTracker.getSocketForPeer(initialId) + val canonicalPeerId = connectionTracker.canonicalPeerId(initialId) + if (currentSocket === socket) { + Log.d(TAG, "Cleaning up peer: $canonicalPeerId (active socket)") + connectionTracker.disconnect(initialId) + meshCore.removePeer(canonicalPeerId) + if (canonicalPeerId != initialId) { + meshCore.removePeer(initialId) + } + } else if (socket == null && currentSocket == null) { + // Fallback: If we don't have a specific socket context but we are already disconnected, ensure cleanup + Log.d(TAG, "Cleaning up peer: $initialId (no active socket)") + connectionTracker.disconnect(initialId) + meshCore.removePeer(canonicalPeerId) + if (canonicalPeerId != initialId) { + meshCore.removePeer(initialId) + } + } else { + Log.d(TAG, "Ignored disconnection for $initialId - socket replaced or inactive") + // Do not remove peer/session, as a new socket has likely taken over + } } } @@ -932,24 +1342,8 @@ class WifiAwareMeshService(private val context: Context) : TransportBridgeServic * @param mentions Optional list of mentioned peer IDs * @param channel Optional channel name */ - fun sendMessage(content: String, mentions: List = emptyList(), channel: String? = null) { - if (content.isEmpty()) return - - serviceScope.launch { - val packet = BitchatPacket( - version = 1u, - type = MessageType.MESSAGE.value, - senderID = hexStringToByteArray(myPeerID), - recipientID = SpecialRecipients.BROADCAST, - timestamp = System.currentTimeMillis().toULong(), - payload = content.toByteArray(Charsets.UTF_8), - signature = null, - ttl = MAX_TTL - ) - val signed = signPacketBeforeBroadcast(packet) - dispatchGlobal(RoutedPacket(signed)) - try { gossipSyncManager.onPublicPacketSeen(signed) } catch (_: Exception) { } - } + override fun sendMessage(content: String, mentions: List, channel: String?) { + meshCore.sendMessage(content, mentions, channel) } /** @@ -960,37 +1354,8 @@ class WifiAwareMeshService(private val context: Context) : TransportBridgeServic * @param recipientNickname Recipient nickname * @param messageID Optional message ID (UUID if null) */ - fun sendPrivateMessage(content: String, recipientPeerID: String, recipientNickname: String, messageID: String? = null) { - if (content.isEmpty() || recipientPeerID.isEmpty()) return - - serviceScope.launch { - val finalId = messageID ?: UUID.randomUUID().toString() - - if (encryptionService.hasEstablishedSession(recipientPeerID)) { - try { - val pm = PrivateMessagePacket(messageID = finalId, content = content) - val tlv = pm.encode() ?: return@launch - val payload = NoisePayload(type = NoisePayloadType.PRIVATE_MESSAGE, data = tlv).encode() - val enc = encryptionService.encrypt(payload, recipientPeerID) - - val pkt = BitchatPacket( - version = 1u, - type = MessageType.NOISE_ENCRYPTED.value, - senderID = hexStringToByteArray(myPeerID), - recipientID = hexStringToByteArray(recipientPeerID), - timestamp = System.currentTimeMillis().toULong(), - payload = enc, - signature = null, - ttl = MAX_TTL - ) - dispatchGlobal(RoutedPacket(signPacketBeforeBroadcast(pkt))) - } catch (e: Exception) { - Log.e(TAG, "Failed to encrypt private message: ${e.message}") - } - } else { - messageHandler.delegate?.initiateNoiseHandshake(recipientPeerID) - } - } + override fun sendPrivateMessage(content: String, recipientPeerID: String, recipientNickname: String, messageID: String?) { + meshCore.sendPrivateMessage(content, recipientPeerID, recipientNickname, messageID) } /** @@ -1001,29 +1366,16 @@ class WifiAwareMeshService(private val context: Context) : TransportBridgeServic * @param recipientPeerID The peer to notify. * @param readerNickname Nickname of the reader (may be shown by the receiver). */ - fun sendReadReceipt(messageID: String, recipientPeerID: String, readerNickname: String) { - serviceScope.launch { - try { - val payload = NoisePayload( - type = NoisePayloadType.READ_RECEIPT, - data = messageID.toByteArray(Charsets.UTF_8) - ).encode() - val enc = encryptionService.encrypt(payload, recipientPeerID) - val pkt = BitchatPacket( - version = 1u, - type = MessageType.NOISE_ENCRYPTED.value, - senderID = hexStringToByteArray(myPeerID), - recipientID = hexStringToByteArray(recipientPeerID), - timestamp = System.currentTimeMillis().toULong(), - payload = enc, - signature = null, - ttl = MAX_TTL - ) - dispatchGlobal(RoutedPacket(signPacketBeforeBroadcast(pkt))) - } catch (e: Exception) { - Log.e(TAG, "Failed to send read receipt: ${e.message}") - } - } + override fun sendReadReceipt(messageID: String, recipientPeerID: String, readerNickname: String) { + meshCore.sendReadReceipt(messageID, recipientPeerID, readerNickname) + } + + override fun sendVerifyChallenge(peerID: String, noiseKeyHex: String, nonceA: ByteArray) { + meshCore.sendVerifyChallenge(peerID, noiseKeyHex, nonceA) + } + + override fun sendVerifyResponse(peerID: String, noiseKeyHex: String, nonceA: ByteArray) { + meshCore.sendVerifyResponse(peerID, noiseKeyHex, nonceA) } /** @@ -1032,28 +1384,8 @@ class WifiAwareMeshService(private val context: Context) : TransportBridgeServic * * @param file Encoded metadata and chunks descriptor of the file to send. */ - fun sendFileBroadcast(file: BitchatFilePacket) { - try { - val payload = file.encode() ?: run { Log.e(TAG, "file TLV encode failed"); return } - serviceScope.launch { - val pkt = BitchatPacket( - version = 2u, // FILE_TRANSFER big length - type = MessageType.FILE_TRANSFER.value, - senderID = hexStringToByteArray(myPeerID), - recipientID = SpecialRecipients.BROADCAST, - timestamp = System.currentTimeMillis().toULong(), - payload = payload, - signature = null, - ttl = MAX_TTL - ) - val signed = signPacketBeforeBroadcast(pkt) - val transferId = sha256Hex(payload) - dispatchGlobal(RoutedPacket(signed, transferId = transferId)) - try { gossipSyncManager.onPublicPacketSeen(signed) } catch (_: Exception) { } - } - } catch (e: Exception) { - Log.e(TAG, "sendFileBroadcast failed: ${e.message}", e) - } + override fun sendFileBroadcast(file: BitchatFilePacket) { + meshCore.sendFileBroadcast(file) } /** @@ -1063,33 +1395,8 @@ class WifiAwareMeshService(private val context: Context) : TransportBridgeServic * @param recipientPeerID Target peer. * @param file Encoded metadata and chunks descriptor of the file to send. */ - fun sendFilePrivate(recipientPeerID: String, file: BitchatFilePacket) { - try { - serviceScope.launch { - if (!encryptionService.hasEstablishedSession(recipientPeerID)) { - messageHandler.delegate?.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 pkt = BitchatPacket( - version = 1u, - type = MessageType.NOISE_ENCRYPTED.value, - senderID = hexStringToByteArray(myPeerID), - recipientID = hexStringToByteArray(recipientPeerID), - timestamp = System.currentTimeMillis().toULong(), - payload = enc, - signature = null, - ttl = MAX_TTL - ) - val signed = signPacketBeforeBroadcast(pkt) - val transferId = sha256Hex(tlv) - dispatchGlobal(RoutedPacket(signed, transferId = transferId)) - } - } catch (e: Exception) { - Log.e(TAG, "sendFilePrivate failed: ${e.message}", e) - } + override fun sendFilePrivate(recipientPeerID: String, file: BitchatFilePacket) { + meshCore.sendFilePrivate(recipientPeerID, file) } /** @@ -1098,116 +1405,57 @@ class WifiAwareMeshService(private val context: Context) : TransportBridgeServic * @param transferId Deterministic id (usually sha256 of the file TLV). * @return true if a transfer with this id was found and cancellation was scheduled, false otherwise. */ - fun cancelFileTransfer(transferId: String): Boolean { - return false + override fun cancelFileTransfer(transferId: String): Boolean { + return meshCore.cancelFileTransfer(transferId) } - /** - * Computes the SHA-256 of the given bytes and returns a lowercase hex string. - * Falls back to the byte-length in hex if MessageDigest is unavailable. - */ - 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) } - /** * Broadcasts an ANNOUNCE packet to the entire mesh. */ - fun sendBroadcastAnnounce() { - serviceScope.launch { - val nickname = delegate?.getNickname() ?: myPeerID - val staticKey = encryptionService.getStaticPublicKey() ?: run { - Log.e(TAG, "No static public key available for announcement"); return@launch - } - val signingKey = encryptionService.getSigningPublicKey() ?: run { - Log.e(TAG, "No signing public key available for announcement"); return@launch - } - - val tlvPayload = IdentityAnnouncement(nickname, staticKey, signingKey).encode() ?: return@launch - - val announcePacket = BitchatPacket( - type = MessageType.ANNOUNCE.value, - ttl = MAX_TTL, - senderID = myPeerID, - payload = tlvPayload - ) - val signed = signPacketBeforeBroadcast(announcePacket) - - dispatchGlobal(RoutedPacket(signed)) - try { gossipSyncManager.onPublicPacketSeen(signed) } catch (_: Exception) { } - } + override fun sendBroadcastAnnounce() { + meshCore.sendBroadcastAnnounce() } /** * Sends an ANNOUNCE packet to a specific peer. */ - fun sendAnnouncementToPeer(peerID: String) { - if (peerManager.hasAnnouncedToPeer(peerID)) return - - val nickname = delegate?.getNickname() ?: myPeerID - val staticKey = encryptionService.getStaticPublicKey() ?: return - val signingKey = encryptionService.getSigningPublicKey() ?: return - - val tlvPayload = IdentityAnnouncement(nickname, staticKey, signingKey).encode() ?: return - - val packet = BitchatPacket( - type = MessageType.ANNOUNCE.value, - ttl = MAX_TTL, - senderID = myPeerID, - payload = tlvPayload - ) - val signed = signPacketBeforeBroadcast(packet) - - dispatchGlobal(RoutedPacket(signed)) - peerManager.markPeerAsAnnouncedTo(peerID) - try { gossipSyncManager.onPublicPacketSeen(signed) } catch (_: Exception) { } - } - - /** - * Sends a LEAVE announcement to all peers before disconnecting. - */ - private fun sendLeaveAnnouncement() { - val nickname = delegate?.getNickname() ?: myPeerID - val packet = BitchatPacket( - type = MessageType.LEAVE.value, - ttl = MAX_TTL, - senderID = myPeerID, - payload = nickname.toByteArray() - ) - dispatchGlobal(RoutedPacket(signPacketBeforeBroadcast(packet))) + override fun sendAnnouncementToPeer(peerID: String) { + meshCore.sendAnnouncementToPeer(peerID) } /** @return Mapping of peer IDs to nicknames. */ - fun getPeerNicknames(): Map = peerManager.getAllPeerNicknames() + override fun getPeerNicknames(): Map = meshCore.getPeerNicknames() /** @return Mapping of peer IDs to RSSI values. */ - fun getPeerRSSI(): Map = peerManager.getAllPeerRSSI() + override fun getPeerRSSI(): Map = meshCore.getPeerRSSI() + + /** @return current active peer count for status surfaces. */ + override fun getActivePeerCount(): Int = meshCore.getActivePeerCount() /** * @return true if a Noise session with the peer is fully established. */ - fun hasEstablishedSession(peerID: String) = encryptionService.hasEstablishedSession(peerID) + override fun hasEstablishedSession(peerID: String) = meshCore.hasEstablishedSession(peerID) /** * @return a human-readable Noise session state for the given peer (implementation-defined). */ - fun getSessionState(peerID: String) = encryptionService.getSessionState(peerID) + override fun getSessionState(peerID: String) = meshCore.getSessionState(peerID) /** * Triggers a Noise handshake with the given peer. Safe to call repeatedly; no-op if already handshaking/established. */ - fun initiateNoiseHandshake(peerID: String) = messageHandler.delegate?.initiateNoiseHandshake(peerID) + override fun initiateNoiseHandshake(peerID: String) = meshCore.initiateNoiseHandshake(peerID) /** * @return the stored public-key fingerprint (hex) for a peer, if known. */ - fun getPeerFingerprint(peerID: String): String? = peerManager.getFingerprintForPeer(peerID) + override fun getPeerFingerprint(peerID: String): String? = meshCore.getPeerFingerprint(peerID) /** * Retrieves the full profile for a peer, including keys and verification state, if available. */ - fun getPeerInfo(peerID: String): PeerInfo? = peerManager.getPeerInfo(peerID) + override fun getPeerInfo(peerID: String): PeerInfo? = meshCore.getPeerInfo(peerID) /** * Updates local metadata for a peer and returns whether the change was applied. @@ -1219,35 +1467,37 @@ class WifiAwareMeshService(private val context: Context) : TransportBridgeServic * @param isVerified Whether this identity is verified by the user. * @return true if the record was updated or created, false otherwise. */ - fun updatePeerInfo( + override fun updatePeerInfo( peerID: String, nickname: String, noisePublicKey: ByteArray, signingPublicKey: ByteArray, isVerified: Boolean - ): Boolean = peerManager.updatePeerInfo(peerID, nickname, noisePublicKey, signingPublicKey, isVerified) + ): Boolean = meshCore.updatePeerInfo(peerID, nickname, noisePublicKey, signingPublicKey, isVerified) /** * @return the local device’s long-term identity fingerprint (hex). */ - fun getIdentityFingerprint(): String = encryptionService.getIdentityFingerprint() + override fun getIdentityFingerprint(): String = meshCore.getIdentityFingerprint() + + override fun getStaticNoisePublicKey(): ByteArray? = meshCore.getStaticNoisePublicKey() /** * @return true if the UI should show an “encrypted” indicator for this peer. */ - fun shouldShowEncryptionIcon(peerID: String) = encryptionService.hasEstablishedSession(peerID) + override fun shouldShowEncryptionIcon(peerID: String) = meshCore.shouldShowEncryptionIcon(peerID) /** * @return a snapshot list of peers with established Noise sessions. */ - fun getEncryptedPeers(): List = emptyList() + override fun getEncryptedPeers(): List = meshCore.getEncryptedPeers() /** * @return the current IPv4/IPv6 address of a connected peer, if any. * Prefers the scoped IPv6 address format. */ - fun getDeviceAddressForPeer(peerID: String): String? = - connectionTracker.peerSockets[peerID]?.let { resolveScopedAddress(it) } + override fun getDeviceAddressForPeer(peerID: String): String? = + meshCore.getDeviceAddressForPeer(peerID) /** * Helper to resolve a scoped IPv6 address from a socket for UI display. @@ -1269,18 +1519,13 @@ class WifiAwareMeshService(private val context: Context) : TransportBridgeServic * @return a mapping of peerID → connected device IP address for all active sockets. * Results are formatted as scoped addresses if applicable. */ - fun getDeviceAddressToPeerMapping(): Map { - val map = mutableMapOf() - connectionTracker.peerSockets.forEach { (pid, sock) -> - map[pid] = resolveScopedAddress(sock) ?: "unknown" - } - return map - } + override fun getDeviceAddressToPeerMapping(): Map = + meshCore.getDeviceAddressToPeerMapping() /** * @return map of peer ID to nickname, bridged for UI warning fix. */ - fun getPeerNicknamesMap(): Map = peerManager.getAllPeerNicknames() + fun getPeerNicknamesMap(): Map = meshCore.getPeerNicknames() /** Returns recently discovered peer IDs via Aware discovery (may not be connected). */ fun getDiscoveredPeerIds(): Set = @@ -1289,44 +1534,58 @@ class WifiAwareMeshService(private val context: Context) : TransportBridgeServic /** * Utility for logs/UI: pretty-prints one peer-to-address mapping per line. */ - fun printDeviceAddressesForPeers(): String = + override fun printDeviceAddressesForPeers(): String = getDeviceAddressToPeerMapping().entries.joinToString("\n") { "${it.key} -> ${it.value}" } /** * @return A detailed string containing the debug status of all mesh components. */ - fun getDebugStatus(): String = buildString { - appendLine("=== Wi-Fi Aware Mesh Debug Status ===") - appendLine("My Peer ID: $myPeerID") - appendLine("Peers: ${connectionTracker.peerSockets.keys}") - appendLine(connectionTracker.getDebugInfo()) - appendLine(peerManager.getDebugInfo(getDeviceAddressToPeerMapping())) - appendLine(fragmentManager.getDebugInfo()) - appendLine(securityManager.getDebugInfo()) - appendLine(storeForwardManager.getDebugInfo()) - appendLine(messageHandler.getDebugInfo()) - appendLine(packetProcessor.getDebugInfo()) + override fun getDebugStatus(): String { + return meshCore.getDebugStatus( + transportInfo = connectionTracker.getDebugInfo(), + deviceMap = getDeviceAddressToPeerMapping(), + extraLines = listOf("Peers: ${connectionTracker.peerSockets.keys}"), + title = "Wi-Fi Aware Mesh Debug Status" + ) } - /** Utility extension to safely close sockets. */ - private fun Socket.closeQuietly() = try { close() } catch (_: Exception) {} + override fun clearAllInternalData() { + meshCore.clearAllInternalData() + } + + override fun clearAllEncryptionData() { + meshCore.clearAllEncryptionData() + } /** Utility extension to safely close server sockets. */ private fun ServerSocket.closeQuietly() = try { close() } catch (_: Exception) {} -} -/** - * Delegate interface for mesh service callbacks (maintains exact same interface) - */ -interface WifiAwareMeshDelegate { - 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 + private inner class WifiAwareTransport : MeshTransport { + override val id: String = "WIFI" + + override fun broadcastPacket(routed: RoutedPacket) { + this@WifiAwareMeshService.broadcastPacket(routed) + } + override fun sendPacketToPeer(peerID: String, packet: BitchatPacket): Boolean { + return this@WifiAwareMeshService.sendPacketToPeer(peerID, packet) + } + override fun cancelTransfer(transferId: String): Boolean { + return fragmentingSender.cancelTransfer(transferId) + } + override fun getDeviceAddressForPeer(peerID: String): String? { + return connectionTracker.getSocketForPeer(peerID)?.let { resolveScopedAddress(it.rawSocket) } + } + + override fun getDeviceAddressToPeerMapping(): Map { + val map = mutableMapOf() + connectionTracker.peerSockets.forEach { (pid, sock) -> + map[pid] = resolveScopedAddress(sock.rawSocket) ?: "unknown" + } + return map + } + override fun getTransportDebugInfo(): String { + return connectionTracker.getDebugInfo() + } + } } diff --git a/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareSupport.kt b/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareSupport.kt new file mode 100644 index 00000000..3e7dbc58 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareSupport.kt @@ -0,0 +1,74 @@ +package com.bitchat.android.wifiaware + +import android.content.Context +import android.content.pm.PackageManager +import android.net.wifi.aware.WifiAwareManager +import android.os.Build + +/** + * Centralized Wi-Fi Aware capability checks. + * + * "Supported" is stable device/API capability. "Available" is runtime state and can change + * when Wi-Fi, location, airplane mode, or system radio state changes. + */ +object WifiAwareSupport { + data class Status( + val supported: Boolean, + val available: Boolean, + val reason: String? = null + ) + + fun evaluate(context: Context): Status { + val appContext = context.applicationContext + + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { + return Status( + supported = false, + available = false, + reason = "requires Android 10+" + ) + } + + val hasFeature = try { + appContext.packageManager.hasSystemFeature(PackageManager.FEATURE_WIFI_AWARE) + } catch (_: Exception) { + false + } + if (!hasFeature) { + return Status( + supported = false, + available = false, + reason = "device does not advertise Wi-Fi Aware support" + ) + } + + val manager = getManager(appContext) + ?: return Status( + supported = false, + available = false, + reason = "WifiAwareManager unavailable" + ) + + val available = try { + manager.isAvailable + } catch (_: Exception) { + false + } + + return Status( + supported = true, + available = available, + reason = if (available) null else "Wi-Fi Aware temporarily unavailable" + ) + } + + fun isSupported(context: Context): Boolean = evaluate(context).supported + + fun getManager(context: Context): WifiAwareManager? { + return try { + context.applicationContext.getSystemService(WifiAwareManager::class.java) + } catch (_: Exception) { + null + } + } +} diff --git a/app/src/main/jniLibs/arm64-v8a/libarti_android.so b/app/src/main/jniLibs/arm64-v8a/libarti_android.so index ba4f614b..f4776ae5 100755 Binary files a/app/src/main/jniLibs/arm64-v8a/libarti_android.so and b/app/src/main/jniLibs/arm64-v8a/libarti_android.so differ diff --git a/app/src/main/jniLibs/armeabi-v7a/libarti_android.so b/app/src/main/jniLibs/armeabi-v7a/libarti_android.so new file mode 100755 index 00000000..846f0842 Binary files /dev/null and b/app/src/main/jniLibs/armeabi-v7a/libarti_android.so differ diff --git a/app/src/main/jniLibs/x86/libarti_android.so b/app/src/main/jniLibs/x86/libarti_android.so new file mode 100755 index 00000000..c3b7b5c6 Binary files /dev/null and b/app/src/main/jniLibs/x86/libarti_android.so differ diff --git a/app/src/main/jniLibs/x86_64/libarti_android.so b/app/src/main/jniLibs/x86_64/libarti_android.so index 9a1e27b6..7b632704 100755 Binary files a/app/src/main/jniLibs/x86_64/libarti_android.so and b/app/src/main/jniLibs/x86_64/libarti_android.so differ diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index 51c67613..3d72337a 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -363,4 +363,41 @@ إغلاق أضف ملاحظة لهذا المكان بلوتوث موصى به + الشبكة تعمل — %1$d أقران + + verify + scan to verify me + scan someone elses qr + scan someone elses qr + show my qr + remove verification + qr unavailable + camera permission is needed to scan qr codes + enable camera + paste verification url + validate + verification requested + security verification + their fingerprint + your fingerprint + handshake pending + open a private chat to view fingerprints + encrypted & verified + encrypted + handshaking + handshake failed + not encrypted + verified + you have verified this persons identity. + not verified + compare these fingerprints with %1$s using a secure channel. + mark as verified + start handshake + copy + Mutual verification + You and %1$s verified each other + mutual verification with %1$s + Verified + You verified %1$s + verified %1$s diff --git a/app/src/main/res/values-bn/strings.xml b/app/src/main/res/values-bn/strings.xml index 57f1db65..3cda37a6 100644 --- a/app/src/main/res/values-bn/strings.xml +++ b/app/src/main/res/values-bn/strings.xml @@ -350,4 +350,41 @@ ব্লুটুথ প্রস্তাবিত + মেশ চলছে — %1$d পিয়ার + + verify + scan to verify me + scan someone elses qr + scan someone elses qr + show my qr + remove verification + qr unavailable + camera permission is needed to scan qr codes + enable camera + paste verification url + validate + verification requested + security verification + their fingerprint + your fingerprint + handshake pending + open a private chat to view fingerprints + encrypted & verified + encrypted + handshaking + handshake failed + not encrypted + verified + you have verified this persons identity. + not verified + compare these fingerprints with %1$s using a secure channel. + mark as verified + start handshake + copy + Mutual verification + You and %1$s verified each other + mutual verification with %1$s + Verified + You verified %1$s + verified %1$s diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 4f8a1e68..c6cb4aac 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -70,7 +70,7 @@ Aus Favoriten entfernen Lesezeichen hinzufügen - + zurück Kanal: %1$s verlassen @@ -170,7 +170,7 @@ Online‑Geohash‑Kanäle Ende‑zu‑Ende‑Verschlüsselung - + Bild %1$d von %2$d Bild nicht verfügbar Bild in "Downloads" gespeichert @@ -364,4 +364,41 @@ schließen füge eine Notiz zu diesem Ort hinzu Bluetooth empfohlen + Mesh läuft — %1$d Peers + + verifizieren + scannen zur verifizierung + anderen qr scannen + anderen qr scannen + mein qr zeigen + verifizierung entfernen + qr nicht verfügbar + kamera-berechtigung nötig + kamera aktivieren + verifizierungs-url einfügen + validieren + verifizierung angefordert + sicherheitsüberprüfung + ihr fingerabdruck + dein fingerabdruck + handshake ausstehend + privatchat öffnen + verschlüsselt & verifiziert + verschlüsselt + handshake + handshake fehlgeschlagen + unverschlüsselt + verifiziert + du hast diese person verifiziert. + nicht verifiziert + vergleiche fingerabdrücke mit %1$s. + als verifiziert markieren + handshake starten + kopieren + Gegenseitige Verifizierung + Du und %1$s habt euch verifiziert + gegenseitige verifizierung mit %1$s + Verifiziert + Du hast %1$s verifiziert + verifiziert %1$s diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 63aa601a..b8fed3aa 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -363,4 +363,41 @@ cerrar agrega una nota para este lugar Bluetooth recomendado + Mesh en ejecución — %1$d pares + + verificar + escanear para verificarme + escanear qr de otro + escanear qr de otro + mostrar mi qr + eliminar verificación + qr no disponible + se necesita permiso de cámara + activar cámara + pegar url de verificación + validar + verificación solicitada + verificación de seguridad + su huella digital + tu huella digital + intercambio pendiente + abrir chat privado para ver + cifrado y verificado + cifrado + negociando + falló negociación + no cifrado + verificado + has verificado esta identidad. + no verificado + compara huellas con %1$s por canal seguro. + marcar verificado + iniciar handshake + copiar + Verificación mutua + Tú y %1$s se verificaron + verificación mutua con %1$s + Verificado + Verificaste a %1$s + verificado %1$s diff --git a/app/src/main/res/values-fa/strings.xml b/app/src/main/res/values-fa/strings.xml index e307c510..8ad97fe9 100644 --- a/app/src/main/res/values-fa/strings.xml +++ b/app/src/main/res/values-fa/strings.xml @@ -350,4 +350,41 @@ بلوتوث توصیه می شود + مش در حال اجرا — %1$d همتا + + verify + scan to verify me + scan someone elses qr + scan someone elses qr + show my qr + remove verification + qr unavailable + camera permission is needed to scan qr codes + enable camera + paste verification url + validate + verification requested + security verification + their fingerprint + your fingerprint + handshake pending + open a private chat to view fingerprints + encrypted & verified + encrypted + handshaking + handshake failed + not encrypted + verified + you have verified this persons identity. + not verified + compare these fingerprints with %1$s using a secure channel. + mark as verified + start handshake + copy + Mutual verification + You and %1$s verified each other + mutual verification with %1$s + Verified + You verified %1$s + verified %1$s diff --git a/app/src/main/res/values-fil/strings.xml b/app/src/main/res/values-fil/strings.xml index 64e4111d..34e1d5f5 100644 --- a/app/src/main/res/values-fil/strings.xml +++ b/app/src/main/res/values-fil/strings.xml @@ -363,4 +363,41 @@ Bluetooth Recommended + Tumatakbo ang Mesh — %1$d na peer + + verify + scan to verify me + scan someone elses qr + scan someone elses qr + show my qr + remove verification + qr unavailable + camera permission is needed to scan qr codes + enable camera + paste verification url + validate + verification requested + security verification + their fingerprint + your fingerprint + handshake pending + open a private chat to view fingerprints + encrypted & verified + encrypted + handshaking + handshake failed + not encrypted + verified + you have verified this persons identity. + not verified + compare these fingerprints with %1$s using a secure channel. + mark as verified + start handshake + copy + Mutual verification + You and %1$s verified each other + mutual verification with %1$s + Verified + You verified %1$s + verified %1$s diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index b6bafc0b..9828a76b 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -70,7 +70,7 @@ Retirer des favoris Ajouter un signet - + retour canal : %1$s quitter @@ -165,7 +165,7 @@ Canaux Geohash en ligne Chiffrement de bout en bout - + Image %1$d sur %2$d Image indisponible Image enregistrée dans "Téléchargements" @@ -377,4 +377,41 @@ fermer ajoutez une note pour cet endroit Bluetooth recommandé + Mesh actif — %1$d pairs + + vérifier + scanner pour me vérifier + scanner un autre qr + scanner un autre qr + afficher mon qr + supprimer la vérification + qr indisponible + permission caméra requise + activer caméra + coller url de vérification + valider + vérification demandée + vérification de sécurité + leur empreinte + votre empreinte + négociation en cours + ouvrir un chat privé + chiffré & vérifié + chiffré + négociation + échec négociation + non chiffré + vérifié + vous avez vérifié cette personne. + non vérifié + comparez avec %1$s via canal sûr. + marquer vérifié + lancer handshake + copier + Vérification mutuelle + Vous et %1$s êtes vérifiés + vérification mutuelle avec %1$s + Vérifié + Vous avez vérifié %1$s + vérifié %1$s diff --git a/app/src/main/res/values-he/strings.xml b/app/src/main/res/values-he/strings.xml index bdf45645..e18b57b8 100644 --- a/app/src/main/res/values-he/strings.xml +++ b/app/src/main/res/values-he/strings.xml @@ -16,5 +16,42 @@ הוסף הערה למקום זה Skip Bluetooth Recommended + רשת Mesh פועלת — %1$d עמיתים + + verify + scan to verify me + scan someone elses qr + scan someone elses qr + show my qr + remove verification + qr unavailable + camera permission is needed to scan qr codes + enable camera + paste verification url + validate + verification requested + security verification + their fingerprint + your fingerprint + handshake pending + open a private chat to view fingerprints + encrypted & verified + encrypted + handshaking + handshake failed + not encrypted + verified + you have verified this persons identity. + not verified + compare these fingerprints with %1$s using a secure channel. + mark as verified + start handshake + copy + Mutual verification + You and %1$s verified each other + mutual verification with %1$s + Verified + You verified %1$s + verified %1$s diff --git a/app/src/main/res/values-hi/strings.xml b/app/src/main/res/values-hi/strings.xml index 6a2cd9f1..02d64711 100644 --- a/app/src/main/res/values-hi/strings.xml +++ b/app/src/main/res/values-hi/strings.xml @@ -70,7 +70,7 @@ पसंदीदा से हटाएँ बुकमार्क जोड़ें - + वापस चैनल: %1$s छोड़ें @@ -363,4 +363,41 @@ बंद करें इस स्थान के लिए एक नोट जोड़ें ब्लूटूथ अनुशंसित + मेश चल रहा है — %1$d पीयर्स + + verify + scan to verify me + scan someone elses qr + scan someone elses qr + show my qr + remove verification + qr unavailable + camera permission is needed to scan qr codes + enable camera + paste verification url + validate + verification requested + security verification + their fingerprint + your fingerprint + handshake pending + open a private chat to view fingerprints + encrypted & verified + encrypted + handshaking + handshake failed + not encrypted + verified + you have verified this persons identity. + not verified + compare these fingerprints with %1$s using a secure channel. + mark as verified + start handshake + copy + Mutual verification + You and %1$s verified each other + mutual verification with %1$s + Verified + You verified %1$s + verified %1$s diff --git a/app/src/main/res/values-id/strings.xml b/app/src/main/res/values-id/strings.xml index 7eeac6e1..fb9830be 100644 --- a/app/src/main/res/values-id/strings.xml +++ b/app/src/main/res/values-id/strings.xml @@ -70,7 +70,7 @@ Hapus dari favorit Tambah bookmark - + kembali Channel: %1$s keluar @@ -170,7 +170,7 @@ Channel Geohash Online Enkripsi end-to-end - + Gambar %1$d dari %2$d Gambar tidak tersedia Gambar disimpan ke \"Downloads\" @@ -363,4 +363,41 @@ tutup tambahkan catatan untuk tempat ini Bluetooth Recommended + Mesh berjalan — %1$d peer + + verify + scan to verify me + scan someone elses qr + scan someone elses qr + show my qr + remove verification + qr unavailable + camera permission is needed to scan qr codes + enable camera + paste verification url + validate + verification requested + security verification + their fingerprint + your fingerprint + handshake pending + open a private chat to view fingerprints + encrypted & verified + encrypted + handshaking + handshake failed + not encrypted + verified + you have verified this persons identity. + not verified + compare these fingerprints with %1$s using a secure channel. + mark as verified + start handshake + copy + Mutual verification + You and %1$s verified each other + mutual verification with %1$s + Verified + You verified %1$s + verified %1$s diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 598ed79d..3db4e85e 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -397,4 +397,41 @@ chiudi aggiungi una nota per questo luogo Bluetooth consigliato + Mesh in esecuzione — %1$d peer + + verifica + scansiona per verificarmi + scansiona qr altrui + scansiona qr altrui + mostra mio qr + rimuovi verifica + qr non disponibile + permesso fotocamera necessario + abilita fotocamera + incolla url verifica + convalida + verifica richiesta + verifica sicurezza + loro impronta + tua impronta + handshake in corso + apri chat privata + criptato & verificato + criptato + handshake + handshake fallito + non criptato + verificato + hai verificato questa persona. + non verificato + confronta con %1$s via canale sicuro. + segna verificato + avvia handshake + copia + Verifica reciproca + Tu e %1$s vi siete verificati + verifica reciproca con %1$s + Verificato + Hai verificato %1$s + verificato %1$s diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index 6b6f6bc9..df489157 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -70,7 +70,7 @@ お気に入りから削除 ブックマークに追加 - + 戻る チャンネル: %1$s 退出 @@ -170,7 +170,7 @@ オンライン Geohash チャンネル エンドツーエンド暗号化 - + 画像 %1$d / %2$d 画像は利用できません 画像を「ダウンロード」に保存しました @@ -363,4 +363,41 @@ 閉じる この場所へのメモを追加 Bluetooth推奨 + メッシュ実行中 — %1$d ピア + + 検証 + 私を検証するためにスキャン + 他人のQRをスキャン + 他人のQRをスキャン + QRコードを表示 + 検証を削除 + QR利用不可 + QRスキャンにカメラ権限が必要です + カメラを有効化 + 検証URLを貼り付け + 検証する + 検証をリクエストしました + セキュリティ検証 + 相手の指紋 + あなたの指紋 + ハンドシェイク保留中 + 指紋を見るにはプライベートチャットを開いてください + 暗号化&検証済み + 暗号化済み + ハンドシェイク中 + ハンドシェイク失敗 + 未暗号化 + 検証済み + この人物の身元を検証しました。 + 未検証 + 安全な経路で %1$s と指紋を比較してください。 + 検証済みにする + ハンドシェイク開始 + コピー + 相互検証 + あなたと %1$s は相互に検証しました + %1$s と相互検証しました + 検証済み + %1$s を検証しました + %1$s を検証しました diff --git a/app/src/main/res/values-ka/strings.xml b/app/src/main/res/values-ka/strings.xml index 7c64f1d0..2d1c6693 100644 --- a/app/src/main/res/values-ka/strings.xml +++ b/app/src/main/res/values-ka/strings.xml @@ -70,7 +70,7 @@ რჩეულებიდან წაშლა სანიშნეს დამატება - + უკან არხი: %1$s გასვლა @@ -170,7 +170,7 @@ ონლაინ geohash არხები ბოლო-მდე დაშიფვრა - + სურათი %1$d %2$d-დან სურათი მიუწვდომელია სურათი შენახულია "ჩამოტვირთვებში" @@ -350,4 +350,41 @@ Bluetooth Recommended + Mesh გაშვებულია — %1$d პირები + + verify + scan to verify me + scan someone elses qr + scan someone elses qr + show my qr + remove verification + qr unavailable + camera permission is needed to scan qr codes + enable camera + paste verification url + validate + verification requested + security verification + their fingerprint + your fingerprint + handshake pending + open a private chat to view fingerprints + encrypted & verified + encrypted + handshaking + handshake failed + not encrypted + verified + you have verified this persons identity. + not verified + compare these fingerprints with %1$s using a secure channel. + mark as verified + start handshake + copy + Mutual verification + You and %1$s verified each other + mutual verification with %1$s + Verified + You verified %1$s + verified %1$s diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 7e47eb2a..68d46136 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -70,7 +70,7 @@ 즐겨찾기에서 제거 북마크 추가 - + 뒤로 채널: %1$s 나가기 @@ -170,7 +170,7 @@ 온라인 geohash 채널 종단간 암호화 - + 이미지 %1$d / %2$d 이미지를 사용할 수 없음 이미지가 \"Downloads\"에 저장됨 @@ -363,4 +363,41 @@ 닫기 이 장소에 노트 추가 블루투스 권장 + 메시 실행 중 — %1$d 피어 + + verify + scan to verify me + scan someone elses qr + scan someone elses qr + show my qr + remove verification + qr unavailable + camera permission is needed to scan qr codes + enable camera + paste verification url + validate + verification requested + security verification + their fingerprint + your fingerprint + handshake pending + open a private chat to view fingerprints + encrypted & verified + encrypted + handshaking + handshake failed + not encrypted + verified + you have verified this persons identity. + not verified + compare these fingerprints with %1$s using a secure channel. + mark as verified + start handshake + copy + Mutual verification + You and %1$s verified each other + mutual verification with %1$s + Verified + You verified %1$s + verified %1$s diff --git a/app/src/main/res/values-mg/strings.xml b/app/src/main/res/values-mg/strings.xml index dea575ea..57676dec 100644 --- a/app/src/main/res/values-mg/strings.xml +++ b/app/src/main/res/values-mg/strings.xml @@ -70,7 +70,7 @@ Hanesorana amin\'ny ankafizina Hanampy bookmark - + miverina fantsona: %1$s hiala @@ -170,7 +170,7 @@ Fantsona Geohash an-tserasera Fandokoana Farany amin\'ny Farany - + Sary %1$d amin\'ny %2$d Tsy misy sary Sary voatahiry ao amin\'ny Downloads @@ -377,4 +377,41 @@ Bluetooth Recommended + Mandeha ny Mesh — %1$d peers + + verify + scan to verify me + scan someone elses qr + scan someone elses qr + show my qr + remove verification + qr unavailable + camera permission is needed to scan qr codes + enable camera + paste verification url + validate + verification requested + security verification + their fingerprint + your fingerprint + handshake pending + open a private chat to view fingerprints + encrypted & verified + encrypted + handshaking + handshake failed + not encrypted + verified + you have verified this persons identity. + not verified + compare these fingerprints with %1$s using a secure channel. + mark as verified + start handshake + copy + Mutual verification + You and %1$s verified each other + mutual verification with %1$s + Verified + You verified %1$s + verified %1$s diff --git a/app/src/main/res/values-ms/strings.xml b/app/src/main/res/values-ms/strings.xml index 261d1990..b4b23655 100644 --- a/app/src/main/res/values-ms/strings.xml +++ b/app/src/main/res/values-ms/strings.xml @@ -3,5 +3,42 @@ Skip Bluetooth Recommended + Mesh sedang berjalan — %1$d rakan + + verify + scan to verify me + scan someone elses qr + scan someone elses qr + show my qr + remove verification + qr unavailable + camera permission is needed to scan qr codes + enable camera + paste verification url + validate + verification requested + security verification + their fingerprint + your fingerprint + handshake pending + open a private chat to view fingerprints + encrypted & verified + encrypted + handshaking + handshake failed + not encrypted + verified + you have verified this persons identity. + not verified + compare these fingerprints with %1$s using a secure channel. + mark as verified + start handshake + copy + Mutual verification + You and %1$s verified each other + mutual verification with %1$s + Verified + You verified %1$s + verified %1$s diff --git a/app/src/main/res/values-ne/strings.xml b/app/src/main/res/values-ne/strings.xml index 008f7fa9..252f81c4 100644 --- a/app/src/main/res/values-ne/strings.xml +++ b/app/src/main/res/values-ne/strings.xml @@ -363,4 +363,41 @@ ब्लुटुथ सिफारिस गरिएको + मेश चलिरहेको छ — %1$d पियर्स + + verify + scan to verify me + scan someone elses qr + scan someone elses qr + show my qr + remove verification + qr unavailable + camera permission is needed to scan qr codes + enable camera + paste verification url + validate + verification requested + security verification + their fingerprint + your fingerprint + handshake pending + open a private chat to view fingerprints + encrypted & verified + encrypted + handshaking + handshake failed + not encrypted + verified + you have verified this persons identity. + not verified + compare these fingerprints with %1$s using a secure channel. + mark as verified + start handshake + copy + Mutual verification + You and %1$s verified each other + mutual verification with %1$s + Verified + You verified %1$s + verified %1$s diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index 8d4c21eb..59705572 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -70,7 +70,7 @@ Verwijderen uit favorieten Bladwijzer toevoegen - + terug kanaal: %1$s verlaten @@ -170,7 +170,7 @@ Online geohash-kanalen End-to-end-versleuteling - + Afbeelding %1$d van %2$d Afbeelding niet beschikbaar Afbeelding opgeslagen in Downloads @@ -338,7 +338,7 @@ Deelnemen Annuleren - + @ bitchat/ · ⧉ @@ -395,4 +395,41 @@ sluiten voeg een notitie toe voor deze plek Bluetooth aanbevolen + Mesh actief — %1$d peers + + verify + scan to verify me + scan someone elses qr + scan someone elses qr + show my qr + remove verification + qr unavailable + camera permission is needed to scan qr codes + enable camera + paste verification url + validate + verification requested + security verification + their fingerprint + your fingerprint + handshake pending + open a private chat to view fingerprints + encrypted & verified + encrypted + handshaking + handshake failed + not encrypted + verified + you have verified this persons identity. + not verified + compare these fingerprints with %1$s using a secure channel. + mark as verified + start handshake + copy + Mutual verification + You and %1$s verified each other + mutual verification with %1$s + Verified + You verified %1$s + verified %1$s diff --git a/app/src/main/res/values-pa-rPK/strings.xml b/app/src/main/res/values-pa-rPK/strings.xml index 1dee4892..58135f7c 100644 --- a/app/src/main/res/values-pa-rPK/strings.xml +++ b/app/src/main/res/values-pa-rPK/strings.xml @@ -70,7 +70,7 @@ پسندیدہ توں ہٹاؤ بُک مارک پاؤ - + واپس چینل: %1$s باہر آؤ @@ -170,7 +170,7 @@ آن لائن geohash چینل اینڈ ٹو اینڈ انکرپشن - + تصویر %1$d وچوں %2$d تصویر دستیاب نئیں تصویر \"Downloads\" وچ سیو ہو گئی @@ -350,4 +350,41 @@ Bluetooth Recommended + میش چل رہا ہے — %1$d ساتھی + + verify + scan to verify me + scan someone elses qr + scan someone elses qr + show my qr + remove verification + qr unavailable + camera permission is needed to scan qr codes + enable camera + paste verification url + validate + verification requested + security verification + their fingerprint + your fingerprint + handshake pending + open a private chat to view fingerprints + encrypted & verified + encrypted + handshaking + handshake failed + not encrypted + verified + you have verified this persons identity. + not verified + compare these fingerprints with %1$s using a secure channel. + mark as verified + start handshake + copy + Mutual verification + You and %1$s verified each other + mutual verification with %1$s + Verified + You verified %1$s + verified %1$s diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 3a15f768..cdbffdd0 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -16,5 +16,42 @@ dodaj notatkę dla tego miejsca Pomiń Bluetooth zalecany + Mesh działa — %1$d peerów + + verify + scan to verify me + scan someone elses qr + scan someone elses qr + show my qr + remove verification + qr unavailable + camera permission is needed to scan qr codes + enable camera + paste verification url + validate + verification requested + security verification + their fingerprint + your fingerprint + handshake pending + open a private chat to view fingerprints + encrypted & verified + encrypted + handshaking + handshake failed + not encrypted + verified + you have verified this persons identity. + not verified + compare these fingerprints with %1$s using a secure channel. + mark as verified + start handshake + copy + Mutual verification + You and %1$s verified each other + mutual verification with %1$s + Verified + You verified %1$s + verified %1$s diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 7f831a48..45f41512 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -70,7 +70,7 @@ Remover dos favoritos Adicionar favorito - + voltar Canal: %1$s sair @@ -170,7 +170,7 @@ Canais geohash online Criptografia de ponta a ponta - + Imagem %1$d de %2$d Imagem indisponível Imagem salva em "Downloads" @@ -363,4 +363,41 @@ fechar adicione uma nota para este local Bluetooth recomendado + Mesh rodando — %1$d pares + + verificar + digitalize para verificar-me + digitalizar outro qr + digitalizar outro qr + mostrar meu qr + remover verificação + qr indisponível + permissão de câmera necessária + ativar câmera + colar url de verificação + validar + verificação solicitada + verificação de segurança + impressão digital deles + sua impressão digital + handshake pendente + abra chat privado para ver + criptografado e verificado + criptografado + handshake + falha no handshake + não criptografado + verificado + você verificou esta pessoa. + não verificado + compare com %1$s em canal seguro. + marcar verificado + iniciar handshake + copiar + Verificação mútua + Você e %1$s verificaram-se + verificação mútua com %1$s + Verificado + Você verificou %1$s + verificou %1$s diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index 75e257b4..147b5f37 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -363,4 +363,41 @@ fechar adicione uma nota para este local Bluetooth recomendado + Mesh em execução — %1$d pares + + verificar + digitalize para verificar-me + digitalizar outro qr + digitalizar outro qr + mostrar meu qr + remover verificação + qr indisponível + permissão de câmera necessária + ativar câmera + colar url de verificação + validar + verificação solicitada + verificação de segurança + impressão digital deles + sua impressão digital + handshake pendente + abra chat privado para ver + criptografado e verificado + criptografado + handshake + falha no handshake + não criptografado + verificado + você verificou esta pessoa. + não verificado + compare com %1$s em canal seguro. + marcar verificado + iniciar handshake + copiar + Verificação mútua + Você e %1$s verificaram-se + verificação mútua com %1$s + Verificado + Você verificou %1$s + verificou %1$s diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 0eeb3bf9..dade7d1d 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -353,4 +353,41 @@ закрыть добавьте заметку для этого места Рекомендуется Bluetooth + Mesh запущен — %1$d пиров + + проверить + сканируй для проверки + сканировать чужой QR + сканировать чужой QR + показать мой QR + удалить проверку + QR недоступен + нужен доступ к камере + включить камеру + вставить URL проверки + проверить + запрос проверки + проверка безопасности + их отпечаток + ваш отпечаток + ожидание рукопожатия + откройте личный чат + шифровано и проверено + шифровано + рукопожатие + сбой рукопожатия + не шифровано + проверено + вы проверили этого человека. + не проверено + сравните с %1$s. + отметить проверенным + начать рукопожатие + копировать + Взаимная проверка + Вы и %1$s проверили друг друга + взаимная проверка с %1$s + Проверено + Вы проверили %1$s + проверен %1$s diff --git a/app/src/main/res/values-sv/strings.xml b/app/src/main/res/values-sv/strings.xml index 335da8a0..9ac7df67 100644 --- a/app/src/main/res/values-sv/strings.xml +++ b/app/src/main/res/values-sv/strings.xml @@ -351,4 +351,41 @@ stäng lägg till en anteckning för den här platsen Bluetooth rekommenderas + Mesh körs — %1$d peers + + verify + scan to verify me + scan someone elses qr + scan someone elses qr + show my qr + remove verification + qr unavailable + camera permission is needed to scan qr codes + enable camera + paste verification url + validate + verification requested + security verification + their fingerprint + your fingerprint + handshake pending + open a private chat to view fingerprints + encrypted & verified + encrypted + handshaking + handshake failed + not encrypted + verified + you have verified this persons identity. + not verified + compare these fingerprints with %1$s using a secure channel. + mark as verified + start handshake + copy + Mutual verification + You and %1$s verified each other + mutual verification with %1$s + Verified + You verified %1$s + verified %1$s diff --git a/app/src/main/res/values-ta/strings.xml b/app/src/main/res/values-ta/strings.xml index 65102d92..1e487548 100644 --- a/app/src/main/res/values-ta/strings.xml +++ b/app/src/main/res/values-ta/strings.xml @@ -3,5 +3,42 @@ Skip Bluetooth Recommended + மெஷ் இயங்குகிறது — %1$d பியர்ஸ் + + verify + scan to verify me + scan someone elses qr + scan someone elses qr + show my qr + remove verification + qr unavailable + camera permission is needed to scan qr codes + enable camera + paste verification url + validate + verification requested + security verification + their fingerprint + your fingerprint + handshake pending + open a private chat to view fingerprints + encrypted & verified + encrypted + handshaking + handshake failed + not encrypted + verified + you have verified this persons identity. + not verified + compare these fingerprints with %1$s using a secure channel. + mark as verified + start handshake + copy + Mutual verification + You and %1$s verified each other + mutual verification with %1$s + Verified + You verified %1$s + verified %1$s diff --git a/app/src/main/res/values-th/strings.xml b/app/src/main/res/values-th/strings.xml index 588779c8..4a4c5021 100644 --- a/app/src/main/res/values-th/strings.xml +++ b/app/src/main/res/values-th/strings.xml @@ -70,7 +70,7 @@ เอาออกจากรายการโปรด เพิ่มที่คั่นหน้า - + กลับ ช่อง: %1$s ออก @@ -170,7 +170,7 @@ ช่อง geohash ออนไลน์ การเข้ารหัสแบบปลายทางถึงปลายทาง - + รูปภาพ %1$d จาก %2$d ไม่สามารถใช้รูปภาพได้ บันทึกรูปภาพไว้ใน "ดาวน์โหลด" แล้ว @@ -350,4 +350,41 @@ Bluetooth Recommended + Mesh กำลังทำงาน — %1$d เพื่อน + + verify + scan to verify me + scan someone elses qr + scan someone elses qr + show my qr + remove verification + qr unavailable + camera permission is needed to scan qr codes + enable camera + paste verification url + validate + verification requested + security verification + their fingerprint + your fingerprint + handshake pending + open a private chat to view fingerprints + encrypted & verified + encrypted + handshaking + handshake failed + not encrypted + verified + you have verified this persons identity. + not verified + compare these fingerprints with %1$s using a secure channel. + mark as verified + start handshake + copy + Mutual verification + You and %1$s verified each other + mutual verification with %1$s + Verified + You verified %1$s + verified %1$s diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index 9117b92c..a2df9dfe 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -351,4 +351,41 @@ kapat bu yer için bir not ekleyin Bluetooth Önerilir + Mesh çalışıyor — %1$d eş + + verify + scan to verify me + scan someone elses qr + scan someone elses qr + show my qr + remove verification + qr unavailable + camera permission is needed to scan qr codes + enable camera + paste verification url + validate + verification requested + security verification + their fingerprint + your fingerprint + handshake pending + open a private chat to view fingerprints + encrypted & verified + encrypted + handshaking + handshake failed + not encrypted + verified + you have verified this persons identity. + not verified + compare these fingerprints with %1$s using a secure channel. + mark as verified + start handshake + copy + Mutual verification + You and %1$s verified each other + mutual verification with %1$s + Verified + You verified %1$s + verified %1$s diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index 401f4f3d..2c631d04 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -3,5 +3,42 @@ Пропустити Рекомендується Bluetooth + Mesh працює — %1$d пірів + + verify + scan to verify me + scan someone elses qr + scan someone elses qr + show my qr + remove verification + qr unavailable + camera permission is needed to scan qr codes + enable camera + paste verification url + validate + verification requested + security verification + their fingerprint + your fingerprint + handshake pending + open a private chat to view fingerprints + encrypted & verified + encrypted + handshaking + handshake failed + not encrypted + verified + you have verified this persons identity. + not verified + compare these fingerprints with %1$s using a secure channel. + mark as verified + start handshake + copy + Mutual verification + You and %1$s verified each other + mutual verification with %1$s + Verified + You verified %1$s + verified %1$s diff --git a/app/src/main/res/values-ur/strings.xml b/app/src/main/res/values-ur/strings.xml index e6510977..291c3e9a 100644 --- a/app/src/main/res/values-ur/strings.xml +++ b/app/src/main/res/values-ur/strings.xml @@ -363,4 +363,41 @@ بند کریں اس جگہ کے لیے ایک نوٹ شامل کریں Bluetooth Recommended + میش چل رہا ہے — %1$d ساتھی + + verify + scan to verify me + scan someone elses qr + scan someone elses qr + show my qr + remove verification + qr unavailable + camera permission is needed to scan qr codes + enable camera + paste verification url + validate + verification requested + security verification + their fingerprint + your fingerprint + handshake pending + open a private chat to view fingerprints + encrypted & verified + encrypted + handshaking + handshake failed + not encrypted + verified + you have verified this persons identity. + not verified + compare these fingerprints with %1$s using a secure channel. + mark as verified + start handshake + copy + Mutual verification + You and %1$s verified each other + mutual verification with %1$s + Verified + You verified %1$s + verified %1$s diff --git a/app/src/main/res/values-vi/strings.xml b/app/src/main/res/values-vi/strings.xml index f6a96348..a1315617 100644 --- a/app/src/main/res/values-vi/strings.xml +++ b/app/src/main/res/values-vi/strings.xml @@ -70,7 +70,7 @@ Xóa khỏi yêu thích Thêm bookmark - + quay lại Kênh: %1$s rời khỏi @@ -170,7 +170,7 @@ Kênh Geohash trực tuyến Mã hóa đầu cuối - + Hình ảnh %1$d trong %2$d Hình ảnh không có sẵn Hình ảnh đã lưu vào \"Downloads\" @@ -350,4 +350,41 @@ Khuyên dùng Bluetooth + Mesh đang chạy — %1$d ngang hàng + + verify + scan to verify me + scan someone elses qr + scan someone elses qr + show my qr + remove verification + qr unavailable + camera permission is needed to scan qr codes + enable camera + paste verification url + validate + verification requested + security verification + their fingerprint + your fingerprint + handshake pending + open a private chat to view fingerprints + encrypted & verified + encrypted + handshaking + handshake failed + not encrypted + verified + you have verified this persons identity. + not verified + compare these fingerprints with %1$s using a secure channel. + mark as verified + start handshake + copy + Mutual verification + You and %1$s verified each other + mutual verification with %1$s + Verified + You verified %1$s + verified %1$s diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 14ed450a..69bff70c 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -16,5 +16,42 @@ 为此地点添加一条笔记 跳过 建议开启蓝牙 + Mesh 运行中 — %1$d 个节点 + + 验证 + 扫描以验证我 + 扫描他人的QR码 + 扫描他人的QR码 + 显示我的QR码 + 移除验证 + QR码不可用 + 需要相机权限扫描QR码 + 启用相机 + 粘贴验证链接 + 验证 + 已请求验证 + 安全验证 + 对方指纹 + 你的指纹 + 握手中 + 打开私聊查看指纹 + 已加密且已验证 + 已加密 + 握手中 + 握手失败 + 未加密 + 已验证 + 你已验证此人身份。 + 未验证 + 请通过安全渠道与 %1$s 比对指纹。 + 标记为已验证 + 开始握手 + 复制 + 相互验证 + 你和 %1$s 已相互验证 + 与 %1$s 相互验证 + 已验证 + 你已验证 %1$s + 已验证 %1$s diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index de9a9333..1dcf6b27 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -16,5 +16,42 @@ 為此地點新增一則筆記 跳過 建議開啟藍牙 + Mesh 運行中 — %1$d 個節點 + + 验证 + 扫描以验证我 + 扫描他人的QR码 + 扫描他人的QR码 + 显示我的QR码 + 移除验证 + QR码不可用 + 需要相机权限扫描QR码 + 启用相机 + 粘贴验证链接 + 验证 + 已请求验证 + 安全验证 + 对方指纹 + 你的指纹 + 握手中 + 打开私聊查看指纹 + 已加密且已验证 + 已加密 + 握手中 + 握手失败 + 未加密 + 已验证 + 你已验证此人身份。 + 未验证 + 请通过安全渠道与 %1$s 比对指纹。 + 标记为已验证 + 开始握手 + 复制 + 相互验证 + 你和 %1$s 已相互验证 + 与 %1$s 相互验证 + 已验证 + 你已验证 %1$s + 已验证 %1$s diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index 5162197d..46caf1af 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -376,4 +376,41 @@ 关闭 为此地点添加一条笔记 建议开启蓝牙 + Mesh 运行中 — %1$d 个节点 + + 验证 + 扫描以验证我 + 扫描他人的QR码 + 扫描他人的QR码 + 显示我的QR码 + 移除验证 + QR码不可用 + 需要相机权限扫描QR码 + 启用相机 + 粘贴验证链接 + 验证 + 已请求验证 + 安全验证 + 对方指纹 + 你的指纹 + 握手中 + 打开私聊查看指纹 + 已加密且已验证 + 已加密 + 握手中 + 握手失败 + 未加密 + 已验证 + 你已验证此人身份。 + 未验证 + 请通过安全渠道与 %1$s 比对指纹。 + 标记为已验证 + 开始握手 + 复制 + 相互验证 + 你和 %1$s 已相互验证 + 与 %1$s 相互验证 + 已验证 + 你已验证 %1$s + 已验证 %1$s diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 88c0f6ee..27a5c5e5 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -64,14 +64,14 @@ Mesh Background Service Keeps the Bluetooth mesh running in the background - Mesh running — %1$d users connected + Mesh running — %1$d peers Add to favorites Remove from favorites Add bookmark - + back channel: %1$s leave @@ -201,7 +201,10 @@ hug %1$s send a friendly hug message block %1$s - block all messages from this user + block all messages from this user + message %1$s + send a private message + #location channels @@ -265,6 +268,44 @@ none disconnect recent scan results + + verify + scan to verify me + scan someone elses qr + scan someone elses qr + show my qr + remove verification + qr unavailable + camera permission is needed to scan qr codes + enable camera + paste verification url + validate + verification requested + security verification + their fingerprint + your fingerprint + handshake pending + open a private chat to view fingerprints + encrypted & verified + encrypted + handshaking + handshake failed + not encrypted + verified + you have verified this persons identity. + not verified + compare these fingerprints with %1$s using a secure channel. + mark as verified + start handshake + copy + + Mutual verification + You and %1$s verified each other + mutual verification with %1$s + Verified + You verified %1$s + verified %1$s + connect debug console clear @@ -282,6 +323,15 @@ bitchat does NOT track your location.\n\nLocation services are required for Bluetooth scanning and for the Geohash chat feature. bitchat needs location services for: • Bluetooth device scanning\n• Discovering nearby users on mesh network\n• Geohash chat feature\n• No tracking or location collection + Background Location Recommended + optional, improves mesh reliability + Android recommends background location so bitchat can scan for nearby devices while the app is not open. This keeps the mesh alive after reboot. + When settings opens, choose "Allow all the time". + bitchat uses background location for: + - scan for nearby devices while the app is closed\n- reconnect after reboot\n- keep the mesh running in the background + We NEVER collect or store your location. Your privacy is safe. + Allow Background Location + Continue without background location Open Location Settings Check Again Location Services Unavailable @@ -367,6 +417,8 @@ Allow bitchat to connect to nearby devices Required by Android to discover nearby bitchat users via Bluetooth bitchat needs this to scan for nearby devices + Recommended to scan for nearby devices while the app is in the background + Allow background location access Receive notifications when you receive private messages Allow bitchat to send you notifications Disable battery optimization to ensure bitchat runs reliably in the background and maintains mesh network connections @@ -375,6 +427,7 @@ Nearby Devices Precise Location + Background Location Microphone Notifications Battery Optimization diff --git a/app/src/test/java/com/bitchat/android/crypto/EncryptionServiceTest.kt b/app/src/test/java/com/bitchat/android/crypto/EncryptionServiceTest.kt new file mode 100644 index 00000000..7e610d32 --- /dev/null +++ b/app/src/test/java/com/bitchat/android/crypto/EncryptionServiceTest.kt @@ -0,0 +1,55 @@ +package com.bitchat.android.crypto + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import com.bitchat.android.noise.NoiseEncryptionService +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertNotNull +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import java.util.Arrays + +@RunWith(RobolectricTestRunner::class) +class EncryptionServiceTest { + + private lateinit var context: Context + private lateinit var encryptionService: EncryptionService + + @Before + fun setup() { + context = ApplicationProvider.getApplicationContext() + encryptionService = EncryptionService(context) + } + + @Test + fun `test clearPersistentIdentity changes keys`() { + // 1. Get initial keys + val initialStaticKey = encryptionService.getStaticPublicKey() + val initialSigningKey = encryptionService.getSigningPublicKey() + val initialFingerprint = encryptionService.getIdentityFingerprint() + + assertNotNull("Initial static key should not be null", initialStaticKey) + assertNotNull("Initial signing key should not be null", initialSigningKey) + + // 2. Call clearPersistentIdentity (Panic Mode) + encryptionService.clearPersistentIdentity() + + // 3. Get keys again. + val afterStaticKey = encryptionService.getStaticPublicKey() + val afterSigningKey = encryptionService.getSigningPublicKey() + val afterFingerprint = encryptionService.getIdentityFingerprint() + + // 4. Verify keys are different (Panic Mode should clear/rotate in-memory keys) + // Note: We use string comparison for byte arrays to be safe in assertion messages + assertNotEquals("Static key should change after panic", + Arrays.toString(initialStaticKey), Arrays.toString(afterStaticKey)) + + assertNotEquals("Signing key should change after panic", + Arrays.toString(initialSigningKey), Arrays.toString(afterSigningKey)) + + assertNotEquals("Fingerprint should change after panic", + initialFingerprint, afterFingerprint) + } +} diff --git a/app/src/test/java/com/bitchat/android/mesh/FragmentManagerTest.kt b/app/src/test/java/com/bitchat/android/mesh/FragmentManagerTest.kt new file mode 100644 index 00000000..4dce6d7f --- /dev/null +++ b/app/src/test/java/com/bitchat/android/mesh/FragmentManagerTest.kt @@ -0,0 +1,191 @@ +package com.bitchat.android.mesh + +import com.bitchat.android.protocol.BitchatPacket +import com.bitchat.android.protocol.MessageType +import com.bitchat.android.model.FragmentPayload +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import java.util.Random + +@RunWith(RobolectricTestRunner::class) +class FragmentManagerTest { + + private lateinit var fragmentManager: FragmentManager + private val senderID = "1122334455667788" + private val recipientID = "8877665544332211" + + @Before + fun setup() { + fragmentManager = FragmentManager() + } + + @Test + fun `test fragmentation without route`() { + // Create a large payload (e.g., 1000 bytes) + val payload = ByteArray(1000) + Random().nextBytes(payload) + + val packet = BitchatPacket( + version = 1u, + type = MessageType.MESSAGE.value, + senderID = hexStringToByteArray(senderID), + recipientID = hexStringToByteArray(recipientID), + timestamp = System.currentTimeMillis().toULong(), + payload = payload, + ttl = 7u, + route = null + ) + + val fragments = fragmentManager.createFragments(packet) + + assertTrue("Should create multiple fragments", fragments.size > 1) + + // Verify each fragment fits in MTU (512) + for (fragment in fragments) { + val encodedSize = fragment.toBinaryData()?.size ?: 0 + assertTrue("Fragment encoded size should be <= 512, was $encodedSize", encodedSize <= 512) + + // Inspect the payload data size + val fragmentPayload = FragmentPayload.decode(fragment.payload) + assertNotNull(fragmentPayload) + } + } + + @Test + fun `test fragmentation with route`() { + // Create a large payload + val payload = ByteArray(1000) + Random().nextBytes(payload) + + // Create a fake route (3 hops) + val route = listOf( + hexStringToByteArray("AABBCCDDEEFF0011"), + hexStringToByteArray("1100FFEEDDCCBBAA"), + hexStringToByteArray("1234567890ABCDEF") + ) + + val packet = BitchatPacket( + version = 2u, + type = MessageType.MESSAGE.value, + senderID = hexStringToByteArray(senderID), + recipientID = hexStringToByteArray(recipientID), + timestamp = System.currentTimeMillis().toULong(), + payload = payload, + ttl = 7u, + route = route + ) + + val fragments = fragmentManager.createFragments(packet) + + assertTrue("Should create multiple fragments", fragments.size > 1) + + // Verify fragments retain the route and version 2 + for (fragment in fragments) { + assertEquals("Fragment version should be 2", 2u.toUByte(), fragment.version) + assertEquals("Fragment should have the route", route.size, fragment.route?.size) + + val encodedSize = fragment.toBinaryData()?.size ?: 0 + assertTrue("Fragment encoded size should be <= 512, was $encodedSize", encodedSize <= 512) + } + } + + @Test + fun `test fragmentation size difference with and without route`() { + // This test specifically checks if the dynamic calculation logic works + // by observing that fragments with routes carry less data payload per fragment + + val payload = ByteArray(2000) // Large enough to ensure full fragments + Random().nextBytes(payload) + + // 1. Without route + val packetNoRoute = BitchatPacket( + version = 1u, + type = MessageType.MESSAGE.value, + senderID = hexStringToByteArray(senderID), + recipientID = hexStringToByteArray(recipientID), + timestamp = System.currentTimeMillis().toULong(), + payload = payload, + ttl = 7u, + route = null + ) + val fragmentsNoRoute = fragmentManager.createFragments(packetNoRoute) + val firstFragPayloadNoRoute = FragmentPayload.decode(fragmentsNoRoute[0].payload) + val dataSizeNoRoute = firstFragPayloadNoRoute?.data?.size ?: 0 + + // 2. With large route (e.g., 5 hops) + val route = List(5) { hexStringToByteArray("000000000000000$it") } + val packetWithRoute = BitchatPacket( + version = 2u, + type = MessageType.MESSAGE.value, + senderID = hexStringToByteArray(senderID), + recipientID = hexStringToByteArray(recipientID), + timestamp = System.currentTimeMillis().toULong(), + payload = payload, + ttl = 7u, + route = route + ) + val fragmentsWithRoute = fragmentManager.createFragments(packetWithRoute) + val firstFragPayloadWithRoute = FragmentPayload.decode(fragmentsWithRoute[0].payload) + val dataSizeWithRoute = firstFragPayloadWithRoute?.data?.size ?: 0 + + println("Data size without route: $dataSizeNoRoute") + println("Data size with route: $dataSizeWithRoute") + + assertTrue("Data payload should be smaller with route", dataSizeWithRoute < dataSizeNoRoute) + + // Rough verification of the math: + // 5 hops * 8 bytes = 40 bytes extra. + // Plus v2 header overhead differences. + // The difference should be roughly 40+ bytes. + assertTrue("Difference should be significant", (dataSizeNoRoute - dataSizeWithRoute) >= 40) + } + + @Test + fun `test reassembly`() { + val originalPayload = ByteArray(1500) + Random().nextBytes(originalPayload) + + val originalPacket = BitchatPacket( + version = 1u, + type = MessageType.FILE_TRANSFER.value, + senderID = hexStringToByteArray(senderID), + recipientID = hexStringToByteArray(recipientID), + timestamp = System.currentTimeMillis().toULong(), + payload = originalPayload, + ttl = 7u + ) + + val fragments = fragmentManager.createFragments(originalPacket) + + var reassembledPacket: BitchatPacket? = null + + // Feed fragments back into FragmentManager + // Note: FragmentManager stores state in incomingFragments + + for (fragment in fragments) { + val result = fragmentManager.handleFragment(fragment) + if (result != null) { + reassembledPacket = result + } + } + + assertNotNull("Should have reassembled packet", reassembledPacket) + assertEquals("Type should match", originalPacket.type, reassembledPacket!!.type) + assertEquals("Payload size should match", originalPacket.payload.size, reassembledPacket.payload.size) + assertTrue("Payload content should match", originalPacket.payload.contentEquals(reassembledPacket.payload)) + } + + private fun hexStringToByteArray(hexString: String): ByteArray { + val result = ByteArray(8) + for (i in 0 until 8) { + val byteStr = hexString.substring(i * 2, i * 2 + 2) + result[i] = byteStr.toInt(16).toByte() + } + return result + } +} diff --git a/app/src/test/java/com/bitchat/android/protocol/BinaryProtocolTest.kt b/app/src/test/java/com/bitchat/android/protocol/BinaryProtocolTest.kt new file mode 100644 index 00000000..8945088b --- /dev/null +++ b/app/src/test/java/com/bitchat/android/protocol/BinaryProtocolTest.kt @@ -0,0 +1,1152 @@ +package com.bitchat.android.protocol + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import java.nio.ByteBuffer +import java.nio.ByteOrder +import java.util.Random + +class BinaryProtocolTest { + + private val senderHex = "1122334455667788" + private val recipientHex = "8877665544332211" + private val fixedTimestamp = 1709600000000uL + + /** + * Minimal v1 broadcast packet (no recipient, no signature, no route) + * + * Baseline correctness test for the simplest possible packet shape. + * Encodes a v1 packet with only the mandatory fields (version, type, TTL, + * timestamp, senderID, payload) and decodes it back, verifying every field + * survives the round-trip. + * + * Catches byte-order bugs, header size miscalculations, and off-by-one + * errors in the payload length field. The payload is deliberately kept + * below the 100-byte compression threshold to ensure compression is NOT + * triggered — isolating pure encode/decode logic from the compression path. + */ + @Test + fun `minimal v1 broadcast packet round-trips correctly`() { + val original = makePacket(payload = "Hello mesh".toByteArray()) + + val decoded = roundTrip(original) + + assertPacketEquals(original, decoded) + assertNull("recipientID should be null", decoded.recipientID) + assertNull("signature should be null", decoded.signature) + assertNull("route should be null", decoded.route) + } + + /** + * v1 packet with recipient + * + * Adds a recipientID to the packet, which sets the HAS_RECIPIENT flag + * (bit 0) in the flags' byte. Encodes and decodes, then verifies the + * recipientID bytes survive the round-trip intact. + * + * This validates the conditional recipient field and its flag. If the + * flag or offset calculation is wrong, every subsequent field in the + * packet (payload, signature) shifts by 8 bytes, corrupting decode. + */ + @Test + fun `v1 packet with recipient round-trips correctly`() { + val original = makePacket( + payload = "Private msg".toByteArray(), + recipientID = hexToBytes(recipientHex) + ) + + val decoded = roundTrip(original) + + assertPacketEquals(original, decoded) + assertNotNull("recipientID must not be null", decoded.recipientID) + assertTrue("recipientID bytes must match", + original.recipientID!!.contentEquals(decoded.recipientID!!)) + } + + /** + * v1 packet with signature + * + * Adds a 64-byte Ed25519 signature to the packet, which sets the + * HAS_SIGNATURE flag (bit 1). Encodes and decodes, then verifies + * the signature bytes survive the round-trip exactly. + * + * The signature is appended AFTER the payload in the wire format. + * If the payload length calculation is off by even one byte, the + * decoder will read wrong bytes for the signature, silently + * producing a corrupted value that fails verification downstream. + */ + @Test + fun `v1 packet with signature round-trips correctly`() { + val signature = ByteArray(64) { (it + 0xA0).toByte() } + val original = makePacket( + payload = "Signed msg".toByteArray(), + signature = signature + ) + + val decoded = roundTrip(original) + + assertPacketEquals(original, decoded) + assertNotNull("signature must not be null", decoded.signature) + assertTrue("signature bytes must match", + original.signature!!.contentEquals(decoded.signature!!)) + } + + /** + * v1 packet with recipient AND signature + * + * The most common real-world packet shape for private signed messages: + * both optional v1 fields (recipientID + signature) are present + * simultaneously. Encodes and decodes, verifying all fields. + * + * Tests that both conditional sections compose correctly. The wire + * layout is: header | senderID | recipientID | payload | signature. + * If either conditional section's size is miscalculated, it shifts + * everything after it, corrupting the remaining fields. + */ + @Test + fun `v1 packet with recipient and signature round-trips correctly`() { + val signature = ByteArray(64) { (it + 0xB0).toByte() } + val original = makePacket( + payload = "Private signed".toByteArray(), + recipientID = hexToBytes(recipientHex), + signature = signature + ) + + val decoded = roundTrip(original) + + assertPacketEquals(original, decoded) + assertNotNull("recipientID must not be null", decoded.recipientID) + assertNotNull("signature must not be null", decoded.signature) + assertTrue("recipientID bytes must match", + original.recipientID!!.contentEquals(decoded.recipientID!!)) + assertTrue("signature bytes must match", + original.signature!!.contentEquals(decoded.signature!!)) + } + + /** + * v2 packet with route + * + * Creates a v2 packet with a 3-hop source route, recipientID, and + * signature — the most complex packet layout the protocol supports. + * Verifies all route hops survive the round-trip, version stays 2, + * and the 4-byte payload length field is handled correctly. + * + * v2 introduces two key changes: 4-byte (instead of 2-byte) payload + * length, and a route section (1-byte hop count + N*8-byte peerIDs). + * Route encoding bugs break source-routed mesh delivery where packets + * must traverse specific intermediate nodes. + */ + @Test + fun `v2 packet with route round-trips correctly`() { + val route = listOf( + hexToBytes("AABBCCDDEEFF0011"), + hexToBytes("1100FFEEDDCCBBAA"), + hexToBytes("1234567890ABCDEF") + ) + val signature = ByteArray(64) { (it + 0xC0).toByte() } + val original = makePacket( + version = 2u, + payload = "Routed msg".toByteArray(), + recipientID = hexToBytes(recipientHex), + signature = signature, + route = route + ) + + val decoded = roundTrip(original) + + assertPacketEquals(original, decoded) + assertNotNull("route must not be null", decoded.route) + assertEquals("route hop count", 3, decoded.route!!.size) + for (i in route.indices) { + assertTrue("route hop $i must match", + route[i].contentEquals(decoded.route!![i])) + } + } + + /** + * v2 packet with empty/null route + * + * Creates v2 packets with route=emptyList() and route=null, then + * verifies the HAS_ROUTE flag is NOT set and both decode cleanly + * with route=null in the result. + * + * Edge case: an empty route list should behave identically to null — + * it should NOT encode a route-count byte of 0, which would waste a + * byte and could confuse decoders that treat count=0 differently. + */ + @Test + fun `v2 packet with empty route decodes as null route`() { + val emptyRoute = makePacket( + version = 2u, + payload = "No route".toByteArray(), + route = emptyList() + ) + val decodedEmpty = roundTrip(emptyRoute) + assertNull("empty route should decode as null", decodedEmpty.route) + + val nullRoute = makePacket( + version = 2u, + payload = "No route".toByteArray(), + route = null + ) + val decodedNull = roundTrip(nullRoute) + assertNull("null route should decode as null", decodedNull.route) + } + + /** + * All MessageType values round-trip + * + * Creates one packet per MessageType (ANNOUNCE, MESSAGE, LEAVE, + * NOISE_HANDSHAKE, NOISE_ENCRYPTED, FRAGMENT, REQUEST_SYNC, + * FILE_TRANSFER), encodes and decodes each, then verifies the type + * field is preserved exactly. + * + * MessageType is stored as a single UByte. If any type value gets + * mangled by signed/unsigned byte conversion (e.g., 0x80+ values + * treated as negative), that entire message class silently breaks. + * Also validates MessageType.fromValue() symmetry — every encoded + * type must map back to its enum constant. + */ + @Test + fun `all MessageType values round-trip correctly`() { + for (msgType in MessageType.entries) { + val original = makePacket( + payload = "type-test".toByteArray(), + type = msgType.value + ) + + val encoded = BinaryProtocol.encode(original) + assertNotNull("Encoding ${msgType.name} must not return null", encoded) + + val decoded = BinaryProtocol.decode(encoded!!) + assertNotNull("Decoding ${msgType.name} must not return null", decoded) + + assertEquals("type must match for ${msgType.name}", + msgType.value, decoded!!.type) + assertEquals("fromValue must resolve for ${msgType.name}", + msgType, MessageType.fromValue(decoded.type)) + } + } + + /** + * Large compressible payload triggers compression + * + * Creates a packet with a 500-byte repeating-text payload (well above + * the 100-byte compression threshold and with low entropy). Encodes it, + * verifies the encoded wire size is smaller than it would be without + * compression, then decodes and verifies the payload is restored exactly. + * + * Validates the full compression pipeline: shouldCompress() → compress() + * → IS_COMPRESSED flag set → original-size field prepended → decompress(). + * A mismatch in the original-size encoding (2 bytes for v1 vs 4 for v2) + * would cause decompression to fail or produce wrong-length output. + */ + @Test + fun `large compressible payload is compressed and decompressed correctly`() { + val repeating = "ABCDEFGH".repeat(63) // 504 bytes, highly compressible + val payload = repeating.toByteArray() + val original = makePacket(payload = payload) + + val encoded = BinaryProtocol.encode(original) + assertNotNull("Encoding must not return null", encoded) + + // Verify IS_COMPRESSED flag is set in the encoded output + val unpadded = MessagePadding.unpad(encoded!!) + val flags = unpadded[11].toUByte() + assertTrue("IS_COMPRESSED flag must be set", + (flags and BinaryProtocol.Flags.IS_COMPRESSED) != 0u.toUByte()) + + // Encoded should be smaller than payload + header overhead, proving compression fired + // Header(13) + sender(8) + payload(504) + padding = would be 525+ uncompressed + // With compression the raw data before padding should be much smaller + val decoded = roundTrip(original) + assertTrue("payload must survive compression round-trip", + original.payload.contentEquals(decoded.payload)) + assertEquals("payload length must match after decompression", + original.payload.size, decoded.payload.size) + } + + /** + * Small payload skips compression + * + * Creates a packet with a 50-byte payload, below the 100-byte + * compression threshold. Verifies the IS_COMPRESSED flag (bit 2) + * is NOT set in the encoded output. + * + * Compressing small payloads wastes bytes — the zlib header overhead + * can make the compressed output larger than the input. The protocol + * must skip compression for payloads under the threshold. + */ + @Test + fun `small payload skips compression`() { + val payload = ByteArray(50) { 0x41 } // 50 bytes of 'A', below threshold + val original = makePacket(payload = payload) + + val encoded = BinaryProtocol.encode(original) + assertNotNull("Encoding must not return null", encoded) + + // Unpad to inspect raw flags byte + val unpadded = MessagePadding.unpad(encoded!!) + // Flags byte is at offset 11 in v1 (version=1, type=1, ttl=1, timestamp=8) + val flags = unpadded[11].toUByte() + assertEquals("IS_COMPRESSED flag must not be set", 0u.toUByte(), + flags and BinaryProtocol.Flags.IS_COMPRESSED) + + // Verify payload still round-trips + val decoded = BinaryProtocol.decode(encoded) + assertNotNull(decoded) + assertTrue("payload must match", payload.contentEquals(decoded!!.payload)) + } + + /** + * High-entropy payload skips compression + * + * Creates a 200-byte payload of random bytes (high entropy, likely + * >90% unique byte values). Verifies the encoder either skips + * compression entirely or doesn't inflate the payload size. + * + * Tests the shouldCompress() entropy check. Compressing encrypted + * or random data typically inflates it — the protocol must detect + * high-entropy payloads and skip compression to avoid wasting bytes + * and breaking the payload-length budget. + */ + @Test + fun `high-entropy payload skips compression`() { + val random = Random(42) // fixed seed for reproducibility + val payload = ByteArray(200) + random.nextBytes(payload) + + val original = makePacket(payload = payload) + val decoded = roundTrip(original) + + assertTrue("high-entropy payload must survive round-trip", + original.payload.contentEquals(decoded.payload)) + } + + /** + * Padding round-trip at each block size + * + * Creates packets with payloads sized to land in each PKCS#7 padding + * bucket (256, 512, 1024, 2048 bytes). For each, verifies the encoded + * output size matches the expected block size, then decodes and confirms + * padding is stripped and the original packet is recovered. + * + * Padding is critical for traffic analysis resistance (all packets + * appear as standard sizes on the wire) and iOS interoperability. + * If the PKCS#7 pad-byte value or validation is wrong, unpadding + * either strips too many bytes (truncating the packet) or too few + * (leaving garbage appended to the signature). + */ + @Test + fun `padding produces correct block sizes and round-trips`() { + // Test that encoded output lands on one of the standard block sizes (256, 512, 1024, 2048) + // and that decode recovers the original payload. + val validBlockSizes = setOf(256, 512, 1024, 2048) + + // Small payload: header(13) + sender(8) + payload(10) = 31 raw → pads to 256 + val smallPayload = ByteArray(10) { (it + 1).toByte() } + val smallPacket = makePacket(payload = smallPayload) + val smallEncoded = BinaryProtocol.encode(smallPacket)!! + assertTrue("Small packet must pad to a standard block size (got ${smallEncoded.size})", + smallEncoded.size in validBlockSizes) + assertEquals("Small packet should pad to 256", 256, smallEncoded.size) + val smallDecoded = BinaryProtocol.decode(smallEncoded)!! + assertTrue("Small payload must survive padding round-trip", + smallPayload.contentEquals(smallDecoded.payload)) + + // Medium payload: header(13) + sender(8) + payload(80) = 101 raw → pads to 256 + val medPayload = ByteArray(80) { (it + 1).toByte() } + val medPacket = makePacket(payload = medPayload) + val medEncoded = BinaryProtocol.encode(medPacket)!! + assertTrue("Medium packet must pad to a standard block size (got ${medEncoded.size})", + medEncoded.size in validBlockSizes) + val medDecoded = BinaryProtocol.decode(medEncoded)!! + assertTrue("Medium payload must survive padding round-trip", + medPayload.contentEquals(medDecoded.payload)) + } + + /** + * Oversized packet bypasses padding + * + * Creates a packet whose encoded form exceeds 2048 bytes (the largest + * PKCS#7 block). Verifies it still encodes and decodes correctly even + * though padding is skipped. + * + * PKCS#7 uses a single byte for the padding length (max 255). When + * the gap between raw size and the next block exceeds 255 bytes, or + * the raw size already exceeds the largest block, padding cannot be + * applied. The protocol must still encode/decode these packets — they + * will typically be fragmented before transmission anyway. + */ + @Test + fun `oversized packet bypasses padding and still round-trips`() { + // 2100-byte payload + 21 header = 2121 raw, exceeds 2048 block + val payload = ByteArray(2100) { (it % 13).toByte() } + val original = makePacket(payload = payload) + + val encoded = BinaryProtocol.encode(original) + assertNotNull("Encoding oversized packet must not return null", encoded) + + val decoded = BinaryProtocol.decode(encoded!!) + assertNotNull("Decoding oversized packet must not return null", decoded) + assertTrue("Oversized payload must survive round-trip", + original.payload.contentEquals(decoded!!.payload)) + } + + /** + * v1 header uses 2-byte payload length, v2 uses 4-byte + * + * Encodes a v1 packet, unpads it, then reads bytes 11-12 as a + * big-endian unsigned short — verifies it matches the payload length. + * Does the same for v2, reading bytes 11-14 as a big-endian int. + * + * This is the core v1/v2 wire format distinction. v1 uses a 2-byte + * (UShort) payload length supporting up to 65535 bytes; v2 uses a + * 4-byte (UInt) length for larger payloads. If the encoder writes + * the wrong width, v1 clients can't read v2 packets and vice versa, + * silently corrupting all subsequent fields. + */ + @Test + fun `v1 uses 2-byte and v2 uses 4-byte payload length`() { + val payload = "length test".toByteArray() + + // v1 header: version(1) + type(1) + ttl(1) + timestamp(8) + flags(1) = 12, then payloadLen at 12-13 + val v1 = makePacket(payload = payload) + val v1Encoded = MessagePadding.unpad(BinaryProtocol.encode(v1)!!) + val v1Len = ByteBuffer.wrap(v1Encoded, 12, 2) + .order(ByteOrder.BIG_ENDIAN).short.toUShort().toInt() + assertEquals("v1 payload length field must match", payload.size, v1Len) + + // v2 header: same 12 bytes, then payloadLen at 12-15 (4 bytes) + val v2 = makePacket(version = 2u, payload = payload) + val v2Encoded = MessagePadding.unpad(BinaryProtocol.encode(v2)!!) + val v2Len = ByteBuffer.wrap(v2Encoded, 12, 4) + .order(ByteOrder.BIG_ENDIAN).int + assertEquals("v2 payload length field must match", payload.size, v2Len) + } + + /** + * v2 route flag is ignored when decoding v1 + * + * Constructs raw v1 bytes and manually sets the HAS_ROUTE flag (bit 3) + * in the flags' byte. Verifies the decoder ignores the route flag for + * v1 packets, per the spec: "HAS_ROUTE is only valid for v2+ packets." + * + * Ensures forward compatibility — if an old v1 packet somehow has + * unexpected flags set (e.g., from a future protocol extension or + * corruption), the decoder must not misinterpret trailing bytes as + * a route section, which would corrupt payload parsing. + */ + @Test + fun `v1 decoder ignores HAS_ROUTE flag`() { + val payload = "route-flag-test".toByteArray() + val original = makePacket(payload = payload) + + val encoded = BinaryProtocol.encode(original)!! + val unpadded = MessagePadding.unpad(encoded) + + // Manually set HAS_ROUTE flag (bit 3) on the v1 packet + val tampered = unpadded.copyOf() + tampered[11] = (tampered[11].toInt() or 0x08).toByte() + + // Re-pad so decode can handle it + val repadded = MessagePadding.pad(tampered, MessagePadding.optimalBlockSize(tampered.size)) + val decoded = BinaryProtocol.decode(repadded) + + assertNotNull("v1 with HAS_ROUTE flag must still decode", decoded) + assertNull("v1 packet must have null route regardless of flag", decoded!!.route) + assertTrue("payload must still be correct", + payload.contentEquals(decoded.payload)) + } + + /** + * Empty payload + * + * Encodes a packet with payload = ByteArray(0), then decodes it and + * verifies the empty payload survives the round-trip. + * + * ANNOUNCE and LEAVE packets can have minimal or empty payloads. + * A zero-length payload must not break the payload-length field + * calculation, cause an off-by-one in buffer positioning, or + * trigger compression (which would fail on empty input). + */ + @Test + fun `empty payload round-trips correctly`() { + val original = makePacket( + payload = ByteArray(0), + type = MessageType.LEAVE.value + ) + + val decoded = roundTrip(original) + + assertEquals("payload must be empty", 0, decoded.payload.size) + assertEquals("type must match", MessageType.LEAVE.value, decoded.type) + } + + /** + * Maximum v1 payload (65535 bytes) + * + * Encodes a v1 packet at the 2-byte payload length limit (65535 bytes, + * the maximum value of a UShort). Decodes and verifies the payload + * content and length survive. + * + * v1 uses an unsigned 16-bit integer for payload length. At the boundary + * of 65535, signed/unsigned conversion bugs surface — Java's short is + * signed (-32768 to 32767), so the encoder must handle the cast to + * UShort correctly. If it wraps to negative, the decoder reads a bogus + * payload length and fails. + */ + @Test + fun `maximum v1 payload length round-trips correctly`() { + val payload = ByteArray(65535) { (it % 251).toByte() } + val original = makePacket(payload = payload) + + val encoded = BinaryProtocol.encode(original) + assertNotNull("Encoding max-size payload must not return null", encoded) + + val decoded = BinaryProtocol.decode(encoded!!) + assertNotNull("Decoding max-size payload must not return null", decoded) + assertEquals("payload length must match", 65535, decoded!!.payload.size) + // Spot-check a few bytes rather than full compare (compression may or may not fire) + // The full content check is what matters + assertTrue("payload content must match", + original.payload.contentEquals(decoded.payload)) + } + + /** + * Truncated data returns null + * + * Passes progressively truncated byte arrays to decode(): first just + * 1 byte, then the minimum header size minus one, then a valid header + * with a truncated payload. Verifies decode returns null for each + * without throwing an exception. + * + * Malformed BLE packets are common — the radio can drop trailing bytes, + * fragment reassembly can fail, or a peer can send garbage. The decoder + * must fail gracefully with null, never throw BufferUnderflowException + * or ArrayIndexOutOfBoundsException, as uncaught exceptions crash the + * mesh service and disconnect all peers. + */ + @Test + fun `truncated data returns null without crashing`() { + // Work with unpadded bytes so truncation produces genuinely incomplete packets. + // Padded data can accidentally contain valid-looking sub-packets. + val valid = makePacket(payload = "truncation test".toByteArray()) + val encoded = BinaryProtocol.encode(valid)!! + val raw = MessagePadding.unpad(encoded) + // raw is the real packet without PKCS#7 padding + + // Truncation points that are definitely too short for the declared payload: + // v1 min = HEADER(13) + SENDER(8) = 21, payload declared as 15, so need 36 total + val truncationPoints = listOf(0, 1, 5, 12, 20, 25, 30) + for (len in truncationPoints) { + if (len > raw.size) continue + val truncated = raw.copyOfRange(0, len) + val result = BinaryProtocol.decode(truncated) + assertNull("Truncated to $len bytes must return null", result) + } + } + + /** + * Invalid version returns null + * + * Passes byte arrays with version=0, version=3, and version=255 to + * the decoder. Verifies each returns null. + * + * Only v1 and v2 are supported by the protocol. Unknown versions must + * be rejected immediately — attempting to parse them would use wrong + * header sizes, payload length widths, and feature flags, leading to + * garbage output or buffer overflows. + */ + @Test + fun `invalid version returns null`() { + val valid = makePacket(payload = "version test".toByteArray()) + val encoded = MessagePadding.unpad(BinaryProtocol.encode(valid)!!) + + for (badVersion in listOf(0, 3, 255)) { + val tampered = encoded.copyOf() + tampered[0] = badVersion.toByte() + val repadded = MessagePadding.pad(tampered, MessagePadding.optimalBlockSize(tampered.size)) + val result = BinaryProtocol.decode(repadded) + assertNull("Version $badVersion must return null", result) + } + } + + /** + * Garbage data returns null + * + * Passes random byte arrays of various sizes (10, 100, 512, 2048) to + * the decoder. Verifies it returns null (or at most a valid-looking + * packet if the random bytes happen to parse) without ever throwing + * an exception. + * + * A fuzz-like robustness test. In a mesh network, any device can send + * any bytes. The decoder must never crash, leak memory, or enter an + * infinite loop on arbitrary input — it should simply return null for + * anything it can't parse as a valid BitchatPacket. + */ + @Test + fun `garbage data returns null without crashing`() { + val random = Random(12345) + for (size in listOf(10, 100, 512, 2048)) { + val garbage = ByteArray(size) + random.nextBytes(garbage) + // Just verify no exception is thrown; null is the expected result + // but random data COULD accidentally form a valid packet structure + try { + BinaryProtocol.decode(garbage) + } catch (e: Exception) { + throw AssertionError("Garbage data of size $size must not throw, got: ${e.message}", e) + } + } + } + + /** + * Sender ID truncation and padding + * + * Tests senderID shorter than 8 bytes (should be zero-padded to 8) + * and longer than 8 bytes (should be truncated to 8). Verifies + * exactly 8 bytes appear in the decoded senderID. + * + * PeerIDs are always exactly 8 bytes in the wire format. If a short + * senderID isn't padded, the encoder writes fewer bytes, shifting all + * subsequent fields. If a long senderID isn't truncated, the encoder + * writes extra bytes into the recipientID or payload region, silently + * corrupting the packet. + */ + @Test + fun `sender ID is padded or truncated to exactly 8 bytes`() { + // Short sender (4 bytes) — should be zero-padded to 8 + val shortSender = byteArrayOf(0x11, 0x22, 0x33, 0x44) + val shortPacket = BitchatPacket( + version = 1u, + type = MessageType.ANNOUNCE.value, + senderID = shortSender, + timestamp = fixedTimestamp, + payload = "short-sender".toByteArray(), + ttl = 3u + ) + val decodedShort = roundTrip(shortPacket) + assertEquals("decoded senderID must be 8 bytes", 8, decodedShort.senderID.size) + // First 4 bytes must match, rest should be zeros + for (i in 0 until 4) { + assertEquals("senderID byte $i must match", shortSender[i], decodedShort.senderID[i]) + } + for (i in 4 until 8) { + assertEquals("senderID byte $i must be zero-padded", 0.toByte(), decodedShort.senderID[i]) + } + + // Long sender (12 bytes) — should be truncated to 8 + val longSender = ByteArray(12) { (it + 0x50).toByte() } + val longPacket = BitchatPacket( + version = 1u, + type = MessageType.ANNOUNCE.value, + senderID = longSender, + timestamp = fixedTimestamp, + payload = "long-sender".toByteArray(), + ttl = 3u + ) + val decodedLong = roundTrip(longPacket) + assertEquals("decoded senderID must be 8 bytes", 8, decodedLong.senderID.size) + for (i in 0 until 8) { + assertEquals("senderID byte $i must match first 8", longSender[i], decodedLong.senderID[i]) + } + } + + /** + * Signing data excludes signature and uses fixed TTL + * + * Creates a packet with a 64-byte signature and TTL=7, then calls + * toBinaryDataForSigning(). Decodes the result and verifies: + * (1) the signature is null (stripped for signing), and (2) the TTL + * is 0 (SYNC_TTL_HOPS), not the original 7. + * + * Packet signatures must be deterministic regardless of how many + * hops the packet has traversed (TTL decrements at each relay). + * If TTL leaks into the signed data, a packet relayed even once + * would fail signature verification at the recipient because the + * TTL it was signed with differs from the TTL it arrives with. + * The signature itself must also be excluded from the data being + * signed (you can't sign data that includes its own signature). + */ + @Test + fun `toBinaryDataForSigning excludes signature and fixes TTL`() { + val signature = ByteArray(64) { (it + 0xD0).toByte() } + val original = makePacket( + payload = "sign-me".toByteArray(), + recipientID = hexToBytes(recipientHex), + signature = signature, + ttl = 7u + ) + + val signingData = original.toBinaryDataForSigning() + assertNotNull("Signing data must not be null", signingData) + + val decoded = BinaryProtocol.decode(signingData!!) + assertNotNull("Signing data must decode successfully", decoded) + + assertNull("Signature must be stripped for signing", decoded!!.signature) + assertEquals("TTL must be fixed to SYNC_TTL_HOPS (0) for signing", + 0u.toUByte(), decoded.ttl) + // Other fields must be preserved + assertEquals("type must match", original.type, decoded.type) + assertEquals("timestamp must match", original.timestamp, decoded.timestamp) + assertTrue("senderID must match", + original.senderID.contentEquals(decoded.senderID)) + assertTrue("recipientID must match", + original.recipientID!!.contentEquals(decoded.recipientID!!)) + assertTrue("payload must match", + original.payload.contentEquals(decoded.payload)) + } + + /** + * v2 packet without route round-trips correctly + * + * Creates a v2 packet with recipientID and signature but NO route — + * the most common real-world v2 shape for private signed messages. + * Verifies all fields survive the round-trip and route is null. + * + * This exercises the 4-byte payload length field without the route + * section. If the v2 header size (15 bytes) or payload length width + * is wrong, all subsequent fields shift, corrupting decode. + * The v2-with-route tests could pass by accident if route parsing + * compensates for a header bug — this test isolates v2 header logic. + */ + @Test + fun `v2 packet without route round-trips correctly`() { + val signature = ByteArray(64) { (it + 0xE0).toByte() } + val original = makePacket( + version = 2u, + payload = "v2 no route".toByteArray(), + recipientID = hexToBytes(recipientHex), + signature = signature, + route = null + ) + + val decoded = roundTrip(original) + + assertPacketEquals(original, decoded) + assertEquals("version must be 2", 2u.toUByte(), decoded.version) + assertNull("route must be null", decoded.route) + assertNotNull("recipientID must not be null", decoded.recipientID) + assertNotNull("signature must not be null", decoded.signature) + assertTrue("recipientID bytes must match", + original.recipientID!!.contentEquals(decoded.recipientID!!)) + assertTrue("signature bytes must match", + original.signature!!.contentEquals(decoded.signature!!)) + } + + /** + * v2 compressed payload round-trips correctly + * + * Creates a v2 packet with a large, compressible payload that triggers + * compression. Verifies the IS_COMPRESSED flag is set, the v2 4-byte + * original-size field is written/read correctly, and the payload + * survives the round-trip intact. + * + * Covers encode lines 220, 290-292 (v2 compressed size field) and + * decode lines 420-424 (v2 compressed size read). + */ + @Test + fun `v2 compressed payload round-trips correctly`() { + val repeating = "ABCDEFGH".repeat(63) // 504 bytes, highly compressible + val payload = repeating.toByteArray() + val original = makePacket(version = 2u, payload = payload) + + val encoded = BinaryProtocol.encode(original) + assertNotNull("Encoding must not return null", encoded) + + // Verify IS_COMPRESSED flag is set + val unpadded = MessagePadding.unpad(encoded!!) + val flags = unpadded[11].toUByte() + assertTrue("IS_COMPRESSED flag must be set", + (flags and BinaryProtocol.Flags.IS_COMPRESSED) != 0u.toUByte()) + + // Verify version is 2 + assertEquals("version byte must be 2", 2.toByte(), unpadded[0]) + + val decoded = roundTrip(original) + assertEquals("version must be 2", 2u.toUByte(), decoded.version) + assertTrue("payload must survive v2 compression round-trip", + original.payload.contentEquals(decoded.payload)) + assertEquals("payload length must match after decompression", + original.payload.size, decoded.payload.size) + } + + /** + * Short recipientID is zero-padded to 8 bytes + * + * Creates a packet with a 4-byte recipientID and verifies that after + * encoding and decoding, the recipientID is exactly 8 bytes with + * trailing zeros. + * + * Covers encode lines 272-273 (recipientID padding). + */ + @Test + fun `short recipientID is zero-padded to 8 bytes`() { + val shortRecipient = byteArrayOf(0x11, 0x22, 0x33, 0x44) + val original = makePacket( + payload = "short-recipient".toByteArray(), + recipientID = shortRecipient + ) + + val decoded = roundTrip(original) + + assertNotNull("recipientID must not be null", decoded.recipientID) + assertEquals("recipientID must be 8 bytes", 8, decoded.recipientID!!.size) + for (i in 0 until 4) { + assertEquals("recipientID byte $i must match", + shortRecipient[i], decoded.recipientID[i]) + } + for (i in 4 until 8) { + assertEquals("recipientID byte $i must be zero-padded", + 0.toByte(), decoded.recipientID[i]) + } + } + + /** + * v2 packet with route but no recipient round-trips correctly + * + * Creates a v2 packet with route hops but recipientID=null. Verifies + * the route survives the round-trip and recipientID remains null. + * + * Covers encode lines 278-280, 248, 222 (route encode without recipient) + * and decode line 378 (hasRecipient=false branch in route offset calc). + */ + @Test + fun `v2 packet with route but no recipient round-trips correctly`() { + val route = listOf( + hexToBytes("AABBCCDDEEFF0011"), + hexToBytes("1100FFEEDDCCBBAA") + ) + val original = makePacket( + version = 2u, + payload = "routed broadcast".toByteArray(), + recipientID = null, + route = route + ) + + val decoded = roundTrip(original) + + assertPacketEquals(original, decoded) + assertNull("recipientID must be null", decoded.recipientID) + assertNotNull("route must not be null", decoded.route) + assertEquals("route hop count", 2, decoded.route!!.size) + for (i in route.indices) { + assertTrue("route hop $i must match", + route[i].contentEquals(decoded.route!![i])) + } + } + + /** + * v2 packet with HAS_ROUTE flag and count zero decodes route as null + * + * Manually crafts raw v2 bytes with the HAS_ROUTE flag set but the + * route count byte = 0. Verifies the decoder treats this as null + * (canonical representation). + * + * Covers decode lines 405-406 (count==0 → null). + */ + @Test + fun `v2 packet with HAS_ROUTE flag and count zero decodes route as null`() { + val payload = "route-zero".toByteArray() + + val buffer = ByteBuffer.allocate(256).apply { order(ByteOrder.BIG_ENDIAN) } + + // v2 header + buffer.put(2.toByte()) // version = 2 + buffer.put(MessageType.MESSAGE.value.toByte()) // type + buffer.put(5.toByte()) // ttl + buffer.putLong(fixedTimestamp.toLong()) // timestamp (8 bytes) + + // Flags: HAS_ROUTE set + buffer.put(BinaryProtocol.Flags.HAS_ROUTE.toByte()) + + // Payload length (4 bytes for v2) + buffer.putInt(payload.size) + + // SenderID (8 bytes) + buffer.put(hexToBytes(senderHex)) + + // Route: count = 0 (1 byte) + buffer.put(0.toByte()) + + // Payload + buffer.put(payload) + + val raw = ByteArray(buffer.position()) + buffer.rewind() + buffer.get(raw) + + val padded = MessagePadding.pad(raw, MessagePadding.optimalBlockSize(raw.size)) + val decoded = BinaryProtocol.decode(padded) + + assertNotNull("Packet with route count=0 must decode", decoded) + assertNull("Route with count=0 must decode as null", decoded!!.route) + assertTrue("Payload must match", payload.contentEquals(decoded.payload)) + } + + /** + * v2 compression bomb is rejected + * + * Same concept as the v1 compression bomb test but with version=2, + * which uses a 4-byte original-size field instead of 2-byte. + * + * Covers decode lines 435-439 via the v2 path (4-byte size field). + */ + @Test + fun `v2 compression bomb is rejected`() { + // Valid raw deflate final empty stored block (1 byte). + // Claim a huge original size to exceed the 50,000:1 ratio guard. + // ratio = 10,000,000 / 1 = 10,000,000:1 + val compressedData = byteArrayOf(0x03) + val declaredOriginalSize = 10_000_000 + + val buffer = ByteBuffer.allocate(256).apply { order(ByteOrder.BIG_ENDIAN) } + + // v2 header + buffer.put(2.toByte()) // version = 2 + buffer.put(MessageType.MESSAGE.value.toByte()) // type + buffer.put(5.toByte()) // ttl + buffer.putLong(fixedTimestamp.toLong()) // timestamp (8 bytes) + + // Flags: IS_COMPRESSED set + buffer.put(BinaryProtocol.Flags.IS_COMPRESSED.toByte()) + + // Payload length (4 bytes for v2): original-size field (4 bytes) + compressed data + val payloadFieldSize = 4 + compressedData.size + buffer.putInt(payloadFieldSize) + + // SenderID (8 bytes) + buffer.put(hexToBytes(senderHex)) + + // Compressed payload section: original size (4 bytes for v2) + compressed data + buffer.putInt(declaredOriginalSize) + buffer.put(compressedData) + + val raw = ByteArray(buffer.position()) + buffer.rewind() + buffer.get(raw) + + val padded = MessagePadding.pad(raw, MessagePadding.optimalBlockSize(raw.size)) + val result = BinaryProtocol.decode(padded) + + assertNull("v2 compression bomb (ratio > 50,000:1) must be rejected", result) + } + + /** + * Compression bomb is rejected + * + * Crafts a packet where the declared original size in the compressed + * payload section is absurdly large relative to the compressed data, + * exceeding the 50,000:1 ratio guard in decodeCore(). Verifies the + * decoder returns null instead of attempting decompression. + * + * Without this check, an attacker could send a tiny packet claiming + * to decompress into gigabytes, causing an OOM crash that kills the + * mesh service and disconnects all peers. The 50,000:1 threshold + * blocks this while still allowing legitimate compression ratios + * (typical text compresses ~3:1 to ~10:1). + */ + @Test + fun `compression bomb is rejected`() { + // Valid raw deflate final empty stored block (1 byte). + // v1 uses a 2-byte (UShort) original-size field, so declared size + // must fit in 0..65535. ratio = 60,000 / 1 = 60,000:1 > 50,000:1 + val tinyCompressed = byteArrayOf(0x03) + val declaredOriginalSize = 60_000 + + val buffer = ByteBuffer.allocate(256).apply { order(ByteOrder.BIG_ENDIAN) } + + // Header (13 bytes for v1) + buffer.put(1.toByte()) // version = 1 + buffer.put(MessageType.MESSAGE.value.toByte()) // type + buffer.put(5.toByte()) // ttl + buffer.putLong(fixedTimestamp.toLong()) // timestamp (8 bytes) + + // Flags: IS_COMPRESSED set + buffer.put(BinaryProtocol.Flags.IS_COMPRESSED.toByte()) + + // Payload length: original-size field (2 bytes) + compressed data (1 byte) = 3 + val payloadFieldSize = 2 + tinyCompressed.size + buffer.putShort(payloadFieldSize.toShort()) + + // SenderID (8 bytes) + buffer.put(hexToBytes(senderHex)) + + // Compressed payload section: original size (2 bytes) + compressed data + buffer.putShort(declaredOriginalSize.toShort()) + buffer.put(tinyCompressed) + + val raw = ByteArray(buffer.position()) + buffer.rewind() + buffer.get(raw) + + // Pad to standard block size so decode() can process it + val padded = MessagePadding.pad(raw, MessagePadding.optimalBlockSize(raw.size)) + val result = BinaryProtocol.decode(padded) + + assertNull("Compression bomb (ratio > 50,000:1) must be rejected", result) + } + + /** + * v1 packet with route silently drops route + * + * Creates a v1 packet with route hops set, encodes and decodes it. + * Since routes are only supported in v2+, the encoder must silently + * drop the route for v1, producing a packet with route=null. + * + * Covers encode lines 222, 248 (false branch: route non-empty but + * version < 2) and decode line 279-280 (route not written for v1). + */ + @Test + fun `v1 packet with route silently drops route`() { + val route = listOf( + hexToBytes("AABBCCDDEEFF0011"), + hexToBytes("1100FFEEDDCCBBAA") + ) + val original = makePacket( + version = 1u, + payload = "v1 route drop".toByteArray(), + route = route + ) + + val decoded = roundTrip(original) + + assertEquals("version must be 1", 1u.toUByte(), decoded.version) + assertNull("route must be null for v1 packet", decoded.route) + assertTrue("payload must survive round-trip", + original.payload.contentEquals(decoded.payload)) + } + + /** + * v2 truncated packet with HAS_ROUTE flag returns null + * + * Manually crafts a v2 packet with the HAS_ROUTE flag set but + * truncates the data before the route count byte. The decoder must + * return null because raw.size < routeOffset + 1. + * + * Covers decode line 382 false branch (raw too short for route count). + */ + @Test + fun `v2 truncated packet with HAS_ROUTE flag returns null`() { + val payload = "truncated-route".toByteArray() + + val buffer = ByteBuffer.allocate(256).apply { order(ByteOrder.BIG_ENDIAN) } + + // v2 header + buffer.put(2.toByte()) // version = 2 + buffer.put(MessageType.MESSAGE.value.toByte()) // type + buffer.put(5.toByte()) // ttl + buffer.putLong(fixedTimestamp.toLong()) // timestamp (8 bytes) + + // Flags: HAS_ROUTE set + buffer.put(BinaryProtocol.Flags.HAS_ROUTE.toByte()) + + // Payload length (4 bytes for v2) + buffer.putInt(payload.size) + + // SenderID (8 bytes) — after this, the route count byte should follow + // but we truncate here, so raw.size < routeOffset + 1 + buffer.put(hexToBytes(senderHex)) + + // Do NOT write route count or payload — truncate right after senderID + val raw = ByteArray(buffer.position()) + buffer.rewind() + buffer.get(raw) + + // Don't pad — pass raw bytes directly so truncation is effective + val result = BinaryProtocol.decode(raw) + + assertNull("Truncated v2 packet with HAS_ROUTE must return null", result) + } + + /** + * v2 compressed packet with payloadLength less than size field returns null + * + * Manually crafts v2 bytes with IS_COMPRESSED flag and a payloadLength + * of 2, which is less than the 4-byte original-size field required for + * v2 compressed payloads. The decoder must return null. + * + * Covers decode line 421 true branch (payloadLength < lengthFieldBytes). + */ + @Test + fun `v2 compressed packet with payloadLength less than size field returns null`() { + val buffer = ByteBuffer.allocate(256).apply { order(ByteOrder.BIG_ENDIAN) } + + // v2 header + buffer.put(2.toByte()) // version = 2 + buffer.put(MessageType.MESSAGE.value.toByte()) // type + buffer.put(5.toByte()) // ttl + buffer.putLong(fixedTimestamp.toLong()) // timestamp (8 bytes) + + // Flags: IS_COMPRESSED set + buffer.put(BinaryProtocol.Flags.IS_COMPRESSED.toByte()) + + // Payload length = 2 (less than 4-byte size field for v2) + buffer.putInt(2) + + // SenderID (8 bytes) + buffer.put(hexToBytes(senderHex)) + + // Write 2 bytes of fake payload data (matching declared payloadLength) + buffer.put(byteArrayOf(0x01, 0x02)) + + val raw = ByteArray(buffer.position()) + buffer.rewind() + buffer.get(raw) + + val padded = MessagePadding.pad(raw, MessagePadding.optimalBlockSize(raw.size)) + val result = BinaryProtocol.decode(padded) + + assertNull("v2 compressed with payloadLength < 4 must return null", result) + } + + private fun hexToBytes(hex: String): ByteArray { + val result = ByteArray(hex.length / 2) + for (i in result.indices) { + result[i] = hex.substring(i * 2, i * 2 + 2).toInt(16).toByte() + } + return result + } + + private fun makePacket( + version: UByte = 1u, + type: UByte = MessageType.MESSAGE.value, + payload: ByteArray, + recipientID: ByteArray? = null, + signature: ByteArray? = null, + ttl: UByte = 5u, + route: List? = null + ) = BitchatPacket( + version = version, + type = type, + senderID = hexToBytes(senderHex), + recipientID = recipientID, + timestamp = fixedTimestamp, + payload = payload, + signature = signature, + ttl = ttl, + route = route + ) + + private fun roundTrip(packet: BitchatPacket): BitchatPacket { + val encoded = BinaryProtocol.encode(packet) + assertNotNull("Encoding must not return null", encoded) + val decoded = BinaryProtocol.decode(encoded!!) + assertNotNull("Decoding must not return null", decoded) + return decoded!! + } + + private fun assertPacketEquals(expected: BitchatPacket, actual: BitchatPacket) { + assertEquals("version", expected.version, actual.version) + assertEquals("type", expected.type, actual.type) + assertEquals("ttl", expected.ttl, actual.ttl) + assertEquals("timestamp", expected.timestamp, actual.timestamp) + assertTrue("senderID", expected.senderID.contentEquals(actual.senderID)) + assertTrue("payload", expected.payload.contentEquals(actual.payload)) + } +} diff --git a/app/src/test/java/com/bitchat/android/ui/CommandProcessorTest.kt b/app/src/test/java/com/bitchat/android/ui/CommandProcessorTest.kt index f1b33d32..431d8500 100644 --- a/app/src/test/java/com/bitchat/android/ui/CommandProcessorTest.kt +++ b/app/src/test/java/com/bitchat/android/ui/CommandProcessorTest.kt @@ -2,7 +2,7 @@ package com.bitchat.android.ui import android.content.Context import androidx.test.core.app.ApplicationProvider -import com.bitchat.android.mesh.BluetoothMeshService +import com.bitchat.android.mesh.MeshService import com.bitchat.android.model.BitchatMessage import junit.framework.TestCase.assertEquals import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -34,7 +34,7 @@ class CommandProcessorTest() { coroutineScope = testScope ) - private val meshService: BluetoothMeshService = mock() + private val meshService: MeshService = mock() @Before fun setup() { diff --git a/app/src/test/kotlin/com/bitchat/FileTransferTest.kt b/app/src/test/kotlin/com/bitchat/FileTransferTest.kt index 1798f5fe..31131540 100644 --- a/app/src/test/kotlin/com/bitchat/FileTransferTest.kt +++ b/app/src/test/kotlin/com/bitchat/FileTransferTest.kt @@ -8,12 +8,14 @@ import org.junit.Assert.assertNotNull import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.ConscryptMode import java.io.File import java.nio.ByteBuffer import java.nio.ByteOrder import java.util.Date @RunWith(RobolectricTestRunner::class) +@ConscryptMode(ConscryptMode.Mode.OFF) // Disable Conscrypt to avoid native library loading issues class FileTransferTest { @Test diff --git a/app/src/test/kotlin/com/bitchat/MeshPacketUtilsTest.kt b/app/src/test/kotlin/com/bitchat/MeshPacketUtilsTest.kt new file mode 100644 index 00000000..83802149 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/MeshPacketUtilsTest.kt @@ -0,0 +1,45 @@ +package com.bitchat + +import com.bitchat.android.mesh.MeshPacketUtils +import junit.framework.TestCase.assertEquals +import org.junit.Test + +class MeshPacketUtilsTest { + @Test + fun hexStringToByteArray_parsesFullId() { + val bytes = MeshPacketUtils.hexStringToByteArray("0011223344556677") + assertEquals(8, bytes.size) + assertEquals(0x00.toByte(), bytes[0]) + assertEquals(0x11.toByte(), bytes[1]) + assertEquals(0x22.toByte(), bytes[2]) + assertEquals(0x33.toByte(), bytes[3]) + assertEquals(0x44.toByte(), bytes[4]) + assertEquals(0x55.toByte(), bytes[5]) + assertEquals(0x66.toByte(), bytes[6]) + assertEquals(0x77.toByte(), bytes[7]) + } + + @Test + fun hexStringToByteArray_parsesShortId() { + val bytes = MeshPacketUtils.hexStringToByteArray("ab") + assertEquals(8, bytes.size) + assertEquals(0xab.toByte(), bytes[0]) + assertEquals(0x00.toByte(), bytes[1]) + } + + @Test + fun hexStringToByteArray_handlesInvalidHex() { + val bytes = MeshPacketUtils.hexStringToByteArray("zz") + assertEquals(8, bytes.size) + assertEquals(0x00.toByte(), bytes[0]) + } + + @Test + fun sha256Hex_matchesKnownValue() { + val hash = MeshPacketUtils.sha256Hex("hello".toByteArray()) + assertEquals( + "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824", + hash + ) + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/mesh/MessageHandlerTest.kt b/app/src/test/kotlin/com/bitchat/android/mesh/MessageHandlerTest.kt new file mode 100644 index 00000000..2c093353 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/mesh/MessageHandlerTest.kt @@ -0,0 +1,117 @@ +package com.bitchat.android.mesh + +import android.os.Build +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.protocol.SpecialRecipients +import com.bitchat.android.services.meshgraph.MeshGraphService +import com.bitchat.android.util.AppConstants +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.any +import org.mockito.kotlin.eq +import org.mockito.kotlin.isNull +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [Build.VERSION_CODES.P], manifest = Config.NONE) +class MessageHandlerTest { + private lateinit var handler: MessageHandler + private lateinit var delegate: MessageHandlerDelegate + + private val myPeerID = "1111222233334444" + private val peerID = "aaaabbbbccccdddd" + private val nickname = "peer" + private val noiseKey = ByteArray(32) { 0x0B } + private val signingKey = ByteArray(32) { 0x0A } + private val signature = ByteArray(64) { 1 } + private val announceClockSkewToleranceMs = 10 * 60 * 1000L + + @Before + fun setup() { + MeshGraphService.resetForTesting() + handler = MessageHandler(myPeerID, RuntimeEnvironment.getApplication()) + delegate = mock() + handler.delegate = delegate + + whenever(delegate.getPeerInfo(peerID)).thenReturn(null) + whenever(delegate.verifyEd25519Signature(any(), any(), any())).thenReturn(true) + whenever(delegate.updatePeerInfo(any(), any(), any(), any(), any())).thenReturn(true) + } + + @After + fun tearDown() { + MeshGraphService.resetForTesting() + } + + @Test + fun `handleAnnounce accepts announce within clock skew tolerance for identity binding`() = runBlocking { + val packet = announcePacket(ageMs = AppConstants.Mesh.STALE_PEER_TIMEOUT_MS + 1_000) + + val result = handler.handleAnnounce(RoutedPacket(packet, peerID, "direct-link")) + + assertTrue("Announce within clock skew tolerance should still store peer identity", result) + verify(delegate).updatePeerInfo(eq(peerID), eq(nickname), any(), any(), eq(true)) + verify(delegate).updatePeerIDBinding(eq(peerID), eq(nickname), any(), isNull()) + } + + @Test + fun `handleAnnounce accepts future announce within clock skew tolerance`() = runBlocking { + val packet = announcePacket(ageMs = -(AppConstants.Mesh.STALE_PEER_TIMEOUT_MS + 1_000)) + + val result = handler.handleAnnounce(RoutedPacket(packet, peerID, "direct-link")) + + assertTrue("Future announce within clock skew tolerance should still store peer identity", result) + verify(delegate).updatePeerInfo(eq(peerID), eq(nickname), any(), any(), eq(true)) + Unit + } + + @Test + fun `handleAnnounce rejects announce older than clock skew tolerance`() = runBlocking { + val packet = announcePacket(ageMs = announceClockSkewToleranceMs + 1_000) + + val result = handler.handleAnnounce(RoutedPacket(packet, peerID, "relay-link")) + + assertFalse("Announce older than clock skew tolerance should not store peer identity", result) + verify(delegate, never()).updatePeerInfo(any(), any(), any(), any(), any()) + verify(delegate, never()).updatePeerIDBinding(any(), any(), any(), any()) + } + + private fun announcePacket( + ageMs: Long, + ttl: UByte = (AppConstants.MESSAGE_TTL_HOPS.toInt() - 1).toUByte() + ): BitchatPacket { + val announcement = IdentityAnnouncement( + nickname = nickname, + noisePublicKey = noiseKey, + signingPublicKey = signingKey + ) + return BitchatPacket( + version = 1u, + type = MessageType.ANNOUNCE.value, + senderID = peerID.hexToBytes(), + recipientID = SpecialRecipients.BROADCAST, + timestamp = (System.currentTimeMillis() - ageMs).toULong(), + payload = announcement.encode()!!, + signature = signature, + ttl = ttl + ) + } + + private fun String.hexToBytes(): ByteArray { + return chunked(2).map { it.toInt(16).toByte() }.toByteArray() + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/mesh/PacketRelayManagerTest.kt b/app/src/test/kotlin/com/bitchat/android/mesh/PacketRelayManagerTest.kt new file mode 100644 index 00000000..ae896e53 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/mesh/PacketRelayManagerTest.kt @@ -0,0 +1,117 @@ + +package com.bitchat.android.mesh + +import com.bitchat.android.model.RoutedPacket +import com.bitchat.android.protocol.BitchatPacket +import com.bitchat.android.protocol.MessageType +import com.bitchat.android.util.toHexString +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever + +@ExperimentalCoroutinesApi +class PacketRelayManagerTest { + + private lateinit var packetRelayManager: PacketRelayManager + private val delegate: PacketRelayManagerDelegate = mock() + + private val myPeerID = "1111111111111111" + private val otherPeerID = "2222222222222222" + private val nextHopPeerID = "3333333333333333" + private val finalRecipientID = "4444444444444444" + + @Before + fun setUp() { + packetRelayManager = PacketRelayManager(myPeerID) + packetRelayManager.delegate = delegate + whenever(delegate.getNetworkSize()).thenReturn(10) + whenever(delegate.getBroadcastRecipient()).thenReturn(byteArrayOf(0,0,0,0,0,0,0,0)) + } + + private fun createPacket(route: List?, recipient: String? = null): BitchatPacket { + return BitchatPacket( + type = MessageType.MESSAGE.value, + senderID = hexStringToPeerBytes(otherPeerID), + recipientID = recipient?.let { hexStringToPeerBytes(it) }, + timestamp = System.currentTimeMillis().toULong(), + payload = "hello".toByteArray(), + ttl = 5u, + route = route + ) + } + + @Test + fun `packet with duplicate hops is dropped`() = runTest { + val route = listOf( + hexStringToPeerBytes(nextHopPeerID), + hexStringToPeerBytes(nextHopPeerID) + ) + val packet = createPacket(route) + val routedPacket = RoutedPacket(packet, otherPeerID) + + packetRelayManager.handlePacketRelay(routedPacket) + + verify(delegate, never()).sendToPeer(any(), any()) + verify(delegate, never()).broadcastPacket(any()) + } + + @Test + fun `valid source-routed packet is relayed to next hop`() = runTest { + val route = listOf( + hexStringToPeerBytes(myPeerID), + hexStringToPeerBytes(nextHopPeerID) + ) + val packet = createPacket(route, finalRecipientID) + val routedPacket = RoutedPacket(packet, otherPeerID) + whenever(delegate.sendToPeer(any(), any())).thenReturn(true) + + packetRelayManager.handlePacketRelay(routedPacket) + + verify(delegate).sendToPeer(org.mockito.kotlin.eq(nextHopPeerID), any()) + verify(delegate, never()).broadcastPacket(any()) + } + + @Test + fun `last hop does not relay further`() = runTest { + val route = listOf( + hexStringToPeerBytes(myPeerID) + ) + val packet = createPacket(route, finalRecipientID) + val routedPacket = RoutedPacket(packet, otherPeerID) + whenever(delegate.sendToPeer(any(), any())).thenReturn(true) + + packetRelayManager.handlePacketRelay(routedPacket) + + verify(delegate).sendToPeer(org.mockito.kotlin.eq(finalRecipientID), any()) + verify(delegate, never()).broadcastPacket(any()) + } + + @Test + fun `packet with empty route is broadcast`() = runTest { + val packet = createPacket(null) + val routedPacket = RoutedPacket(packet, otherPeerID) + + packetRelayManager.handlePacketRelay(routedPacket) + + verify(delegate, never()).sendToPeer(any(), any()) + verify(delegate).broadcastPacket(any()) + } + + 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/test/kotlin/com/bitchat/android/mesh/SecurityManagerTest.kt b/app/src/test/kotlin/com/bitchat/android/mesh/SecurityManagerTest.kt new file mode 100644 index 00000000..aa629ca7 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/mesh/SecurityManagerTest.kt @@ -0,0 +1,286 @@ +package com.bitchat.android.mesh + +import android.os.Build +import com.bitchat.android.crypto.EncryptionService +import com.bitchat.android.model.IdentityAnnouncement +import com.bitchat.android.protocol.BitchatPacket +import com.bitchat.android.protocol.MessageType +import org.junit.After +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.* +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [Build.VERSION_CODES.P], manifest = Config.NONE) +class SecurityManagerTest { + + private lateinit var securityManager: SecurityManager + private lateinit var fakeEncryptionService: FakeEncryptionService + private lateinit var mockDelegate: SecurityManagerDelegate + + private val myPeerID = "1111222233334444" + private val otherPeerID = "aaaabbbbccccdddd" + private val unknownPeerID = "9999888877776666" + + private val dummyPayload = "Hello World".toByteArray() + private val validSignature = ByteArray(64) { 1 } + private val invalidSignature = ByteArray(64) { 0 } + + // Key pairs (using dummy bytes for mock verification) + private val otherSigningKey = ByteArray(32) { 0xA } + private val otherNoiseKey = ByteArray(32) { 0xB } + + // Fake implementation to bypass initialization issues in tests + open class FakeEncryptionService : EncryptionService(RuntimeEnvironment.getApplication()) { + var shouldVerify: Boolean = true + var lastVerifySignature: ByteArray? = null + var lastVerifyKey: ByteArray? = null + + override fun initialize() { + // Do nothing to avoid KeyStore access in tests + } + + override fun verifyEd25519Signature(signature: ByteArray, data: ByteArray, publicKeyBytes: ByteArray): Boolean { + lastVerifySignature = signature + lastVerifyKey = publicKeyBytes + + // Simple logic: if configured to verify, check if signature matches validSignature + // We use the signature bytes passed in setup() + if (shouldVerify) { + return signature.contentEquals(byteArrayOf(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1)) + } + return false + } + } + + @Before + fun setup() { + fakeEncryptionService = FakeEncryptionService() + mockDelegate = mock() + + securityManager = SecurityManager(fakeEncryptionService, myPeerID) + securityManager.delegate = mockDelegate + } + + @After + fun tearDown() { + if (::securityManager.isInitialized) { + securityManager.shutdown() + } + } + + @Test + fun `validatePacket - rejects packet with missing signature`() { + val packet = BitchatPacket( + type = MessageType.MESSAGE.value, + ttl = 10u, + senderID = otherPeerID, + payload = dummyPayload + ) + packet.signature = null + + val result = securityManager.validatePacket(packet, otherPeerID) + + assertFalse("Packet without signature should be rejected", result) + } + + @Test + fun `validatePacket - rejects packet with invalid signature`() { + setupKnownPeer(otherPeerID, otherSigningKey) + + val packet = BitchatPacket( + type = MessageType.MESSAGE.value, + ttl = 10u, + senderID = otherPeerID, + payload = dummyPayload + ) + packet.signature = invalidSignature + + val result = securityManager.validatePacket(packet, otherPeerID) + + assertFalse("Packet with invalid signature should be rejected", result) + } + + @Test + fun `validatePacket - rejects packet from unknown peer (no key)`() { + whenever(mockDelegate.getPeerInfo(unknownPeerID)).thenReturn(null) + + val packet = BitchatPacket( + type = MessageType.MESSAGE.value, + ttl = 10u, + senderID = unknownPeerID, + payload = dummyPayload + ) + packet.signature = validSignature + + val result = securityManager.validatePacket(packet, unknownPeerID) + + assertFalse("Packet from unknown peer should be rejected (cannot verify signature)", result) + } + + @Test + fun `validatePacket - accepts packet with valid signature from known peer`() { + setupKnownPeer(otherPeerID, otherSigningKey) + + val packet = BitchatPacket( + type = MessageType.MESSAGE.value, + ttl = 10u, + senderID = otherPeerID, + payload = dummyPayload + ) + packet.signature = validSignature + + val result = securityManager.validatePacket(packet, otherPeerID) + + assertTrue("Valid signed packet from known peer should be accepted", result) + } + + @Test + fun `validatePacket - accepts ANNOUNCE packet from unknown peer (extracts key)`() { + val announcement = IdentityAnnouncement( + nickname = "New User", + noisePublicKey = otherNoiseKey, + signingPublicKey = otherSigningKey + ) + val payload = announcement.encode()!! + + val packet = BitchatPacket( + type = MessageType.ANNOUNCE.value, + ttl = 10u, + senderID = unknownPeerID, + payload = payload + ) + packet.signature = validSignature + + whenever(mockDelegate.getPeerInfo(unknownPeerID)).thenReturn(null) + + val result = securityManager.validatePacket(packet, unknownPeerID) + + assertTrue("ANNOUNCE from unknown peer should be accepted (key extracted from payload)", result) + // Verify we used the correct key + assertTrue("Should have used extracted key for verification", + fakeEncryptionService.lastVerifyKey.contentEquals(otherSigningKey)) + } + + @Test + fun `validatePacket - rejects ANNOUNCE packet with invalid signature`() { + val announcement = IdentityAnnouncement( + nickname = "New User", + noisePublicKey = otherNoiseKey, + signingPublicKey = otherSigningKey + ) + val payload = announcement.encode()!! + + val packet = BitchatPacket( + type = MessageType.ANNOUNCE.value, + ttl = 10u, + senderID = unknownPeerID, + payload = payload + ) + packet.signature = invalidSignature + + val result = securityManager.validatePacket(packet, unknownPeerID) + + assertFalse("ANNOUNCE with invalid signature should be rejected", result) + } + + @Test + fun `validatePacket - rejects ANNOUNCE packet with malformed payload`() { + val packet = BitchatPacket( + type = MessageType.ANNOUNCE.value, + ttl = 10u, + senderID = unknownPeerID, + payload = byteArrayOf(0x00, 0x01, 0x02) + ) + packet.signature = validSignature + + val result = securityManager.validatePacket(packet, unknownPeerID) + + assertFalse("ANNOUNCE with malformed payload should be rejected (cannot extract key)", result) + } + + @Test + fun `validatePacket - ignores own packets`() { + val packet = BitchatPacket( + type = MessageType.MESSAGE.value, + ttl = 10u, + senderID = myPeerID, + payload = dummyPayload + ) + packet.signature = null + + val result = securityManager.validatePacket(packet, myPeerID) + + assertFalse("Own packets should return false (skipped)", result) + } + + @Test + fun `validatePacket - detects duplicates`() { + setupKnownPeer(otherPeerID, otherSigningKey) + + val packet = BitchatPacket( + type = MessageType.MESSAGE.value, + ttl = 10u, + senderID = otherPeerID, + payload = dummyPayload + ) + packet.signature = validSignature + + val result1 = securityManager.validatePacket(packet, otherPeerID) + assertTrue("First packet should be accepted", result1) + + val result2 = securityManager.validatePacket(packet, otherPeerID) + assertFalse("Duplicate packet should be rejected", result2) + } + + @Test + fun `validatePacket - handles ANNOUNCE duplicates correctly`() { + val announcement = IdentityAnnouncement( + nickname = "New User", + noisePublicKey = otherNoiseKey, + signingPublicKey = otherSigningKey + ) + val payload = announcement.encode()!! + + // 1. Initial Announce (Fresh) + val packet1 = BitchatPacket( + type = MessageType.ANNOUNCE.value, + ttl = com.bitchat.android.util.AppConstants.MESSAGE_TTL_HOPS, // 7u + senderID = unknownPeerID, + payload = payload + ) + packet1.signature = validSignature + + whenever(mockDelegate.getPeerInfo(unknownPeerID)).thenReturn(null) + + assertTrue("First ANNOUNCE should be accepted", securityManager.validatePacket(packet1, unknownPeerID)) + + // 2. Relayed Duplicate (Lower TTL) + val packet2 = packet1.copy(ttl = (com.bitchat.android.util.AppConstants.MESSAGE_TTL_HOPS - 1u).toUByte()) + assertFalse("Relayed duplicate ANNOUNCE should be rejected", securityManager.validatePacket(packet2, unknownPeerID)) + + // 3. Direct Duplicate (Max TTL) + val packet3 = packet1.copy(ttl = com.bitchat.android.util.AppConstants.MESSAGE_TTL_HOPS) + assertTrue("Fresh duplicate ANNOUNCE should be accepted", securityManager.validatePacket(packet3, unknownPeerID)) + } + + private fun setupKnownPeer(peerID: String, signingKey: ByteArray) { + val info = PeerInfo( + id = peerID, + nickname = "Test User", + isConnected = true, + isDirectConnection = true, + noisePublicKey = ByteArray(32), + signingPublicKey = signingKey, + isVerifiedNickname = false, + lastSeen = System.currentTimeMillis() + ) + whenever(mockDelegate.getPeerInfo(peerID)).thenReturn(info) + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/services/AppStateStoreTest.kt b/app/src/test/kotlin/com/bitchat/android/services/AppStateStoreTest.kt new file mode 100644 index 00000000..eb5fcd71 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/services/AppStateStoreTest.kt @@ -0,0 +1,99 @@ +package com.bitchat.android.services + +import com.bitchat.android.model.BitchatMessage +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import java.util.Date + +class AppStateStoreTest { + @Before + fun setUp() { + AppStateStore.clear() + } + + @After + fun tearDown() { + AppStateStore.clear() + } + + @Test + fun `public timeline collapses request sync replay even when android message ids differ`() { + val timestamp = Date(1_700_000_000_000L) + val originalDelivery = BitchatMessage( + id = "random-id-from-first-delivery", + sender = "alice", + content = "hello from sync", + timestamp = timestamp, + senderPeerID = "1122334455667788" + ) + val requestSyncReplay = originalDelivery.copy(id = "different-random-id-from-replay") + + AppStateStore.addPublicMessage(originalDelivery) + AppStateStore.addPublicMessage(requestSyncReplay) + + assertEquals(listOf(originalDelivery), AppStateStore.publicMessages.value) + } + + @Test + fun `public timeline still keeps same content sent at different packet timestamps`() { + val first = BitchatMessage( + id = "first-packet-id", + sender = "alice", + content = "same text", + timestamp = Date(1_700_000_000_000L), + senderPeerID = "1122334455667788" + ) + val second = first.copy( + id = "second-packet-id", + timestamp = Date(first.timestamp.time + 1_000L) + ) + + AppStateStore.addPublicMessage(first) + AppStateStore.addPublicMessage(second) + + assertEquals(listOf(first, second), AppStateStore.publicMessages.value) + } + + @Test + fun `peer list merges transport updates instead of overwriting`() { + AppStateStore.setTransportPeers("WIFI", listOf("wifi-peer")) + AppStateStore.setTransportPeers("BLE", emptyList()) + + assertEquals(listOf("wifi-peer"), AppStateStore.peers.value) + + AppStateStore.setTransportPeers("BLE", listOf("ble-peer")) + + assertEquals(listOf("wifi-peer", "ble-peer"), AppStateStore.peers.value) + } + + @Test + fun `direct peers union across transports`() { + AppStateStore.setTransportDirectPeers("BLE", listOf("ble-1", "shared")) + AppStateStore.setTransportDirectPeers("WIFI", listOf("wifi-1", "shared")) + + assertEquals( + setOf("ble-1", "wifi-1", "shared"), + AppStateStore.getDirectPeers() + ) + } + + @Test + fun `clearing one transport keeps the other transport direct peers`() { + AppStateStore.setTransportDirectPeers("BLE", listOf("ble-1")) + AppStateStore.setTransportDirectPeers("WIFI", listOf("wifi-1")) + + AppStateStore.clearTransportDirectPeers("WIFI") + + assertEquals(setOf("ble-1"), AppStateStore.getDirectPeers()) + } + + @Test + fun `latest direct peer set replaces previous set for same transport`() { + AppStateStore.setTransportDirectPeers("WIFI", listOf("wifi-1", "wifi-2")) + AppStateStore.setTransportDirectPeers("WIFI", listOf("wifi-3")) + + assertEquals(setOf("wifi-3"), AppStateStore.getDirectPeers()) + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/services/meshgraph/MeshGraphServiceTest.kt b/app/src/test/kotlin/com/bitchat/android/services/meshgraph/MeshGraphServiceTest.kt new file mode 100644 index 00000000..dc27ff99 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/services/meshgraph/MeshGraphServiceTest.kt @@ -0,0 +1,126 @@ +package com.bitchat.android.services.meshgraph + +import org.junit.Assert.* +import org.junit.Test +import org.junit.Before + +class MeshGraphServiceTest { + + private lateinit var service: MeshGraphService + + @Before + fun setUp() { + // Use the test-only API to reset the singleton state safely + MeshGraphService.resetForTesting() + service = MeshGraphService.getInstance() + } + + @Test + fun testUpdateFromAnnouncement_AddsNeighbors() { + val origin = "PeerA" + val neighbors = listOf("PeerB", "PeerC") + val timestamp = 100UL + + service.updateFromAnnouncement(origin, "Alice", neighbors, timestamp) + + val snapshot = service.graphState.value + // Verify nodes + assertTrue(snapshot.nodes.any { it.peerID == "PeerA" }) + assertTrue(snapshot.nodes.any { it.peerID == "PeerB" }) + assertTrue(snapshot.nodes.any { it.peerID == "PeerC" }) + + // Verify edges (unconfirmed because B and C haven't announced A) + // A -> B + val edgeAB = snapshot.edges.find { (it.a == "PeerA" && it.b == "PeerB") || (it.a == "PeerB" && it.b == "PeerA") } + assertNotNull(edgeAB) + assertFalse(edgeAB!!.isConfirmed) + assertEquals("PeerA", edgeAB.confirmedBy) + + // A -> C + val edgeAC = snapshot.edges.find { (it.a == "PeerA" && it.b == "PeerC") || (it.a == "PeerC" && it.b == "PeerA") } + assertNotNull(edgeAC) + assertFalse(edgeAC!!.isConfirmed) + assertEquals("PeerA", edgeAC.confirmedBy) + } + + @Test + fun testUpdateFromAnnouncement_NewerTimestampReplacesNeighbors() { + val origin = "PeerA" + + // Initial state: A -> {B, C} + service.updateFromAnnouncement(origin, "Alice", listOf("PeerB", "PeerC"), 100UL) + + // Update: A -> {B, D} (newer timestamp) + service.updateFromAnnouncement(origin, "Alice", listOf("PeerB", "PeerD"), 200UL) + + val snapshot = service.graphState.value + + // Verify Edge A-B exists + assertNotNull(snapshot.edges.find { (it.a == "PeerA" && it.b == "PeerB") || (it.a == "PeerB" && it.b == "PeerA") }) + + // Verify Edge A-D exists + assertNotNull(snapshot.edges.find { (it.a == "PeerA" && it.b == "PeerD") || (it.a == "PeerD" && it.b == "PeerA") }) + + // Verify Edge A-C does NOT exist + assertNull(snapshot.edges.find { (it.a == "PeerA" && it.b == "PeerC") || (it.a == "PeerC" && it.b == "PeerA") }) + } + + @Test + fun testUpdateFromAnnouncement_OlderTimestampIsIgnored() { + val origin = "PeerA" + + // Initial state: A -> {B, C} at ts=200 + service.updateFromAnnouncement(origin, "Alice", listOf("PeerB", "PeerC"), 200UL) + + // Old Update: A -> {D} at ts=100 + service.updateFromAnnouncement(origin, "Alice", listOf("PeerD"), 100UL) + + val snapshot = service.graphState.value + + // Should still be {B, C} + assertNotNull(snapshot.edges.find { (it.a == "PeerA" && it.b == "PeerB") || (it.a == "PeerB" && it.b == "PeerA") }) + assertNotNull(snapshot.edges.find { (it.a == "PeerA" && it.b == "PeerC") || (it.a == "PeerC" && it.b == "PeerA") }) + assertNull(snapshot.edges.find { (it.a == "PeerA" && it.b == "PeerD") || (it.a == "PeerD" && it.b == "PeerA") }) + } + + @Test + fun testUpdateFromAnnouncement_NullNeighborsClearsList_TheFix() { + val origin = "PeerA" + + // Initial state: A -> {B, C} at ts=100 + service.updateFromAnnouncement(origin, "Alice", listOf("PeerB", "PeerC"), 100UL) + + // Update with NULL neighbors (omitted TLV) at ts=200 + service.updateFromAnnouncement(origin, "Alice", null, 200UL) + + val snapshot = service.graphState.value + + // All edges from A should be gone + val edgesFromA = snapshot.edges.filter { it.a == "PeerA" || it.b == "PeerA" } + assertTrue("Edges from PeerA should be empty after null update", edgesFromA.isEmpty()) + + // Nodes B and C might still exist if they were added to the node list, but connected edges are gone. + // Actually, publishSnapshot collects nodes from nicknames and announcements. + // Since we provided nicknames for PeerA, it should be there. + // PeerB and PeerC were only in announcements. Since A's announcement is cleared, and B/C never announced, + // they might disappear from the node list if 'nicknames' doesn't contain them. + // Let's check edges primarily as that's what routing cares about. + } + + @Test + fun testUpdateFromAnnouncement_NullNeighborsWithOlderTimestampIsIgnored() { + val origin = "PeerA" + + // Initial state: A -> {B, C} at ts=200 + service.updateFromAnnouncement(origin, "Alice", listOf("PeerB", "PeerC"), 200UL) + + // Old Update with NULL neighbors at ts=100 + service.updateFromAnnouncement(origin, "Alice", null, 100UL) + + val snapshot = service.graphState.value + + // Should still be {B, C} because the null update was older + assertFalse(snapshot.edges.isEmpty()) + assertNotNull(snapshot.edges.find { (it.a == "PeerA" && it.b == "PeerB") || (it.a == "PeerB" && it.b == "PeerA") }) + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/sync/GCSFilterTest.kt b/app/src/test/kotlin/com/bitchat/android/sync/GCSFilterTest.kt new file mode 100644 index 00000000..3a9bfb62 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/sync/GCSFilterTest.kt @@ -0,0 +1,86 @@ +package com.bitchat.android.sync + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import java.util.Random + +class GCSFilterTest { + + @Test + fun testGCSFilterBasic() { + val random = Random(42) + val ids = List(20) { + val bytes = ByteArray(16) + random.nextBytes(bytes) + bytes + } + + // Build filter with plenty of bytes (no trimming) + val params = GCSFilter.buildFilter(ids, maxBytes = 400, targetFpr = 0.01) + val sorted = GCSFilter.decodeToSortedSet(params.p, params.m, params.data) + + for (id in ids) { + val v = GCSFilter.h64(id) % params.m + val nonZeroV = if (v == 0L) 1L else v + assertTrue("Filter should contain all encoded IDs", GCSFilter.contains(sorted, nonZeroV)) + } + } + + @Test + fun testGCSFilterWithTrimming() { + val random = Random(42) + // 50 IDs + val ids = List(50) { + val bytes = ByteArray(16) + random.nextBytes(bytes) + bytes + } + + // Force trimming by setting maxBytes to a very small value (e.g., 20 bytes) + val maxBytes = 20 + val params = GCSFilter.buildFilter(ids, maxBytes = maxBytes, targetFpr = 0.01) + + // Ensure some trimming actually happened + assertTrue("Params data size should be <= maxBytes", params.data.size <= maxBytes) + + val sorted = GCSFilter.decodeToSortedSet(params.p, params.m, params.data) + + // Let's verify that the first trimmedN elements in ids are all matched + val trimmedN = (params.m ushr params.p).toInt() + assertTrue("At least some elements should have been encoded", trimmedN > 0) + + val retainedIds = ids.take(trimmedN) + for (id in retainedIds) { + val v = GCSFilter.h64(id) % params.m + val nonZeroV = if (v == 0L) 1L else v + assertTrue("Retained ID should be found in filter", GCSFilter.contains(sorted, nonZeroV)) + } + } + + @Test + fun testGCSFilterHandlesCollisionsAndZeroBucket() { + val random = Random(42) + // Generate a large number of IDs to guarantee collisions (mapping to the same bucket) and some zero-bucket mapping + val ids = List(200) { + val bytes = ByteArray(16) + random.nextBytes(bytes) + bytes + } + + // Build GCS filter - this should complete successfully without throwing repeat count exceptions or negative-count crashes + val params = GCSFilter.buildFilter(ids, maxBytes = 100, targetFpr = 0.05) + val sorted = GCSFilter.decodeToSortedSet(params.p, params.m, params.data) + + // Verify elements are successfully stored and found (including those mapping to 0) + var foundCount = 0 + for (id in ids) { + val v = GCSFilter.h64(id) % params.m + val nonZeroV = if (v == 0L) 1L else v + if (GCSFilter.contains(sorted, nonZeroV)) { + foundCount++ + } + } + assertTrue("Should successfully decode and find elements after deduplication", foundCount > 0) + } +} diff --git a/docs/GeohashPresenceSpec.md b/docs/GeohashPresenceSpec.md new file mode 100644 index 00000000..321a491f --- /dev/null +++ b/docs/GeohashPresenceSpec.md @@ -0,0 +1,96 @@ +# Geohash Presence Specification + +## Overview + +The Geohash Presence feature provides a mechanism to track online participants in geohash-based location channels. It uses a dedicated ephemeral Nostr event kind to broadcast "heartbeats," ensuring accurate and privacy-preserving online counts. + +## Nostr Protocol + +### Event Kind +A new ephemeral event kind is defined for presence heartbeats: +- **Kind:** `20001` (`GEOHASH_PRESENCE`) +- **Type:** Ephemeral (not stored by relays long-term) + +### Event Structure +The presence event mimics the structure of a geohash chat message (Kind 20000) but without content or nickname metadata, to minimize overhead and focus purely on "liveness". + +```json +{ + "kind": 20001, + "created_at": , + "tags": [ + ["g", ""] + ], + "content": "", + "pubkey": "", + "id": "", + "sig": "" +} +``` + +* **`content`**: Must be empty string. +* **`tags`**: Must include `["g", ""]`. Should NOT include `["n", ""]`. +* **`pubkey`**: The ephemeral identity derived specifically for this geohash (same as used for chat messages). + +## Client Behavior + +### 1. Broadcasting Presence + +Clients MUST broadcast a Kind 20001 presence event globally when the app is open, regardless of which screen the user is viewing. + +* **Global Heartbeat:** + * **Trigger:** Application start / initialization, or whenever location (available geohashes) changes. + * **Frequency:** Randomized loop interval between **40s and 80s** (average 60s). + * **Scope:** Sent to *all* geohash channels corresponding to the device's *current physical location*. + * **Privacy Restriction:** Presence MUST ONLY be broadcast to low-precision geohash levels to protect user privacy. Specifically: + * **Allowed:** `REGION` (precision 2), `PROVINCE` (precision 4), `CITY` (precision 5). + * **Denied:** `NEIGHBORHOOD` (precision 6), `BLOCK` (precision 7), `BUILDING` (precision 8+). + * **Decorrelation:** Individual broadcasts within a heartbeat loop must be separated by random delays (e.g., 2-5 seconds) to prevent temporal correlation of public keys across different geohash levels. The main loop delay is adjusted to maintain the target average cadence. + +### 2. Subscribing to Presence + +Clients must update their Nostr filters to listen for both chat and presence events on geohash channels. + +* **Filter:** + * `kinds`: `[20000, 20001]` + * `#g`: `[""]` + +### 3. Participant Counting + +The "online participants" count shown in the UI aggregates unique public keys from both presence heartbeats and active chat messages. + +* **Logic:** + * Maintain a map of `pubkey -> last_seen_timestamp` for each geohash. + * Update `last_seen_timestamp` upon receiving a valid **Kind 20001 (Presence)** OR **Kind 20000 (Chat)** event. + * A participant is considered "online" if their `last_seen_timestamp` is within the last **5 minutes**. + +### 4. UI Presentation + +The presentation of the participant count depends on the geohash precision level and data availability. + +* **Standard Display:** For channels where presence is broadcast (Region, Province, City) OR any channel where at least one participant has been detected, show the exact count: `[N people]`. +* **High-Precision Uncertainty:** For high-precision channels (Neighborhood, Block, Building) where: + * Presence broadcasting is disabled (privacy restriction). + * **AND** the detected participant count is `0`. + * **Display:** `[? people]` + * **Reasoning:** Since clients don't announce themselves in these channels, a count of "0" is misleading (people could be lurking). + +### 5. Implementation Details (Android Reference) + +* **`NostrKind.GEOHASH_PRESENCE`**: Added constant `20001`. +* **`NostrProtocol.createGeohashPresenceEvent`**: Helper to generate the event. +* **`GeohashViewModel`**: + * `startGlobalPresenceHeartbeat()`: Coroutine that `collectLatest` on `LocationChannelManager.availableChannels`. + * Implements randomized loop logic (40-80s) and per-broadcast random delays (2-5s). + * Filters channels by `precision <= 5` before broadcasting. +* **`GeohashMessageHandler`**: + * Refactored `onEvent` to update participant counts for both Kind 20000 and 20001. +* **`LocationChannelsSheet`**: + * Implements the `[? people]` display logic for high-precision, zero-count channels. + +## Benefits + +* **Accuracy:** Counts reflect both active listeners (via heartbeats) and active speakers (via messages). +* **Privacy:** High-precision location presence is NOT broadcast. Temporal correlation between different levels is obfuscated via random delays. +* **Consistency:** "Online" status is maintained globally while the app is open. +* **Transparency:** The UI correctly reflects uncertainty (`?`) when privacy rules prevent accurate passive counting. diff --git a/docs/SOURCE_ROUTING.md b/docs/SOURCE_ROUTING.md index f6d101e2..1fdd2c6f 100644 --- a/docs/SOURCE_ROUTING.md +++ b/docs/SOURCE_ROUTING.md @@ -1,78 +1,142 @@ -# Source-Based Routing for BitChat Packets +# Source-Based Routing for BitChat Packets (v2) -This document specifies an optional source-based routing extension to the BitChat packet format. A sender may attach a hop-by-hop route (list of peer IDs) to instruct relays on the intended path. Relays that support this feature will try to forward to the next hop directly; otherwise, they fall back to regular broadcast relaying. +This document specifies the Source-Based Routing extension (v2) for the BitChat protocol. This upgrade enables efficient unicast routing across the mesh by allowing senders to specify an explicit path of intermediate relays. -Status: optional and backward-compatible. +**Status:** Implemented in Android and iOS. Backward compatible (v1 clients ignore routing data). -## Layering Overview +--- -- Outer packet: BitChat binary packet with unchanged fixed header (version/type/ttl/timestamp/flags/payloadLength). -- Flags: adds a new bit `HAS_ROUTE (0x08)`. -- Variable sections (when present, in order): - 1) `SenderID` (8 bytes) - 2) `RecipientID` (8 bytes) if `HAS_RECIPIENT` - 3) `Route` (if `HAS_ROUTE`): `count` (1 byte) + `count * 8` bytes hop IDs - 4) `Payload` (with optional compression preamble) - 5) `Signature` (64 bytes) if `HAS_SIGNATURE` +## 1. Protocol Versioning & Layering -Unknown flags are ignored by older implementations (they will simply not see a route and continue broadcasting as before). +To support source routing and larger payloads, the packet format has been upgraded to **Version 2**. -## Route Field Encoding +* **Version 1 (Legacy):** 2-byte payload length limit. Ignores routing flags. +* **Version 2 (Current):** 4-byte payload length limit. Supports Source Routing. -- Presence: Signaled by the `HAS_ROUTE (0x08)` bit in `flags`. -- Layout (immediately after optional `RecipientID`): - - `count`: 1 byte (0..255) - - `hops`: concatenation of `count` peer IDs, each encoded as exactly 8 bytes -- Peer ID encoding (8 bytes): same as used elsewhere in BitChat (16 hex chars → 8 bytes; left-to-right conversion; pad with `0x00` if shorter). This matches the on‑wire `senderID`/`recipientID` encoding. -- Size impact: `1 + 8*N` bytes, where `N = count`. -- Empty route: `HAS_ROUTE` with `count = 0` is treated as no route (relays ignore it). +**Key Rule:** The `HAS_ROUTE (0x08)` flag is **only valid** if the packet `version >= 2`. Relays receiving a v1 packet must ignore this flag even if set. -## Sender Behavior +--- -- Applicability: Intended for addressed packets (i.e., where `recipientID` is set and is not the broadcast ID). For broadcast packets, omit the route. -- Path computation: Use Dijkstra’s shortest path (unit weights) on your internal mesh topology to find a route from `src` (your peerID) to `dst` (recipient peerID). The hop list SHOULD include the full path `[src, ..., dst]`. -- Encoding: Set `HAS_ROUTE`, write `count = path.length`, then the 8‑byte hop IDs in order. Keep `count <= 255`. -- Signing: The route is covered by the Ed25519 signature (recommended): - - Signature input is the canonical encoding with `signature` omitted and `ttl = 0` (TTL excluded to allow relay decrement) — same rule as base protocol. +## 2. Packet Structure Comparison -## Relay Behavior +The following diagram illustrates the structural differences between a standard v1 packet and a source-routed v2 packet. -When receiving a packet that is not addressed to you: +### V1 Packet (Legacy) +```text ++-------------------+---------------------------------------------------------+ +| Fixed Header (14) | Variable Sections | ++-------------------+----------+-------------+------------------+-------------+ +| Ver: 1 (1B) | SenderID | RecipientID | Payload | Signature | +| Type, TTL, etc. | (8B) | (8B) | (Length in Head) | (64B) | +| Len: 2 Bytes | | (Optional) | | (Optional) | ++-------------------+----------+-------------+------------------+-------------+ +``` -1) If `HAS_ROUTE` is not set, or the route is empty, relay using your normal broadcast logic (subject to TTL/probability policies). -2) If `HAS_ROUTE` is set and your peer ID appears at index `i` in the hop list: - - If there is a next hop at `i+1`, attempt a targeted unicast to that next hop if you have a direct connection to it. - - If successful, do NOT broadcast this packet further. - - If not directly connected (or the send fails), fall back to broadcast relaying. - - If you are the last hop (no `i+1`), proceed with standard handling (e.g., if not addressed to you, do not relay further). +### V2 Packet (Source Routed) +```text ++-------------------+-----------------------------------------------------------------------------+ +| Fixed Header (16) | Variable Sections | ++-------------------+----------+-------------+-----------------------+------------------+-------------+ +| Ver: 2 (1B) | SenderID | RecipientID | SOURCE ROUTE | Payload | Signature | +| Type, TTL, etc. | (8B) | (8B) | (Variable) | (Length in Head) | (64B) | +| Len: 4 Bytes | | (Required*) | Only if HAS_ROUTE=1 | | (Optional) | ++-------------------+----------+-------------+-----------------------+------------------+-------------+ +``` -TTL handling remains unchanged: relays decrement TTL by 1 before forwarding (whether targeted or broadcast). If TTL reaches 0, do not relay. +**(*) Note:** A `Route` can be attached to **any** packet type that has a `RecipientID` (flag `HAS_RECIPIENT` set). -## Receiver Behavior (Destination) +### Fixed Header Differences -- This extension does not change how addressed packets are handled by the final recipient. If the packet is addressed to you (`recipientID == myPeerID`), process it normally (e.g., decrypt Noise payload, verify signatures, etc.). -- Signature verification MUST include the route field when present; route tampering will invalidate the signature. +| Field | Size (v1) | Size (v2) | Description | +|---|---|---|---| +| **Version** | 1 byte | 1 byte | `0x01` vs `0x02` | +| **Payload Length** | **2 bytes** | **4 bytes** | `UInt32` in v2 to support large files. **Excludes** route/IDs/sig. | +| **Total Size** | **14 bytes** | **16 bytes** | V2 header is 2 bytes larger. | -## Compatibility +--- -- Omission: If `HAS_ROUTE` is omitted, legacy behavior applies. Relays that don’t implement this feature will ignore the route entirely, because they won’t set or check `HAS_ROUTE`. -- Partial support: If any relay on the path cannot directly reach the next hop, it will fall back to broadcast relaying; delivery is still probabilistic like the base protocol. +## 3. Source Route Specification -## Minimal Example (conceptual) +The `Source Route` field is a variable-length list of **intermediate hops** that the packet must traverse. -- Header (fixed 13 bytes): unchanged. -- Variable sections (ordered): - - `SenderID(8)` - - `RecipientID(8)` (if present) - - `HAS_ROUTE` set → `count=3`, `hops = [H0 H1 H2]` where each `Hk` is 8 bytes - - Payload (optionally compressed) - - Signature (64) +* **Location:** Immediately follows `RecipientID`. +* **Structure:** + * `Count` (1 byte): Number of intermediate hops (`N`). + * `Hops` (`N * 8` bytes): Sequence of Peer IDs. -Where `H0` is the sender’s peer ID, `H2` is the recipient’s peer ID, and `H1` is an intermediate relay. The receiver verifies the signature over the packet encoding (with `ttl = 0` and `signature` omitted), which includes the `hops` when `HAS_ROUTE` is set. +### Intermediate Hops Only +The route list MUST contain **only** the intermediate relays between the sender and the recipient. +* **DO NOT** include the `SenderID` (it is already in the packet). +* **DO NOT** include the `RecipientID` (it is already in the packet). -## Operational Notes +**Example:** +Topology: `Alice (Sender) -> Bob -> Charlie -> Dave (Recipient)` +* Packet `SenderID`: Alice +* Packet `RecipientID`: Dave +* Packet `Route`: `[Bob, Charlie]` (Count = 2) -- Routing optimality depends on the freshness and completeness of the topology your implementation has learned (e.g., via gossip of direct neighbors). Recompute routes as needed. -- Route length should be kept small to reduce overhead and the probability of missing a direct link at some hop. -- Implementations may introduce policy controls (e.g., disable source routing, cap max route length). +--- +## 4. Topology Discovery (Gossip) + +To calculate routes, nodes need a view of the network topology. This is achieved via a **Neighbor List** extension to the `IdentityAnnouncement` packet. + +* **Mechanism:** `IdentityAnnouncement` packets contain a TLV (Type-Length-Value) payload. +* **New TLV Type:** `0x04` (Direct Neighbors). +* **Content:** A list of Peer IDs that the announcing node is directly connected to. + +**TLV Structure (Type 0x04):** +```text +[Type: 0x04] [Length: 1B] [Count: 1B] [NeighborID1 (8B)] [NeighborID2 (8B)] ... +``` +Nodes receiving this TLV update their local mesh graph, linking the sender to the listed neighbors. + +### Edge Verification (Two-Way Handshake) + +To prevent spoofing and routing through stale connections, the Mesh Graph service implements a strict two-way handshake verification: + +* **Unconfirmed Edge:** If Peer A announces Peer B, but Peer B does *not* announce Peer A, the connection is treated as **unconfirmed**. Unconfirmed edges are visualized as dotted lines in debug tools but are **excluded** from route calculations. +* **Confirmed Edge:** An edge is only valid for routing when **both** peers explicitly announce each other in their neighbor lists. This ensures that the connection is bidirectional and currently active from both perspectives. + +--- + +## 5. Fragmentation & Source Routing + +When a large source-routed packet (e.g., File Transfer) exceeds the MTU and requires fragmentation: + +1. **Version Inheritance:** All fragments MUST be marked as **Version 2**. +2. **Route Inheritance:** All fragments MUST contain the **exact same Route field** as the parent packet. + +**Why?** If fragments were sent as v1 packets or without routes, they would fall back to flooding, negating the bandwidth benefits of source routing for large data transfers. + +--- + +## 6. Security & Signing + +Source routing is fully secured by the existing Ed25519 signature scheme. + +* **Scope:** The signature covers the **entire packet structure** (Header + Sender + Recipient + Route + Payload). +* **Verification:** The receiver verifies the signature against the `SenderID`'s public key. +* **Integrity:** Any tampering with the route list by malicious relays will invalidate the signature, causing the packet to be dropped by the destination. + +**Signature Input Construction:** +Serialize the packet exactly as transmitted, but temporarily set `TTL = 0` and remove the `Signature` bytes. + +--- + +## 7. Relay Logic + +When a node receives a packet **not** addressed to itself: + +1. **Check Route:** + * Is `Version >= 2`? + * Is `HAS_ROUTE` flag set? + * Is the route list non-empty? +2. **If YES (Source Routed):** + * Find local Peer ID in the route list at index `i`. + * **Next Hop:** The peer at `i + 1`. + * **Last Hop:** If `i` is the last index, the Next Hop is the `RecipientID`. + * **Action:** Attempt to unicast (`sendToPeer`) to the Next Hop. + * **Fallback:** If the Next Hop is unreachable, **fall back to broadcast/flood** to ensure delivery. +3. **If NO (Standard):** + * Flood the packet to all connected neighbors (subject to TTL and probability rules). diff --git a/docs/device_manager.md b/docs/device_manager.md index 092403b0..c42cd2c5 100644 --- a/docs/device_manager.md +++ b/docs/device_manager.md @@ -38,7 +38,6 @@ Timers: - Added a `DeviceMonitoringManager` instance and provided a `disconnectCallback` that: - disconnects client GATT connections via `BluetoothConnectionTracker`. - cancels server connections via `BluetoothGattServer.cancelConnection`. -- Exposed `noteAnnounceReceived(address)` as a small helper for higher layers. - Updated `componentDelegate.onPacketReceived` to notify per-device activity to the monitor. 2) GATT Client @@ -59,7 +58,6 @@ Timers: 4) ANNOUNCE Binding - File: `BluetoothMeshService.kt` (in the ANNOUNCE handler where we first map device → peer) -- After mapping a device address to a peer on first verified ANNOUNCE, call `connectionManager.noteAnnounceReceived(address)` to cancel the 15s timer for that device. ## Behavior Summary diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index f7bfdd8a..b6a5411a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -45,6 +45,13 @@ gms-location = "21.3.0" # Security security-crypto = "1.1.0-beta01" +# QR +zxing-core = "3.5.4" + +# CameraX / ML Kit +camerax = "1.5.2" +mlkit-barcode = "17.3.0" + # Testing junit = "4.13.2" androidx-test-ext = "1.2.1" @@ -54,9 +61,12 @@ mockito-inline = "4.1.0" roboelectric = "4.15" kotlinx-coroutines-test = "1.6" +lifecycle-process = "2.8.7" + [libraries] # AndroidX Core androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "core-ktx" } +androidx-lifecycle-process = { module = "androidx.lifecycle:lifecycle-process", version.ref = "lifecycle-process" } androidx-lifecycle-runtime-ktx = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "lifecycle-runtime" } androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activity-compose" } androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "appcompat" } @@ -104,6 +114,15 @@ gms-location = { module = "com.google.android.gms:play-services-location", versi # Security androidx-security-crypto = { module = "androidx.security:security-crypto", version.ref = "security-crypto" } +# QR +zxing-core = { module = "com.google.zxing:core", version.ref = "zxing-core" } + +# CameraX / ML Kit +androidx-camera-camera2 = { module = "androidx.camera:camera-camera2", version.ref = "camerax" } +androidx-camera-lifecycle = { module = "androidx.camera:camera-lifecycle", version.ref = "camerax" } +androidx-camera-compose = { module = "androidx.camera:camera-compose", version.ref = "camerax" } +mlkit-barcode-scanning = { module = "com.google.mlkit:barcode-scanning", version.ref = "mlkit-barcode" } + # Testing junit = { module = "junit:junit", version.ref = "junit" } androidx-test-ext-junit = { module = "androidx.test.ext:junit", version.ref = "androidx-test-ext" } diff --git a/tools/arti-build/ARTI_VERSION b/tools/arti-build/ARTI_VERSION index c68d1ff0..5b590193 100644 --- a/tools/arti-build/ARTI_VERSION +++ b/tools/arti-build/ARTI_VERSION @@ -1 +1 @@ -arti-v1.7.0 +arti-v1.9.0 \ No newline at end of file diff --git a/tools/arti-build/build-arti.sh b/tools/arti-build/build-arti.sh index 0a592be5..2a31fefa 100755 --- a/tools/arti-build/build-arti.sh +++ b/tools/arti-build/build-arti.sh @@ -129,15 +129,17 @@ done # Architectures to build if [ "$RELEASE_ONLY" = true ]; then - TARGETS=("aarch64-linux-android") + TARGETS=("aarch64-linux-android" "armv7-linux-androideabi") else - TARGETS=("aarch64-linux-android" "x86_64-linux-android") + TARGETS=("aarch64-linux-android" "x86_64-linux-android" "armv7-linux-androideabi" "i686-linux-android") fi # Map Rust targets to Android ABI names declare -A ABI_MAP=( ["aarch64-linux-android"]="arm64-v8a" ["x86_64-linux-android"]="x86_64" + ["armv7-linux-androideabi"]="armeabi-v7a" + ["i686-linux-android"]="x86" ) # Toolchain placeholders (set in detect_ndk_host) @@ -331,6 +333,18 @@ clone_or_update_arti() { git clean -ffdqx --quiet print_success "Arti source ready at version $VERSION" + + # Apply patches + if [ -d "$SCRIPT_DIR/patches" ]; then + print_info "Applying patches..." + for patch in "$SCRIPT_DIR/patches"/*.patch; do + if [ -f "$patch" ]; then + print_info "Applying $(basename "$patch")" + git apply "$patch" || { print_error "Failed to apply $patch"; exit 1; } + fi + done + fi + echo "" }