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..282f3295 100644 --- a/app/src/main/java/com/bitchat/android/BitchatApplication.kt +++ b/app/src/main/java/com/bitchat/android/BitchatApplication.kt @@ -46,6 +46,13 @@ class BitchatApplication : Application() { val enabled = com.bitchat.android.ui.debug.DebugPreferenceManager.getWifiAwareEnabled(false) com.bitchat.android.wifiaware.WifiAwareController.initialize(this, enabled) } catch (_: Exception) { } + + // Initialize Geohash Registries for persistence + try { + com.bitchat.android.nostr.GeohashAliasRegistry.initialize(this) + com.bitchat.android.nostr.GeohashConversationRegistry.initialize(this) + } catch (_: Exception) { } + // Initialize mesh service preferences try { com.bitchat.android.service.MeshServicePreferences.init(this) } catch (_: Exception) { } diff --git a/app/src/main/java/com/bitchat/android/MainActivity.kt b/app/src/main/java/com/bitchat/android/MainActivity.kt index 2496018f..7b1ee53d 100644 --- a/app/src/main/java/com/bitchat/android/MainActivity.kt +++ b/app/src/main/java/com/bitchat/android/MainActivity.kt @@ -26,6 +26,7 @@ import com.bitchat.android.onboarding.BatteryOptimizationManager import com.bitchat.android.onboarding.BatteryOptimizationPreferenceManager import com.bitchat.android.onboarding.BatteryOptimizationScreen import com.bitchat.android.onboarding.BatteryOptimizationStatus +import com.bitchat.android.onboarding.BackgroundLocationPermissionScreen import com.bitchat.android.onboarding.InitializationErrorScreen import com.bitchat.android.onboarding.InitializingScreen import com.bitchat.android.onboarding.LocationCheckScreen @@ -42,6 +43,7 @@ import com.bitchat.android.ui.theme.BitchatTheme import com.bitchat.android.wifiaware.WifiAwareController import com.bitchat.android.wifiaware.WifiAwareMeshDelegate import com.bitchat.android.nostr.PoWPreferenceManager +import com.bitchat.android.services.VerificationService import kotlinx.coroutines.delay import kotlinx.coroutines.launch @@ -170,6 +172,9 @@ class MainActivity : OrientationAwareActivity() { activity = this, permissionManager = permissionManager, onOnboardingComplete = ::handleOnboardingComplete, + onBackgroundLocationRequired = { + mainViewModel.updateOnboardingState(OnboardingState.BACKGROUND_LOCATION_EXPLANATION) + }, onOnboardingFailed = ::handleOnboardingFailed ) @@ -318,6 +323,21 @@ class MainActivity : OrientationAwareActivity() { ) } + OnboardingState.BACKGROUND_LOCATION_EXPLANATION -> { + BackgroundLocationPermissionScreen( + modifier = modifier, + onContinue = { + onboardingCoordinator.requestBackgroundLocation() + }, + onRetry = { + onboardingCoordinator.checkBackgroundLocationAndProceed() + }, + onSkip = { + onboardingCoordinator.skipBackgroundLocation() + } + ) + } + OnboardingState.CHECKING, OnboardingState.INITIALIZING, OnboardingState.COMPLETE -> { // Set up back navigation handling for the chat screen val backCallback = object : OnBackPressedCallback(true) { @@ -444,10 +464,17 @@ class MainActivity : OrientationAwareActivity() { if (permissionManager.isFirstTimeLaunch()) { Log.d("MainActivity", "First time launch, showing permission explanation") mainViewModel.updateOnboardingState(OnboardingState.PERMISSION_EXPLANATION) - } else if (permissionManager.areAllPermissionsGranted()) { - Log.d("MainActivity", "Existing user with permissions, initializing app") - mainViewModel.updateOnboardingState(OnboardingState.INITIALIZING) - initializeApp() + } else if (permissionManager.areRequiredPermissionsGranted()) { + Log.d("MainActivity", "Existing user with required permissions") + if (permissionManager.needsBackgroundLocationPermission() && + !permissionManager.isBackgroundLocationGranted() && + !com.bitchat.android.onboarding.BackgroundLocationPreferenceManager.isSkipped(this@MainActivity) + ) { + mainViewModel.updateOnboardingState(OnboardingState.BACKGROUND_LOCATION_EXPLANATION) + } else { + mainViewModel.updateOnboardingState(OnboardingState.INITIALIZING) + initializeApp() + } } else { Log.d("MainActivity", "Existing user missing permissions, showing explanation") mainViewModel.updateOnboardingState(OnboardingState.PERMISSION_EXPLANATION) @@ -723,6 +750,7 @@ class MainActivity : OrientationAwareActivity() { // Handle any notification intent handleNotificationIntent(intent) + handleVerificationIntent(intent) // Small delay to ensure mesh service is fully initialized delay(500) @@ -751,6 +779,7 @@ class MainActivity : OrientationAwareActivity() { // Handle notification intents when app is already running if (mainViewModel.onboardingState.value == OnboardingState.COMPLETE) { handleNotificationIntent(intent) + handleVerificationIntent(intent) } } @@ -761,9 +790,6 @@ class MainActivity : OrientationAwareActivity() { // Reattach mesh delegate to new ChatViewModel instance after Activity recreation try { meshService.delegate = chatViewModel } catch (_: Exception) { } try { WifiAwareController.getService()?.delegate = wifiAwareDelegate } catch (_: Exception) { } - // Set app foreground state - meshService.connectionManager.setAppBackgroundState(false) - chatViewModel.setAppBackgroundState(false) // Check if Bluetooth was disabled while app was backgrounded val currentBluetoothStatus = bluetoothStatusManager.checkBluetoothStatus() @@ -793,9 +819,6 @@ class MainActivity : OrientationAwareActivity() { super.onPause() // Only set background state if app is fully initialized if (mainViewModel.onboardingState.value == OnboardingState.COMPLETE) { - // Set app background state - meshService.connectionManager.setAppBackgroundState(true) - chatViewModel.setAppBackgroundState(true) // Detach UI delegate so the foreground service can own DM notifications while UI is closed try { meshService.delegate = null } catch (_: Exception) { } try { WifiAwareController.getService()?.delegate = null } catch (_: Exception) { } @@ -824,8 +847,9 @@ class MainActivity : OrientationAwareActivity() { if (peerID != null) { Log.d("MainActivity", "Opening private chat with $senderNickname (peerID: $peerID) from notification") - // Open the private chat with this peer - chatViewModel.startPrivateChat(peerID) + // Open the private chat sheet with this peer + chatViewModel.showMeshPeerList() + chatViewModel.showPrivateChatSheet(peerID) // Clear notifications for this sender since user is now viewing the chat chatViewModel.clearNotificationsForSender(peerID) @@ -861,6 +885,17 @@ class MainActivity : OrientationAwareActivity() { } } + private fun handleVerificationIntent(intent: Intent) { + val uri = intent.data ?: return + if (uri.scheme != "bitchat" || uri.host != "verify") return + + chatViewModel.showVerificationSheet() + val qr = VerificationService.verifyScannedQR(uri.toString()) + if (qr != null) { + chatViewModel.beginQRVerification(qr) + } + } + override fun onDestroy() { super.onDestroy() 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..dce58031 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,10 +88,6 @@ class BluetoothConnectionManager( // Public property for address-peer mapping val addressPeerMap get() = connectionTracker.addressPeerMap - // Expose first-announce helpers to higher layers - fun noteAnnounceReceived(address: String) { connectionTracker.noteAnnounceReceived(address) } - fun hasSeenFirstAnnounce(address: String): Boolean = connectionTracker.hasSeenFirstAnnounce(address) - init { powerManager.delegate = this // Observe debug settings to enforce role state while active @@ -107,30 +106,58 @@ class BluetoothConnectionManager( if (enabled) startClient() else stopClient() } } - // Connection caps: enforce on change + + // Centralized limit enforcement on any setting change connectionScope.launch { - dbg.maxConnectionsOverall.collect { - if (!isActive) return@collect - connectionTracker.enforceConnectionLimits() - // Also enforce server side best-effort - serverManager.enforceServerLimit(dbg.maxServerConnections.value) - } - } - connectionScope.launch { - dbg.maxClientConnections.collect { - if (!isActive) return@collect - connectionTracker.enforceConnectionLimits() - } - } - connectionScope.launch { - dbg.maxServerConnections.collect { - if (!isActive) return@collect - serverManager.enforceServerLimit(dbg.maxServerConnections.value) + combine( + dbg.maxConnectionsOverall, + dbg.maxServerConnections, + dbg.maxClientConnections + ) { _, _, _ -> + // We don't need the values here, we just need to trigger enforcement + Unit + }.collect { + if (isActive) { + enforceStrictLimits() + } } } } catch (_: Exception) { } } + /** + * Centralized connection limit enforcement + */ + private fun enforceStrictLimits() { + if (!isActive) return + + try { + val dbg = com.bitchat.android.ui.debug.DebugSettingsManager.getInstance() + val maxOverall = dbg.maxConnectionsOverall.value + val maxServer = dbg.maxServerConnections.value + val maxClient = dbg.maxClientConnections.value + + // Get list of connections to evict to satisfy all constraints + val toEvict = connectionTracker.getConnectionsToEvict(maxOverall, maxServer, maxClient) + + if (toEvict.isNotEmpty()) { + Log.i(TAG, "Enforcing limits (max: $maxOverall, s: $maxServer, c: $maxClient) - evicting ${toEvict.size} connections") + + toEvict.forEach { conn -> + if (conn.isClient) { + Log.d(TAG, "Evicting client ${conn.device.address}") + try { conn.gatt?.disconnect() } catch (_: Exception) { } + } else { + Log.d(TAG, "Evicting server ${conn.device.address}") + serverManager.disconnectDevice(conn.device) + } + } + } + } catch (e: Exception) { + Log.e(TAG, "Error enforcing limits: ${e.message}") + } + } + /** * Start all Bluetooth services with power optimization */ @@ -247,13 +274,6 @@ class BluetoothConnectionManager( return active } - /** - * Set app background state for power optimization - */ - fun setAppBackgroundState(inBackground: Boolean) { - powerManager.setAppBackgroundState(inBackground) - } - /** * Broadcast packet to connected devices with connection limit enforcement * Automatically fragments large packets to fit within BLE MTU limits @@ -268,6 +288,16 @@ class BluetoothConnectionManager( ) } + fun sendToPeer(peerID: String, routed: RoutedPacket): Boolean { + if (!isActive) return false + return packetBroadcaster.sendToPeer( + peerID, + routed, + serverManager.getGattServer(), + serverManager.getCharacteristic() + ) + } + fun cancelTransfer(transferId: String): Boolean { return packetBroadcaster.cancelTransfer(transferId) } @@ -392,12 +422,7 @@ class BluetoothConnectionManager( } // Enforce connection limits - connectionTracker.enforceConnectionLimits() - // Best-effort server cap - try { - val maxServer = com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().maxServerConnections.value - serverManager.enforceServerLimit(maxServer) - } catch (_: Exception) { } + enforceStrictLimits() } } 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..2bb22fce 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothGattClientManager.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothGattClientManager.kt @@ -320,6 +320,22 @@ class BluetoothGattClientManager( return } + // Try to extract peerID from Service Data (if available) for stable identity + val serviceData = scanRecord?.getServiceData(ParcelUuid(AppConstants.Mesh.Gatt.SERVICE_UUID)) + val peerID = if (serviceData != null && serviceData.size >= 8) { + serviceData.joinToString("") { "%02x".format(it) } + } else { + null + } + + if (peerID != null) { + // Log.v(TAG, "Found peerID $peerID in scan record for $deviceAddress") + if (connectionTracker.isPeerConnected(peerID)) { + Log.d(TAG, "Deduplication: Peer $peerID is already connected (ignoring $deviceAddress)") + return + } + } + // Log.d(TAG, "Received scan result from $deviceAddress - already connected: ${connectionTracker.isDeviceConnected(deviceAddress)}") // Store RSSI from scan results for later use (especially for server connections) @@ -332,7 +348,7 @@ class BluetoothGattClientManager( deviceName = device.name, deviceAddress = deviceAddress, rssi = rssi, - peerID = null // peerID unknown at scan time + peerID = peerID // Use the discovered peerID if available ) ) } catch (_: Exception) { } @@ -342,13 +358,12 @@ class BluetoothGattClientManager( Log.d(TAG, "Skipping device $deviceAddress due to weak signal: $rssi < ${powerManager.getRSSIThreshold()}") // Even if we skip connecting, still publish scan result to debug UI try { - val pid: String? = null // We don't know peerID until packet exchange DebugSettingsManager.getInstance().addScanResult( DebugScanResult( deviceName = device.name, deviceAddress = deviceAddress, rssi = rssi, - peerID = pid + peerID = peerID ) ) } catch (_: Exception) { } @@ -366,14 +381,19 @@ class BluetoothGattClientManager( return } - if (connectionTracker.isConnectionLimitReached()) { - Log.d(TAG, "Connection limit reached (${powerManager.getMaxConnections()})") + // Check if connection limit is reached + val dbg = try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance() } catch (_: Exception) { null } + val maxOverall = dbg?.maxConnectionsOverall?.value ?: powerManager.getMaxConnections() + val maxClient = dbg?.maxClientConnections?.value ?: maxOverall + + if (!connectionTracker.canConnectAsClient(maxOverall, maxClient)) { + Log.d(TAG, "Client connection limit reached (overall: $maxOverall, client: $maxClient)") return } // Add pending connection and start connection if (connectionTracker.addPendingConnection(deviceAddress)) { - connectToDevice(device, rssi) + connectToDevice(device, rssi, peerID) } } @@ -381,11 +401,11 @@ class BluetoothGattClientManager( * Connect to a device as GATT client */ @Suppress("DEPRECATION") - private fun connectToDevice(device: BluetoothDevice, rssi: Int) { + private fun connectToDevice(device: BluetoothDevice, rssi: Int, peerID: String? = null) { if (!permissionManager.hasBluetoothPermissions()) return val deviceAddress = device.address - Log.i(TAG, "Connecting to bitchat device: $deviceAddress") + Log.i(TAG, "Connecting to bitchat device: $deviceAddress (peerID: $peerID)") val gattCallback = object : BluetoothGattCallback() { override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) { @@ -435,7 +455,8 @@ class BluetoothGattClientManager( device = gatt.device, gatt = gatt, rssi = rssi, - isClient = true + isClient = true, + peerID = peerID // Store the peerID discovered during scan ) connectionTracker.addDeviceConnection(deviceAddress, deviceConn) 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..ad7c9cf1 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothGattServerManager.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothGattServerManager.kt @@ -24,7 +24,8 @@ class BluetoothGattServerManager( private val connectionTracker: BluetoothConnectionTracker, private val permissionManager: BluetoothPermissionManager, private val powerManager: PowerManager, - private val delegate: BluetoothConnectionManagerDelegate? + private val delegate: BluetoothConnectionManagerDelegate?, + private val myPeerID: String ) { companion object { @@ -45,18 +46,15 @@ class BluetoothGattServerManager( // State management private var isActive = false - // Enforce a server connection limit by canceling the oldest connections (best-effort) - fun enforceServerLimit(maxServer: Int) { - if (maxServer <= 0) return + /** + * Disconnect a specific device (used by ConnectionManager to enforce overall limits) + */ + fun disconnectDevice(device: BluetoothDevice) { try { - val subs = connectionTracker.getSubscribedDevices() - if (subs.size > maxServer) { - val excess = subs.size - maxServer - subs.take(excess).forEach { d -> - try { gattServer?.cancelConnection(d) } catch (_: Exception) { } - } - } - } catch (_: Exception) { } + gattServer?.cancelConnection(device) + } catch (e: Exception) { + Log.w(TAG, "Error disconnecting device ${device.address}: ${e.message}") + } } /** @@ -122,9 +120,10 @@ class BluetoothGattServerManager( // Try to cancel any active connections explicitly before closing try { - val devices = connectionTracker.getSubscribedDevices() - devices.forEach { d -> - try { gattServer?.cancelConnection(d) } catch (_: Exception) { } + // Disconnect ALL server connections + val servers = connectionTracker.getConnectedDevices().values.filter { !it.isClient } + servers.forEach { d -> + try { gattServer?.cancelConnection(d.device) } catch (_: Exception) { } } } catch (_: Exception) { } @@ -358,13 +357,27 @@ 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) { val mode = try { powerManager.getPowerInfo().split("Current Mode: ")[1].split("\n")[0] } catch (_: Exception) { "unknown" } - Log.i(TAG, "Advertising started (power mode: $mode)") + Log.i(TAG, "Advertising started (power mode: $mode) with stable ID: ${peerIDBytes.joinToString("") { "%02x".format(it) }}") } override fun onStartFailure(errorCode: Int) { @@ -373,7 +386,7 @@ class BluetoothGattServerManager( } try { - bleAdvertiser.startAdvertising(settings, data, advertiseCallback) + bleAdvertiser.startAdvertising(settings, data, scanResponse, advertiseCallback) } catch (se: SecurityException) { Log.e(TAG, "SecurityException starting advertising (missing permission?): ${se.message}") } catch (e: Exception) { 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 c42a3c6c..5d069ac7 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt @@ -4,18 +4,23 @@ import android.content.Context import android.util.Log import com.bitchat.android.crypto.EncryptionService 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.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 kotlinx.coroutines.* +import java.util.* +import kotlin.math.sign +import kotlin.random.Random /** * Bluetooth mesh service - REFACTORED to use component-based architecture @@ -30,21 +35,27 @@ import kotlinx.coroutines.launch * - BluetoothConnectionManager: BLE connections and GATT operations * - PacketProcessor: Incoming packet routing */ -class BluetoothMeshService(private val context: Context) : MeshService, TransportBridgeService.TransportLayer { +class BluetoothMeshService(private val context: Context) : TransportBridgeService.TransportLayer { + private val debugManager by lazy { try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance() } catch (e: Exception) { null } } + companion object { private const val TAG = "BluetoothMeshService" private val MAX_TTL: UByte = com.bitchat.android.util.AppConstants.MESSAGE_TTL_HOPS } - // Core components + // Core components - each handling specific responsibilities private val encryptionService = EncryptionService(context) // My peer identification - derived from persisted Noise identity fingerprint (first 16 hex chars) - override val myPeerID: String = encryptionService.getIdentityFingerprint().take(16) - private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) - private val bleTransport = BleMeshTransport() - private lateinit var meshCore: MeshCore - internal lateinit var connectionManager: BluetoothConnectionManager + val myPeerID: String = encryptionService.getIdentityFingerprint().take(16) + 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) + internal val connectionManager = BluetoothConnectionManager(context, myPeerID, fragmentManager) // Made internal for access + private val packetProcessor = PacketProcessor(myPeerID) + private lateinit var gossipSyncManager: GossipSyncManager // Service-level notification manager for background (no-UI) DMs private val serviceNotificationManager = com.bitchat.android.ui.NotificationManager( context.applicationContext, @@ -56,28 +67,25 @@ class BluetoothMeshService(private val context: Context) : MeshService, Transpor private var isActive = false // Delegate for message callbacks (maintains same interface) - override var delegate: BluetoothMeshDelegate? = null - set(value) { - field = value - if (::meshCore.isInitialized) { - meshCore.delegate = value - meshCore.refreshPeerList() - } - } + var delegate: BluetoothMeshDelegate? = null + + // Coroutines + private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) // Tracks whether this instance has been terminated via stopServices() private var terminated = false init { Log.i(TAG, "Initializing BluetoothMeshService for peer=$myPeerID") - meshCore = MeshCore( - context = context.applicationContext, - scope = serviceScope, - transport = bleTransport, - encryptionService = encryptionService, + VerificationService.configure(encryptionService) + setupDelegates() + messageHandler.packetProcessor = packetProcessor + //startPeriodicDebugLogging() + + // Initialize sync manager (needs serviceScope) + gossipSyncManager = GossipSyncManager( myPeerID = myPeerID, - maxTtl = MAX_TTL, - sharedGossipManager = null, - gossipConfigProvider = object : GossipSyncManager.ConfigProvider { + scope = serviceScope, + configProvider = object : GossipSyncManager.ConfigProvider { override fun seenCapacity(): Int = try { com.bitchat.android.ui.debug.DebugPreferenceManager.getSeenPacketCapacity(500) } catch (_: Exception) { 500 } @@ -89,90 +97,52 @@ class BluetoothMeshService(private val context: Context) : MeshService, Transpor override fun gcsTargetFpr(): Double = try { com.bitchat.android.ui.debug.DebugPreferenceManager.getGcsFprPercent(1.0) / 100.0 } catch (_: Exception) { 0.01 } - }, - hooks = MeshCore.Hooks( - onMessageReceived = { message -> handleMessageReceived(message) }, - onPeerIdBindingUpdated = { newPeerID, _, publicKey, previousPeerID -> - try { - com.bitchat.android.favorites.FavoritesPersistenceService.shared - .findNostrPubkey(publicKey) - ?.let { npub -> - com.bitchat.android.favorites.FavoritesPersistenceService.shared - .updateNostrPublicKeyForPeerID(newPeerID, npub) - } - } catch (_: Exception) { } - Log.d(TAG, "Updated peer ID binding: $newPeerID (was: $previousPeerID) publicKey=${publicKey.toHexString().take(16)}...") - }, - onAnnounceProcessed = { routed, _ -> - val deviceAddress = routed.relayAddress - val pid = routed.peerID - if (deviceAddress != null && pid != null) { - if (!connectionManager.hasSeenFirstAnnounce(deviceAddress)) { - connectionManager.addressPeerMap[deviceAddress] = pid - connectionManager.noteAnnounceReceived(deviceAddress) - Log.d(TAG, "Mapped device $deviceAddress to peer $pid on FIRST-ANNOUNCE for this connection") - try { meshCore.setDirectConnection(pid, true) } catch (_: Exception) { } - try { meshCore.gossipSyncManager.scheduleInitialSyncToPeer(pid, 1_000) } catch (_: Exception) { } - } - } - }, - readReceiptInterceptor = { messageId, recipientPeerId -> - val geo = runCatching { com.bitchat.android.services.MessageRouter.tryGetInstance() }.getOrNull() - val isGeoAlias = try { - val map = com.bitchat.android.nostr.GeohashAliasRegistry.snapshot() - map.containsKey(recipientPeerId) - } catch (_: Exception) { false } - if (isGeoAlias && geo != null) { - geo.sendReadReceipt(com.bitchat.android.model.ReadReceipt(messageId), recipientPeerId) - true - } else { - val seenStore = try { - com.bitchat.android.services.SeenMessageStore.getInstance(context.applicationContext) - } catch (_: Exception) { null } - if (seenStore?.hasRead(messageId) == true) { - Log.d(TAG, "Skipping read receipt for $messageId - already marked read") - true - } else { - false - } - } - }, - onReadReceiptSent = { messageId -> - try { - com.bitchat.android.services.SeenMessageStore.getInstance(context.applicationContext) - .markRead(messageId) - } catch (_: Exception) { } - }, - announcementNicknameProvider = { - try { com.bitchat.android.services.NicknameProvider.getNickname(context, myPeerID) } catch (_: Exception) { null } - } - ) + } ) - connectionManager = BluetoothConnectionManager(context, myPeerID, meshCore.fragmentManager) - bleTransport.connectionManager = connectionManager - setupConnectionManagerDelegate() + // Wire sync manager delegate + gossipSyncManager.delegate = object : GossipSyncManager.Delegate { + override fun sendPacket(packet: BitchatPacket) { + broadcastRoutedPacket(RoutedPacket(packet)) + } + override fun sendPacketToPeer(peerID: String, packet: BitchatPacket) { + sendPacketToPeerAcrossTransports(peerID, packet) + } + override fun signPacketForBroadcast(packet: BitchatPacket): BitchatPacket { + return signPacketBeforeBroadcast(packet) + } + } - // Register as shared instance for Wi-Fi Aware transport - com.bitchat.android.service.MeshServiceHolder.setGossipManager(meshCore.gossipSyncManager) - - Log.d(TAG, "MeshCore initialized") - //startPeriodicDebugLogging() - - // Register with cross-layer transport bridge + com.bitchat.android.service.MeshServiceHolder.setGossipManager(gossipSyncManager) 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) - meshCore.sendFromBridge(packet) + connectionManager.broadcastPacket(packet) } override fun sendToPeer(peerID: String, packet: BitchatPacket) { connectionManager.sendPacketToPeer(peerID, packet) } + + private fun broadcastRoutedPacket(routed: RoutedPacket) { + connectionManager.broadcastPacket(routed) + TransportBridgeService.broadcast("BLE", routed) + } + + private fun sendPacketToPeerAcrossTransports(peerID: String, packet: BitchatPacket): Boolean { + val sentOverBle = connectionManager.sendPacketToPeer(peerID, packet) + TransportBridgeService.sendToPeer("BLE", peerID, packet) + return sentOverBle + } /** * Start periodic debug logging every 10 seconds @@ -195,35 +165,447 @@ class BluetoothMeshService(private val context: Context) : MeshService, Transpor } } - private fun setupConnectionManagerDelegate() { - try { - connectionManager.setNicknameResolver { pid -> meshCore.getPeerNickname(pid) } - } catch (_: Exception) { } - - connectionManager.delegate = object : BluetoothConnectionManagerDelegate { - override fun onPacketReceived(packet: BitchatPacket, peerID: String, device: android.bluetooth.BluetoothDevice?) { + /** + * Send broadcast announcement every 30 seconds + */ + private fun sendPeriodicBroadcastAnnounce() { + serviceScope.launch { + Log.d(TAG, "Starting periodic announce loop") + while (isActive) { try { - val nick = meshCore.getPeerNicknames()[peerID] - com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().logIncoming( - packetType = packet.type.toString(), - fromPeerID = peerID, - fromNickname = nick, - fromDeviceAddress = device?.address - ) + delay(30000) // 30 seconds + sendBroadcastAnnounce() + } catch (e: Exception) { + Log.e(TAG, "Error in periodic broadcast announce: ${e.message}") + } + } + Log.d(TAG, "Periodic announce loop ended (isActive=$isActive)") + } + } + + /** + * Setup delegate connections between components + */ + private fun setupDelegates() { + Log.d(TAG, "Setting up component delegates") + // Provide nickname resolver to BLE broadcaster and debug manager + try { + 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.setPeers(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) + Log.d(TAG, "Removed Noise session for offline peer $peerID") + } catch (e: Exception) { + Log.w(TAG, "Failed to remove Noise session for $peerID: ${e.message}") + } + } + } + + // SecurityManager delegate for key exchange notifications + securityManager.delegate = object : SecurityManagerDelegate { + override fun onKeyExchangeCompleted(peerID: String, peerPublicKeyData: ByteArray) { + // Send announcement and cached messages after key exchange + serviceScope.launch { + Log.d(TAG, "Key exchange completed with $peerID; sending follow-ups") + delay(100) + sendAnnouncementToPeer(peerID) + + delay(1000) + storeForwardManager.sendCachedMessages(peerID) + } + } + + override fun sendHandshakeResponse(peerID: String, response: ByteArray) { + // Send Noise handshake response + val responsePacket = BitchatPacket( + version = 1u, + type = MessageType.NOISE_HANDSHAKE.value, + senderID = hexStringToByteArray(myPeerID), + recipientID = hexStringToByteArray(peerID), + timestamp = System.currentTimeMillis().toULong(), + payload = response, + ttl = MAX_TTL + ) + // Sign the handshake response + val signedPacket = signPacketBeforeBroadcast(responsePacket) + broadcastRoutedPacket(RoutedPacket(signedPacket)) + Log.d(TAG, "Sent Noise handshake response to $peerID (${response.size} bytes)") + } + + override fun getPeerInfo(peerID: String): PeerInfo? { + return peerManager.getPeerInfo(peerID) + } + } + + // StoreForwardManager delegates + 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) { + broadcastRoutedPacket(RoutedPacket(packet)) + } + } + + // MessageHandler delegates + messageHandler.delegate = object : MessageHandlerDelegate { + // Peer management + 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) + } + + // Packet operations + override fun sendPacket(packet: BitchatPacket) { + // Sign the packet before broadcasting + val signedPacket = signPacketBeforeBroadcast(packet) + broadcastRoutedPacket(RoutedPacket(signedPacket)) + } + + override fun relayPacket(routed: RoutedPacket) { + broadcastRoutedPacket(routed) + } + + override fun getBroadcastRecipient(): ByteArray { + return SpecialRecipients.BROADCAST + } + + // Cryptographic operations + 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) + } + + // Noise protocol operations + override fun hasNoiseSession(peerID: String): Boolean { + return encryptionService.hasEstablishedSession(peerID) + } + + override fun initiateNoiseHandshake(peerID: String) { + try { + // Initiate proper Noise handshake with specific peer + val handshakeData = encryptionService.initiateHandshake(peerID) + + if (handshakeData != null) { + val packet = BitchatPacket( + version = 1u, + type = MessageType.NOISE_HANDSHAKE.value, + senderID = hexStringToByteArray(myPeerID), + recipientID = hexStringToByteArray(peerID), + timestamp = System.currentTimeMillis().toULong(), + payload = handshakeData, + ttl = MAX_TTL + ) + + // Sign the handshake packet before broadcasting + val signedPacket = signPacketBeforeBroadcast(packet) + 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") + } + + } catch (e: Exception) { + Log.e(TAG, "Failed to initiate Noise handshake with $peerID: ${e.message}") + } + } + + override fun processNoiseHandshakeMessage(payload: ByteArray, peerID: String): ByteArray? { + return try { + encryptionService.processHandshakeMessage(payload, peerID) + } catch (e: Exception) { + Log.e(TAG, "Failed to process handshake message from $peerID: ${e.message}") + null + } + } + + override fun updatePeerIDBinding(newPeerID: String, nickname: String, + publicKey: ByteArray, previousPeerID: String?) { + + Log.d(TAG, "Updating peer ID binding: $newPeerID (was: $previousPeerID) with nickname: $nickname and public key: ${publicKey.toHexString().take(16)}...") + // Update peer mapping in the PeerManager for peer ID rotation support + peerManager.addOrUpdatePeer(newPeerID, nickname) + + // Store fingerprint for the peer via centralized fingerprint manager + val fingerprint = peerManager.storeFingerprintForPeer(newPeerID, publicKey) + + // Index existing Nostr mapping by the new peerID if we have it + try { + com.bitchat.android.favorites.FavoritesPersistenceService.shared.findNostrPubkey(publicKey)?.let { npub -> + com.bitchat.android.favorites.FavoritesPersistenceService.shared.updateNostrPublicKeyForPeerID(newPeerID, npub) + } } catch (_: Exception) { } - meshCore.processIncoming(packet, peerID, device?.address) + + // If there was a previous peer ID, remove it to avoid duplicates + previousPeerID?.let { oldPeerID -> + peerManager.removePeer(oldPeerID) + } + + Log.d(TAG, "Updated peer ID binding: $newPeerID (was: $previousPeerID), fingerprint: ${fingerprint.take(16)}...") + } + + // Message operations + override fun decryptChannelMessage(encryptedContent: ByteArray, channel: String): String? { + return delegate?.decryptChannelMessage(encryptedContent, channel) + } + + // Callbacks + override fun onMessageReceived(message: BitchatMessage) { + // Always reflect into process-wide store so UI can hydrate after recreation + try { + when { + message.isPrivate -> { + val peer = message.senderPeerID ?: "" + if (peer.isNotEmpty()) com.bitchat.android.services.AppStateStore.addPrivateMessage(peer, message) + } + message.channel != null -> { + com.bitchat.android.services.AppStateStore.addChannelMessage(message.channel!!, message) + } + else -> { + com.bitchat.android.services.AppStateStore.addPublicMessage(message) + } + } + } catch (_: Exception) { } + // And forward to UI delegate if attached + delegate?.didReceiveMessage(message) + + // If no UI delegate attached (app closed), show DM notification via service manager + if (delegate == null && message.isPrivate) { + try { + val senderPeerID = message.senderPeerID + if (senderPeerID != null) { + val nick = try { peerManager.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) { } + } + } + + 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 delegates + 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) + } + + // Network information for relay manager + 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) { + serviceScope.launch { messageHandler.handleNoiseEncrypted(routed) } + } + + override fun handleAnnounce(routed: RoutedPacket) { + serviceScope.launch { + // Process the announce + val isFirst = messageHandler.handleAnnounce(routed) + + // 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) { + // 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 + Log.d(TAG, "Mapped device $deviceAddress to peer $pid (TTL=${routed.packet.ttl})") + + // Mark as directly connected - refresh UI state + try { peerManager.refreshPeerList() } catch (_: Exception) { } + + // Initial sync for this direct peer + try { gossipSyncManager.scheduleInitialSyncToPeer(pid, 1_000) } catch (_: Exception) { } + } + } + // Track for sync + try { gossipSyncManager.onPublicPacketSeen(routed.packet) } catch (_: Exception) { } + } + } + + override fun handleMessage(routed: RoutedPacket) { + serviceScope.launch { messageHandler.handleMessage(routed) } + // Track broadcast messages for sync + 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? { + // Track broadcast fragments for gossip sync + 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@BluetoothMeshService.sendAnnouncementToPeer(peerID) + } + + override fun sendCachedMessages(peerID: String) { + storeForwardManager.sendCachedMessages(peerID) + } + + override fun relayPacket(routed: RoutedPacket) { + 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 + val req = RequestSyncPacket.decode(routed.packet.payload) ?: return + gossipSyncManager.handleRequestSync(fromPeer, req) + } + } + + // 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 { + 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 serviceScope.launch { Log.d(TAG, "Device connected: ${device.address}; scheduling announce") delay(200) - meshCore.sendBroadcastAnnounce() + sendBroadcastAnnounce() } + // Verbose debug: device connected try { val addr = device.address val peer = connectionManager.addressPeerMap[addr] - val nick = peer?.let { meshCore.getPeerNickname(it) } ?: "unknown" + val nick = peer?.let { peerManager.getPeerNickname(it) } ?: "unknown" com.bitchat.android.ui.debug.DebugSettingsManager.getInstance() .logPeerConnection(peer ?: "unknown", nick, addr, isInbound = !connectionManager.isClientConnection(addr)!!) } catch (_: Exception) { } @@ -232,62 +614,37 @@ class BluetoothMeshService(private val context: Context) : MeshService, Transpor override fun onDeviceDisconnected(device: android.bluetooth.BluetoothDevice) { Log.d(TAG, "Device disconnected: ${device.address}") val addr = device.address + // Remove mapping and, if that was the last direct path for the peer, clear direct flag 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) { - try { meshCore.setDirectConnection(peer, false) } catch (_: Exception) { } - } + // Verbose debug: device disconnected try { - val nick = meshCore.getPeerNickname(peer) ?: "unknown" + val nick = peerManager.getPeerNickname(peer) ?: "unknown" com.bitchat.android.ui.debug.DebugSettingsManager.getInstance() .logPeerDisconnection(peer, nick, addr) } catch (_: Exception) { } } } - + override fun onRSSIUpdated(deviceAddress: String, rssi: Int) { + // Find the peer ID for this device address and update RSSI in PeerManager connectionManager.addressPeerMap[deviceAddress]?.let { peerID -> - meshCore.updatePeerRSSI(peerID, rssi) + peerManager.updatePeerRSSI(peerID, rssi) } } } } - - private fun handleMessageReceived(message: BitchatMessage) { - try { - when { - message.isPrivate -> { - val peer = message.senderPeerID ?: "" - if (peer.isNotEmpty()) com.bitchat.android.services.AppStateStore.addPrivateMessage(peer, message) - } - message.channel != null -> { - com.bitchat.android.services.AppStateStore.addChannelMessage(message.channel!!, message) - } - else -> { - com.bitchat.android.services.AppStateStore.addPublicMessage(message) - } - } - } catch (_: Exception) { } - - 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) { } - } - } /** * Start the mesh service */ - override fun startServices() { + fun startServices() { // Prevent double starts (defensive programming) if (isActive) { Log.w(TAG, "Mesh service already active, ignoring duplicate start request") @@ -303,8 +660,13 @@ class BluetoothMeshService(private val context: Context) : MeshService, Transpor if (connectionManager.startServices()) { isActive = true - meshCore.startCore() - Log.d(TAG, "MeshCore started") + + // Start periodic announcements for peer discovery and connectivity + sendPeriodicBroadcastAnnounce() + Log.d(TAG, "Started periodic broadcast announcements (every 30 seconds)") + // Start periodic syncs + gossipSyncManager.start() + Log.d(TAG, "GossipSyncManager started") } else { Log.e(TAG, "Failed to start Bluetooth services") } @@ -313,7 +675,7 @@ class BluetoothMeshService(private val context: Context) : MeshService, Transpor /** * Stop all mesh services */ - override fun stopServices() { + fun stopServices() { if (!isActive) { Log.w(TAG, "Mesh service not active, ignoring stop request") return @@ -321,23 +683,26 @@ class BluetoothMeshService(private val context: Context) : MeshService, Transpor Log.i(TAG, "Stopping Bluetooth mesh service") isActive = false - - // Unregister from bridge TransportBridgeService.unregister("BLE") // Send leave announcement - meshCore.sendLeaveAnnouncement() + sendLeaveAnnouncement() serviceScope.launch { Log.d(TAG, "Stopping subcomponents and cancelling scope...") delay(200) // Give leave message time to send // Stop all components - meshCore.stopCore() - Log.d(TAG, "MeshCore stopped") + gossipSyncManager.stop() + Log.d(TAG, "GossipSyncManager stopped") connectionManager.stopServices() Log.d(TAG, "BluetoothConnectionManager stop requested") - meshCore.shutdown() + peerManager.shutdown() + fragmentManager.shutdown() + securityManager.shutdown() + storeForwardManager.shutdown() + messageHandler.shutdown() + packetProcessor.shutdown() // Mark this instance as terminated and cancel its scope so it won't be reused terminated = true @@ -361,26 +726,133 @@ class BluetoothMeshService(private val context: Context) : MeshService, Transpor /** * Send public message */ - override fun sendMessage(content: String, mentions: List, channel: String?) { - meshCore.sendMessage(content, mentions, channel) + 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 + ) + + // Sign the packet before broadcasting + val signedPacket = signPacketBeforeBroadcast(packet) + broadcastRoutedPacket(RoutedPacket(signedPacket)) + // Track our own broadcast message for sync + try { gossipSyncManager.onPublicPacketSeen(signedPacket) } catch (_: Exception) { } + } } /** * Send a file over mesh as a broadcast MESSAGE (public mesh timeline/channels). */ - override fun sendFileBroadcast(file: com.bitchat.android.model.BitchatFilePacket) { - meshCore.sendFileBroadcast(file) + fun sendFileBroadcast(file: com.bitchat.android.model.BitchatFilePacket) { + try { + Log.d(TAG, "📤 sendFileBroadcast: name=${file.fileName}, size=${file.fileSize}") + val payload = file.encode() + if (payload == null) { + Log.e(TAG, "❌ Failed to encode file packet in sendFileBroadcast") + return + } + Log.d(TAG, "📦 Encoded payload: ${payload.size} bytes") + serviceScope.launch { + val packet = BitchatPacket( + version = 2u, // FILE_TRANSFER uses v2 for 4-byte payload length to support large files + 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(packet) + // Use a stable transferId based on the file TLV payload for progress tracking + val transferId = sha256Hex(payload) + broadcastRoutedPacket(RoutedPacket(signed, transferId = transferId)) + try { gossipSyncManager.onPublicPacketSeen(signed) } catch (_: Exception) { } + } + } catch (e: Exception) { + Log.e(TAG, "❌ sendFileBroadcast failed: ${e.message}", e) + Log.e(TAG, "❌ File: name=${file.fileName}, size=${file.fileSize}") + } } /** * Send a file as an encrypted private message using Noise protocol */ - override fun sendFilePrivate(recipientPeerID: String, file: com.bitchat.android.model.BitchatFilePacket) { - meshCore.sendFilePrivate(recipientPeerID, file) + fun sendFilePrivate(recipientPeerID: String, file: com.bitchat.android.model.BitchatFilePacket) { + try { + Log.d(TAG, "📤 sendFilePrivate (ENCRYPTED): to=$recipientPeerID, name=${file.fileName}, size=${file.fileSize}") + + serviceScope.launch { + // Check if we have an established Noise session + if (encryptionService.hasEstablishedSession(recipientPeerID)) { + try { + // Encode the file packet as TLV + val filePayload = file.encode() + if (filePayload == null) { + Log.e(TAG, "❌ Failed to encode file packet for private send") + return@launch + } + Log.d(TAG, "📦 Encoded file TLV: ${filePayload.size} bytes") + + // Create NoisePayload wrapper (type byte + file TLV data) - same as iOS + val noisePayload = com.bitchat.android.model.NoisePayload( + type = com.bitchat.android.model.NoisePayloadType.FILE_TRANSFER, + data = filePayload + ) + + // Encrypt the payload using Noise + val encrypted = encryptionService.encrypt(noisePayload.encode(), recipientPeerID) + if (encrypted == null) { + Log.e(TAG, "❌ Failed to encrypt file for $recipientPeerID") + return@launch + } + Log.d(TAG, "🔐 Encrypted file payload: ${encrypted.size} bytes") + + // Create NOISE_ENCRYPTED packet (not FILE_TRANSFER!) + 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 + ) + + // Sign and send the encrypted packet + val signed = signPacketBeforeBroadcast(packet) + // Use a stable transferId based on the unencrypted file TLV payload for progress tracking + val transferId = sha256Hex(filePayload) + broadcastRoutedPacket(RoutedPacket(signed, transferId = transferId)) + Log.d(TAG, "✅ Sent encrypted file to $recipientPeerID") + + } catch (e: Exception) { + Log.e(TAG, "❌ Failed to encrypt file for $recipientPeerID: ${e.message}", e) + } + } else { + // No session - initiate handshake but don't queue file + Log.w(TAG, "⚠️ No Noise session with $recipientPeerID for file transfer, initiating handshake") + messageHandler.delegate?.initiateNoiseHandshake(recipientPeerID) + } + } + } catch (e: Exception) { + Log.e(TAG, "❌ sendFilePrivate failed: ${e.message}", e) + Log.e(TAG, "❌ File: to=$recipientPeerID, name=${file.fileName}, size=${file.fileSize}") + } } - override fun cancelFileTransfer(transferId: String): Boolean { - return meshCore.cancelFileTransfer(transferId) + fun cancelFileTransfer(transferId: String): Boolean { + return connectionManager.cancelTransfer(transferId) } // Local helper to hash payloads to a stable hex ID for progress mapping @@ -394,149 +866,540 @@ class BluetoothMeshService(private val context: Context) : MeshService, Transpor * Send private message - SIMPLIFIED iOS-compatible version * Uses NoisePayloadType system exactly like iOS SimplifiedBluetoothService */ - override fun sendPrivateMessage(content: String, recipientPeerID: String, recipientNickname: String, messageID: String?) { - meshCore.sendPrivateMessage(content, recipientPeerID, recipientNickname, messageID) + fun sendPrivateMessage(content: String, recipientPeerID: String, recipientNickname: String, messageID: String? = null) { + if (content.isEmpty() || recipientPeerID.isEmpty()) return + if (recipientNickname.isEmpty()) return + + serviceScope.launch { + val finalMessageID = messageID ?: java.util.UUID.randomUUID().toString() + + Log.d(TAG, "📨 Sending PM to $recipientPeerID: ${content.take(30)}...") + + // Check if we have an established Noise session + if (encryptionService.hasEstablishedSession(recipientPeerID)) { + try { + // Create TLV-encoded private message exactly like iOS + val privateMessage = com.bitchat.android.model.PrivateMessagePacket( + messageID = finalMessageID, + content = content + ) + + val tlvData = privateMessage.encode() + if (tlvData == null) { + Log.e(TAG, "Failed to encode private message with TLV") + return@launch + } + + // Create message payload with NoisePayloadType prefix: [type byte] + [TLV data] + val messagePayload = com.bitchat.android.model.NoisePayload( + type = com.bitchat.android.model.NoisePayloadType.PRIVATE_MESSAGE, + data = tlvData + ) + + // Encrypt the payload + val encrypted = encryptionService.encrypt(messagePayload.encode(), recipientPeerID) + + // Create NOISE_ENCRYPTED packet exactly like iOS + 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 = MAX_TTL + ) + + // Sign the packet before broadcasting + val signedPacket = signPacketBeforeBroadcast(packet) + 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 + // This was causing self-notifications - iOS doesn't do this + // The UI handles showing sent messages through its own message sending logic + + } catch (e: Exception) { + Log.e(TAG, "Failed to encrypt private message for $recipientPeerID: ${e.message}") + } + } else { + // Fire and forget - initiate handshake but don't queue exactly like iOS + Log.d(TAG, "🤝 No session with $recipientPeerID, initiating handshake") + messageHandler.delegate?.initiateNoiseHandshake(recipientPeerID) + + // FIXED: Don't send didReceiveMessage for our own sent messages + // The UI will handle showing the message in the chat interface + } + } } /** * Send read receipt for a received private message - NEW NoisePayloadType implementation * Uses same encryption approach as iOS SimplifiedBluetoothService */ - override fun sendReadReceipt(messageID: String, recipientPeerID: String, readerNickname: String) { - meshCore.sendReadReceipt(messageID, recipientPeerID, readerNickname) + fun sendReadReceipt(messageID: String, recipientPeerID: String, readerNickname: String) { + serviceScope.launch { + Log.d(TAG, "📖 Sending read receipt for message $messageID to $recipientPeerID") + + // Route geohash read receipts via MessageRouter instead of here + val geo = runCatching { com.bitchat.android.services.MessageRouter.tryGetInstance() }.getOrNull() + val isGeoAlias = try { + val map = com.bitchat.android.nostr.GeohashAliasRegistry.snapshot() + map.containsKey(recipientPeerID) + } catch (_: Exception) { false } + if (isGeoAlias && geo != null) { + geo.sendReadReceipt(com.bitchat.android.model.ReadReceipt(messageID), recipientPeerID) + return@launch + } + + try { + // Avoid duplicate read receipts: check persistent store first + val seenStore = try { com.bitchat.android.services.SeenMessageStore.getInstance(context.applicationContext) } catch (_: Exception) { null } + if (seenStore?.hasRead(messageID) == true) { + Log.d(TAG, "Skipping read receipt for $messageID - already marked read") + return@launch + } + + // Create read receipt payload using NoisePayloadType exactly like iOS + val readReceiptPayload = com.bitchat.android.model.NoisePayload( + type = com.bitchat.android.model.NoisePayloadType.READ_RECEIPT, + data = messageID.toByteArray(Charsets.UTF_8) + ) + + // Encrypt the payload + val encrypted = encryptionService.encrypt(readReceiptPayload.encode(), recipientPeerID) + + // Create NOISE_ENCRYPTED packet exactly like iOS + 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 // Same TTL as iOS messageTTL + ) + + // Sign the packet before broadcasting + val signedPacket = signPacketBeforeBroadcast(packet) + broadcastRoutedPacket(RoutedPacket(signedPacket)) + Log.d(TAG, "📤 Sent read receipt to $recipientPeerID for message $messageID") + + // Persist as read after successful send + try { seenStore?.markRead(messageID) } catch (_: Exception) { } + + } catch (e: Exception) { + Log.e(TAG, "Failed to send read receipt to $recipientPeerID: ${e.message}") + } + } + } + + // 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 */ - override fun sendBroadcastAnnounce() { - meshCore.sendBroadcastAnnounce() + fun sendBroadcastAnnounce() { + Log.d(TAG, "Sending broadcast announce") + serviceScope.launch { + val nickname = try { com.bitchat.android.services.NicknameProvider.getNickname(context, myPeerID) } catch (_: Exception) { myPeerID } + + // Get the static public key for the announcement + val staticKey = encryptionService.getStaticPublicKey() + if (staticKey == null) { + Log.e(TAG, "No static public key available for announcement") + return@launch + } + + // Get the signing public key for the announcement + val signingKey = encryptionService.getSigningPublicKey() + if (signingKey == null) { + Log.e(TAG, "No signing public key available for announcement") + return@launch + } + + // Create iOS-compatible IdentityAnnouncement with TLV encoding + val announcement = IdentityAnnouncement(nickname, staticKey, signingKey) + 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, + ttl = MAX_TTL, + senderID = myPeerID, + payload = tlvPayload + ) + + // Sign the packet using our signing key (exactly like iOS) + val signedPacket = encryptionService.signData(announcePacket.toBinaryDataForSigning()!!)?.let { signature -> + announcePacket.copy(signature = signature) + } ?: announcePacket + + 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) { } + } } /** * Send announcement to specific peer with TLV-encoded identity announcement - exactly like iOS */ - override fun sendAnnouncementToPeer(peerID: String) { - meshCore.sendAnnouncementToPeer(peerID) + fun sendAnnouncementToPeer(peerID: String) { + if (peerManager.hasAnnouncedToPeer(peerID)) return + + val nickname = try { com.bitchat.android.services.NicknameProvider.getNickname(context, myPeerID) } catch (_: Exception) { myPeerID } + + // Get the static public key for the announcement + val staticKey = encryptionService.getStaticPublicKey() + if (staticKey == null) { + Log.e(TAG, "No static public key available for peer announcement") + return + } + + // Get the signing public key for the announcement + val signingKey = encryptionService.getSigningPublicKey() + if (signingKey == null) { + Log.e(TAG, "No signing public key available for peer announcement") + return + } + + // Create iOS-compatible IdentityAnnouncement with TLV encoding + val announcement = IdentityAnnouncement(nickname, staticKey, signingKey) + 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, + ttl = MAX_TTL, + senderID = myPeerID, + payload = tlvPayload + ) + + // Sign the packet using our signing key (exactly like iOS) + val signedPacket = encryptionService.signData(packet.toBinaryDataForSigning()!!)?.let { signature -> + packet.copy(signature = signature) + } ?: packet + + broadcastRoutedPacket(RoutedPacket(signedPacket)) + peerManager.markPeerAsAnnouncedTo(peerID) + Log.d(TAG, "Sent iOS-compatible signed TLV peer announce to $peerID (${tlvPayload.size} bytes)") + + // Track announce for sync + 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.toList() + direct.take(10) + } catch (_: Exception) { + emptyList() + } + } + + /** + * Send leave announcement + */ + private fun sendLeaveAnnouncement() { + val packet = BitchatPacket( + type = MessageType.LEAVE.value, + ttl = MAX_TTL, + senderID = myPeerID, + payload = byteArrayOf() + ) + + // Sign the packet before broadcasting + val signedPacket = signPacketBeforeBroadcast(packet) + broadcastRoutedPacket(RoutedPacket(signedPacket)) + } + /** * Get peer nicknames */ - override fun getPeerNicknames(): Map = meshCore.getPeerNicknames() + fun getPeerNicknames(): Map = peerManager.getAllPeerNicknames() /** * Get peer RSSI values */ - override fun getPeerRSSI(): Map = meshCore.getPeerRSSI() + fun getPeerRSSI(): Map = peerManager.getAllPeerRSSI() /** * Check if we have an established Noise session with a peer */ - override fun hasEstablishedSession(peerID: String): Boolean { - return meshCore.hasEstablishedSession(peerID) + fun hasEstablishedSession(peerID: String): Boolean { + return encryptionService.hasEstablishedSession(peerID) } /** * Get session state for a peer (for UI state display) */ - override fun getSessionState(peerID: String): com.bitchat.android.noise.NoiseSession.NoiseSessionState { - return meshCore.getSessionState(peerID) + fun getSessionState(peerID: String): com.bitchat.android.noise.NoiseSession.NoiseSessionState { + return encryptionService.getSessionState(peerID) } /** * Initiate Noise handshake with a specific peer (public API) */ - override fun initiateNoiseHandshake(peerID: String) { - meshCore.initiateNoiseHandshake(peerID) + fun initiateNoiseHandshake(peerID: String) { + // Delegate to the existing implementation in the MessageHandler delegate + messageHandler.delegate?.initiateNoiseHandshake(peerID) } /** * Get peer fingerprint for identity management */ - override fun getPeerFingerprint(peerID: String): String? { - return meshCore.getPeerFingerprint(peerID) + fun getPeerFingerprint(peerID: String): String? { + return peerManager.getFingerprintForPeer(peerID) } /** * Get current active peer count (for status/notifications) */ - override fun getActivePeerCount(): Int { - return meshCore.getActivePeerCount() + fun getActivePeerCount(): Int { + return try { peerManager.getActivePeerCount() } catch (_: Exception) { 0 } } /** * Get peer info for verification purposes */ - override fun getPeerInfo(peerID: String): PeerInfo? { - return meshCore.getPeerInfo(peerID) + fun getPeerInfo(peerID: String): PeerInfo? { + return peerManager.getPeerInfo(peerID) } /** * Update peer information with verification data */ - override fun updatePeerInfo( + fun updatePeerInfo( peerID: String, nickname: String, noisePublicKey: ByteArray, signingPublicKey: ByteArray, isVerified: Boolean ): Boolean { - return meshCore.updatePeerInfo(peerID, nickname, noisePublicKey, signingPublicKey, isVerified) + return peerManager.updatePeerInfo(peerID, nickname, noisePublicKey, signingPublicKey, isVerified) } /** * Get our identity fingerprint */ - override fun getIdentityFingerprint(): String { - return meshCore.getIdentityFingerprint() + fun getIdentityFingerprint(): String { + return encryptionService.getIdentityFingerprint() + } + + fun getStaticNoisePublicKey(): ByteArray? { + return encryptionService.getStaticPublicKey() } /** * Check if encryption icon should be shown for a peer */ - override fun shouldShowEncryptionIcon(peerID: String): Boolean { - return meshCore.shouldShowEncryptionIcon(peerID) + fun shouldShowEncryptionIcon(peerID: String): Boolean { + return encryptionService.hasEstablishedSession(peerID) } /** * Get all peers with established encrypted sessions */ - override fun getEncryptedPeers(): List { - return meshCore.getEncryptedPeers() + fun getEncryptedPeers(): List { + // SIMPLIFIED: Return empty list for now since we don't have direct access to sessionManager + // This method is not critical for the session retention fix + return emptyList() } /** * Get device address for a specific peer ID */ - override fun getDeviceAddressForPeer(peerID: String): String? { - return meshCore.getDeviceAddressForPeer(peerID) + fun getDeviceAddressForPeer(peerID: String): String? { + return connectionManager.addressPeerMap.entries.find { it.value == peerID }?.key } /** * Get all device addresses mapped to their peer IDs */ - override fun getDeviceAddressToPeerMapping(): Map { - return meshCore.getDeviceAddressToPeerMapping() + fun getDeviceAddressToPeerMapping(): Map { + return connectionManager.addressPeerMap.toMap() } /** * Print device addresses for all connected peers */ - override fun printDeviceAddressesForPeers(): String { - return meshCore.getDebugInfoWithDeviceAddresses(connectionManager.addressPeerMap) + fun printDeviceAddressesForPeers(): String { + return peerManager.getDebugInfoWithDeviceAddresses(connectionManager.addressPeerMap) } /** * Get debug status information */ - override fun getDebugStatus(): String { - return meshCore.getDebugStatus( - transportInfo = connectionManager.getDebugInfo(), - deviceMap = connectionManager.addressPeerMap, - extraLines = listOf(meshCore.getFingerprintDebugInfo()), - title = "Bluetooth Mesh Service Debug Status" - ) + fun getDebugStatus(): String { + return buildString { + appendLine("=== Bluetooth Mesh Service Debug Status ===") + appendLine("My Peer ID: $myPeerID") + appendLine() + appendLine(connectionManager.getDebugInfo()) + appendLine() + appendLine(peerManager.getDebugInfo(connectionManager.addressPeerMap)) + appendLine() + appendLine(peerManager.getFingerprintDebugInfo()) + appendLine() + appendLine(fragmentManager.getDebugInfo()) + appendLine() + appendLine(securityManager.getDebugInfo()) + appendLine() + appendLine(storeForwardManager.getDebugInfo()) + appendLine() + appendLine(messageHandler.getDebugInfo()) + appendLine() + appendLine(packetProcessor.getDebugInfo()) + } + } + + /** + * Convert hex string peer ID to binary data (8 bytes) - exactly same as iOS + */ + private fun hexStringToByteArray(hexString: String): ByteArray { + val result = ByteArray(8) { 0 } // Initialize with zeros, exactly 8 bytes + 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 + } + + /** + * Sign packet before broadcasting using our signing private key + */ + 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 = withRoute.toBinaryDataForSigning() + if (packetDataForSigning == null) { + Log.w(TAG, "Failed to encode packet type ${packet.type} for signing, sending unsigned") + 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)") + withRoute.copy(signature = signature) + } else { + Log.w(TAG, "Failed to sign packet type ${packet.type}, sending unsigned") + withRoute + } + } catch (e: Exception) { + Log.w(TAG, "Error signing packet type ${packet.type}: ${e.message}, sending unsigned") + packet + } } // MARK: - Panic Mode Support @@ -544,10 +1407,18 @@ class BluetoothMeshService(private val context: Context) : MeshService, Transpor /** * Clear all internal mesh service data (for panic mode) */ - override fun clearAllInternalData() { + fun clearAllInternalData() { Log.w(TAG, "🚨 Clearing all mesh service internal data") try { - meshCore.clearAllInternalData() + // Stop services to cease broadcasting old ID immediately + stopServices() + + // Clear all managers + fragmentManager.clearAllFragments() + storeForwardManager.clearAllCache() + securityManager.clearAllData() + peerManager.clearAllPeers() + peerManager.clearAllFingerprints() Log.d(TAG, "✅ Cleared all mesh service internal data") } catch (e: Exception) { Log.e(TAG, "❌ Error clearing mesh service internal data: ${e.message}") @@ -557,42 +1428,31 @@ class BluetoothMeshService(private val context: Context) : MeshService, Transpor /** * Clear all encryption and cryptographic data (for panic mode) */ - override fun clearAllEncryptionData() { + fun clearAllEncryptionData() { Log.w(TAG, "🚨 Clearing all encryption data") try { - meshCore.clearAllEncryptionData() + // Clear encryption service persistent identity (includes Ed25519 signing keys) + encryptionService.clearPersistentIdentity() Log.d(TAG, "✅ Cleared all encryption data") } catch (e: Exception) { Log.e(TAG, "❌ Error clearing encryption data: ${e.message}") } } +} - private inner class BleMeshTransport : MeshTransport { - override val id: String = "BLE" - var connectionManager: BluetoothConnectionManager? = null - - override fun broadcastPacket(routed: RoutedPacket) { - connectionManager?.broadcastPacket(routed) - } - - override fun sendPacketToPeer(peerID: String, packet: BitchatPacket) { - connectionManager?.sendPacketToPeer(peerID, packet) - } - - override fun cancelTransfer(transferId: String): Boolean { - return connectionManager?.cancelTransfer(transferId) ?: false - } - - override fun getDeviceAddressForPeer(peerID: String): String? { - return connectionManager?.addressPeerMap?.entries?.find { it.value == peerID }?.key - } - - override fun getDeviceAddressToPeerMapping(): Map { - return connectionManager?.addressPeerMap?.toMap() ?: emptyMap() - } - - override fun getTransportDebugInfo(): String { - return connectionManager?.getDebugInfo() ?: "" - } - } +/** + * Delegate interface for mesh service callbacks (maintains exact same interface) + */ +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 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 + // registerPeerPublicKey REMOVED - fingerprints now handled centrally in PeerManager } 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..37af030d 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothPacketBroadcaster.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothPacketBroadcaster.kt @@ -42,7 +42,8 @@ import kotlinx.coroutines.channels.actor class BluetoothPacketBroadcaster( private val connectionScope: CoroutineScope, private val connectionTracker: BluetoothConnectionTracker, - private val fragmentManager: FragmentManager? + private val fragmentManager: FragmentManager?, + private val myPeerID: String ) { companion object { @@ -68,7 +69,9 @@ class BluetoothPacketBroadcaster( incomingAddr: String?, toPeer: String?, toDeviceAddress: String, - ttl: UByte + ttl: UByte, + packetVersion: UByte = 1u, + routeInfo: String? = null ) { try { val fromNick = incomingPeer?.let { nicknameResolver?.invoke(it) } @@ -80,7 +83,9 @@ class BluetoothPacketBroadcaster( toPeerID = toPeer, toNickname = toNick, toDeviceAddress = toDeviceAddress, - previousHopPeerID = incomingPeer + previousHopPeerID = incomingPeer, + packetVersion = packetVersion, + routeInfo = routeInfo ) // Keep the verbose relay message for human readability manager.logPacketRelayDetailed( @@ -94,7 +99,9 @@ class BluetoothPacketBroadcaster( toNickname = toNick, toDeviceAddress = toDeviceAddress, ttl = ttl, - isRelay = true + isRelay = true, + packetVersion = packetVersion, + routeInfo = routeInfo ) } catch (_: Exception) { // Silently ignore debug logging failures @@ -221,17 +228,19 @@ class BluetoothPacketBroadcaster( TransferProgressManager.start(transferId, 1) } val typeName = MessageType.fromValue(packet.type)?.name ?: packet.type.toString() + val senderPeerID = routed.peerID ?: packet.senderID.toHexString() val incomingAddr = routed.relayAddress val incomingPeer = incomingAddr?.let { connectionTracker.addressPeerMap[it] } - val senderPeerID = routed.peerID ?: packet.senderID.toHexString() val senderNick = senderPeerID.let { pid -> nicknameResolver?.invoke(pid) } + val route = packet.route + val routeInfo = if (!route.isNullOrEmpty()) "routed: ${route.size} hops" else null // Prefer server-side subscriptions val serverTarget = connectionTracker.getSubscribedDevices() .firstOrNull { connectionTracker.addressPeerMap[it.address] == targetPeerID } if (serverTarget != null) { if (notifyDevice(serverTarget, data, gattServer, characteristic)) { - logPacketRelay(typeName, senderPeerID, senderNick, incomingPeer, incomingAddr, targetPeerID, serverTarget.address, packet.ttl) + logPacketRelay(typeName, senderPeerID, senderNick, incomingPeer, incomingAddr, targetPeerID, serverTarget.address, packet.ttl, packet.version, routeInfo) if (transferId != null) { TransferProgressManager.progress(transferId, 1, 1) TransferProgressManager.complete(transferId, 1) @@ -245,7 +254,7 @@ class BluetoothPacketBroadcaster( .firstOrNull { connectionTracker.addressPeerMap[it.device.address] == targetPeerID } if (clientTarget != null) { if (writeToDeviceConn(clientTarget, data)) { - logPacketRelay(typeName, senderPeerID, senderNick, incomingPeer, incomingAddr, targetPeerID, clientTarget.device.address, packet.ttl) + logPacketRelay(typeName, senderPeerID, senderNick, incomingPeer, incomingAddr, targetPeerID, clientTarget.device.address, packet.ttl, packet.version, routeInfo) if (transferId != null) { TransferProgressManager.progress(transferId, 1, 1) TransferProgressManager.complete(transferId, 1) @@ -283,6 +292,46 @@ class BluetoothPacketBroadcaster( } } } + + /** + * Targeted send to a specific peer (by peerID) if directly connected. + * Returns true if sent to at least one matching connection. + */ + fun sendToPeer( + targetPeerID: String, + routed: RoutedPacket, + gattServer: BluetoothGattServer?, + characteristic: BluetoothGattCharacteristic? + ): Boolean { + val packet = routed.packet + val data = packet.toBinaryData() ?: return false + val typeName = MessageType.fromValue(packet.type)?.name ?: packet.type.toString() + val senderPeerID = routed.peerID ?: packet.senderID.toHexString() + val incomingAddr = routed.relayAddress + val incomingPeer = incomingAddr?.let { connectionTracker.addressPeerMap[it] } + val senderNick = senderPeerID.let { pid -> nicknameResolver?.invoke(pid) } + + // Try server-side connections first + val targetDevice = connectionTracker.getSubscribedDevices() + .firstOrNull { connectionTracker.addressPeerMap[it.address] == targetPeerID } + if (targetDevice != null) { + if (notifyDevice(targetDevice, data, gattServer, characteristic)) { + logPacketRelay(typeName, senderPeerID, senderNick, incomingPeer, incomingAddr, targetPeerID, targetDevice.address, packet.ttl) + return true + } + } + + // Try client-side connections next + val targetConn = connectionTracker.getConnectedDevices().values + .firstOrNull { connectionTracker.addressPeerMap[it.device.address] == targetPeerID } + if (targetConn != null) { + if (writeToDeviceConn(targetConn, data)) { + logPacketRelay(typeName, senderPeerID, senderNick, incomingPeer, incomingAddr, targetPeerID, targetConn.device.address, packet.ttl) + return true + } + } + return false + } /** * Internal broadcast implementation - runs in serialized actor context @@ -299,11 +348,52 @@ class BluetoothPacketBroadcaster( val incomingAddr = routed.relayAddress val incomingPeer = incomingAddr?.let { connectionTracker.addressPeerMap[it] } val senderNick = senderPeerID.let { pid -> nicknameResolver?.invoke(pid) } + val route = packet.route + val routeInfo = if (!route.isNullOrEmpty()) "routed: ${route.size} hops" else null + + // Source Routing for Originating Packets + // If we are the sender and a source route is defined, we must send ONLY to the first hop. + if (packet.senderID.toHexString() == myPeerID && !packet.route.isNullOrEmpty()) { + val firstHop = packet.route!![0].toHexString() + Log.d(TAG, "Source Routing: Packet has explicit route, attempting to send to first hop: $firstHop") + + var sent = false + + // Try to find first hop in server connections (subscribedDevices) + val serverTarget = connectionTracker.getSubscribedDevices() + .firstOrNull { connectionTracker.addressPeerMap[it.address] == firstHop } + + if (serverTarget != null) { + Log.d(TAG, "Source Routing: sending directly to first hop (server conn) $firstHop: ${serverTarget.address}") + if (notifyDevice(serverTarget, data, gattServer, characteristic)) { + val toPeer = connectionTracker.addressPeerMap[serverTarget.address] + logPacketRelay(typeName, senderPeerID, senderNick, incomingPeer, incomingAddr, toPeer, serverTarget.address, packet.ttl, packet.version, routeInfo) + sent = true + } + } + + // Try to find first hop in client connections if not sent yet + if (!sent) { + val clientTarget = connectionTracker.getConnectedDevices().values + .firstOrNull { connectionTracker.addressPeerMap[it.device.address] == firstHop } + + if (clientTarget != null) { + Log.d(TAG, "Source Routing: sending directly to first hop (client conn) $firstHop: ${clientTarget.device.address}") + if (writeToDeviceConn(clientTarget, data)) { + val toPeer = connectionTracker.addressPeerMap[clientTarget.device.address] + logPacketRelay(typeName, senderPeerID, senderNick, incomingPeer, incomingAddr, toPeer, clientTarget.device.address, packet.ttl, packet.version, routeInfo) + sent = true + } + } + } + + if (sent) return + + Log.w(TAG, "Source Routing: First hop $firstHop not connected. Falling back to standard broadcast logic.") + } if (packet.recipientID != SpecialRecipients.BROADCAST) { - val recipientID = packet.recipientID?.let { - String(it).replace("\u0000", "").trim() - } ?: "" + val recipientID = packet.recipientID?.toHexString() ?: "" // Try to find the recipient in server connections (subscribedDevices) val targetDevice = connectionTracker.getSubscribedDevices() @@ -314,7 +404,7 @@ class BluetoothPacketBroadcaster( Log.d(TAG, "Send packet type ${packet.type} directly to target device for recipient $recipientID: ${targetDevice.address}") if (notifyDevice(targetDevice, data, gattServer, characteristic)) { val toPeer = connectionTracker.addressPeerMap[targetDevice.address] - logPacketRelay(typeName, senderPeerID, senderNick, incomingPeer, incomingAddr, toPeer, targetDevice.address, packet.ttl) + logPacketRelay(typeName, senderPeerID, senderNick, incomingPeer, incomingAddr, toPeer, targetDevice.address, packet.ttl, packet.version, routeInfo) return // Sent, no need to continue } } @@ -328,7 +418,7 @@ class BluetoothPacketBroadcaster( Log.d(TAG, "Send packet type ${packet.type} directly to target client connection for recipient $recipientID: ${targetDeviceConn.device.address}") if (writeToDeviceConn(targetDeviceConn, data)) { val toPeer = connectionTracker.addressPeerMap[targetDeviceConn.device.address] - logPacketRelay(typeName, senderPeerID, senderNick, incomingPeer, incomingAddr, toPeer, targetDeviceConn.device.address, packet.ttl) + logPacketRelay(typeName, senderPeerID, senderNick, incomingPeer, incomingAddr, toPeer, targetDeviceConn.device.address, packet.ttl, packet.version, routeInfo) return // Sent, no need to continue } } @@ -338,9 +428,9 @@ class BluetoothPacketBroadcaster( val subscribedDevices = connectionTracker.getSubscribedDevices() val connectedDevices = connectionTracker.getConnectedDevices() - Log.i(TAG, "Broadcasting packet type ${packet.type} to ${subscribedDevices.size} server + ${connectedDevices.size} client connections") + Log.i(TAG, "Broadcasting packet v${packet.version} type ${packet.type} to ${subscribedDevices.size} server + ${connectedDevices.size} client connections") - val senderID = String(packet.senderID).replace("\u0000", "") + val senderID = packet.senderID.toHexString() // Send to server connections (devices connected to our GATT server) subscribedDevices.forEach { device -> @@ -355,7 +445,7 @@ class BluetoothPacketBroadcaster( val sent = notifyDevice(device, data, gattServer, characteristic) if (sent) { val toPeer = connectionTracker.addressPeerMap[device.address] - logPacketRelay(typeName, senderPeerID, senderNick, incomingPeer, incomingAddr, toPeer, device.address, packet.ttl) + logPacketRelay(typeName, senderPeerID, senderNick, incomingPeer, incomingAddr, toPeer, device.address, packet.ttl, packet.version, routeInfo) } } @@ -373,7 +463,7 @@ class BluetoothPacketBroadcaster( val sent = writeToDeviceConn(deviceConn, data) if (sent) { val toPeer = connectionTracker.addressPeerMap[deviceConn.device.address] - logPacketRelay(typeName, senderPeerID, senderNick, incomingPeer, incomingAddr, toPeer, deviceConn.device.address, packet.ttl) + logPacketRelay(typeName, senderPeerID, senderNick, incomingPeer, incomingAddr, toPeer, deviceConn.device.address, packet.ttl, packet.version, routeInfo) } } } 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/MeshCore.kt b/app/src/main/java/com/bitchat/android/mesh/MeshCore.kt index 9bbe1b57..9f72752d 100644 --- a/app/src/main/java/com/bitchat/android/mesh/MeshCore.kt +++ b/app/src/main/java/com/bitchat/android/mesh/MeshCore.kt @@ -21,6 +21,7 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking +import java.util.concurrent.ConcurrentHashMap /** * Shared mesh coordinator that wires all mesh-layer components and provides common APIs @@ -53,6 +54,7 @@ class MeshCore( private val storeForwardManager = StoreForwardManager() private val messageHandler = MessageHandler(myPeerID, context.applicationContext) private val packetProcessor = PacketProcessor(myPeerID) + private val directPeers = ConcurrentHashMap.newKeySet() val gossipSyncManager: GossipSyncManager = sharedGossipManager ?: GossipSyncManager(myPeerID = myPeerID, scope = scope, configProvider = gossipConfigProvider) @@ -65,6 +67,7 @@ class MeshCore( init { messageHandler.packetProcessor = packetProcessor + peerManager.isPeerDirectlyConnected = { peerID -> directPeers.contains(peerID) } setupDelegates() if (sharedGossipManager == null) { @@ -307,6 +310,14 @@ class MeshCore( override fun onReadReceiptReceived(messageID: String, peerID: String) { delegate?.didReceiveReadReceipt(messageID, peerID) } + + override fun onVerifyChallengeReceived(peerID: String, payload: ByteArray, timestampMs: Long) { + // MeshDelegate intentionally does not expose QR verification yet. + } + + override fun onVerifyResponseReceived(peerID: String, payload: ByteArray, timestampMs: Long) { + // MeshDelegate intentionally does not expose QR verification yet. + } } packetProcessor.delegate = object : PacketProcessorDelegate { @@ -383,6 +394,12 @@ class MeshCore( dispatchGlobal(routed) } + override fun sendToPeer(peerID: String, routed: RoutedPacket): Boolean { + transport.sendPacketToPeer(peerID, routed.packet) + TransportBridgeService.sendToPeer(transport.id, peerID, routed.packet) + return true + } + override fun handleRequestSync(routed: RoutedPacket) { val fromPeer = routed.peerID ?: return val req = RequestSyncPacket.decode(routed.packet.payload) ?: return @@ -604,7 +621,12 @@ class MeshCore( } fun setDirectConnection(peerID: String, isDirect: Boolean) { - peerManager.setDirectConnection(peerID, isDirect) + if (isDirect) { + directPeers.add(peerID) + } else { + directPeers.remove(peerID) + } + peerManager.refreshPeerList() } fun updatePeerRSSI(peerID: String, rssi: Int) { diff --git a/app/src/main/java/com/bitchat/android/mesh/MeshDelegate.kt b/app/src/main/java/com/bitchat/android/mesh/MeshDelegate.kt index 2c3341e5..4c12dbd1 100644 --- a/app/src/main/java/com/bitchat/android/mesh/MeshDelegate.kt +++ b/app/src/main/java/com/bitchat/android/mesh/MeshDelegate.kt @@ -15,5 +15,3 @@ interface MeshDelegate { fun getNickname(): String? fun isFavorite(peerID: String): Boolean } - -typealias BluetoothMeshDelegate = MeshDelegate 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..ecac572b 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.* @@ -157,6 +158,14 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro // Simplified: Call delegate with messageID and peerID directly delegate?.onReadReceiptReceived(messageID, peerID) } + com.bitchat.android.model.NoisePayloadType.VERIFY_CHALLENGE -> { + Log.d(TAG, "🔐 Verify challenge received from $peerID (${noisePayload.data.size} bytes)") + delegate?.onVerifyChallengeReceived(peerID, noisePayload.data, packet.timestamp.toLong()) + } + com.bitchat.android.model.NoisePayloadType.VERIFY_RESPONSE -> { + Log.d(TAG, "🔐 Verify response received from $peerID (${noisePayload.data.size} bytes)") + delegate?.onVerifyResponseReceived(peerID, noisePayload.data, packet.timestamp.toLong()) + } } } catch (e: Exception) { @@ -278,6 +287,13 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro previousPeerID = null ) + // Update mesh graph from gossip neighbors (only if TLV present) + try { + val neighborsOrNull = com.bitchat.android.services.meshgraph.GossipTLV.decodeNeighborsFromAnnouncementPayload(packet.payload) + com.bitchat.android.services.meshgraph.MeshGraphService.getInstance() + .updateFromAnnouncement(peerID, nickname, neighborsOrNull, packet.timestamp) + } catch (_: Exception) { } + Log.d(TAG, "✅ Processed verified TLV announce: stored identity for $peerID") return isFirstAnnounce } @@ -385,7 +401,7 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro } val savedPath = com.bitchat.android.features.file.FileUtils.saveIncomingFile(appContext, file) val message = BitchatMessage( - id = java.util.UUID.randomUUID().toString().uppercase(), + id = PacketIdUtil.computeIdHex(packet).uppercase(), sender = delegate?.getPeerNickname(peerID) ?: "unknown", content = savedPath, type = com.bitchat.android.features.file.FileUtils.messageTypeForMime(file.mimeType), @@ -401,6 +417,7 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro // Fallback: plain text val message = BitchatMessage( + id = PacketIdUtil.computeIdHex(packet).uppercase(), sender = delegate?.getPeerNickname(peerID) ?: "unknown", content = String(packet.payload, Charsets.UTF_8), senderPeerID = peerID, @@ -611,4 +628,6 @@ interface MessageHandlerDelegate { fun onChannelLeave(channel: String, fromPeer: String) fun onDeliveryAckReceived(messageID: String, peerID: String) fun onReadReceiptReceived(messageID: String, peerID: String) + fun onVerifyChallengeReceived(peerID: String, payload: ByteArray, timestampMs: Long) + fun onVerifyResponseReceived(peerID: String, payload: ByteArray, timestampMs: Long) } 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 27ce6cb0..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()) @@ -165,10 +168,18 @@ class PeerManager { } /** - * Get peer info + * Get peer info with dynamic direct connection status */ fun getPeerInfo(peerID: String): PeerInfo? { - return peers[peerID] + return peers[peerID]?.let { info -> + // Dynamically check direct connection status from ConnectionManager + val isDirect = isPeerDirectlyConnected?.invoke(peerID) ?: false + if (info.isDirectConnection != isDirect) { + info.copy(isDirectConnection = isDirect) + } else { + info + } + } } /** @@ -179,27 +190,12 @@ class PeerManager { } /** - * Get all verified peers + * Get all verified peers with dynamic direct connection status */ fun getVerifiedPeers(): Map { - return peers.filterValues { it.isVerifiedNickname } - } - - /** - * Set whether a peer is directly connected over Bluetooth. - * Triggers a peer list update to refresh UI badges. - */ - fun setDirectConnection(peerID: String, isDirect: Boolean) { - peers[peerID]?.let { existing -> - if (existing.isDirectConnection != isDirect) { - peers[peerID] = existing.copy(isDirectConnection = isDirect) - notifyPeerListUpdate() - // NEW: notify UI state (if available via delegate path) about directness change - try { - // Best-effort: delegate path flows up to ChatViewModel via didUpdatePeerList - // No direct reference to UI layer here by design. - } catch (_: Exception) { } - } + return peers.filterValues { it.isVerifiedNickname }.mapValues { (_, info) -> + val isDirect = isPeerDirectlyConnected?.invoke(info.id) ?: false + if (info.isDirectConnection != isDirect) info.copy(isDirectConnection = isDirect) else info } } @@ -319,8 +315,7 @@ class PeerManager { */ fun isPeerActive(peerID: String): Boolean { val info = peers[peerID] ?: return false - val now = System.currentTimeMillis() - return (now - info.lastSeen) <= stalePeerTimeoutMs && info.isConnected + return info.isConnected } /** @@ -348,8 +343,7 @@ class PeerManager { * Get list of active peer IDs */ fun getActivePeerIDs(): List { - val now = System.currentTimeMillis() - return peers.filterValues { (now - it.lastSeen) <= stalePeerTimeoutMs && it.isConnected } + return peers.filterValues { it.isConnected } .keys .toList() .sorted() @@ -386,7 +380,11 @@ class PeerManager { return buildString { appendLine("=== Peer Manager Debug Info ===") appendLine("Active Peers: ${activeIds.size}") - peers.forEach { (peerID, info) -> + peers.forEach { (peerID, storedInfo) -> + // Use dynamic direct status for debug log accuracy + val isDirect = isPeerDirectlyConnected?.invoke(peerID) ?: false + val info = if (storedInfo.isDirectConnection != isDirect) storedInfo.copy(isDirectConnection = isDirect) else storedInfo + val timeSince = (now - info.lastSeen) / 1000 val rssi = peerRSSI[peerID]?.let { "${it} dBm" } ?: "No RSSI" val deviceAddress = addressPeerMap?.entries?.find { it.value == peerID }?.key 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/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 63320384..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,17 +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 - val localPeerID = calculateFingerprint(staticIdentityPublicKey).take(16) - sessionManager = NoiseSessionManager(staticIdentityPrivateKey, staticIdentityPublicKey, localPeerID) - - // Set up session callbacks - sessionManager.onSessionEstablished = { peerID, remoteStaticKey -> - handleSessionEstablished(peerID, remoteStaticKey) - } } - + // MARK: - Public Interface /** @@ -136,7 +154,23 @@ class NoiseEncryptionService(private val context: Context) { * Clear persistent identity (for panic mode) */ fun clearPersistentIdentity() { + Log.w(TAG, "🚨 Panic Mode: Clearing persistent identity and rotating in-memory keys") + + // 1. Clear storage identityStateManager.clearIdentityData() + + // 2. Clear all sessions immediately + if (::sessionManager.isInitialized) { + sessionManager.shutdown() + } + + // 3. Regenerate keys immediately (in-memory rotation) + loadOrGenerateKeys() + + // 4. Re-initialize SessionManager with new keys + initializeSessionManager() + + Log.d(TAG, "✅ Identity cleared and keys rotated") } // MARK: - Handshake Management @@ -479,7 +513,9 @@ class NoiseEncryptionService(private val context: Context) { * Clean shutdown */ fun shutdown() { - sessionManager.shutdown() + if (::sessionManager.isInitialized) { + sessionManager.shutdown() + } channelEncryption.clear() // No need to clear fingerprints here - they are managed centrally } 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..36a1343d 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 @@ -40,7 +41,8 @@ class PermissionManager(private val context: Context) { } /** - * Get all permissions required by the app + * Get required permissions that can be requested together. + * Background location is handled separately to ensure correct request order. * Note: Notification permission is optional and not included here, * so the app works without notification access. */ @@ -77,6 +79,27 @@ class PermissionManager(private val context: Context) { return permissions } + /** + * Background location permission is required on Android 10+ for background BLE scanning. + * Must be requested after foreground location permissions are granted. + */ + fun needsBackgroundLocationPermission(): Boolean { + return Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q + } + + fun getBackgroundLocationPermission(): String? { + return if (needsBackgroundLocationPermission()) { + Manifest.permission.ACCESS_BACKGROUND_LOCATION + } else { + null + } + } + + fun isBackgroundLocationGranted(): Boolean { + val permission = getBackgroundLocationPermission() ?: return true + return isPermissionGranted(permission) + } + /** * Get optional permissions that improve the experience but aren't required. * Currently includes POST_NOTIFICATIONS on Android 13+. @@ -98,9 +121,13 @@ class PermissionManager(private val context: Context) { } /** - * Check if all required permissions are granted + * Check if all required permissions are granted (background location is optional). */ fun areAllPermissionsGranted(): Boolean { + return areRequiredPermissionsGranted() + } + + fun areRequiredPermissionsGranted(): Boolean { return getRequiredPermissions().all { isPermissionGranted(it) } } @@ -136,6 +163,11 @@ class PermissionManager(private val context: Context) { return getRequiredPermissions().filter { !isPermissionGranted(it) } } + fun getMissingBackgroundLocationPermission(): List { + val permission = getBackgroundLocationPermission() ?: return emptyList() + return if (isPermissionGranted(permission)) emptyList() else listOf(permission) + } + /** * Get categorized permission information for display */ @@ -196,6 +228,19 @@ class PermissionManager(private val context: Context) { ) } + if (needsBackgroundLocationPermission()) { + val backgroundPermission = listOf(Manifest.permission.ACCESS_BACKGROUND_LOCATION) + categories.add( + PermissionCategory( + type = PermissionType.BACKGROUND_LOCATION, + description = context.getString(R.string.perm_background_location_desc), + permissions = backgroundPermission, + isGranted = backgroundPermission.all { isPermissionGranted(it) }, + systemDescription = context.getString(R.string.perm_background_location_system) + ) + ) + } + // Notifications category (if applicable) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { categories.add( @@ -235,7 +280,7 @@ class PermissionManager(private val context: Context) { appendLine("Permission Diagnostics:") appendLine("Android SDK: ${Build.VERSION.SDK_INT}") appendLine("First time launch: ${isFirstTimeLaunch()}") - appendLine("All permissions granted: ${areAllPermissionsGranted()}") + appendLine("Required permissions granted: ${areAllPermissionsGranted()}") appendLine() getCategorizedPermissions().forEach { category -> @@ -247,7 +292,7 @@ class PermissionManager(private val context: Context) { appendLine() } - val missing = getMissingPermissions() + val missing = getMissingPermissions() + getMissingBackgroundLocationPermission() if (missing.isNotEmpty()) { appendLine("Missing permissions:") missing.forEach { permission -> @@ -279,6 +324,7 @@ data class PermissionCategory( enum class PermissionType(val nameValue: String) { NEARBY_DEVICES("Nearby Devices"), PRECISE_LOCATION("Precise Location"), + BACKGROUND_LOCATION("Background Location"), MICROPHONE("Microphone"), NOTIFICATIONS("Notifications"), WIFI_AWARE("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..db17ba1c 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,10 +244,14 @@ object BinaryProtocol { if (isCompressed) { flags = flags or Flags.IS_COMPRESSED } + // HAS_ROUTE is only supported for v2+ packets + if (!packet.route.isNullOrEmpty() && packet.version >= 2u.toUByte()) { + flags = flags or Flags.HAS_ROUTE + } buffer.put(flags.toByte()) // Payload length (2 or 4 bytes, big-endian) - includes original size if compressed - val payloadDataSize = payload.size + if (isCompressed) 2 else 0 + val payloadDataSize = payload.size + sizeFieldBytes if (packet.version >= 2u.toUByte()) { buffer.putInt(payloadDataSize) // 4 bytes for v2+ } else { @@ -256,12 +273,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 +355,8 @@ object BinaryProtocol { val hasRecipient = (flags and Flags.HAS_RECIPIENT) != 0u.toUByte() val hasSignature = (flags and Flags.HAS_SIGNATURE) != 0u.toUByte() val isCompressed = (flags and Flags.IS_COMPRESSED) != 0u.toUByte() + // HAS_ROUTE is only valid for v2+ packets; ignore the flag for v1 + val hasRoute = (version >= 2u.toUByte()) && (flags and Flags.HAS_ROUTE) != 0u.toUByte() // Payload length - version-dependent (2 or 4 bytes) val payloadLength = if (version >= 2u.toUByte()) { @@ -332,9 +365,29 @@ object BinaryProtocol { buffer.getShort().toUShort().toUInt() // 2 bytes for v1, convert to UInt } - // Calculate expected total size + if (payloadLength > com.bitchat.android.util.AppConstants.Protocol.MAX_PAYLOAD_LENGTH.toUInt()) { + Log.w("BinaryProtocol", "Payload length ${payloadLength} exceeds maximum allowed (${com.bitchat.android.util.AppConstants.Protocol.MAX_PAYLOAD_LENGTH})") + return null + } + var expectedSize = headerSize + SENDER_ID_SIZE + payloadLength.toInt() if (hasRecipient) expectedSize += RECIPIENT_ID_SIZE + var routeCount = 0 + if (hasRoute) { + // Peek count (1 byte) without consuming buffer for now + // The buffer is currently positioned at the start of SenderID (after fixed header) + // We must skip SenderID and RecipientID (if present) to find the route count + val currentPos = buffer.position() + var routeOffset = currentPos + SENDER_ID_SIZE + if (hasRecipient) { + routeOffset += RECIPIENT_ID_SIZE + } + + if (raw.size >= routeOffset + 1) { + routeCount = raw[routeOffset].toUByte().toInt() + } + expectedSize += 1 + (routeCount * SENDER_ID_SIZE) + } if (hasSignature) expectedSize += SIGNATURE_SIZE if (raw.size < expectedSize) return null @@ -350,15 +403,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 +467,11 @@ object BinaryProtocol { timestamp = timestamp, payload = payload, signature = signature, - ttl = ttl + ttl = ttl, + route = route ) - } catch (e: Exception) { + } catch (e: Throwable) { Log.e("BinaryProtocol", "Error decoding packet: ${e.message}") return null } 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 998c03b2..aa9e6fc3 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,8 @@ class MeshForegroundService : Service() { private lateinit var notificationManager: NotificationManagerCompat private var updateJob: Job? = null - private var meshService: BluetoothMeshService? = null + private val meshService: BluetoothMeshService? + get() = MeshServiceHolder.meshService private val serviceJob = Job() private val scope = CoroutineScope(Dispatchers.Default + serviceJob) private var isInForeground: Boolean = false @@ -122,15 +125,15 @@ class MeshForegroundService : Service() { notificationManager = NotificationManagerCompat.from(this) createChannel() - // Adopt or create the mesh service + // Ensure mesh service exists in holder (create if needed) val existing = MeshServiceHolder.meshService - meshService = existing ?: MeshServiceHolder.getOrCreate(applicationContext) if (existing != null) { - android.util.Log.d("MeshForegroundService", "Adopted existing BluetoothMeshService from holder") + Log.d("MeshForegroundService", "Using existing BluetoothMeshService from holder") } else { - android.util.Log.i("MeshForegroundService", "Created/adopted new BluetoothMeshService via holder") + val created = MeshServiceHolder.getOrCreate(applicationContext) + Log.i("MeshForegroundService", "Created new BluetoothMeshService via holder") + MeshServiceHolder.attach(created) } - MeshServiceHolder.attach(meshService!!) } override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { @@ -176,7 +179,7 @@ class MeshForegroundService : Service() { // If we became eligible and are not in foreground yet, promote once if (MeshServicePreferences.isBackgroundEnabled(true) && hasAllRequiredPermissions() && !isInForeground) { val n = buildNotification(meshService?.getActivePeerCount() ?: 0) - startForeground(NOTIFICATION_ID, n) + startForegroundCompat(n) isInForeground = true } else { updateNotification(force = true) @@ -191,7 +194,7 @@ class MeshForegroundService : Service() { // Promote exactly once when eligible, otherwise stay background (or stop) if (MeshServicePreferences.isBackgroundEnabled(true) && hasAllRequiredPermissions() && !isInForeground) { val notification = buildNotification(meshService?.getActivePeerCount() ?: 0) - startForeground(NOTIFICATION_ID, notification) + startForegroundCompat(notification) isInForeground = true } @@ -226,7 +229,8 @@ class MeshForegroundService : Service() { if (!hasBluetoothPermissions()) return try { android.util.Log.d("MeshForegroundService", "Ensuring mesh service is started") - meshService?.startServices() + val service = MeshServiceHolder.getOrCreate(applicationContext) + service.startServices() } catch (e: Exception) { android.util.Log.e("MeshForegroundService", "Failed to start mesh service: ${e.message}") } @@ -326,6 +330,35 @@ class MeshForegroundService : Service() { } } + private fun hasLocationPermission(): Boolean { + val fine = androidx.core.content.ContextCompat.checkSelfPermission(this, android.Manifest.permission.ACCESS_FINE_LOCATION) == android.content.pm.PackageManager.PERMISSION_GRANTED + val coarse = androidx.core.content.ContextCompat.checkSelfPermission(this, android.Manifest.permission.ACCESS_COARSE_LOCATION) == android.content.pm.PackageManager.PERMISSION_GRANTED + return fine || coarse + } + + private fun startForegroundCompat(notification: Notification) { + if (Build.VERSION.SDK_INT >= 34) { + val type = if (hasLocationPermission()) { + ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE or ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION + } else { + ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE + } + try { + startForeground(NOTIFICATION_ID, notification, type) + } catch (e: SecurityException) { + // Fallback for cases where "While In Use" permission exists but background start is restricted + if (type and ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION != 0) { + android.util.Log.w("MeshForegroundService", "Failed to start with LOCATION type, falling back to CONNECTED_DEVICE: ${e.message}") + startForeground(NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE) + } else { + throw e + } + } + } else { + startForeground(NOTIFICATION_ID, notification) + } + } + override fun onDestroy() { updateJob?.cancel() updateJob = null 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..0997beff 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,7 @@ import kotlinx.coroutines.flow.asStateFlow object AppStateStore { // Global de-dup set by message id to avoid duplicate keys in Compose lists private val seenMessageIds = mutableSetOf() + private val seenPublicMessageKeys = mutableSetOf() // Connected peer IDs (mesh ephemeral IDs) private val _peers = MutableStateFlow>(emptyList()) val peers: StateFlow> = _peers.asStateFlow() @@ -35,8 +36,10 @@ object AppStateStore { fun addPublicMessage(msg: BitchatMessage) { synchronized(this) { - if (seenMessageIds.contains(msg.id)) return + val publicKey = publicMessageKey(msg) + if (seenMessageIds.contains(msg.id) || seenPublicMessageKeys.contains(publicKey)) return seenMessageIds.add(msg.id) + seenPublicMessageKeys.add(publicKey) _publicMessages.value = _publicMessages.value + msg } } @@ -100,10 +103,22 @@ object AppStateStore { fun clear() { synchronized(this) { seenMessageIds.clear() + seenPublicMessageKeys.clear() _peers.value = emptyList() _publicMessages.value = emptyList() _privateMessages.value = emptyMap() _channelMessages.value = emptyMap() } } + + private fun publicMessageKey(msg: BitchatMessage): String { + val sender = msg.senderPeerID ?: msg.sender + return listOf( + sender, + msg.timestamp.time.toString(), + msg.type.name, + msg.channel ?: "", + msg.content + ).joinToString("\u001F") + } } 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 98958fd4..81aa1e73 100644 --- a/app/src/main/java/com/bitchat/android/services/MessageRouter.kt +++ b/app/src/main/java/com/bitchat/android/services/MessageRouter.kt @@ -11,7 +11,7 @@ import com.bitchat.android.nostr.NostrTransport */ class MessageRouter private constructor( private val context: Context, - private val mesh: BluetoothMeshService, + private var mesh: BluetoothMeshService, private val nostr: NostrTransport ) { companion object { @@ -19,22 +19,22 @@ class MessageRouter private constructor( @Volatile private var INSTANCE: MessageRouter? = null fun tryGetInstance(): MessageRouter? = INSTANCE fun getInstance(context: Context, mesh: BluetoothMeshService): MessageRouter { - return INSTANCE ?: synchronized(this) { - val nostr = NostrTransport.getInstance(context) - INSTANCE?.also { - // Update mesh reference if needed and keep senderPeerID in sync - it.nostr.senderPeerID = mesh.myPeerID - return it - } - MessageRouter(context.applicationContext, mesh, nostr).also { instance -> - instance.nostr.senderPeerID = mesh.myPeerID - // Register for favorites changes to flush outbox - try { - com.bitchat.android.favorites.FavoritesPersistenceService.shared.addListener(instance.favoriteListener) - } catch (_: Exception) {} - INSTANCE = instance + val instance = INSTANCE ?: synchronized(this) { + INSTANCE ?: run { + val nostr = NostrTransport.getInstance(context) + MessageRouter(context.applicationContext, mesh, nostr).also { instance -> + // Register for favorites changes to flush outbox + try { + com.bitchat.android.favorites.FavoritesPersistenceService.shared.addListener(instance.favoriteListener) + } catch (_: Exception) {} + INSTANCE = instance + } } } + // Always update mesh reference and sync peer ID + instance.mesh = mesh + instance.nostr.senderPeerID = mesh.myPeerID + return instance } } 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/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..99244290 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( 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..0d22a0d5 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 } @@ -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/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 86c5f67b..12b5f72f 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,16 @@ import android.util.Log import androidx.core.app.NotificationManagerCompat import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope +import com.bitchat.android.favorites.FavoritesPersistenceService import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow import com.bitchat.android.mesh.BluetoothMeshDelegate import com.bitchat.android.mesh.BluetoothMeshService +import com.bitchat.android.service.MeshServiceHolder import com.bitchat.android.model.BitchatMessage import com.bitchat.android.model.BitchatMessageType +import com.bitchat.android.nostr.NostrIdentityBridge import com.bitchat.android.protocol.BitchatPacket @@ -19,6 +24,13 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.launch import java.util.Date import kotlin.random.Random +import com.bitchat.android.services.VerificationService +import com.bitchat.android.identity.SecureIdentityStateManager +import com.bitchat.android.noise.NoiseSession +import com.bitchat.android.nostr.GeohashAliasRegistry +import com.bitchat.android.util.dataFromHexString +import com.bitchat.android.util.hexEncodedString +import java.security.MessageDigest /** * Refactored ChatViewModel - Main coordinator for bitchat functionality @@ -26,8 +38,12 @@ import kotlin.random.Random */ class ChatViewModel( application: Application, - val meshService: BluetoothMeshService + initialMeshService: BluetoothMeshService ) : AndroidViewModel(application), BluetoothMeshDelegate { + + // Made var to support mesh service replacement after panic clear + var meshService: BluetoothMeshService = initialMeshService + private set private val debugManager by lazy { try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance() } catch (e: Exception) { null } } companion object { @@ -46,6 +62,20 @@ class ChatViewModel( mediaSendingManager.sendImageNote(toPeerIDOrNull, channelOrNull, filePath) } + fun getCurrentNpub(): String? { + return try { + NostrIdentityBridge + .getCurrentNostrIdentity(getApplication()) + ?.npub + } catch (_: Exception) { + null + } + } + + fun buildMyQRString(nickname: String, npub: String?): String { + return VerificationService.buildMyQRString(nickname, npub) ?: "" + } + // MARK: - State management private val state = ChatState( scope = viewModelScope, @@ -57,6 +87,7 @@ class ChatViewModel( // Specialized managers private val dataManager = DataManager(application.applicationContext) + private val identityManager by lazy { SecureIdentityStateManager(getApplication()) } private val messageManager = MessageManager(state) private val channelManager = ChannelManager(state, messageManager, dataManager, viewModelScope) @@ -75,8 +106,19 @@ class ChatViewModel( NotificationIntervalManager() ) + private val verificationHandler = VerificationHandler( + context = application.applicationContext, + scope = viewModelScope, + getMeshService = { meshService }, + identityManager = identityManager, + state = state, + notificationManager = notificationManager, + messageManager = messageManager + ) + val verifiedFingerprints = verificationHandler.verifiedFingerprints + // Media file sending manager - private val mediaSendingManager = MediaSendingManager(state, messageManager, channelManager, meshService) + private val mediaSendingManager = MediaSendingManager(state, messageManager, channelManager) { meshService } // Delegate handler for mesh callbacks private val meshDelegateHandler = MeshDelegateHandler( @@ -120,7 +162,6 @@ class ChatViewModel( val passwordProtectedChannels: StateFlow> = 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,6 +175,10 @@ 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 @@ -247,6 +292,9 @@ class ChatViewModel( // Initialize favorites persistence service com.bitchat.android.favorites.FavoritesPersistenceService.initialize(getApplication()) + // Load verified fingerprints from secure storage + verificationHandler.loadVerifiedFingerprints() + // Ensure NostrTransport knows our mesh peer ID for embedded packets try { @@ -361,6 +409,8 @@ class ChatViewModel( setCurrentPrivateChatPeer(null) // Clear mesh mention notifications since user is now back in mesh chat clearMeshMentionNotifications() + // Ensure sheet is hidden + hidePrivateChatSheet() } // MARK: - Open Latest Unread Private Chat @@ -409,12 +459,7 @@ class ChatViewModel( canonical ?: targetKey } - startPrivateChat(openPeer) - - // If sidebar visible, hide it to focus on the private chat - if (state.getShowSidebarValue()) { - state.setShowSidebar(false) - } + showPrivateChatSheet(openPeer) } catch (e: Exception) { Log.w(TAG, "openLatestUnreadPrivateChat failed: ${e.message}") } @@ -468,6 +513,10 @@ class ChatViewModel( ).also { canonical -> if (canonical != state.getSelectedPrivateChatPeerValue()) { privateChatManager.startPrivateChat(canonical, meshService) + // If we're in the private chat sheet, update its active peer too + if (state.getPrivateChatSheetPeerValue() != null) { + showPrivateChatSheet(canonical) + } } } // Send private message @@ -651,6 +700,18 @@ class ChatViewModel( // Update fingerprint mappings from centralized manager val fingerprints = privateChatManager.getAllPeerFingerprints() state.setPeerFingerprints(fingerprints) + fingerprints.forEach { (peerID, fingerprint) -> + identityManager.cachePeerFingerprint(peerID, fingerprint) + val info = try { meshService.getPeerInfo(peerID) } catch (_: Exception) { null } + val noiseKeyHex = info?.noisePublicKey?.hexEncodedString() + if (noiseKeyHex != null) { + identityManager.cachePeerNoiseKey(peerID, noiseKeyHex) + identityManager.cacheNoiseFingerprint(noiseKeyHex, fingerprint) + } + info?.nickname?.takeIf { it.isNotBlank() }?.let { nickname -> + identityManager.cacheFingerprintNickname(fingerprint, nickname) + } + } // Merge nicknames from BLE and Wi‑Fi Aware to display names for all peers val bleNick = meshService.getPeerNicknames() @@ -672,6 +733,34 @@ class ChatViewModel( } state.setPeerDirect(directMap) } catch (_: Exception) { } + + // Flush any pending QR verification once a Noise session is established + currentPeers.forEach { peerID -> + if (meshService.getSessionState(peerID) is NoiseSession.NoiseSessionState.Established) { + verificationHandler.sendPendingVerificationIfNeeded(peerID) + } + } + } + + // MARK: - QR Verification + + fun isPeerVerified(peerID: String, verifiedFingerprints: Set): 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 @@ -680,41 +769,87 @@ class ChatViewModel( return meshService.getDebugStatus() } - // Note: Mesh service restart is now handled by MainActivity - // This function is no longer needed - - fun setAppBackgroundState(inBackground: Boolean) { - // Forward to notification manager for notification logic - notificationManager.setAppBackgroundState(inBackground) - } - fun setCurrentPrivateChatPeer(peerID: String?) { - // Update notification manager with current private chat peer notificationManager.setCurrentPrivateChatPeer(peerID) } fun setCurrentGeohash(geohash: String?) { - // Update notification manager with current geohash for notification logic notificationManager.setCurrentGeohash(geohash) } fun clearNotificationsForSender(peerID: String) { - // Clear notifications when user opens a chat notificationManager.clearNotificationsForSender(peerID) } fun clearNotificationsForGeohash(geohash: String) { - // Clear notifications when user opens a geohash chat notificationManager.clearNotificationsForGeohash(geohash) } - /** - * Clear mesh mention notifications when user opens mesh chat - */ fun clearMeshMentionNotifications() { notificationManager.clearMeshMentionNotifications() } + private var reopenSidebarAfterVerification = false + + fun showVerificationSheet(fromSidebar: Boolean = false) { + if (fromSidebar) { + reopenSidebarAfterVerification = true + } + state.setShowVerificationSheet(true) + } + + fun hideVerificationSheet() { + state.setShowVerificationSheet(false) + if (reopenSidebarAfterVerification) { + reopenSidebarAfterVerification = false + state.setShowMeshPeerList(true) + } + } + + fun showSecurityVerificationSheet() { + state.setShowSecurityVerificationSheet(true) + } + + fun hideSecurityVerificationSheet() { + state.setShowSecurityVerificationSheet(false) + } + + fun showMeshPeerList() { + state.setShowMeshPeerList(true) + } + + fun hideMeshPeerList() { + state.setShowMeshPeerList(false) + } + + fun showPrivateChatSheet(peerID: String) { + state.setPrivateChatSheetPeer(peerID) + } + + fun hidePrivateChatSheet() { + state.setPrivateChatSheetPeer(null) + } + + fun getPeerFingerprintForDisplay(peerID: String): String? { + return verificationHandler.getPeerFingerprintForDisplay(peerID) + } + + fun getMyFingerprint(): String { + return verificationHandler.getMyFingerprint() + } + + fun resolvePeerDisplayNameForFingerprint(peerID: String): String { + return verificationHandler.resolvePeerDisplayNameForFingerprint(peerID) + } + + fun verifyFingerprintValue(fingerprint: String) { + verificationHandler.verifyFingerprintValue(fingerprint) + } + + fun unverifyFingerprintValue(fingerprint: String) { + verificationHandler.unverifyFingerprintValue(fingerprint) + } + // MARK: - Command Autocomplete (delegated) fun updateCommandSuggestions(input: String) { @@ -760,6 +895,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 +929,11 @@ class ChatViewModel( privateChatManager.clearAllPrivateChats() dataManager.clearAllData() + // Clear seen message store + try { + com.bitchat.android.services.SeenMessageStore.getInstance(getApplication()).clear() + } catch (_: Exception) { } + // Clear all mesh service data clearAllMeshServiceData() @@ -794,6 +942,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 +964,37 @@ 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: ${meshService.myPeerID}") + } + + /** + * Recreate the mesh service with a fresh identity after panic clear. + * This ensures the new cryptographic keys are used for a new peer ID. + */ + private fun recreateMeshServiceAfterPanic() { + val oldPeerID = meshService.myPeerID + + // Clear the holder so getOrCreate() returns a fresh instance + MeshServiceHolder.clear() + + // Create fresh mesh service with new identity (keys were regenerated in clearAllCryptographicData) + val freshMeshService = MeshServiceHolder.getOrCreate(getApplication()) + + // Replace our reference and set up the new service + meshService = freshMeshService + meshService.delegate = this + + // Restart mesh operations with new identity + meshService.startServices() + meshService.sendBroadcastAnnounce() + + Log.d( + TAG, + "✅ Mesh service recreated. Old peerID: $oldPeerID, New peerID: ${meshService.myPeerID}" + ) } /** @@ -843,7 +1021,7 @@ class ChatViewModel( // Clear secure identity state (if used) try { - val identityManager = com.bitchat.android.identity.SecureIdentityStateManager(getApplication()) + val identityManager = SecureIdentityStateManager(getApplication()) identityManager.clearIdentityData() // Also clear secure values used by FavoritesPersistenceService (favorites + peerID index) try { @@ -856,7 +1034,7 @@ class ChatViewModel( // Clear FavoritesPersistenceService persistent relationships try { - com.bitchat.android.favorites.FavoritesPersistenceService.shared.clearAllFavorites() + FavoritesPersistenceService.shared.clearAllFavorites() Log.d(TAG, "✅ Cleared FavoritesPersistenceService relationships") } catch (_: Exception) { } @@ -884,7 +1062,7 @@ class ChatViewModel( * End geohash sampling */ fun endGeohashSampling() { - // No-op in refactored architecture; sampling subscriptions are short-lived + geohashViewModel.endGeohashSampling() } /** @@ -899,7 +1077,19 @@ class ChatViewModel( */ fun startGeohashDM(pubkeyHex: String) { geohashViewModel.startGeohashDM(pubkeyHex) { convKey -> - startPrivateChat(convKey) + showPrivateChatSheet(convKey) + } + } + + fun startGeohashDMByNickname(nickname: String) { + geohashViewModel.startGeohashDMByNickname(nickname) { convKey -> + showPrivateChatSheet(convKey) + } + } + + fun startGeohashDMByShortId(shortId: String) { + geohashViewModel.startGeohashDMByShortId(shortId) { convKey -> + showPrivateChatSheet(convKey) } } @@ -923,15 +1113,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 +1125,6 @@ class ChatViewModel( hideAppInfo() true } - // Close sidebar - state.getShowSidebarValue() -> { - hideSidebar() - true - } // Close password dialog state.getShowPasswordPromptValue() -> { state.setShowPasswordPrompt(false) @@ -955,7 +1132,7 @@ class ChatViewModel( true } // Exit private chat - state.getSelectedPrivateChatPeerValue() != null -> { + state.getSelectedPrivateChatPeerValue() != null || state.getPrivateChatSheetPeerValue() != null -> { endPrivateChat() true } @@ -985,5 +1162,6 @@ class ChatViewModel( */ fun colorForNostrPubkey(pubkeyHex: String, isDark: Boolean): androidx.compose.ui.graphics.Color { return geohashViewModel.colorForNostrPubkey(pubkeyHex, isDark) - } +} + } 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..5bf7003b 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() } } } @@ -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/MediaSendingManager.kt b/app/src/main/java/com/bitchat/android/ui/MediaSendingManager.kt index fc134a1b..055825e8 100644 --- a/app/src/main/java/com/bitchat/android/ui/MediaSendingManager.kt +++ b/app/src/main/java/com/bitchat/android/ui/MediaSendingManager.kt @@ -16,8 +16,11 @@ class MediaSendingManager( private val state: ChatState, private val messageManager: MessageManager, private val channelManager: ChannelManager, - private val meshService: BluetoothMeshService + private val getMeshService: () -> BluetoothMeshService ) { + // Helper to get current mesh service (may change after panic clear) + private val meshService: BluetoothMeshService + get() = getMeshService() companion object { private const val TAG = "MediaSendingManager" private const val MAX_FILE_SIZE = com.bitchat.android.util.AppConstants.Media.MAX_FILE_SIZE_BYTES // 50MB limit 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 e76ad1cf..c3794cec 100644 --- a/app/src/main/java/com/bitchat/android/ui/MeshDelegateHandler.kt +++ b/app/src/main/java/com/bitchat/android/ui/MeshDelegateHandler.kt @@ -238,6 +238,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) 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..e5688a4e --- /dev/null +++ b/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt @@ -0,0 +1,992 @@ +package com.bitchat.android.ui + +import com.bitchat.android.R +import android.util.Log +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.* +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.* +import androidx.compose.material.icons.outlined.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.text.style.TextOverflow +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.bitchat.android.core.ui.component.button.CloseButton +import com.bitchat.android.core.ui.component.sheet.BitchatBottomSheet +import com.bitchat.android.core.ui.component.sheet.BitchatSheetCenterTopBar +import com.bitchat.android.core.ui.component.sheet.BitchatSheetTitle +import com.bitchat.android.core.ui.component.sheet.BitchatSheetTopBar +import com.bitchat.android.geohash.ChannelID +import com.bitchat.android.ui.theme.BASE_FONT_SIZE +import com.bitchat.android.nostr.GeohashAliasRegistry +import com.bitchat.android.nostr.GeohashConversationRegistry + + +/** + * Sheet components for ChatScreen + * Extracted from ChatScreen.kt for better organization + */ + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun MeshPeerListSheet( + isPresented: Boolean, + viewModel: ChatViewModel, + onDismiss: () -> Unit, + onShowVerification: () -> Unit, + modifier: Modifier = Modifier +) { + val colorScheme = MaterialTheme.colorScheme + + val connectedPeers by viewModel.connectedPeers.collectAsStateWithLifecycle() + val joinedChannels by viewModel.joinedChannels.collectAsStateWithLifecycle() + val currentChannel by viewModel.currentChannel.collectAsStateWithLifecycle() + val selectedPrivatePeer by viewModel.selectedPrivateChatPeer.collectAsStateWithLifecycle() + val nickname by viewModel.nickname.collectAsStateWithLifecycle() + val unreadChannelMessages by viewModel.unreadChannelMessages.collectAsStateWithLifecycle() + val peerNicknames by viewModel.peerNicknames.collectAsStateWithLifecycle() + val peerRSSI by viewModel.peerRSSI.collectAsStateWithLifecycle() + val selectedLocationChannel by viewModel.selectedLocationChannel.collectAsStateWithLifecycle() + + // Bottom sheet state + val sheetState = rememberModalBottomSheetState( + skipPartiallyExpanded = true + ) + + // Scroll state for animated top bar + val listState = rememberLazyListState() + val isScrolled by remember { + derivedStateOf { + listState.firstVisibleItemIndex > 0 || listState.firstVisibleItemScrollOffset > 0 + } + } + val topBarAlpha by animateFloatAsState( + targetValue = if (isScrolled) 0.95f else 0f, + label = "topBarAlpha" + ) + + if (isPresented) { + BitchatBottomSheet( + modifier = modifier, + onDismissRequest = onDismiss, + sheetState = sheetState, + ) { + Box(modifier = Modifier.fillMaxWidth()) { + LazyColumn( + state = listState, + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(top = 64.dp, bottom = 20.dp) + ) { + // Channels section + if (joinedChannels.isNotEmpty()) { + item(key = "channels_header") { + Text( + text = stringResource(id = R.string.channels).uppercase(), + style = MaterialTheme.typography.labelLarge, + color = colorScheme.onSurface.copy(alpha = 0.7f), + fontWeight = FontWeight.Bold, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp) + .padding(top = 8.dp, bottom = 4.dp) + ) + } + + items( + items = joinedChannels.toList(), + key = { "channel_$it" } + ) { channel -> + val isSelected = channel == currentChannel + val unreadCount = unreadChannelMessages[channel] ?: 0 + + ChannelRow( + channel = channel, + isSelected = isSelected, + unreadCount = unreadCount, + colorScheme = colorScheme, + onChannelClick = { + // Check if this is a DM channel (starts with @) + if (channel.startsWith("@")) { + // Extract peer name and find the peer ID + val peerName = channel.removePrefix("@") + val peerID = + peerNicknames.entries.firstOrNull { it.value == peerName }?.key + if (peerID != null) { + viewModel.showPrivateChatSheet(peerID) + onDismiss() + } + } else { + // Regular channel switch + viewModel.switchToChannel(channel) + onDismiss() + } + }, + onLeaveChannel = { + viewModel.leaveChannel(channel) + }, + ) + } + } + + // People section - switch between mesh and geohash lists (iOS-compatible) + item(key = "people_section") { + when (selectedLocationChannel) { + is ChannelID.Location -> { + // Show geohash people list when in location channel + GeohashPeopleList( + viewModel = viewModel, + onTapPerson = onDismiss + ) + } + + else -> { + // Show mesh peer list when in mesh channel (default) + PeopleSection( + modifier = Modifier.padding(top = if (joinedChannels.isNotEmpty()) 16.dp else 0.dp), + connectedPeers = connectedPeers, + peerNicknames = peerNicknames, + peerRSSI = peerRSSI, + nickname = nickname, + colorScheme = colorScheme, + selectedPrivatePeer = selectedPrivatePeer, + viewModel = viewModel, + onPrivateChatStart = { peerID -> + viewModel.showPrivateChatSheet(peerID) + onDismiss() + } + ) + } + } + } + } + + // TopBar (animated) + BitchatSheetTopBar( + title = { + BitchatSheetTitle(text = stringResource(id = R.string.your_network)) + }, + backgroundAlpha = topBarAlpha, + actions = { + if (selectedLocationChannel !is ChannelID.Location) { + IconButton( + onClick = onShowVerification, + modifier = Modifier.size(24.dp) + ) { + Icon( + imageVector = Icons.Outlined.QrCode, + contentDescription = stringResource(R.string.verify_title), + tint = colorScheme.onSurface.copy(alpha = 0.8f), + modifier = Modifier.size(18.dp) + ) + } + } + }, + onClose = onDismiss, + ) + } + } + + } +} + +@Composable +private fun ChannelRow( + channel: String, + isSelected: Boolean, + unreadCount: Int, + colorScheme: ColorScheme, + onChannelClick: () -> Unit, + onLeaveChannel: () -> Unit, +) { + Surface( + onClick = onChannelClick, + color = if (isSelected) { + colorScheme.primaryContainer.copy(alpha = 0.15f) + } else { + Color.Transparent + }, + shape = MaterialTheme.shapes.medium, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp, vertical = 2.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Row( + modifier = Modifier.weight(1f), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + // Unread badge + if (unreadCount > 0) { + UnreadBadge( + count = unreadCount, + colorScheme = colorScheme + ) + } + + Text( + text = channel, + style = MaterialTheme.typography.bodyMedium.copy( + fontFamily = FontFamily.Monospace, + fontSize = BASE_FONT_SIZE.sp + ), + color = if (isSelected) colorScheme.primary else colorScheme.onSurface, + fontWeight = if (isSelected) FontWeight.Medium else FontWeight.Normal + ) + } + + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + // Leave channel button + CloseButton( + onClick = onLeaveChannel, + ) + } + } + } +} + + + +@Composable +fun PeopleSection( + modifier: Modifier = Modifier, + connectedPeers: List, + peerNicknames: Map, + peerRSSI: Map, + nickname: String, + colorScheme: ColorScheme, + selectedPrivatePeer: String?, + viewModel: ChatViewModel, + onPrivateChatStart: (String) -> Unit +) { + Column(modifier = modifier) { + Text( + text = stringResource(id = R.string.people).uppercase(), + style = MaterialTheme.typography.labelLarge, + color = colorScheme.onSurface.copy(alpha = 0.7f), + fontWeight = FontWeight.Bold, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp) + .padding(top = 8.dp, bottom = 4.dp) + ) + + if (connectedPeers.isEmpty()) { + Text( + text = stringResource(id = R.string.no_one_connected), + style = MaterialTheme.typography.bodyMedium.copy( + fontFamily = FontFamily.Monospace, + fontSize = 12.sp + ), + color = colorScheme.onSurface.copy(alpha = 0.5f), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 40.dp, vertical = 12.dp) + ) + } + + // Observe reactive state for favorites and fingerprints + val hasUnreadPrivateMessages by viewModel.unreadPrivateMessages.collectAsStateWithLifecycle() + val privateChats by viewModel.privateChats.collectAsStateWithLifecycle() + val favoritePeers by viewModel.favoritePeers.collectAsStateWithLifecycle() + val peerFingerprints by viewModel.peerFingerprints.collectAsStateWithLifecycle() + val verifiedFingerprints by viewModel.verifiedFingerprints.collectAsStateWithLifecycle() + + // Reactive favorite computation for all peers + val peerFavoriteStates = remember(favoritePeers, peerFingerprints, connectedPeers) { + connectedPeers.associateWith { peerID -> + // Reactive favorite computation - same as ChatHeader + val fingerprint = peerFingerprints[peerID] + fingerprint != null && favoritePeers.contains(fingerprint) + } + } + + val peerVerifiedStates = remember(verifiedFingerprints, peerFingerprints, connectedPeers) { + connectedPeers.associateWith { peerID -> + viewModel.isPeerVerified(peerID, verifiedFingerprints) + } + } + + // Build mapping of connected peerID -> noise key hex to unify with offline favorites + val noiseHexByPeerID: Map = 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, _) = 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.meshService.getPeerInfo(peerID)?.isDirectConnection == true } catch (_: Exception) { false } + PeerItem( + peerID = peerID, + displayName = displayName, + isDirect = isDirectLive, + isSelected = peerID == selectedPrivatePeer, + isFavorite = isFavorite, + isVerified = isVerified, + hasUnreadDM = combinedHasUnread, + colorScheme = colorScheme, + viewModel = viewModel, + onItemClick = { onPrivateChatStart(peerID) }, + onToggleFavorite = { + Log.d("SidebarComponents", "Sidebar toggle favorite: peerID=$peerID, currentFavorite=$isFavorite") + viewModel.toggleFavorite(peerID) + }, + unreadCount = if (combinedUnreadCount > 0) combinedUnreadCount else if (combinedHasUnread) 1 else 0, + showNostrGlobe = false, + showHashSuffix = showHash + ) + } + + // Append offline favorites we actively favorite (and not currently connected) + offlineFavorites.forEach { fav -> + val favPeerID = fav.peerNoisePublicKey.joinToString("") { b -> "%02x".format(b) } + // If any connected peer maps to this noise key, skip showing the offline entry + val isMappedToConnected = noiseHexByPeerID.values.any { it.equals(favPeerID, ignoreCase = true) } + if (isMappedToConnected) return@forEach + + // Resolve potential Nostr conversation key for this favorite (for unread detection) + val nostrConvKey: String? = try { + val npubOrHex = com.bitchat.android.favorites.FavoritesPersistenceService.shared.findNostrPubkey(fav.peerNoisePublicKey) + if (npubOrHex != null) { + val hex = if (npubOrHex.startsWith("npub")) { + val (hrp, data) = com.bitchat.android.nostr.Bech32.decode(npubOrHex) + if (hrp == "npub") data.joinToString("") { "%02x".format(it) } else null + } else { + npubOrHex.lowercase() + } + hex?.let { "nostr_${it.take(16)}" } + } else null + } catch (_: Exception) { null } + + val hasUnread = hasUnreadPrivateMessages.contains(favPeerID) || (nostrConvKey != null && hasUnreadPrivateMessages.contains(nostrConvKey)) + + // If user clicks an offline favorite and the mapped peer is currently connected under a different ID, + // open chat with the connected peerID instead of the noise hex for a seamless window + val mappedConnectedPeerID = noiseHexByPeerID.entries.firstOrNull { it.value.equals(favPeerID, ignoreCase = true) }?.key + val dn = peerNicknames[favPeerID] ?: fav.peerNickname + val (bName, _) = splitSuffix(dn) + val showHash = (baseNameCounts[bName] ?: 0) > 1 + + val isVerified = viewModel.isNoisePublicKeyVerified(fav.peerNoisePublicKey, verifiedFingerprints) + + // Compute unreadCount from either noise conversation or Nostr conversation + val unreadCount = ( + privateChats[favPeerID]?.count { msg -> msg.sender != nickname && hasUnreadPrivateMessages.contains(favPeerID) } ?: 0 + ) + ( + if (nostrConvKey != null) privateChats[nostrConvKey]?.count { msg -> msg.sender != nickname && hasUnreadPrivateMessages.contains(nostrConvKey) } ?: 0 else 0 + ) + + PeerItem( + peerID = favPeerID, + displayName = dn, + isDirect = false, + isSelected = (mappedConnectedPeerID ?: favPeerID) == selectedPrivatePeer, + isFavorite = true, + isVerified = isVerified, + hasUnreadDM = hasUnread, + colorScheme = colorScheme, + viewModel = viewModel, + onItemClick = { onPrivateChatStart(mappedConnectedPeerID ?: favPeerID) }, + onToggleFavorite = { + Log.d("SidebarComponents", "Sidebar toggle favorite (offline): peerID=$favPeerID") + viewModel.toggleFavorite(favPeerID) + }, + unreadCount = if (unreadCount > 0) unreadCount else if (hasUnread) 1 else 0, + showNostrGlobe = (fav.isMutual && fav.peerNostrPublicKey != null), + showHashSuffix = showHash + ) + appendedOfflineIds.add(favPeerID) + } + + // NOTE: Do NOT append Nostr-only (nostr_*) conversations to the mesh people list. + // Geohash DMs should appear in the GeohashPeople list for the active geohash, not in mesh offline contacts. + // We intentionally remove previously-added behavior that mixed geohash DMs into mesh sidebar. + // If you need to surface non-geohash offline mesh conversations in the future, do it here for 64-hex noise IDs only. + /* + val alreadyShownIds = connectedIds + appendedOfflineIds + privateChats.keys + .filter { key -> + // Only include 64-hex noise IDs (mesh identities); exclude any nostr_* aliases + hex64Regex.matches(key) && + !alreadyShownIds.contains(key) && + // Skip if this key maps to a connected peer via noiseHex mapping + !noiseHexByPeerID.values.any { it.equals(key, ignoreCase = true) } + } + .sortedBy { key -> privateChats[key]?.lastOrNull()?.timestamp } + .forEach { convKey -> + val lastSender = privateChats[convKey]?.lastOrNull()?.sender + val dn = peerNicknames[convKey] ?: (lastSender ?: convKey.take(12)) + val (bName, _) = splitSuffix(dn) + val showHash = (baseNameCounts[bName] ?: 0) > 1 + + PeerItem( + peerID = convKey, + displayName = dn, + isDirect = false, + isSelected = convKey == selectedPrivatePeer, + isFavorite = false, + hasUnreadDM = hasUnreadPrivateMessages.contains(convKey), + colorScheme = colorScheme, + viewModel = viewModel, + onItemClick = { onPrivateChatStart(convKey) }, + onToggleFavorite = { viewModel.toggleFavorite(convKey) }, + unreadCount = privateChats[convKey]?.count { msg -> + msg.sender != nickname && hasUnreadPrivateMessages.contains(convKey) + } ?: if (hasUnreadPrivateMessages.contains(convKey)) 1 else 0, + showNostrGlobe = false, + showHashSuffix = showHash + ) + } + */ + // End intentional removal + + } +} + +@Composable +private fun PeerItem( + peerID: String, + displayName: String, + isDirect: Boolean, + isSelected: Boolean, + isFavorite: Boolean, + isVerified: Boolean, + hasUnreadDM: Boolean, + colorScheme: ColorScheme, + viewModel: ChatViewModel, + onItemClick: () -> Unit, + onToggleFavorite: () -> Unit, + unreadCount: Int = 0, + showNostrGlobe: Boolean = false, + showHashSuffix: Boolean = true +) { + val currentNickname by viewModel.nickname.collectAsStateWithLifecycle() + // Split display name for hashtag suffix support (iOS-compatible) + val (baseNameRaw, suffixRaw) = splitSuffix(displayName) + val baseName = truncateNickname(baseNameRaw) + val suffix = if (showHashSuffix) suffixRaw else "" + val isMe = displayName == "You" || peerID == currentNickname + + // Get consistent peer color (iOS-compatible) + val isDark = colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f + val assignedColor = viewModel.colorForMeshPeer(peerID, isDark) + val baseColor = if (isMe) Color(0xFFFF9500) else assignedColor + + Surface( + onClick = onItemClick, + color = if (isSelected) { + colorScheme.primaryContainer.copy(alpha = 0.15f) + } else { + Color.Transparent + }, + shape = MaterialTheme.shapes.medium, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp, vertical = 2.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Row( + modifier = Modifier.weight(1f), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + // Connection/status indicator + if (hasUnreadDM) { + // Show mail icon for unread DMs (iOS orange) + Icon( + imageVector = Icons.Filled.Email, + contentDescription = stringResource(R.string.cd_unread_message), + modifier = Modifier.size(16.dp), + tint = Color(0xFFFF9500) // iOS orange + ) + } else if (showNostrGlobe) { + // Purple globe to indicate Nostr availability + Icon( + imageVector = Icons.Filled.Public, + contentDescription = stringResource(R.string.cd_reachable_via_nostr), + modifier = Modifier.size(16.dp), + tint = Color(0xFF9C27B0) // Purple + ) + } else if (!isDirect && isFavorite) { + // Offline favorited user: show outlined circle icon + Icon( + imageVector = Icons.Outlined.Circle, + contentDescription = stringResource(R.string.cd_offline_favorite), + modifier = Modifier.size(16.dp), + tint = Color.Gray + ) + } else { + Icon( + imageVector = if (isDirect) Icons.Outlined.Bluetooth else Icons.Filled.Route, + contentDescription = if (isDirect) "Direct Bluetooth" else "Routed", + modifier = Modifier.size(16.dp), + tint = colorScheme.onSurface.copy(alpha = 0.6f) + ) + } + + // Display name with iOS-style color and hashtag suffix support + Row(verticalAlignment = Alignment.CenterVertically) { + // Base name with peer-specific color + Text( + text = baseName, + style = MaterialTheme.typography.bodyMedium.copy( + fontFamily = FontFamily.Monospace, + fontSize = BASE_FONT_SIZE.sp, + fontWeight = if (isMe) FontWeight.Bold else FontWeight.Normal + ), + color = baseColor, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + + // Hashtag suffix in lighter shade (iOS-style) + if (suffix.isNotEmpty()) { + Text( + text = suffix, + style = MaterialTheme.typography.bodyMedium.copy( + fontFamily = FontFamily.Monospace, + fontSize = BASE_FONT_SIZE.sp + ), + color = baseColor.copy(alpha = 0.6f) + ) + } + } + } + + if (isVerified) { + Spacer(modifier = Modifier.width(4.dp)) + Icon( + imageVector = Icons.Filled.Verified, + contentDescription = null, + modifier = Modifier.size(14.dp), + tint = Color(0xFF32D74B) // iOS Green + ) + } + + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + // Favorite star with proper filled/outlined states + IconButton( + onClick = onToggleFavorite, + modifier = Modifier.size(32.dp) + ) { + Icon( + imageVector = if (isFavorite) Icons.Filled.Star else Icons.Outlined.Star, + contentDescription = if (isFavorite) "Remove from favorites" else "Add to favorites", + modifier = Modifier.size(16.dp), + tint = if (isFavorite) Color(0xFFFFD700) else Color(0xFF4CAF50) + ) + } + } + } + } +} + +/** + * Reusable unread badge component for both channels and private messages + */ +@Composable +private fun UnreadBadge( + count: Int, + colorScheme: ColorScheme, + modifier: Modifier = Modifier +) { + if (count > 0) { + Box( + modifier = modifier + .background( + color = Color(0xFFFFD700), // Yellow color + shape = RoundedCornerShape(10.dp) + ) + .padding(horizontal = 6.dp, vertical = 2.dp) + .defaultMinSize(minWidth = 18.dp, minHeight = 18.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = if (count > 99) "99+" else count.toString(), + style = MaterialTheme.typography.labelSmall.copy( + fontSize = 10.sp, + fontWeight = FontWeight.Bold, + fontFamily = FontFamily.Monospace + ), + color = Color.Black // Black text on yellow background + ) + } + } +} + +/** + * Convert RSSI value (dBm) to signal strength percentage (0-100) + * RSSI typically ranges from -30 (excellent) to -100 (very poor) + * Maps to 0-100 scale where: + * - 0-32: No signal (0 bars) + * - 33-65: Weak (1 bar) + * - 66-98: Good (2 bars) + * - 99-100: Excellent (3 bars) + */ +private fun convertRSSIToSignalStrength(rssi: Int?): Int { + if (rssi == null) return 0 + + return when { + rssi >= -40 -> 100 // Excellent signal + rssi >= -55 -> 85 // Very good signal + rssi >= -70 -> 70 // Good signal + rssi >= -85 -> 50 // Fair signal + rssi >= -100 -> 25 // Poor signal + else -> 0 // Very poor or no signal + } +} + +/** + * Nested Private Chat Sheet - iOS-style nested bottom sheet + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun PrivateChatSheet( + isPresented: Boolean, + peerID: String, + viewModel: ChatViewModel, + onDismiss: () -> Unit +) { + val colorScheme = MaterialTheme.colorScheme + val privateChats by viewModel.privateChats.collectAsStateWithLifecycle() + val peerNicknames by viewModel.peerNicknames.collectAsStateWithLifecycle() + val nickname by viewModel.nickname.collectAsStateWithLifecycle() + val connectedPeers by viewModel.connectedPeers.collectAsStateWithLifecycle() + val peerDirectMap by viewModel.peerDirect.collectAsStateWithLifecycle() + val peerSessionStates by viewModel.peerSessionStates.collectAsStateWithLifecycle() + val favoritePeers by viewModel.favoritePeers.collectAsStateWithLifecycle() + val peerFingerprints by viewModel.peerFingerprints.collectAsStateWithLifecycle() + + val verifiedFingerprints by viewModel.verifiedFingerprints.collectAsStateWithLifecycle() + + // Start private chat when screen opens + LaunchedEffect(peerID) { + viewModel.startPrivateChat(peerID) + } + + val isNostrPeer = peerID.startsWith("nostr_") || peerID.startsWith("nostr:") + + // Compute display name and title text reactively + val displayName = peerNicknames[peerID] ?: peerID.take(12) + val titleText = remember(peerID, peerNicknames) { + if (isNostrPeer) { + val gh = GeohashConversationRegistry.get(peerID) ?: "geohash" + val fullPubkey = GeohashAliasRegistry.get(peerID) ?: "" + val name = if (fullPubkey.isNotEmpty()) { + viewModel.geohashViewModel.displayNameForGeohashConversation(fullPubkey, gh) + } else { + peerNicknames[peerID] ?: "unknown" + } + "#$gh/@$name" + } else { + peerNicknames[peerID] ?: peerID.take(12) + } + } + + val messages = privateChats[peerID] ?: emptyList() + val isDirect = peerDirectMap[peerID] == true + val isConnected = connectedPeers.contains(peerID) || isDirect + val sessionState = peerSessionStates[peerID] + val fingerprint = peerFingerprints[peerID] + val isFavorite = remember(favoritePeers, fingerprint) { + if (fingerprint != null) favoritePeers.contains(fingerprint) else viewModel.isFavorite(peerID) + } + + val isVerified = remember(peerID, verifiedFingerprints) { + viewModel.isPeerVerified(peerID, verifiedFingerprints) + } + + val securityModifier = if (!isNostrPeer) { + Modifier.clickable { viewModel.showSecurityVerificationSheet() } + } else { + Modifier + } + + val sheetState = rememberModalBottomSheetState( + skipPartiallyExpanded = true + ) + + if (isPresented) { + BitchatBottomSheet( + onDismissRequest = onDismiss, + sheetState = sheetState, + ) { + Box(modifier = Modifier.fillMaxSize()) { + Column( + modifier = Modifier.fillMaxSize() + ) { + Spacer(modifier = Modifier.height(64.dp)) + + HorizontalDivider(color = colorScheme.outline.copy(alpha = 0.3f)) + + // Messages list + var forceScrollToBottom by remember { mutableStateOf(false) } + var isScrolledUp by remember { mutableStateOf(false) } + + MessagesList( + messages = messages, + currentUserNickname = nickname, + meshService = viewModel.meshService, + modifier = Modifier.weight(1f), + forceScrollToBottom = forceScrollToBottom, + onScrolledUpChanged = { isUp -> isScrolledUp = isUp }, + onNicknameClick = { /* handle mention */ }, + onMessageLongPress = { /* handle long press */ }, + onCancelTransfer = { msg -> viewModel.cancelMediaSend(msg.id) }, + onImageClick = { _, _, _ -> /* handle image click */ } + ) + + HorizontalDivider(color = colorScheme.outline.copy(alpha = 0.3f)) + + // Input section + var messageText by remember { + mutableStateOf( + androidx.compose.ui.text.input.TextFieldValue( + "" + ) + ) + } + + ChatInputSection( + messageText = messageText, + onMessageTextChange = { newText -> + messageText = newText + viewModel.updateMentionSuggestions(newText.text) + }, + onSend = { + if (messageText.text.trim().isNotEmpty()) { + viewModel.sendMessage(messageText.text.trim()) + messageText = androidx.compose.ui.text.input.TextFieldValue("") + forceScrollToBottom = !forceScrollToBottom + } + }, + onSendVoiceNote = { peer, channel, path -> + viewModel.sendVoiceNote(peer, channel, path) + }, + onSendImageNote = { peer, channel, path -> + viewModel.sendImageNote(peer, channel, path) + }, + onSendFileNote = { peer, channel, path -> + viewModel.sendFileNote(peer, channel, path) + }, + showCommandSuggestions = false, + commandSuggestions = emptyList(), + showMentionSuggestions = false, + mentionSuggestions = emptyList(), + onCommandSuggestionClick = { }, + onMentionSuggestionClick = { }, + selectedPrivatePeer = peerID, + currentChannel = null, + nickname = nickname, + colorScheme = colorScheme, + showMediaButtons = true + ) + } + + // TopBar (fixed at top, iOS-style) + BitchatSheetCenterTopBar( + onClose = onDismiss, + modifier = Modifier.align(Alignment.TopCenter), + navigationIcon = { + IconButton( + onClick = onDismiss, + modifier = Modifier + .align(Alignment.CenterStart) + .padding(start = 16.dp) + .size(32.dp) + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(R.string.chat_back), + tint = colorScheme.onSurface + ) + } + }, + title = { + // Center content: connection status + name + encryption + Row( + modifier = Modifier.align(Alignment.Center), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp) + ) { + when { + isDirect -> { + Icon( + imageVector = Icons.Outlined.SettingsInputAntenna, + contentDescription = stringResource(R.string.cd_connected_peers), + modifier = Modifier.size(14.dp), + tint = colorScheme.onSurface.copy(alpha = 0.6f) + ) + } + isConnected -> { + Icon( + imageVector = Icons.Filled.Route, + contentDescription = stringResource(R.string.cd_ready_for_handshake), + modifier = Modifier.size(14.dp), + tint = colorScheme.onSurface.copy(alpha = 0.6f) + ) + } + isNostrPeer -> { + Icon( + imageVector = Icons.Filled.Public, + contentDescription = stringResource(R.string.cd_nostr_reachable), + modifier = Modifier.size(14.dp), + tint = Color(0xFF9C27B0) + ) + } + } + + Text( + text = titleText, + style = MaterialTheme.typography.titleMedium.copy( + fontWeight = FontWeight.Bold, + fontFamily = FontFamily.Monospace + ), + color = if (isNostrPeer) Color(0xFFFF9500) else colorScheme.onSurface + ) + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.then(securityModifier) + ) { + if (!isNostrPeer) { + NoiseSessionIcon( + sessionState = sessionState, + modifier = Modifier.size(14.dp) + ) + } + + if (isVerified) { + Spacer(modifier = Modifier.width(4.dp)) + Icon( + imageVector = Icons.Filled.Verified, + contentDescription = stringResource(R.string.verify_title), + modifier = Modifier.size(14.dp), + tint = Color(0xFF32D74B) // iOS Green + ) + } + } + + IconButton( + onClick = { viewModel.toggleFavorite(peerID) }, + modifier = Modifier.size(28.dp) + ) { + Icon( + imageVector = if (isFavorite) Icons.Filled.Star else Icons.Outlined.Star, + contentDescription = if (isFavorite) stringResource(R.string.cd_remove_favorite) else stringResource(R.string.cd_add_favorite), + modifier = Modifier.size(16.dp), + tint = if (isFavorite) Color(0xFFFFD700) else colorScheme.onSurface.copy(alpha = 0.6f) + ) + } + } + } + ) + } + } + } +} 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..986c8c63 100644 --- a/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt +++ b/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt @@ -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) } } 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 830ed752..a0c581ee 100644 --- a/app/src/main/java/com/bitchat/android/ui/PrivateChatManager.kt +++ b/app/src/main/java/com/bitchat/android/ui/PrivateChatManager.kt @@ -298,6 +298,16 @@ class PrivateChatManager( if (!isPeerBlocked(senderPeerID)) { // Ensure chat exists messageManager.initializePrivateChat(senderPeerID) + + // Exception: Nostr messages (nostr_ prefix) originate in Kotlin layer and MUST be added here. + if (senderPeerID.startsWith("nostr_")) { + if (suppressUnread) { + messageManager.addPrivateMessageNoUnread(senderPeerID, message) + } else { + messageManager.addPrivateMessage(senderPeerID, message) + } + } + // Track as unread for read receipt purposes if not focused if (!suppressUnread && state.getSelectedPrivateChatPeerValue() != senderPeerID) { val unreadList = unreadReceivedMessages.getOrPut(senderPeerID) { mutableListOf() } @@ -474,6 +484,12 @@ class PrivateChatManager( unread.add(targetPeerID) state.setUnreadPrivateMessages(unread) } + + // If we're currently viewing one of the temp aliases in the sheet, switch to the permanent ID + val sheetPeer = state.getPrivateChatSheetPeerValue() + if (sheetPeer != null && tryMergeKeys.contains(sheetPeer)) { + state.setPrivateChatSheetPeer(targetPeerID) + } } } 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..9a4a1592 --- /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.meshService.initiateNoiseHandshake(selectedPeerID) }, + onVerify = { fp -> viewModel.verifyFingerprintValue(fp) }, + onUnverify = { fp -> viewModel.unverifyFingerprintValue(fp) } + ) + } + } + } +} + +@Composable +private fun SecurityVerificationHeader( + accent: Color, + onClose: () -> Unit +) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = stringResource(R.string.security_verification_title), + style = MaterialTheme.typography.titleSmall.copy( + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.Bold + ), + color = accent + ) + Spacer(modifier = Modifier.weight(1f)) + CloseButton(onClick = onClose) + } +} + +@Composable +private fun buildStatusInfo( + isVerified: Boolean, + sessionState: String?, + accent: Color +): SecurityStatusInfo { + val text = when { + isVerified -> stringResource(R.string.fingerprint_status_verified) + sessionState == "established" -> stringResource(R.string.fingerprint_status_encrypted) + sessionState == "handshaking" -> stringResource(R.string.fingerprint_status_handshaking) + sessionState == "failed" -> stringResource(R.string.fingerprint_status_failed) + else -> stringResource(R.string.fingerprint_status_uninitialized) + } + val icon = when { + isVerified -> Icons.Filled.Verified + sessionState == "handshaking" -> Icons.Outlined.Sync + sessionState == "failed" -> Icons.Outlined.OutlinedWarning + sessionState == "established" -> Icons.Filled.Lock + else -> Icons.Outlined.NoEncryption + } + val tint = when { + isVerified -> Color(0xFF32D74B) + sessionState == "failed" -> Color(0xFFFF3B30) + sessionState == "handshaking" -> Color(0xFFFF9500) + sessionState == "established" -> Color(0xFF32D74B) + else -> accent.copy(alpha = 0.6f) + } + return SecurityStatusInfo(text, icon, tint) +} + +@Composable +private fun SecurityStatusCard( + displayName: String, + accent: Color, + boxColor: Color, + statusInfo: SecurityStatusInfo +) { + Row( + modifier = Modifier + .fillMaxWidth() + .background(boxColor, shape = MaterialTheme.shapes.medium) + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = statusInfo.icon, + contentDescription = null, + tint = statusInfo.tint + ) + Spacer(modifier = Modifier.width(12.dp)) + Column { + Text( + text = displayName, + style = MaterialTheme.typography.titleMedium.copy( + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.Bold + ), + color = accent + ) + Text( + text = statusInfo.text, + style = MaterialTheme.typography.bodySmall.copy( + fontFamily = FontFamily.Monospace + ), + color = accent.copy(alpha = 0.8f) + ) + } + } +} + +@Composable +private fun SecurityVerificationActions( + isVerified: Boolean, + fingerprint: String?, + displayName: String, + accent: Color, + canStartHandshake: Boolean, + onStartHandshake: () -> Unit, + onVerify: (String) -> Unit, + onUnverify: (String) -> Unit +) { + if (canStartHandshake) { + Button( + onClick = onStartHandshake, + colors = ButtonDefaults.buttonColors( + containerColor = accent, + contentColor = Color.White + ), + modifier = Modifier.fillMaxWidth() + ) { + Text( + text = stringResource(R.string.fingerprint_start_handshake), + fontFamily = FontFamily.Monospace, + fontSize = 12.sp + ) + } + } + + if (isVerified) { + VerificationStatusRow( + icon = Icons.Filled.Verified, + iconTint = Color(0xFF32D74B), + text = stringResource(R.string.fingerprint_verified_label), + textTint = Color(0xFF32D74B) + ) + Text( + text = stringResource(R.string.fingerprint_verified_message), + style = MaterialTheme.typography.bodySmall.copy( + fontFamily = FontFamily.Monospace + ), + color = accent.copy(alpha = 0.7f), + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center + ) + Button( + onClick = { fingerprint?.let(onUnverify) }, + colors = ButtonDefaults.buttonColors( + containerColor = Color(0xFFFF3B30), + contentColor = Color.White + ), + modifier = Modifier.fillMaxWidth() + ) { + Text( + text = stringResource(R.string.verify_remove), + fontFamily = FontFamily.Monospace, + fontSize = 12.sp + ) + } + } else { + VerificationStatusRow( + icon = Icons.Filled.Warning, + iconTint = Color(0xFFFF9500), + text = stringResource(R.string.fingerprint_not_verified_label), + textTint = Color(0xFFFF9500) + ) + Text( + text = stringResource(R.string.fingerprint_not_verified_message_fmt, displayName), + style = MaterialTheme.typography.bodySmall.copy( + fontFamily = FontFamily.Monospace + ), + color = accent.copy(alpha = 0.7f), + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center + ) + if (fingerprint != null) { + Button( + onClick = { onVerify(fingerprint) }, + colors = ButtonDefaults.buttonColors( + containerColor = Color(0xFF34C759), + contentColor = Color.White + ), + modifier = Modifier.fillMaxWidth() + ) { + Text( + text = stringResource(R.string.fingerprint_mark_verified), + fontFamily = FontFamily.Monospace, + fontSize = 12.sp + ) + } + } + } +} + +@Composable +private fun VerificationStatusRow( + icon: ImageVector, + iconTint: Color, + text: String, + textTint: Color +) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = iconTint + ) + Spacer(modifier = Modifier.width(6.dp)) + Text( + text = text, + style = MaterialTheme.typography.bodyMedium.copy( + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.Bold + ), + color = textTint + ) + } +} + +@Composable +private fun FingerprintBlock( + title: String, + fingerprint: String?, + boxColor: Color, + accent: Color +) { + val clipboardManager = LocalClipboardManager.current + var showMenu by remember(fingerprint) { mutableStateOf(false) } + val interactionSource = remember { MutableInteractionSource() } + + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + text = title, + style = MaterialTheme.typography.labelSmall.copy( + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.Bold + ), + color = accent.copy(alpha = 0.8f) + ) + if (fingerprint != null) { + Column { + Text( + text = formatFingerprint(fingerprint), + style = MaterialTheme.typography.bodyMedium.copy( + fontFamily = FontFamily.Monospace, + fontSize = 14.sp + ), + color = accent, + textAlign = TextAlign.Center, + modifier = Modifier + .fillMaxWidth() + .combinedClickable( + interactionSource = interactionSource, + indication = null, + onClick = {}, + onLongClick = { showMenu = true } + ) + .background(boxColor, shape = MaterialTheme.shapes.small) + .padding(16.dp), + ) + DropdownMenu( + expanded = showMenu, + onDismissRequest = { showMenu = false } + ) { + DropdownMenuItem( + text = { Text(text = stringResource(R.string.fingerprint_copy)) }, + onClick = { + clipboardManager.setText(AnnotatedString(fingerprint)) + showMenu = false + } + ) + } + } + } else { + Text( + text = stringResource(R.string.fingerprint_pending), + style = MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace), + color = Color(0xFFFF9500), + modifier = Modifier.padding(16.dp) + ) + } + } +} + +private fun formatFingerprint(fingerprint: String): String { + val upper = fingerprint.uppercase() + val sb = StringBuilder() + upper.forEachIndexed { index, c -> + if (index > 0 && index % 4 == 0) { + if (index % 16 == 0) sb.append('\n') else sb.append(' ') + } + sb.append(c) + } + return sb.toString() +} 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..2b0d503f --- /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.BluetoothMeshService +import com.bitchat.android.model.BitchatMessage +import com.bitchat.android.noise.NoiseSession +import com.bitchat.android.nostr.GeohashAliasRegistry +import com.bitchat.android.services.VerificationService +import com.bitchat.android.util.dataFromHexString +import com.bitchat.android.util.hexEncodedString +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import java.security.MessageDigest +import java.util.Date +import java.util.concurrent.ConcurrentHashMap + +/** + * Handles QR verification logic and state, extracted from ChatViewModel. + */ +class VerificationHandler( + private val context: Context, + private val scope: CoroutineScope, + private val getMeshService: () -> BluetoothMeshService, + private val identityManager: SecureIdentityStateManager, + private val state: ChatState, + private val notificationManager: NotificationManager, + private val messageManager: MessageManager +) { + // Helper to get current mesh service (may change after panic clear) + private val meshService: BluetoothMeshService + get() = getMeshService() + + private val _verifiedFingerprints = MutableStateFlow>(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..04d93e96 --- /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.meshService.getPeerFingerprint(peerID!!) + if (fingerprint != null && fingerprints.contains(fingerprint)) { + Spacer(modifier = Modifier.height(16.dp)) + Button( + onClick = { viewModel.unverifyFingerprint(peerID!!) }, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.errorContainer, + contentColor = MaterialTheme.colorScheme.onErrorContainer + ), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + ) { + Text( + text = stringResource(R.string.verify_remove), + fontFamily = FontFamily.Monospace, + fontSize = 12.sp + ) + } + } + } + } + } +} + +@Composable +private fun VerificationHeader( + accent: Color, + onClose: () -> Unit, + modifier: Modifier = Modifier +) { + Row( + modifier = modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = stringResource(R.string.verify_title).uppercase(), + fontSize = 14.sp, + fontFamily = FontFamily.Monospace, + color = accent + ) + CloseButton(onClick = onClose) + } +} + +@Composable +private fun MyQrTabContent( + qrString: String, + nickname: String, + accent: Color +) { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Top + ) { + Spacer(modifier = Modifier.height(24.dp)) + + Text( + text = stringResource(R.string.verify_my_qr_title), + style = MaterialTheme.typography.titleMedium, + fontFamily = FontFamily.Monospace, + color = accent + ) + + Spacer(modifier = Modifier.height(32.dp)) + + if (qrString.isNotBlank()) { + Box( + modifier = Modifier + .clip(RoundedCornerShape(24.dp)) + .background(Color.White) + .padding(20.dp) // Quiet zone + ) { + QRCodeImage(data = qrString, size = 260.dp) + } + } else { + Box( + modifier = Modifier + .size(260.dp) + .clip(RoundedCornerShape(24.dp)) + .background(Color.White.copy(alpha = 0.5f)), + contentAlignment = Alignment.Center + ) { + Text( + text = stringResource(R.string.verify_qr_unavailable), + fontFamily = FontFamily.Monospace, + fontSize = 12.sp, + color = Color.Black.copy(alpha = 0.6f) + ) + } + } + + Spacer(modifier = Modifier.height(32.dp)) + + // User Nickname + Text( + text = nickname, + style = MaterialTheme.typography.headlineSmall, + fontFamily = FontFamily.Monospace, + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Center + ) + + Spacer(modifier = Modifier.height(8.dp)) + + // Helper text + Text( + text = stringResource(R.string.app_name).lowercase(), + style = MaterialTheme.typography.bodyMedium, + fontFamily = FontFamily.Monospace, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f), + textAlign = TextAlign.Center + ) + } +} + +@OptIn(ExperimentalPermissionsApi::class) +@Composable +private fun ScanTabContent( + accent: Color, + onScan: (String) -> Unit +) { + val permissionState = rememberPermissionState(android.Manifest.permission.CAMERA) + + Column( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + if (permissionState.status.isGranted) { + Box( + modifier = Modifier + .weight(1f) + .fillMaxWidth() + .clip(RoundedCornerShape(24.dp)) + .background(Color.Black), + contentAlignment = Alignment.Center + ) { + ScannerView(onScan = onScan) + + // Overlay border + Box( + modifier = Modifier + .size(280.dp) + .border(2.dp, accent.copy(alpha = 0.8f), RoundedCornerShape(16.dp)) + ) + + // Corner accents for the overlay + Box(modifier = Modifier.size(260.dp)) { + // This could be drawn with Canvas for cooler effect, but simple border is cleaner for now + } + + Text( + text = stringResource(R.string.verify_scan_prompt_friend), + color = Color.White, + fontFamily = FontFamily.Monospace, + fontSize = 12.sp, + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(bottom = 32.dp) + .background(Color.Black.copy(alpha = 0.6f), RoundedCornerShape(8.dp)) + .padding(horizontal = 12.dp, vertical = 8.dp) + ) + } + } else { + Column( + modifier = Modifier + .fillMaxWidth() + .weight(1f) + .background( + MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f), + RoundedCornerShape(24.dp) + ) + .padding(24.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Icon( + imageVector = Icons.Outlined.QrCodeScanner, + contentDescription = null, + modifier = Modifier.size(64.dp), + tint = accent + ) + Spacer(modifier = Modifier.height(24.dp)) + Text( + text = stringResource(R.string.verify_camera_permission), + fontFamily = FontFamily.Monospace, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSurface + ) + Spacer(modifier = Modifier.height(32.dp)) + Button( + onClick = { permissionState.launchPermissionRequest() }, + colors = ButtonDefaults.buttonColors(containerColor = accent) + ) { + Text( + text = stringResource(R.string.verify_request_camera), + fontFamily = FontFamily.Monospace + ) + } + } + } + } +} + +@Composable +private fun ScannerView( + onScan: (String) -> Unit +) { + val context = LocalContext.current + val lifecycleOwner = LocalLifecycleOwner.current + var lastValid by remember { mutableStateOf(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/DebugSettingsManager.kt b/app/src/main/java/com/bitchat/android/ui/debug/DebugSettingsManager.kt index 669331e6..0ae205a9 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 @@ -464,7 +468,9 @@ class DebugSettingsManager private constructor() { toNickname: String?, toDeviceAddress: String?, ttl: UByte?, - isRelay: Boolean = true + isRelay: Boolean = true, + packetVersion: UByte = 1u, + routeInfo: String? = null ) { // Build message only if verbose logging is enabled, but always update stats val senderLabel = when { @@ -487,18 +493,20 @@ class DebugSettingsManager private constructor() { val fromAddr = fromDeviceAddress ?: "?" val toAddr = toDeviceAddress ?: "?" val ttlStr = ttl?.toString() ?: "?" + val routeStr = if (routeInfo != null) " $routeInfo" else "" if (verboseLoggingEnabled.value) { if (isRelay) { + // Relay: show [previousPeer] -> [nextPeer] addDebugMessage( DebugMessage.RelayEvent( - "♻️ Relayed $packetType by $senderLabel from $fromName (${fromPeerID ?: "?"}, $fromAddr) to $toName (${toPeerID ?: "?"}, $toAddr) with TTL $ttlStr" + "♻️ Relayed v$packetVersion $packetType by $senderLabel from $fromName (${fromPeerID ?: "?"}, $fromAddr) to $toName (${toPeerID ?: "?"}, $toAddr) with TTL $ttlStr$routeStr" ) ) } else { addDebugMessage( DebugMessage.PacketEvent( - "📤 Sent $packetType by $senderLabel to $toName (${toPeerID ?: "?"}, $toAddr) with TTL $ttlStr" + "📤 Sent v$packetVersion $packetType by $senderLabel to $toName (${toPeerID ?: "?"}, $toAddr) with TTL $ttlStr$routeStr" ) ) } @@ -507,12 +515,52 @@ class DebugSettingsManager private constructor() { // Do not update counters here; this path is for readable logs only. } - // Explicit incoming/outgoing logging to avoid double counting - fun logIncoming(packetType: String, fromPeerID: String?, fromNickname: String?, fromDeviceAddress: String?) { - if (verboseLoggingEnabled.value) { - val who = fromNickname ?: fromPeerID ?: "unknown" - addDebugMessage(DebugMessage.PacketEvent("📥 Incoming $packetType from $who (${fromPeerID ?: "?"}, ${fromDeviceAddress ?: "?"})")) + // MARK: - Debug Events for Animation + sealed class MeshVisualEvent { + data class PacketActivity(val peerID: String) : MeshVisualEvent() + data class RouteActivity(val route: List) : 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 +569,11 @@ class DebugSettingsManager private constructor() { deviceIncomingTotalsMap[it] = (deviceIncomingTotalsMap[it] ?: 0L) + 1L _perDeviceIncomingTotalsFlow.value = deviceIncomingTotalsMap.toMap() } - fromPeerID?.let { - perPeerIncoming.getOrPut(it) { ConcurrentLinkedQueue() }.offer(now) - peerIncomingTotalsMap[it] = (peerIncomingTotalsMap[it] ?: 0L) + 1L - _perPeerIncomingTotalsFlow.value = peerIncomingTotalsMap.toMap() - } + + perPeerIncoming.getOrPut(fromPeerID) { ConcurrentLinkedQueue() }.offer(now) + peerIncomingTotalsMap[fromPeerID] = (peerIncomingTotalsMap[fromPeerID] ?: 0L) + 1L + _perPeerIncomingTotalsFlow.value = peerIncomingTotalsMap.toMap() + // bump totals val cur = _relayStats.value _relayStats.value = cur.copy( @@ -535,10 +583,11 @@ class DebugSettingsManager private constructor() { if (visible) updateRelayStatsFromTimestamps() } - fun logOutgoing(packetType: String, toPeerID: String?, toNickname: String?, toDeviceAddress: String?, previousHopPeerID: String? = null) { + fun logOutgoing(packetType: String, toPeerID: String?, toNickname: String?, toDeviceAddress: String?, previousHopPeerID: String? = null, packetVersion: UByte = 1u, routeInfo: String? = null) { if (verboseLoggingEnabled.value) { val who = toNickname ?: toPeerID ?: "unknown" - addDebugMessage(DebugMessage.PacketEvent("📤 Outgoing $packetType to $who (${toPeerID ?: "?"}, ${toDeviceAddress ?: "?"})")) + val routeStr = if (routeInfo != null) " $routeInfo" else "" + addDebugMessage(DebugMessage.PacketEvent("📤 Outgoing v$packetVersion $packetType to $who (${toPeerID ?: "?"}, ${toDeviceAddress ?: "?"})$routeStr")) } val now = System.currentTimeMillis() val visible = _debugSheetVisible.value 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..e9018e70 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,65 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.draw.rotate import com.bitchat.android.mesh.BluetoothMeshService +import com.bitchat.android.services.meshgraph.MeshGraphService import kotlinx.coroutines.launch +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.graphics.drawscope.drawIntoCanvas +import androidx.compose.ui.graphics.nativeCanvas import androidx.compose.ui.res.stringResource import com.bitchat.android.R import androidx.compose.ui.platform.LocalContext -import com.bitchat.android.service.MeshServicePreferences -import com.bitchat.android.service.MeshForegroundService +import com.bitchat.android.core.ui.component.sheet.BitchatBottomSheet +import com.bitchat.android.core.ui.component.sheet.BitchatSheetTopBar +import com.bitchat.android.core.ui.component.sheet.BitchatSheetTitle + +@Composable +fun MeshTopologySection() { + val colorScheme = MaterialTheme.colorScheme + val graphService = remember { MeshGraphService.getInstance() } + val snapshot by graphService.graphState.collectAsState() + + Surface(shape = RoundedCornerShape(12.dp), color = colorScheme.surfaceVariant.copy(alpha = 0.2f)) { + Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Icon(Icons.Filled.SettingsEthernet, contentDescription = null, tint = Color(0xFF8E8E93)) + Text("mesh topology", fontFamily = FontFamily.Monospace, fontSize = 14.sp, fontWeight = FontWeight.Medium) + } + val nodes = snapshot.nodes + val edges = snapshot.edges + val empty = nodes.isEmpty() + if (empty) { + Text("no gossip yet", fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.6f)) + } else { + ForceDirectedMeshGraph( + nodes = nodes, + edges = edges, + modifier = Modifier + .fillMaxWidth() + .height(300.dp) + .background(colorScheme.surface.copy(alpha = 0.4f)) + ) + + // Flexible peer list + FlowRow( + modifier = Modifier.fillMaxWidth().padding(top = 8.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + nodes.forEach { node -> + val label = "${node.peerID.take(8)} • ${node.nickname ?: "unknown"}" + Text( + text = label, + fontFamily = FontFamily.Monospace, + fontSize = 11.sp, + color = colorScheme.onSurface.copy(alpha = 0.85f) + ) + } + } + } + } + } +} private enum class GraphMode { OVERALL, PER_DEVICE, PER_PEER } @@ -45,7 +98,6 @@ fun DebugSettingsSheet( onDismiss: () -> Unit, meshService: BluetoothMeshService ) { - val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = false) val colorScheme = MaterialTheme.colorScheme val manager = remember { DebugSettingsManager.getInstance() } @@ -71,6 +123,16 @@ fun DebugSettingsSheet( val wifiAwareDiscovered by manager.wifiAwareDiscovered.collectAsState() val wifiAwareConnected by manager.wifiAwareConnected.collectAsState() // Persistent notification is now controlled solely by MeshServicePreferences.isBackgroundEnabled + val listState = rememberLazyListState() + val isScrolled by remember { + derivedStateOf { + listState.firstVisibleItemIndex > 0 || listState.firstVisibleItemScrollOffset > 0 + } + } + val topBarAlpha by animateFloatAsState( + targetValue = if (isScrolled) 0.95f else 0f, + label = "topBarAlpha" + ) // Push live connected devices from mesh service whenever sheet is visible LaunchedEffect(isPresented) { @@ -112,35 +174,31 @@ fun DebugSettingsSheet( if (!isPresented) return - ModalBottomSheet( + BitchatBottomSheet( onDismissRequest = onDismiss, - sheetState = sheetState ) { // Mark debug sheet visible/invisible to gate heavy work LaunchedEffect(Unit) { DebugSettingsManager.getInstance().setDebugSheetVisible(true) } DisposableEffect(Unit) { onDispose { DebugSettingsManager.getInstance().setDebugSheetVisible(false) } } - LazyColumn( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp) - .padding(bottom = 24.dp), - verticalArrangement = Arrangement.spacedBy(16.dp) - ) { - item { - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { - Icon(Icons.Filled.BugReport, contentDescription = null, tint = Color(0xFFFF9500)) - Text(stringResource(R.string.debug_tools), fontFamily = FontFamily.Monospace, fontSize = 18.sp, fontWeight = FontWeight.Medium) + Box(modifier = Modifier.fillMaxWidth()) { + LazyColumn( + state = listState, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + contentPadding = PaddingValues(top = 80.dp, bottom = 24.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + item { + Text( + text = stringResource(R.string.debug_tools_desc), + fontFamily = FontFamily.Monospace, + fontSize = 12.sp, + color = colorScheme.onSurface.copy(alpha = 0.7f) + ) } - Text( - text = stringResource(R.string.debug_tools_desc), - fontFamily = FontFamily.Monospace, - fontSize = 12.sp, - color = colorScheme.onSurface.copy(alpha = 0.7f) - ) - } - // Verbose logging toggle item { Surface(shape = RoundedCornerShape(12.dp), color = colorScheme.surfaceVariant.copy(alpha = 0.2f)) { @@ -161,6 +219,11 @@ fun DebugSettingsSheet( } } + // Mesh topology visualization (moved below verbose logging) + item { + MeshTopologySection() + } + // GATT controls item { Surface(shape = RoundedCornerShape(12.dp), color = colorScheme.surfaceVariant.copy(alpha = 0.2f)) { @@ -410,7 +473,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 @@ -587,9 +650,6 @@ fun DebugSettingsSheet( } } - - - // Connected devices item { Surface(shape = RoundedCornerShape(12.dp), color = colorScheme.surfaceVariant.copy(alpha = 0.2f)) { @@ -672,7 +732,29 @@ fun DebugSettingsSheet( } } - item { Spacer(Modifier.height(16.dp)) } + item { Spacer(Modifier.height(16.dp)) } + } + + BitchatSheetTopBar( + onClose = onDismiss, + modifier = Modifier.align(Alignment.TopCenter), + backgroundAlpha = topBarAlpha, + title = { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Icon( + imageVector = Icons.Filled.BugReport, + contentDescription = null, + tint = Color(0xFFFF9500) + ) + BitchatSheetTitle( + text = stringResource(R.string.debug_tools) + ) + } + } + ) } } } 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..2df56b5d --- /dev/null +++ b/app/src/main/java/com/bitchat/android/ui/debug/MeshGraph.kt @@ -0,0 +1,409 @@ +package com.bitchat.android.ui.debug + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.gestures.detectDragGestures +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.PathEffect +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.nativeCanvas +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.bitchat.android.services.meshgraph.MeshGraphService +import kotlin.math.* +import kotlin.random.Random +import androidx.compose.material3.MaterialTheme +import com.bitchat.android.ui.debug.DebugSettingsManager.MeshVisualEvent + +// Physics constants +private const val REPULSION_FORCE = 100000f +private const val SPRING_LENGTH = 150f +private const val SPRING_STRENGTH = 0.02f +private const val CENTER_GRAVITY = 0.02f +private const val DAMPING = 0.85f +private const val MAX_VELOCITY = 30f +private const val PULSE_DECAY = 0.05f +private const val ROUTE_DECAY = 0.02f + +private class GraphNodeState( + val id: String, + var label: String, + var x: Float, + var y: Float +) { + var vx: Float = 0f + var vy: Float = 0f + var isDragged: Boolean = false + var pulseLevel: Float = 0f // 0f to 1f, used for size/glow animation +} + +private class Simulation { + val nodes = mutableMapOf() + // 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, + 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 + ) + } + } + } +} 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/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 da7e487b..3d72337a 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -364,4 +364,40 @@ أضف ملاحظة لهذا المكان بلوتوث موصى به الشبكة تعمل — %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 5ad1e3be..3cda37a6 100644 --- a/app/src/main/res/values-bn/strings.xml +++ b/app/src/main/res/values-bn/strings.xml @@ -351,4 +351,40 @@ ব্লুটুথ প্রস্তাবিত মেশ চলছে — %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 c249686b..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 @@ -365,4 +365,40 @@ 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 8d08dbcf..b8fed3aa 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -364,4 +364,40 @@ 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 696e9198..8ad97fe9 100644 --- a/app/src/main/res/values-fa/strings.xml +++ b/app/src/main/res/values-fa/strings.xml @@ -351,4 +351,40 @@ بلوتوث توصیه می شود مش در حال اجرا — %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 f99490f0..34e1d5f5 100644 --- a/app/src/main/res/values-fil/strings.xml +++ b/app/src/main/res/values-fil/strings.xml @@ -364,4 +364,40 @@ 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 6172cad5..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" @@ -378,4 +378,40 @@ 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 ea62ba26..e18b57b8 100644 --- a/app/src/main/res/values-he/strings.xml +++ b/app/src/main/res/values-he/strings.xml @@ -17,5 +17,41 @@ 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 a47db283..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 छोड़ें @@ -364,4 +364,40 @@ इस स्थान के लिए एक नोट जोड़ें ब्लूटूथ अनुशंसित मेश चल रहा है — %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 36f99bca..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\" @@ -364,4 +364,40 @@ 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 d47b7c48..3db4e85e 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -398,4 +398,40 @@ 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 88345489..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 画像は利用できません 画像を「ダウンロード」に保存しました @@ -364,4 +364,40 @@ この場所へのメモを追加 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 c0dd2e95..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-დან სურათი მიუწვდომელია სურათი შენახულია "ჩამოტვირთვებში" @@ -351,4 +351,40 @@ 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 67eb9949..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\"에 저장됨 @@ -364,4 +364,40 @@ 이 장소에 노트 추가 블루투스 권장 메시 실행 중 — %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 13e72350..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 @@ -378,4 +378,40 @@ 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 076432ce..b4b23655 100644 --- a/app/src/main/res/values-ms/strings.xml +++ b/app/src/main/res/values-ms/strings.xml @@ -4,5 +4,41 @@ 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 55232b27..252f81c4 100644 --- a/app/src/main/res/values-ne/strings.xml +++ b/app/src/main/res/values-ne/strings.xml @@ -364,4 +364,40 @@ ब्लुटुथ सिफारिस गरिएको मेश चलिरहेको छ — %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 2b2c73f2..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/ · ⧉ @@ -396,4 +396,40 @@ 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 b61ecff2..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\" وچ سیو ہو گئی @@ -351,4 +351,40 @@ 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 02230456..cdbffdd0 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -17,5 +17,41 @@ 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 93252060..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" @@ -364,4 +364,40 @@ 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 49af2b3d..147b5f37 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -364,4 +364,40 @@ 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 398a3f87..dade7d1d 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -354,4 +354,40 @@ добавьте заметку для этого места Рекомендуется 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 2486519e..9ac7df67 100644 --- a/app/src/main/res/values-sv/strings.xml +++ b/app/src/main/res/values-sv/strings.xml @@ -352,4 +352,40 @@ 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 56875871..1e487548 100644 --- a/app/src/main/res/values-ta/strings.xml +++ b/app/src/main/res/values-ta/strings.xml @@ -4,5 +4,41 @@ 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 519acbc4..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 ไม่สามารถใช้รูปภาพได้ บันทึกรูปภาพไว้ใน "ดาวน์โหลด" แล้ว @@ -351,4 +351,40 @@ 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 8a5ba1cd..a2df9dfe 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -352,4 +352,40 @@ 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 2849f0c7..2c631d04 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -4,5 +4,41 @@ Пропустити Рекомендується 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 d97bc162..291c3e9a 100644 --- a/app/src/main/res/values-ur/strings.xml +++ b/app/src/main/res/values-ur/strings.xml @@ -364,4 +364,40 @@ اس جگہ کے لیے ایک نوٹ شامل کریں 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 af260d4e..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\" @@ -351,4 +351,40 @@ 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 48f898a1..69bff70c 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -17,5 +17,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-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 3cff5586..1dcf6b27 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -17,5 +17,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-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index a755eff0..46caf1af 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -377,4 +377,40 @@ 为此地点添加一条笔记 建议开启蓝牙 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 8c10d751..27a5c5e5 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -71,7 +71,7 @@ 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/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/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..3c680777 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/services/AppStateStoreTest.kt @@ -0,0 +1,58 @@ +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) + } +} 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/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 "" }